From 1eeb14c50fc6ffe85d367b8983a8f85078e89cd2 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 14:57:02 +0800 Subject: [PATCH 01/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20SSE=20=E4=BC=A0=E8=BE=93=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...架构】SSE客户端传输层收口约定-2026-06-03.md | 34 +++ src/services/aiService.ts | 209 +++++++----------- .../creation-agent/creationAgentSse.ts | 109 +-------- .../creative-agent/creativeAgentSse.ts | 123 ++--------- src/services/rpg-entry/rpgProfileClient.ts | 152 ++----------- src/services/sseStream.test.ts | 98 ++++++++ src/services/sseStream.ts | 168 ++++++++++++++ .../visualNovelRuntimeSse.ts | 118 +--------- 10 files changed, 442 insertions(+), 579 deletions(-) create mode 100644 docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md create mode 100644 src/services/sseStream.test.ts create mode 100644 src/services/sseStream.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 2a616fff..f9f9bdd3 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-03 前端 SSE 客户端传输层统一收口 + +- 背景:创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态等多个前端 client 曾各自手写 SSE 边界扫描、`TextDecoder` 解码、JSON 解析和流结束 flush,导致 CRLF / LF、UTF-8 尾部、多行 `data:` 和提前停止释放 reader 的处理容易漂移。 +- 决策:前端 SSE 传输层统一使用 `src/services/sseStream.ts`;`readSseStream` 负责事件边界、解码 flush、多行 data 和提前停止取消 reader,`readSseJsonStream` 负责 JSON object 事件解析与异常 JSON 静默跳过。业务 client 只保留领域事件归一化、结果聚合和中文错误文案,后续不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环。 +- 影响范围:`src/services/sseStream.ts`、`src/services/aiService.ts`、`src/services/creation-agent/creationAgentSse.ts`、`src/services/creative-agent/creativeAgentSse.ts`、`src/services/visual-novel-runtime/visualNovelRuntimeSse.ts`、`src/services/rpg-entry/rpgProfileClient.ts`、前端 SSE 相关测试与架构文档。 +- 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/creation-agent/creationAgentSse.test.ts src/services/creative-agent/creativeAgentSse.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/ai.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 `npx eslint ... --max-warnings 0` 通过。 +- 关联文档:`docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md`。 + ## 2026-06-03 最近创作只复用创作模板入口 - 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。 diff --git a/docs/README.md b/docs/README.md index 0bd60aaa..154cd937 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,8 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段 AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md) 为最新口径:只吸收 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验,禁止迁入外部社区、支付、榜单、私有存档或回放。 +前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`,事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。 + ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 diff --git a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md new file mode 100644 index 00000000..0948019a --- /dev/null +++ b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md @@ -0,0 +1,34 @@ +# SSE 客户端传输层收口约定 + +更新时间:`2026-06-03` + +## 背景 + +前端多个服务 client 需要读取 Server-Sent Events,包括创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态。旧实现分别在各自文件里手写事件边界查找、`TextDecoder` 解码、JSON 解析和流结束 flush,容易出现 CRLF / LF 边界不一致、UTF-8 多字节字符尾部丢失、错误事件处理漂移,以及长连接达到最终状态后没有及时释放的问题。 + +## 决策 + +前端 SSE 的传输层统一收口到 `src/services/sseStream.ts`: + +- `readSseStream` 负责读取 `Response.body`、识别 `\n\n` 与 `\r\n\r\n` 事件边界、合并多行 `data:`、flush `TextDecoder` 尾部缓冲,并支持事件处理函数返回 `false` 后取消 reader。 +- `readSseJsonStream` 只在传输事件基础上解析 JSON object,空 data 与异常 JSON 继续按旧口径静默跳过。 +- 各业务 client 只保留领域事件归一化、最终结果聚合和中文错误文案,不再重复实现 SSE 边界扫描、reader 循环或 UTF-8 flush。 +- OpenAI 兼容流、`[DONE]` 哨兵或其它非 JSON SSE 可直接使用 `readSseStream`;业务 JSON 事件优先使用 `readSseJsonStream`。 + +## 落地范围 + +本次先收口以下客户端: + +- `src/services/aiService.ts` +- `src/services/creation-agent/creationAgentSse.ts` +- `src/services/creative-agent/creativeAgentSse.ts` +- `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts` +- `src/services/rpg-entry/rpgProfileClient.ts` + +后续新增 SSE client 时不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing,应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。 + +## 验收 + +- `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。 +- 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。 +- `npm run typecheck` 不产生新的类型错误。 diff --git a/src/services/aiService.ts b/src/services/aiService.ts index eda0fb45..4112042e 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -35,13 +35,14 @@ import type { TextStreamOptions, } from './aiTypes'; import { fetchWithApiAuth, requestJson } from './apiClient'; -import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes'; import { parseLineListContent } from './llmParsers'; import { buildStoryMomentFromRuntimeProjection, getStoryRuntimeProjection, resolveRuntimeStoryAction, } from './rpg-runtime/rpgRuntimeStoryClient'; +import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes'; +import { parseSseJsonObject, readSseJsonStream, readSseStream } from './sseStream'; const RUNTIME_API_BASE = '/api/runtime'; @@ -108,81 +109,96 @@ async function requestPlainTextStream( throw new Error('streaming response body is unavailable'); } - const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; let accumulatedText = ''; - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; + await readSseStream(response, ({ data }) => { + if (data === '[DONE]') { + return false; } - buffer += decoder.decode(value, { stream: true }); - - while (buffer.includes('\n\n')) { - const boundary = buffer.indexOf('\n\n'); - const eventBlock = buffer.slice(0, boundary); - buffer = buffer.slice(boundary + 2); - - for (const rawLine of eventBlock.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (!line.startsWith('data:')) { - continue; - } - - const data = line.slice(5).trim(); - if (!data || data === '[DONE]') { - continue; - } - - try { - const parsed = JSON.parse(data); - const delta = parsed?.choices?.[0]?.delta?.content; - if (typeof delta === 'string' && delta.length > 0) { - accumulatedText += delta; - options.onUpdate?.(accumulatedText); - } - } catch { - // Ignore malformed SSE frames. - } - } + const parsed = parseSseJsonObject(data); + if (!parsed) { + return; } - } + + const delta = readPlainTextStreamDelta(parsed); + if (delta) { + accumulatedText += delta; + options.onUpdate?.(accumulatedText); + } + }); return accumulatedText.trim(); } -type ParsedSseEvent = { - event: string | null; - data: string; -}; +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null + ? (value as Record) + : null; +} -function parseSseEventBlock(eventBlock: string): ParsedSseEvent | null { - let eventName: string | null = null; - const dataLines: string[] = []; +function readPlainTextStreamDelta(parsed: Record) { + const choices = Array.isArray(parsed.choices) ? parsed.choices : []; + const firstChoice = asRecord(choices[0]); + const delta = asRecord(firstChoice?.delta); + const content = delta?.content; + return typeof content === 'string' ? content : ''; +} - for (const rawLine of eventBlock.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (!line) continue; - if (line.startsWith('event:')) { - eventName = line.slice(6).trim() || null; - continue; - } - if (line.startsWith('data:')) { - dataLines.push(line.slice(5).trim()); - } - } +function readSseEventMessage( + parsed: Record, + fallbackMessage: string, +) { + return typeof parsed.message === 'string' ? parsed.message : fallbackMessage; +} - if (dataLines.length === 0) { - return null; - } +function coerceNpcChatTurnResult( + parsed: Record, +): NpcChatTurnResult { + return parsed as unknown as NpcChatTurnResult; +} - return { - event: eventName, - data: dataLines.join('\n'), +function readNpcReplyDelta(parsed: Record) { + return typeof parsed.text === 'string' ? parsed.text : ''; +} + +function readNpcCompletedReply(result: NpcChatTurnResult) { + return typeof result.npcReply === 'string' ? result.npcReply : ''; +} + +async function readNpcChatTurnFromSse( + response: Response, + options: { onReplyUpdate?: (text: string) => void } = {}, +): Promise { + let accumulatedReply = ''; + const completedResultRef: { current: NpcChatTurnResult | null } = { + current: null, }; + + await readSseJsonStream(response, ({ eventName, parsed }) => { + if (eventName === 'reply_delta') { + accumulatedReply = readNpcReplyDelta(parsed); + options.onReplyUpdate?.(accumulatedReply); + return; + } + + if (eventName === 'complete') { + completedResultRef.current = coerceNpcChatTurnResult(parsed); + accumulatedReply = readNpcCompletedReply(completedResultRef.current); + options.onReplyUpdate?.(accumulatedReply); + return false; + } + + if (eventName === 'error') { + throw new Error(readSseEventMessage(parsed, 'NPC 聊天续写失败')); + } + }); + + if (!completedResultRef.current) { + throw new Error('NPC 聊天续写结果为空'); + } + + return completedResultRef.current; } export async function generateInitialStory( @@ -508,72 +524,9 @@ export async function streamNpcChatTurn( throw new Error('streaming response body is unavailable'); } - const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; - let accumulatedReply = ''; - let completedResult: NpcChatTurnResult | null = null; - - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }); - - while (buffer.includes('\n\n')) { - const boundary = buffer.indexOf('\n\n'); - const eventBlock = buffer.slice(0, boundary); - buffer = buffer.slice(boundary + 2); - - const parsedEvent = parseSseEventBlock(eventBlock); - if (!parsedEvent) { - continue; - } - - if (parsedEvent.data === '[DONE]') { - continue; - } - - if (parsedEvent.event === 'reply_delta') { - const payloadRecord = JSON.parse(parsedEvent.data) as Record< - string, - unknown - >; - const nextText = - typeof payloadRecord.text === 'string' ? payloadRecord.text : ''; - accumulatedReply = nextText; - options.onReplyUpdate?.(accumulatedReply); - continue; - } - - if (parsedEvent.event === 'complete') { - completedResult = JSON.parse(parsedEvent.data) as NpcChatTurnResult; - accumulatedReply = completedResult.npcReply; - options.onReplyUpdate?.(accumulatedReply); - continue; - } - - if (parsedEvent.event === 'error') { - const payloadRecord = JSON.parse(parsedEvent.data) as Record< - string, - unknown - >; - throw new Error( - typeof payloadRecord.message === 'string' - ? payloadRecord.message - : 'NPC 聊天续写失败', - ); - } - } - } - - if (!completedResult) { - throw new Error('NPC 聊天续写结果为空'); - } - - return completedResult; + return readNpcChatTurnFromSse(response, { + onReplyUpdate: options.onReplyUpdate, + }); } export async function streamNpcRecruitDialogue( diff --git a/src/services/creation-agent/creationAgentSse.ts b/src/services/creation-agent/creationAgentSse.ts index f8ed2f8a..a12dda95 100644 --- a/src/services/creation-agent/creationAgentSse.ts +++ b/src/services/creation-agent/creationAgentSse.ts @@ -1,5 +1,6 @@ import type { VisualNovelAgentStreamEvent } from '../../../packages/shared/src/contracts/visualNovel'; import type { TextStreamOptions } from '../aiTypes'; +import { readSseJsonStream } from '../sseStream'; type CreationAgentSseOptions = TextStreamOptions & { fallbackMessage: string; @@ -24,65 +25,6 @@ type CreationAgentSseOptions = TextStreamOptions & { | null; }; -function findSseEventBoundary(buffer: string) { - const lfBoundary = buffer.indexOf('\n\n'); - const crlfBoundary = buffer.indexOf('\r\n\r\n'); - - if (lfBoundary === -1 && crlfBoundary === -1) { - return null; - } - - if (lfBoundary === -1) { - return { - index: crlfBoundary, - length: 4, - }; - } - - if (crlfBoundary === -1 || lfBoundary < crlfBoundary) { - return { - index: lfBoundary, - length: 2, - }; - } - - return { - index: crlfBoundary, - length: 4, - }; -} - -function parseSseEventBlock(eventBlock: string) { - let eventName = 'message'; - const dataLines: string[] = []; - - for (const rawLine of eventBlock.split(/\r?\n/u)) { - const line = rawLine.trim(); - - if (line.startsWith('event:')) { - eventName = line.slice(6).trim() || 'message'; - continue; - } - - if (line.startsWith('data:')) { - dataLines.push(line.slice(5).trim()); - } - } - - return { - eventName, - data: dataLines.join('\n'), - }; -} - -function parseJsonObject(data: string) { - try { - return JSON.parse(data) as Record; - } catch { - return null; - } -} - type NormalizedCreationAgentSseEvent = NonNullable< CreationAgentSseOptions['normalizeEvent'] > extends (eventName: string, parsed: Record) => infer TResult @@ -147,71 +89,30 @@ export async function readCreationAgentSessionFromSse( response: Response, options: CreationAgentSseOptions, ) { - const streamBody = response.body; - if (!streamBody) { - throw new Error('streaming response body is unavailable'); - } - - const reader = streamBody.getReader(); - const decoder = new TextDecoder('utf-8'); const resolveSession = options.resolveSession ?? ((rawSession: unknown) => (rawSession as TSession | null) ?? null); - let buffer = ''; let finalSession: TSession | null = null; const normalizeEvent = options.normalizeEvent ?? normalizeDefaultCreationAgentEvent; - const consumeBuffer = () => { - for (;;) { - const boundary = findSseEventBoundary(buffer); - if (!boundary) { - break; - } - - const eventBlock = buffer.slice(0, boundary.index); - buffer = buffer.slice(boundary.index + boundary.length); - const { eventName, data } = parseSseEventBlock(eventBlock); - - if (!data) { - continue; - } - - const parsed = parseJsonObject(data); - if (!parsed) { - continue; - } + await readSseJsonStream(response, ({ eventName, parsed }) => { const normalized = normalizeEvent(eventName, parsed); if (normalized?.kind === 'reply_delta') { options.onUpdate?.(normalized.text); - continue; + return; } if (normalized?.kind === 'session') { finalSession = resolveSession(normalized.session); - continue; + return; } if (normalized?.kind === 'error') { throw new Error(normalized.message || options.fallbackMessage); } - } - }; - - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }); - consumeBuffer(); - } - - // 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。 - buffer += decoder.decode(); - consumeBuffer(); + }); if (!finalSession) { throw new Error(options.incompleteMessage); diff --git a/src/services/creative-agent/creativeAgentSse.ts b/src/services/creative-agent/creativeAgentSse.ts index 9e2bdf74..306d6547 100644 --- a/src/services/creative-agent/creativeAgentSse.ts +++ b/src/services/creative-agent/creativeAgentSse.ts @@ -4,6 +4,7 @@ import type { CreativeDraftEditResult, } from '../../../packages/shared/src/contracts/creativeAgent'; import type { TextStreamOptions } from '../aiTypes'; +import { readSseJsonStream } from '../sseStream'; type CreativeAgentSseOptions = TextStreamOptions & { fallbackMessage: string; @@ -16,65 +17,6 @@ type CreativeAgentSseResult = { draftEditResult: CreativeDraftEditResult | null; }; -function findSseEventBoundary(buffer: string) { - const lfBoundary = buffer.indexOf('\n\n'); - const crlfBoundary = buffer.indexOf('\r\n\r\n'); - - if (lfBoundary === -1 && crlfBoundary === -1) { - return null; - } - - if (lfBoundary === -1) { - return { - index: crlfBoundary, - length: 4, - }; - } - - if (crlfBoundary === -1 || lfBoundary < crlfBoundary) { - return { - index: lfBoundary, - length: 2, - }; - } - - return { - index: crlfBoundary, - length: 4, - }; -} - -function parseSseEventBlock(eventBlock: string) { - let eventName = 'message'; - const dataLines: string[] = []; - - for (const rawLine of eventBlock.split(/\r?\n/u)) { - const line = rawLine.trim(); - - if (line.startsWith('event:')) { - eventName = line.slice(6).trim() || 'message'; - continue; - } - - if (line.startsWith('data:')) { - dataLines.push(line.slice(5).trim()); - } - } - - return { - eventName, - data: dataLines.join('\n'), - }; -} - -function parseJsonObject(data: string) { - try { - return JSON.parse(data) as Record; - } catch { - return null; - } -} - function normalizeCreativeAgentSseEvent( eventName: string, data: Record, @@ -105,13 +47,9 @@ function normalizeCreativeAgentSseEvent( function handleParsedCreativeAgentEvent( eventName: string, - parsed: Record | null, + parsed: Record, options: CreativeAgentSseOptions, ): Partial | null { - if (!parsed) { - return null; - } - const normalizedEvent = normalizeCreativeAgentSseEvent(eventName, parsed); if (normalizedEvent) { options.onEvent?.(normalizedEvent); @@ -168,59 +106,24 @@ export async function readCreativeAgentResultFromSse( response: Response, options: CreativeAgentSseOptions, ): Promise { - const streamBody = response.body; - if (!streamBody) { - throw new Error('streaming response body is unavailable'); - } - - const reader = streamBody.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; const result: CreativeAgentSseResult = { session: null, draftEditResult: null, }; - const consumeBuffer = () => { - for (;;) { - const boundary = findSseEventBoundary(buffer); - if (!boundary) { - break; - } - - const eventBlock = buffer.slice(0, boundary.index); - buffer = buffer.slice(boundary.index + boundary.length); - const { eventName, data } = parseSseEventBlock(eventBlock); - if (!data) { - continue; - } - - const nextResult = handleParsedCreativeAgentEvent( - eventName, - parseJsonObject(data), - options, - ); - if (nextResult?.session) { - result.session = nextResult.session; - } - if (nextResult?.draftEditResult) { - result.draftEditResult = nextResult.draftEditResult; - } + await readSseJsonStream(response, ({ eventName, parsed }) => { + const nextResult = handleParsedCreativeAgentEvent( + eventName, + parsed, + options, + ); + if (nextResult?.session) { + result.session = nextResult.session; } - }; - - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; + if (nextResult?.draftEditResult) { + result.draftEditResult = nextResult.draftEditResult; } - - buffer += decoder.decode(value, { stream: true }); - consumeBuffer(); - } - - buffer += decoder.decode(); - consumeBuffer(); + }); if (!result.session) { throw new Error(options.incompleteMessage); diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index 0751d4db..8aab97bc 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -1,14 +1,14 @@ import type { + ClaimProfileTaskRewardResponse, ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, - ClaimProfileTaskRewardResponse, PlatformBrowseHistoryBatchSyncRequest, PlatformBrowseHistoryResponse, PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfilePlayStatsResponse, - ProfileReferralInviteCenterResponse, ProfileRechargeCenterResponse, + ProfileReferralInviteCenterResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse, ProfileTaskCenterResponse, @@ -24,10 +24,11 @@ import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { fetchWithApiAuth } from '../apiClient'; import { - RUNTIME_BACKGROUND_AUTH_OPTIONS, requestRpgRuntimeJson, + RUNTIME_BACKGROUND_AUTH_OPTIONS, type RuntimeRequestOptions, } from '../rpg-runtime/rpgRuntimeRequest'; +import { readSseJsonStream } from '../sseStream'; export type { RuntimeRequestOptions }; @@ -132,65 +133,6 @@ type RechargeOrderSseEvent = payload: { message: string }; }; -function findSseEventBoundary(buffer: string) { - const lfBoundary = buffer.indexOf('\n\n'); - const crlfBoundary = buffer.indexOf('\r\n\r\n'); - - if (lfBoundary === -1 && crlfBoundary === -1) { - return null; - } - - if (lfBoundary === -1) { - return { - index: crlfBoundary, - length: 4, - }; - } - - if (crlfBoundary === -1 || lfBoundary < crlfBoundary) { - return { - index: lfBoundary, - length: 2, - }; - } - - return { - index: crlfBoundary, - length: 4, - }; -} - -function parseSseEventBlock(eventBlock: string) { - let eventName = 'message'; - const dataLines: string[] = []; - - for (const rawLine of eventBlock.split(/\r?\n/u)) { - const line = rawLine.trim(); - - if (line.startsWith('event:')) { - eventName = line.slice(6).trim() || 'message'; - continue; - } - - if (line.startsWith('data:')) { - dataLines.push(line.slice(5).trim()); - } - } - - return { - eventName, - data: dataLines.join('\n'), - }; -} - -function parseJsonObject(data: string) { - try { - return JSON.parse(data) as Record; - } catch { - return null; - } -} - function normalizeRechargeOrderSseEvent( eventName: string, parsed: Record, @@ -264,81 +206,33 @@ export async function watchWechatRpgProfileRechargeOrder( throw new Error('streaming response body is unavailable'); } - const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; let finalResponse: ConfirmWechatProfileRechargeOrderResponse | null = null; let lastResponse: ConfirmWechatProfileRechargeOrderResponse | null = null; - let streamDone = false; - const consumeBuffer = () => { - for (;;) { - const boundary = findSseEventBoundary(buffer); - if (!boundary) { - break; - } - - const eventBlock = buffer.slice(0, boundary.index); - buffer = buffer.slice(boundary.index + boundary.length); - const { eventName, data } = parseSseEventBlock(eventBlock); - - if (!data) { - continue; - } - - const parsed = parseJsonObject(data); - if (!parsed) { - continue; - } - const normalized = normalizeRechargeOrderSseEvent(eventName, parsed); - if (!normalized) { - continue; - } - - if (normalized.type === 'order') { - lastResponse = normalized.payload; - if (normalized.payload.order.status !== 'pending') { - finalResponse = normalized.payload; - } - continue; - } - - if (normalized.type === 'done') { - streamDone = true; - if (!finalResponse && lastResponse) { - finalResponse = lastResponse; - } - continue; - } - - throw new Error(normalized.payload.message || '订阅充值订单状态失败'); - } - }; - - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; + await readSseJsonStream(response, ({ eventName, parsed }) => { + const normalized = normalizeRechargeOrderSseEvent(eventName, parsed); + if (!normalized) { + return; } - buffer += decoder.decode(value, { stream: true }); - consumeBuffer(); - if (finalResponse) { - break; + if (normalized.type === 'order') { + lastResponse = normalized.payload; + if (normalized.payload.order.status !== 'pending') { + finalResponse = normalized.payload; + return false; + } + return; } - if (streamDone) { - break; - } - } - buffer += decoder.decode(); - consumeBuffer(); - - if (!finalResponse) { - if (lastResponse) { - finalResponse = lastResponse; + if (normalized.type === 'done') { + if (!finalResponse && lastResponse) { + finalResponse = lastResponse; + } + return false; } - } + + throw new Error(normalized.payload.message || '订阅充值订单状态失败'); + }); if (!finalResponse) { throw new Error('充值订单状态流返回不完整'); diff --git a/src/services/sseStream.test.ts b/src/services/sseStream.test.ts new file mode 100644 index 00000000..8049d0ba --- /dev/null +++ b/src/services/sseStream.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from 'vitest'; + +import { readSseJsonStream, readSseStream } from './sseStream'; + +function createChunkedStreamResponse(chunks: Uint8Array[]) { + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + }, + }); +} + +test('readSseJsonStream flushes decoder tail and handles CRLF boundaries', async () => { + const encoder = new TextEncoder(); + const prefix = encoder.encode('event: reply_delta\r\ndata: {"text":"'); + const replyBytes = encoder.encode('溪上春风'); + const suffix = encoder.encode('"}\r\n\r\n'); + const splitIndex = replyBytes.length - 1; + const events: Array<{ eventName: string; parsed: Record }> = + []; + + await readSseJsonStream( + createChunkedStreamResponse([ + new Uint8Array([...prefix, ...replyBytes.slice(0, splitIndex)]), + new Uint8Array([...replyBytes.slice(splitIndex), ...suffix]), + ]), + ({ eventName, parsed }) => { + events.push({ eventName, parsed }); + }, + ); + + expect(events).toEqual([ + { + eventName: 'reply_delta', + parsed: { text: '溪上春风' }, + }, + ]); +}); + +test('readSseJsonStream skips malformed json and keeps valid LF events', async () => { + const encoder = new TextEncoder(); + const events: Array<{ eventName: string; parsed: Record }> = + []; + + await readSseJsonStream( + createChunkedStreamResponse([ + encoder.encode( + 'event: malformed\ndata: not-json\n\n' + + 'event: ready\ndata: {"value":7}\n\n', + ), + ]), + ({ eventName, parsed }) => { + events.push({ eventName, parsed }); + }, + ); + + expect(events).toEqual([ + { + eventName: 'ready', + parsed: { value: 7 }, + }, + ]); +}); + +test('readSseStream can stop early and cancel the reader', async () => { + const encoder = new TextEncoder(); + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + 'event: first\ndata: one\n\n' + 'event: second\ndata: two\n\n', + ), + ); + }, + cancel() { + cancelled = true; + }, + }); + const events: string[] = []; + + await readSseStream(new Response(stream), ({ eventName }) => { + events.push(eventName); + return false; + }); + + expect(events).toEqual(['first']); + expect(cancelled).toBe(true); +}); diff --git a/src/services/sseStream.ts b/src/services/sseStream.ts new file mode 100644 index 00000000..6659faaa --- /dev/null +++ b/src/services/sseStream.ts @@ -0,0 +1,168 @@ +export type SseStreamEvent = { + eventName: string; + data: string; +}; + +export type SseJsonStreamEvent = SseStreamEvent & { + parsed: Record; +}; + +type SseEventBoundary = { + index: number; + length: number; +}; + +type SseStreamEventHandler = ( + event: TEvent, +) => void | boolean; + +function findSseEventBoundary(buffer: string): SseEventBoundary | null { + const lfBoundary = buffer.indexOf('\n\n'); + const crlfBoundary = buffer.indexOf('\r\n\r\n'); + + if (lfBoundary === -1 && crlfBoundary === -1) { + return null; + } + + if (lfBoundary === -1) { + return { + index: crlfBoundary, + length: 4, + }; + } + + if (crlfBoundary === -1 || lfBoundary < crlfBoundary) { + return { + index: lfBoundary, + length: 2, + }; + } + + return { + index: crlfBoundary, + length: 4, + }; +} + +function parseSseEventBlock(eventBlock: string): SseStreamEvent | null { + let eventName = 'message'; + const dataLines: string[] = []; + + for (const rawLine of eventBlock.split(/\r?\n/u)) { + const line = rawLine.trim(); + + if (line.startsWith('event:')) { + eventName = line.slice(6).trim() || 'message'; + continue; + } + + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trim()); + } + } + + const data = dataLines.join('\n'); + if (!data) { + return null; + } + + return { + eventName, + data, + }; +} + +export function parseSseJsonObject(data: string): Record | null { + try { + const parsed = JSON.parse(data) as unknown; + return typeof parsed === 'object' && parsed !== null + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +export async function readSseStream( + response: Response, + onEvent: SseStreamEventHandler, +) { + const streamBody = response.body; + if (!streamBody) { + throw new Error('streaming response body is unavailable'); + } + + const reader = streamBody.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let shouldContinue = true; + let completed = false; + + const consumeBuffer = () => { + for (;;) { + if (!shouldContinue) { + break; + } + + const boundary = findSseEventBoundary(buffer); + if (!boundary) { + break; + } + + const eventBlock = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary.length); + const event = parseSseEventBlock(eventBlock); + if (!event) { + continue; + } + + if (onEvent(event) === false) { + shouldContinue = false; + } + } + }; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + consumeBuffer(); + if (!shouldContinue) { + break; + } + } + + if (shouldContinue) { + // 流结束后 flush 解码器,避免 UTF-8 多字节字符残留在内部缓冲里。 + buffer += decoder.decode(); + consumeBuffer(); + completed = true; + } + } finally { + if (!completed && typeof reader.cancel === 'function') { + await reader.cancel().catch(() => {}); + } + reader.releaseLock?.(); + } +} + +export function readSseJsonStream( + response: Response, + onEvent: SseStreamEventHandler, +) { + return readSseStream(response, (event) => { + const parsed = parseSseJsonObject(event.data); + if (!parsed) { + return; + } + + return onEvent({ + ...event, + parsed, + }); + }); +} diff --git a/src/services/visual-novel-runtime/visualNovelRuntimeSse.ts b/src/services/visual-novel-runtime/visualNovelRuntimeSse.ts index a4892f2f..1aac77e8 100644 --- a/src/services/visual-novel-runtime/visualNovelRuntimeSse.ts +++ b/src/services/visual-novel-runtime/visualNovelRuntimeSse.ts @@ -2,6 +2,7 @@ import type { VisualNovelRunSnapshot, VisualNovelRuntimeStreamEvent, } from '../../../packages/shared/src/contracts/visualNovel'; +import { readSseJsonStream } from '../sseStream'; type VisualNovelRuntimeSseOptions = { fallbackMessage: string; @@ -9,65 +10,6 @@ type VisualNovelRuntimeSseOptions = { onEvent?: (event: VisualNovelRuntimeStreamEvent) => void; }; -function findSseEventBoundary(buffer: string) { - const lfBoundary = buffer.indexOf('\n\n'); - const crlfBoundary = buffer.indexOf('\r\n\r\n'); - - if (lfBoundary === -1 && crlfBoundary === -1) { - return null; - } - - if (lfBoundary === -1) { - return { - index: crlfBoundary, - length: 4, - }; - } - - if (crlfBoundary === -1 || lfBoundary < crlfBoundary) { - return { - index: lfBoundary, - length: 2, - }; - } - - return { - index: crlfBoundary, - length: 4, - }; -} - -function parseSseEventBlock(eventBlock: string) { - let eventName = 'message'; - const dataLines: string[] = []; - - for (const rawLine of eventBlock.split(/\r?\n/u)) { - const line = rawLine.trim(); - - if (line.startsWith('event:')) { - eventName = line.slice(6).trim() || 'message'; - continue; - } - - if (line.startsWith('data:')) { - dataLines.push(line.slice(5).trim()); - } - } - - return { - eventName, - data: dataLines.join('\n'), - }; -} - -function parseJsonObject(data: string) { - try { - return JSON.parse(data) as Record; - } catch { - return null; - } -} - function normalizeVisualNovelRuntimeEvent( eventName: string, parsed: Record, @@ -115,59 +57,19 @@ export async function readVisualNovelRuntimeRunFromSse( response: Response, options: VisualNovelRuntimeSseOptions, ) { - const streamBody = response.body; - if (!streamBody) { - throw new Error('streaming response body is unavailable'); - } - - const reader = streamBody.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; let finalRun: VisualNovelRunSnapshot | null = null; - const consumeBuffer = () => { - for (;;) { - const boundary = findSseEventBoundary(buffer); - if (!boundary) { - break; - } - - const eventBlock = buffer.slice(0, boundary.index); - buffer = buffer.slice(boundary.index + boundary.length); - const { eventName, data } = parseSseEventBlock(eventBlock); - if (!data) { - continue; - } - - const parsed = parseJsonObject(data); - if (!parsed) { - continue; - } - - const event = normalizeVisualNovelRuntimeEvent(eventName, parsed); - if (!event) { - continue; - } - - const nextRun = handleVisualNovelRuntimeEvent(event, options); - if (nextRun) { - finalRun = nextRun; - } - } - }; - - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; + await readSseJsonStream(response, ({ eventName, parsed }) => { + const event = normalizeVisualNovelRuntimeEvent(eventName, parsed); + if (!event) { + return; } - buffer += decoder.decode(value, { stream: true }); - consumeBuffer(); - } - - buffer += decoder.decode(); - consumeBuffer(); + const nextRun = handleVisualNovelRuntimeEvent(event, options); + if (nextRun) { + finalRun = nextRun; + } + }); if (!finalRun) { throw new Error(options.incompleteMessage); From cf0840d9e9aa0adbce216ef38c3857ac66a6f1c5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 15:34:52 +0800 Subject: [PATCH 02/67] =?UTF-8?q?refactor:=20=E6=B7=B1=E5=8C=96=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=85=A5=E5=8F=A3=E4=BD=9C=E5=93=81=E6=B5=81=E4=B8=8E?= =?UTF-8?q?=E4=BD=9C=E5=93=81=E6=9E=B6=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 16 ++ docs/README.md | 4 + ...架构】WorkShelfModule收口计划-2026-06-03.md | 27 +++ ...入口PublicGalleryFlowModule收口计划-2026-06-03.md | 35 +++ .../CustomWorldCreationHub.tsx | 45 +--- .../PlatformEntryFlowShellImpl.tsx | 132 ++---------- .../platformPublicGalleryFlow.test.ts | 204 ++++++++++++++++++ .../platformPublicGalleryFlow.ts | 128 +++++++++++ 8 files changed, 434 insertions(+), 157 deletions(-) create mode 100644 docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md create mode 100644 docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md create mode 100644 src/components/platform-entry/platformPublicGalleryFlow.test.ts create mode 100644 src/components/platform-entry/platformPublicGalleryFlow.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index f9f9bdd3..a3aff1f8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,6 +24,22 @@ - 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/creation-agent/creationAgentSse.test.ts src/services/creative-agent/creativeAgentSse.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/ai.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 `npx eslint ... --max-warnings 0` 通过。 - 关联文档:`docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md`。 +## 2026-06-03 平台入口公开作品流身份规则收口 + +- 背景:平台入口公开作品推荐流需要同时处理 RPG、拼图、抓大鹅、跳一跳、敲木鱼、视觉小说、Bark Battle、宝贝识物等卡片,公开作品身份、跨玩法去重、排序和推荐运行态 kind 判定曾放在 `PlatformEntryFlowShellImpl.tsx` 巨型实现里。 +- 决策:公开作品身份和排序规则统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey`、`getPlatformRecommendRuntimeKind`、`isSamePlatformPublicGalleryEntry` 和 `mergePlatformPublicGalleryEntries`。`edutainment` key 必须带 `templateId`,RPG 卡片回退为 `rpg`。 +- 影响范围:平台入口推荐流、公开作品详情、推荐 runtime 启动、跨玩法公开作品合并,以及后续新增玩法的入口接入。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md`。 + +## 2026-06-03 Work Shelf 打开动作交由 item Adapter + +- 背景:`creationWorkShelf.ts` 已经为每个 `CreationWorkShelfItem` 生成 `actions.open`,但 `CustomWorldCreationHub.tsx` 点击卡片后仍按 `item.source.kind` 重复分发 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物的打开逻辑。 +- 决策:`CreationWorkShelfItem.actions.open` 作为作品架打开动作的正式 Interface;Hub 只保留 `onOpenShelfItem` 通知和 `item.actions.open()` 调用,不再读取玩法 kind 做打开分支。 +- 影响范围:创作中心作品架卡片点击、作品架动作 Adapter、后续新增玩法作品架接入。 +- 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`。 + ## 2026-06-03 最近创作只复用创作模板入口 - 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。 diff --git a/docs/README.md b/docs/README.md index 154cd937..5bb6fbd1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,6 +39,10 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`,事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。 +平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + +创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,Hub 不再按玩法 `kind` 分发,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 diff --git a/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md new file mode 100644 index 00000000..a1bbc0c0 --- /dev/null +++ b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md @@ -0,0 +1,27 @@ +# 【前端架构】Work Shelf Module 收口计划 + +## 背景 + +创作中心作品架需要同时展示 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物等作品。`creationWorkShelf.ts` 已经统一了卡片标题、摘要、封面、发布码、分享路径、指标、生成态和动作 Adapter,但 `CustomWorldCreationHub.tsx` 仍在点击作品卡时按玩法 `kind` 再写一遍打开逻辑,导致调用方仍须理解每种玩法。 + +## 决策 + +`CreationWorkShelfItem.actions.open` 是打开作品的正式 **Interface**。`CustomWorldCreationHub.tsx` 只负责卡片点击与 `onOpenShelfItem` 通知,然后调用 `item.actions.open()`,不再根据 `item.source.kind` 分发玩法。 + +此决策让 `creationWorkShelf.ts` 的 **Module** 更 deep: + +- **Implementation**:玩法差异、草稿 / 已发布分支、profileId 进入方式和回调绑定都留在 Work Shelf Adapter 内。 +- **Interface**:Hub 只需要 `CreationWorkShelfItem`,不需要知道每种玩法的打开规则。 +- **Leverage**:新增玩法时只补 shelf item 映射与 Adapter,Hub 不再新增 switch 分支。 +- **Locality**:作品架点击行为的错误集中在 `creationWorkShelf.ts` 与其测试里定位。 + +## 后续深化 + +下一步可把 `buildCreationWorkShelfItems` 当前的长参数列表继续收口为 per-kind Source Adapter registry。届时 Hub / 平台壳传入玩法数据源和回调时,可逐步减少按玩法平铺的参数数量。删除、刷新和直达恢复也可沿同一 seam 收口。 + +## 验证 + +- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md new file mode 100644 index 00000000..5cb2ca6c --- /dev/null +++ b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md @@ -0,0 +1,35 @@ +# 【前端架构】平台入口 Public Gallery Flow Module 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 同时承载平台入口、推荐流、公开作品详情、运行态启动和作品架刷新。公开作品列表中的身份识别、跨玩法去重、时间排序和推荐运行态类型判定原本散落在入口巨型实现中,后续每新增一种玩法都需要在巨型文件内追加判断,影响前端架构的复用、统一和扩展。 + +## 决策 + +新增 `src/components/platform-entry/platformPublicGalleryFlow.ts`,作为平台入口公开作品流的 **Module**。该 Module 的 **Interface** 固定收口为: + +- `getPlatformPublicGalleryEntryKey(entry)`:按玩法类型、作者和 `profileId` 生成公开作品身份。 +- `getPlatformRecommendRuntimeKind(entry)`:把公开作品卡映射为推荐运行态 kind。 +- `isSamePlatformPublicGalleryEntry(left, right)`:按公开作品身份比较。 +- `mergePlatformPublicGalleryEntries(rpgEntries, puzzleEntries)`:统一完成 RPG 与各玩法公开作品去重、覆盖和倒序排序。 + +入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份和排序规则。`isRecommendRuntimeReadyForEntry` 暂留入口壳层,因为它依赖各运行态 run 状态,直接抽出会把更多 runtime 状态类型拖入这个 Module,降低本次改造的 locality。 + +## 玩法身份规则 + +- `big-fish`、`puzzle`、`jump-hop`、`wooden-fish`、`match3d`、`square-hole`、`visual-novel`、`bark-battle` 使用自身 `sourceType` 作为 key kind。 +- `edutainment` 使用 `edutainment:${templateId}` 作为 key kind,避免后续幼教类模板共用 `sourceType` 时互相覆盖。 +- 没有 `sourceType` 的 RPG 公开作品回退为 `rpg`。 +- 最终 key 格式为 `${kind}:${ownerUserId}:${profileId}`。 +- 合并时后进入的相同 key 会覆盖先进入的卡片,然后按 `publishedAt ?? updatedAt` 新到旧排序;非法时间按 `0` 处理。 + +## 后续深化 + +下一步可继续把平台入口的作品架刷新、删除确认和直达恢复逻辑收口成更深的 Work Shelf **Module**。当前 `platformPublicGalleryFlow` 先提供一个稳定 seam,使公开作品 identity 与 runtime kind 的修改集中在一处。 + +## 验证 + +- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 971a8f31..e4be3bd6 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -7,10 +7,10 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; -import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import type { CustomWorldProfile } from '../../types'; import type { @@ -20,10 +20,10 @@ import type { import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes'; import { buildCreationWorkShelfItems, - getCreationWorkShelfItemTime, type CreationWorkShelfItem, type CreationWorkShelfMetricId, type CreationWorkShelfRuntimeState, + getCreationWorkShelfItemTime, } from './creationWorkShelf'; import { CustomWorldCreationStartCard, @@ -274,6 +274,7 @@ export function CustomWorldCreationHub({ barkBattleItems, items, match3dItems, + squareHoleItems, onDeleteBigFish, onDeleteMatch3D, onDeleteSquareHole, @@ -341,44 +342,8 @@ export function CustomWorldCreationHub({ function handleOpenShelfItem(item: CreationWorkShelfItem) { onOpenShelfItem?.(item); - switch (item.source.kind) { - case 'puzzle': - onOpenPuzzleDetail?.(item.source.item); - return; - case 'baby-object-match': - onOpenBabyObjectMatchDetail?.(item.source.item); - return; - case 'visual-novel': - onOpenVisualNovelDetail?.(item.source.item); - return; - case 'bark-battle': - onOpenBarkBattleDetail?.(item.source.item); - return; - case 'big-fish': - onOpenBigFishDetail?.(item.source.item); - return; - case 'match3d': - onOpenMatch3DDetail?.(item.source.item); - return; - case 'square-hole': - onOpenSquareHoleDetail?.(item.source.item); - return; - case 'jump-hop': - onOpenJumpHopDetail?.(item.source.item); - return; - case 'wooden-fish': - onOpenWoodenFishDetail?.(item.source.item); - return; - case 'rpg': - if (item.status === 'draft') { - onOpenDraft(item.source.item); - return; - } - - if (item.source.item.profileId) { - onEnterPublished(item.source.item.profileId); - } - } + // 中文注释:玩法差异由 Work Shelf Adapter 承载,Hub 只负责响应卡片点击。 + item.actions.open(); } function buildDeleteAction(item: CreationWorkShelfItem) { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index b305027c..71489366 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -108,6 +108,7 @@ import type { VisualNovelWorkDetail, VisualNovelWorkSummary, } from '../../../packages/shared/src/contracts/visualNovel'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { buildPublicWorkStagePath, @@ -216,17 +217,12 @@ import { buildSquareHoleGenerationAnchorEntries, buildWoodenFishGenerationAnchorEntries, createMiniGameDraftGenerationState, - resolveMiniGameDraftGenerationStartedAtMs, type MiniGameDraftGenerationKind, type MiniGameDraftGenerationPhase, type MiniGameDraftGenerationState, + resolveMiniGameDraftGenerationStartedAtMs, } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; -import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage'; -import { - getUnifiedCreationSpec, - type UnifiedCreationPlayId, -} from '../unified-creation/unifiedCreationSpecs'; import { buildBabyObjectMatchPublicWorkCode, buildBarkBattlePublicWorkCode, @@ -350,7 +346,6 @@ import { type WoodenFishWorkProfileResponse, type WoodenFishWorkspaceCreateRequest, } from '../../services/wooden-fish/woodenFishClient'; -import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { PublishShareModal } from '../common/PublishShareModal'; @@ -390,6 +385,11 @@ import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreation import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave'; import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController'; +import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage'; +import { + getUnifiedCreationSpec, + type UnifiedCreationPlayId, +} from '../unified-creation/unifiedCreationSpecs'; import { buildVisualNovelEntryGenerationAnchorEntries, buildVisualNovelEntryGenerationProgress, @@ -438,11 +438,18 @@ import { PlatformErrorDialog, type PlatformErrorDialogPayload, } from './PlatformErrorDialog'; +import { PlatformFeedbackView } from './PlatformFeedbackView'; +import { + getPlatformPublicGalleryEntryKey, + getPlatformRecommendRuntimeKind, + isSamePlatformPublicGalleryEntry, + mergePlatformPublicGalleryEntries, + type RecommendRuntimeKind, +} from './platformPublicGalleryFlow'; import { PlatformTaskCompletionDialog, type PlatformTaskCompletionDialogPayload, } from './PlatformTaskCompletionDialog'; -import { PlatformFeedbackView } from './PlatformFeedbackView'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; @@ -511,17 +518,6 @@ type BarkBattleRuntimeReturnStage = | 'work-detail' | 'platform'; type BigFishRuntimeSessionSource = 'draft' | 'work' | null; -type RecommendRuntimeKind = - | 'bark-battle' - | 'big-fish' - | 'edutainment' - | 'jump-hop' - | 'match3d' - | 'puzzle' - | 'square-hole' - | 'wooden-fish' - | 'visual-novel' - | 'rpg'; type SquareHoleRuntimeReturnStage = | 'square-hole-result' | 'work-detail' @@ -625,77 +621,6 @@ const PUZZLE_DRAFT_GENERATION_POINT_COST = 2; const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3; -function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) { - const rawTime = entry.publishedAt ?? entry.updatedAt; - const timestamp = new Date(rawTime).getTime(); - return Number.isNaN(timestamp) ? 0 : timestamp; -} - -function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) { - const kind = isBigFishGalleryEntry(entry) - ? 'big-fish' - : isPuzzleGalleryEntry(entry) - ? 'puzzle' - : isJumpHopGalleryEntry(entry) - ? 'jump-hop' - : isWoodenFishGalleryEntry(entry) - ? 'wooden-fish' - : isMatch3DGalleryEntry(entry) - ? 'match3d' - : isSquareHoleGalleryEntry(entry) - ? 'square-hole' - : isVisualNovelGalleryEntry(entry) - ? 'visual-novel' - : isBarkBattleGalleryEntry(entry) - ? 'bark-battle' - : isEdutainmentGalleryEntry(entry) - ? `edutainment:${entry.templateId}` - : 'rpg'; - return `${kind}:${entry.ownerUserId}:${entry.profileId}`; -} - -function getPlatformRecommendRuntimeKind( - entry: PlatformPublicGalleryCard, -): RecommendRuntimeKind { - if (isBigFishGalleryEntry(entry)) { - return 'big-fish'; - } - - if (isPuzzleGalleryEntry(entry)) { - return 'puzzle'; - } - - if (isJumpHopGalleryEntry(entry)) { - return 'jump-hop'; - } - - if (isWoodenFishGalleryEntry(entry)) { - return 'wooden-fish'; - } - - if (isMatch3DGalleryEntry(entry)) { - return 'match3d'; - } - - if (isSquareHoleGalleryEntry(entry)) { - return 'square-hole'; - } - - if (isVisualNovelGalleryEntry(entry)) { - return 'visual-novel'; - } - - if (isBarkBattleGalleryEntry(entry)) { - return 'bark-battle'; - } - - if (isEdutainmentGalleryEntry(entry)) { - return 'edutainment'; - } - - return 'rpg'; -} - function isRecommendRuntimeReadyForEntry( entry: PlatformPublicGalleryCard, state: RecommendRuntimeState, @@ -739,33 +664,6 @@ function isRecommendRuntimeReadyForEntry( return true; } -function isSamePlatformPublicGalleryEntry( - left: PlatformPublicGalleryCard, - right: PlatformPublicGalleryCard, -) { - return ( - getPlatformPublicGalleryEntryKey(left) === - getPlatformPublicGalleryEntryKey(right) - ); -} - -function mergePlatformPublicGalleryEntries( - rpgEntries: CustomWorldGalleryCard[], - puzzleEntries: PlatformPublicGalleryCard[], -) { - const entryMap = new Map(); - - [...rpgEntries, ...puzzleEntries].forEach((entry) => { - entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); - }); - - return Array.from(entryMap.values()).sort( - (left, right) => - getPlatformPublicGalleryEntryTime(right) - - getPlatformPublicGalleryEntryTime(left), - ); -} - function mapRpgGalleryCardToPublicWorkDetail( entry: CustomWorldGalleryCard, ): PlatformPublicGalleryCard { diff --git a/src/components/platform-entry/platformPublicGalleryFlow.test.ts b/src/components/platform-entry/platformPublicGalleryFlow.test.ts new file mode 100644 index 00000000..7003243c --- /dev/null +++ b/src/components/platform-entry/platformPublicGalleryFlow.test.ts @@ -0,0 +1,204 @@ +import { expect, test } from 'vitest'; + +import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import { + EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, + EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; +import { + getPlatformPublicGalleryEntryKey, + getPlatformPublicGalleryEntryTime, + getPlatformRecommendRuntimeKind, + isSamePlatformPublicGalleryEntry, + mergePlatformPublicGalleryEntries, + type RecommendRuntimeKind, +} from './platformPublicGalleryFlow'; + +type TypedPlatformPublicGalleryCard = Extract< + PlatformPublicGalleryCard, + { sourceType: string } +>; +type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType']; +type TypedPlatformPublicGalleryCardOverrides = Partial< + Omit +>; + +function buildRpgEntry( + overrides: Partial = {}, +): CustomWorldGalleryCard { + return { + ownerUserId: 'user-1', + profileId: 'rpg-profile', + publicWorkCode: 'CW-RPG', + authorPublicUserCode: null, + visibility: 'published', + publishedAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T01:00:00.000Z', + authorDisplayName: '玩家', + worldName: 'RPG 世界', + subtitle: '公开作品', + summaryText: '公开作品摘要', + coverImageSrc: null, + themeMode: 'martial', + playableNpcCount: 1, + landmarkCount: 1, + ...overrides, + }; +} + +function buildTypedEntry( + sourceType: PlatformGallerySourceType, + overrides: TypedPlatformPublicGalleryCardOverrides = {}, +): PlatformPublicGalleryCard { + const common = { + workId: `${sourceType}-work`, + profileId: `${sourceType}-profile`, + publicWorkCode: `${sourceType}-code`, + ownerUserId: 'user-1', + authorDisplayName: '玩家', + worldName: `${sourceType} 作品`, + subtitle: '公开作品', + summaryText: '公开作品摘要', + coverImageSrc: null, + themeTags: [sourceType], + visibility: 'published' as const, + publishedAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T01:00:00.000Z', + }; + + switch (sourceType) { + case 'puzzle': + return { ...common, ...overrides, sourceType }; + case 'big-fish': + return { ...common, ...overrides, sourceType }; + case 'match3d': + return { ...common, ...overrides, sourceType }; + case 'square-hole': + return { ...common, ...overrides, sourceType }; + case 'visual-novel': + return { ...common, ...overrides, sourceType }; + case 'jump-hop': + return { ...common, ...overrides, sourceType }; + case 'wooden-fish': + return { ...common, ...overrides, sourceType }; + case 'edutainment': + return { + ...common, + ...overrides, + sourceType, + templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + }; + case 'bark-battle': + return { + ...common, + ...overrides, + sourceType, + authorPublicUserCode: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + themeMode: 'martial', + playableNpcCount: 1, + landmarkCount: 1, + }; + default: { + const exhaustive: never = sourceType; + return exhaustive; + } + } +} + +test('platform public gallery flow resolves stable key and runtime kind for every play kind', () => { + const cases: Array< + [sourceType: PlatformGallerySourceType, keyKind: string, kind: RecommendRuntimeKind] + > = [ + ['big-fish', 'big-fish', 'big-fish'], + ['puzzle', 'puzzle', 'puzzle'], + ['jump-hop', 'jump-hop', 'jump-hop'], + ['wooden-fish', 'wooden-fish', 'wooden-fish'], + ['match3d', 'match3d', 'match3d'], + ['square-hole', 'square-hole', 'square-hole'], + ['visual-novel', 'visual-novel', 'visual-novel'], + ['bark-battle', 'bark-battle', 'bark-battle'], + [ + 'edutainment', + `edutainment:${EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID}`, + 'edutainment', + ], + ]; + + cases.forEach(([sourceType, keyKind, kind]) => { + const entry = buildTypedEntry(sourceType); + + expect(getPlatformPublicGalleryEntryKey(entry)).toBe( + `${keyKind}:user-1:${sourceType}-profile`, + ); + expect(getPlatformRecommendRuntimeKind(entry)).toBe(kind); + }); + + const rpgEntry = buildRpgEntry(); + + expect(getPlatformPublicGalleryEntryKey(rpgEntry)).toBe( + 'rpg:user-1:rpg-profile', + ); + expect(getPlatformRecommendRuntimeKind(rpgEntry)).toBe('rpg'); +}); + +test('platform public gallery flow compares entries by resolved identity', () => { + const left = buildTypedEntry('puzzle'); + const sameIdentity = buildTypedEntry('puzzle', { + workId: 'other-work', + worldName: '新标题', + }); + const otherKind = buildTypedEntry('match3d', { + ownerUserId: left.ownerUserId, + profileId: left.profileId, + }); + + expect(isSamePlatformPublicGalleryEntry(left, sameIdentity)).toBe(true); + expect(isSamePlatformPublicGalleryEntry(left, otherKind)).toBe(false); +}); + +test('platform public gallery flow merges duplicate identities and sorts newest first', () => { + const staleRpgEntry = buildRpgEntry({ + profileId: 'shared-rpg', + worldName: '旧版 RPG', + publishedAt: '2026-06-01T00:00:00.000Z', + }); + const freshRpgEntry = buildRpgEntry({ + profileId: 'shared-rpg', + worldName: '新版 RPG', + publishedAt: '2026-06-04T00:00:00.000Z', + }); + const middleRpgEntry = buildRpgEntry({ + profileId: 'middle-rpg', + worldName: '中间 RPG', + publishedAt: '2026-06-02T00:00:00.000Z', + }); + const updatedOnlyEntry = buildTypedEntry('big-fish', { + profileId: 'updated-only', + publishedAt: null, + updatedAt: '2026-06-03T00:00:00.000Z', + }); + const invalidTimeEntry = buildTypedEntry('puzzle', { + profileId: 'invalid-time', + publishedAt: 'not-a-date', + updatedAt: 'still-not-a-date', + }); + + const merged = mergePlatformPublicGalleryEntries( + [staleRpgEntry, middleRpgEntry], + [invalidTimeEntry, updatedOnlyEntry, freshRpgEntry], + ); + + expect(merged).toHaveLength(4); + expect(merged.map((entry) => entry.profileId)).toEqual([ + 'shared-rpg', + 'updated-only', + 'middle-rpg', + 'invalid-time', + ]); + expect(merged[0]?.worldName).toBe('新版 RPG'); + expect(getPlatformPublicGalleryEntryTime(invalidTimeEntry)).toBe(0); +}); diff --git a/src/components/platform-entry/platformPublicGalleryFlow.ts b/src/components/platform-entry/platformPublicGalleryFlow.ts new file mode 100644 index 00000000..6a28e0c9 --- /dev/null +++ b/src/components/platform-entry/platformPublicGalleryFlow.ts @@ -0,0 +1,128 @@ +import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import { + isBarkBattleGalleryEntry, + isBigFishGalleryEntry, + isEdutainmentGalleryEntry, + isJumpHopGalleryEntry, + isMatch3DGalleryEntry, + isPuzzleGalleryEntry, + isSquareHoleGalleryEntry, + isVisualNovelGalleryEntry, + isWoodenFishGalleryEntry, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; + +export type RecommendRuntimeKind = + | 'bark-battle' + | 'big-fish' + | 'edutainment' + | 'jump-hop' + | 'match3d' + | 'puzzle' + | 'square-hole' + | 'wooden-fish' + | 'visual-novel' + | 'rpg'; + +export function getPlatformPublicGalleryEntryTime( + entry: PlatformPublicGalleryCard, +) { + const rawTime = entry.publishedAt ?? entry.updatedAt; + const timestamp = new Date(rawTime).getTime(); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +export function getPlatformPublicGalleryEntryKey( + entry: PlatformPublicGalleryCard, +) { + // 同一作品身份由玩法、作者与 profile 共同确定,避免不同玩法共享 profileId 时误合并。 + const kind = isBigFishGalleryEntry(entry) + ? 'big-fish' + : isPuzzleGalleryEntry(entry) + ? 'puzzle' + : isJumpHopGalleryEntry(entry) + ? 'jump-hop' + : isWoodenFishGalleryEntry(entry) + ? 'wooden-fish' + : isMatch3DGalleryEntry(entry) + ? 'match3d' + : isSquareHoleGalleryEntry(entry) + ? 'square-hole' + : isVisualNovelGalleryEntry(entry) + ? 'visual-novel' + : isBarkBattleGalleryEntry(entry) + ? 'bark-battle' + : isEdutainmentGalleryEntry(entry) + ? `edutainment:${entry.templateId}` + : 'rpg'; + return `${kind}:${entry.ownerUserId}:${entry.profileId}`; +} + +export function getPlatformRecommendRuntimeKind( + entry: PlatformPublicGalleryCard, +): RecommendRuntimeKind { + if (isBigFishGalleryEntry(entry)) { + return 'big-fish'; + } + + if (isPuzzleGalleryEntry(entry)) { + return 'puzzle'; + } + + if (isJumpHopGalleryEntry(entry)) { + return 'jump-hop'; + } + + if (isWoodenFishGalleryEntry(entry)) { + return 'wooden-fish'; + } + + if (isMatch3DGalleryEntry(entry)) { + return 'match3d'; + } + + if (isSquareHoleGalleryEntry(entry)) { + return 'square-hole'; + } + + if (isVisualNovelGalleryEntry(entry)) { + return 'visual-novel'; + } + + if (isBarkBattleGalleryEntry(entry)) { + return 'bark-battle'; + } + + if (isEdutainmentGalleryEntry(entry)) { + return 'edutainment'; + } + + return 'rpg'; +} + +export function isSamePlatformPublicGalleryEntry( + left: PlatformPublicGalleryCard, + right: PlatformPublicGalleryCard, +) { + return ( + getPlatformPublicGalleryEntryKey(left) === + getPlatformPublicGalleryEntryKey(right) + ); +} + +export function mergePlatformPublicGalleryEntries( + rpgEntries: CustomWorldGalleryCard[], + puzzleEntries: PlatformPublicGalleryCard[], +) { + const entryMap = new Map(); + + [...rpgEntries, ...puzzleEntries].forEach((entry) => { + entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); + }); + + return Array.from(entryMap.values()).sort( + (left, right) => + getPlatformPublicGalleryEntryTime(right) - + getPlatformPublicGalleryEntryTime(left), + ); +} From 5783bfeea6d45bb66cb2a006be01e7a421864f9f Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 15:47:26 +0800 Subject: [PATCH 03/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E6=9E=B6=20Source=20Adapter=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 4 +- ...架构】WorkShelfModule收口计划-2026-06-03.md | 8 +- .../creationWorkShelf.test.ts | 83 ++++++- .../custom-world-home/creationWorkShelf.ts | 202 ++++++++++++------ 4 files changed, 225 insertions(+), 72 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index a3aff1f8..6b02e60d 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -35,8 +35,8 @@ ## 2026-06-03 Work Shelf 打开动作交由 item Adapter - 背景:`creationWorkShelf.ts` 已经为每个 `CreationWorkShelfItem` 生成 `actions.open`,但 `CustomWorldCreationHub.tsx` 点击卡片后仍按 `item.source.kind` 重复分发 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物的打开逻辑。 -- 决策:`CreationWorkShelfItem.actions.open` 作为作品架打开动作的正式 Interface;Hub 只保留 `onOpenShelfItem` 通知和 `item.actions.open()` 调用,不再读取玩法 kind 做打开分支。 -- 影响范围:创作中心作品架卡片点击、作品架动作 Adapter、后续新增玩法作品架接入。 +- 决策:`CreationWorkShelfItem.actions.open` 作为作品架打开动作的正式 Interface;Hub 只保留 `onOpenShelfItem` 通知和 `item.actions.open()` 调用,不再读取玩法 kind 做打开分支。`buildCreationWorkShelfItemsFromSources` 与 `CreationWorkShelfSourceAdapter` 作为 source registry Interface,统一执行 flatten、运行态覆盖、持久化生成态兜底和更新时间排序;旧 `buildCreationWorkShelfItems` 保留兼容,但内部改为组装 source adapters。 +- 影响范围:创作中心作品架卡片点击、作品架动作 Adapter、source registry、后续新增玩法作品架接入。 - 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`。 diff --git a/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md index a1bbc0c0..b6e954b9 100644 --- a/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md @@ -8,16 +8,18 @@ `CreationWorkShelfItem.actions.open` 是打开作品的正式 **Interface**。`CustomWorldCreationHub.tsx` 只负责卡片点击与 `onOpenShelfItem` 通知,然后调用 `item.actions.open()`,不再根据 `item.source.kind` 分发玩法。 +`buildCreationWorkShelfItemsFromSources` 是作品架 source registry 的正式 **Interface**。每个玩法提供一个 `CreationWorkShelfSourceAdapter`,Adapter 负责把玩法数据、删除权限、打开动作和特殊动作映射为 `CreationWorkShelfItem[]`。registry 统一执行 flatten、运行态覆盖、持久化生成态兜底和更新时间排序。 + 此决策让 `creationWorkShelf.ts` 的 **Module** 更 deep: - **Implementation**:玩法差异、草稿 / 已发布分支、profileId 进入方式和回调绑定都留在 Work Shelf Adapter 内。 -- **Interface**:Hub 只需要 `CreationWorkShelfItem`,不需要知道每种玩法的打开规则。 +- **Interface**:Hub 只需要 `CreationWorkShelfItem`;后续调用方也可只传 `CreationWorkShelfSourceAdapter[]`,不需要知道每种玩法的打开规则、状态覆盖和排序规则。 - **Leverage**:新增玩法时只补 shelf item 映射与 Adapter,Hub 不再新增 switch 分支。 -- **Locality**:作品架点击行为的错误集中在 `creationWorkShelf.ts` 与其测试里定位。 +- **Locality**:作品架点击行为、source flatten、运行态覆盖和排序错误集中在 `creationWorkShelf.ts` 与其测试里定位。 ## 后续深化 -下一步可把 `buildCreationWorkShelfItems` 当前的长参数列表继续收口为 per-kind Source Adapter registry。届时 Hub / 平台壳传入玩法数据源和回调时,可逐步减少按玩法平铺的参数数量。删除、刷新和直达恢复也可沿同一 seam 收口。 +`buildCreationWorkShelfItems` 仍保留旧长参数兼容入口,但其 **Implementation** 已改为组装 `CreationWorkShelfSourceAdapter[]` 后复用 `buildCreationWorkShelfItemsFromSources`。下一步可让 Hub / 平台壳逐步直接传入 source adapters,从而减少按玩法平铺的参数数量。删除、刷新和直达恢复也可沿同一 seam 收口。 ## 验证 diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index e0866d6d..d5d7f1fa 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -5,10 +5,11 @@ import { expect, test, vi } from 'vitest'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { buildCreationWorkShelfItems, + buildCreationWorkShelfItemsFromSources, + type CreationWorkShelfItem, getCreationWorkShelfItemTime, hasBarkBattleRequiredImages, isPersistedBarkBattleDraftGenerating, - type CreationWorkShelfItem, } from './creationWorkShelf'; import { CustomWorldWorkCard } from './CustomWorldWorkCard'; @@ -56,6 +57,86 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code', expect(items[1]?.publicWorkCode).toBeNull(); }); +test('buildCreationWorkShelfItemsFromSources flattens source adapters and applies runtime state', () => { + const [staleRpgItem] = buildCreationWorkShelfItems({ + rpgItems: [ + { + workId: 'draft:rpg-source-adapter', + sourceType: 'agent_session', + status: 'draft', + title: '旧 RPG 草稿', + subtitle: '待完善', + summary: '通过 source adapter 输入。', + coverImageSrc: null, + updatedAt: '2026-05-01T00:00:00.000Z', + publishedAt: null, + stage: 'clarifying', + stageLabel: '待完善', + playableNpcCount: 0, + landmarkCount: 0, + sessionId: 'rpg-source-adapter', + profileId: null, + canResume: true, + canEnterWorld: false, + }, + ], + bigFishItems: [], + puzzleItems: [], + }); + const [freshPuzzleItem] = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [ + { + workId: 'puzzle:source-adapter', + profileId: 'puzzle-source-adapter', + ownerUserId: 'user-1', + authorDisplayName: '拼图作者', + levelName: '新拼图', + summary: '新近拼图。', + themeTags: ['灯塔'], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-05-03T00:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + }, + ], + }); + + const items = buildCreationWorkShelfItemsFromSources({ + sources: [ + { + kind: 'rpg', + buildItems: () => (staleRpgItem ? [staleRpgItem] : []), + }, + { + kind: 'puzzle', + buildItems: () => (freshPuzzleItem ? [freshPuzzleItem] : []), + }, + ], + getItemState: (item) => + item.id === staleRpgItem?.id + ? { + isGenerating: true, + hasUnreadUpdate: true, + titleOverride: '生成中 RPG 草稿', + } + : null, + }); + + expect(items.map((item) => item.id)).toEqual([ + 'puzzle:source-adapter', + 'draft:rpg-source-adapter', + ]); + expect(items[1]?.title).toBe('生成中 RPG 草稿'); + expect(items[1]?.isGenerating).toBe(true); + expect(items[1]?.hasUnreadUpdate).toBe(true); +}); + test('buildCreationWorkShelfItems maps wooden fish items with WF public code', () => { const onOpenWoodenFishDetail = vi.fn(); const woodenFishWork = { diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 1b12420f..eb068ca6 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -2,19 +2,19 @@ import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contrac import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; -import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildBabyObjectMatchPublicWorkCode, - buildCustomWorldPublicWorkCode, buildBarkBattlePublicWorkCode, buildBigFishPublicWorkCode, + buildCustomWorldPublicWorkCode, buildJumpHopPublicWorkCode, buildMatch3DPublicWorkCode, buildPuzzlePublicWorkCode, @@ -157,6 +157,11 @@ export type CreationWorkShelfRuntimeState = { summaryOverride?: string; }; +export type CreationWorkShelfSourceAdapter = { + kind: CreationWorkShelfKind; + buildItems: () => readonly CreationWorkShelfItem[]; +}; + export function buildCreationWorkShelfItems(params: { rpgItems: CustomWorldWorkSummary[]; rpgLibraryEntries?: CustomWorldLibraryEntry[]; @@ -252,70 +257,135 @@ export function buildCreationWorkShelfItems(params: { getItemState, } = params; - return [ - ...rpgItems.map((item) => - mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, { - onOpenDraft: onOpenRpgDraft, - onEnterPublished: onEnterRpgPublished, - onDelete: onDeleteRpg, - }), - ), - ...bigFishItems.map((item) => - mapBigFishWorkToShelfItem(item, canDeleteBigFish, { - onOpen: onOpenBigFishDetail, - onDelete: onDeleteBigFish, - }), - ), - ...match3dItems.map((item) => - mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, { - onOpen: onOpenMatch3DDetail, - onDelete: onDeleteMatch3D, - }), - ), - ...squareHoleItems.map((item) => - mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, { - onOpen: onOpenSquareHoleDetail, - onDelete: onDeleteSquareHole, - }), - ), - ...jumpHopItems.map((item) => - mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, { - onOpen: onOpenJumpHopDetail, - onDelete: onDeleteJumpHop, - }), - ), - ...woodenFishItems.map((item) => - mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, { - onOpen: onOpenWoodenFishDetail, - onDelete: onDeleteWoodenFish, - }), - ), - ...puzzleItems.map((item) => - mapPuzzleWorkToShelfItem(item, canDeletePuzzle, { - onOpen: onOpenPuzzleDetail, - onDelete: onDeletePuzzle, - onClaimPointIncentive: onClaimPuzzlePointIncentive, - }), - ), - ...babyObjectMatchItems.map((item) => - mapBabyObjectMatchDraftToShelfItem(item, canDeleteBabyObjectMatch, { - onOpen: onOpenBabyObjectMatchDetail, - onDelete: onDeleteBabyObjectMatch, - }), - ), - ...mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) => - mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, { - onOpen: onOpenBarkBattleDetail, - onDelete: onDeleteBarkBattle, - }), - ), - ...visualNovelItems.map((item) => - mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, { - onOpen: onOpenVisualNovelDetail, - onDelete: onDeleteVisualNovel, - }), - ), - ] + return buildCreationWorkShelfItemsFromSources({ + sources: [ + { + kind: 'rpg', + buildItems: () => + rpgItems.map((item) => + mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, { + onOpenDraft: onOpenRpgDraft, + onEnterPublished: onEnterRpgPublished, + onDelete: onDeleteRpg, + }), + ), + }, + { + kind: 'big-fish', + buildItems: () => + bigFishItems.map((item) => + mapBigFishWorkToShelfItem(item, canDeleteBigFish, { + onOpen: onOpenBigFishDetail, + onDelete: onDeleteBigFish, + }), + ), + }, + { + kind: 'match3d', + buildItems: () => + match3dItems.map((item) => + mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, { + onOpen: onOpenMatch3DDetail, + onDelete: onDeleteMatch3D, + }), + ), + }, + { + kind: 'square-hole', + buildItems: () => + squareHoleItems.map((item) => + mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, { + onOpen: onOpenSquareHoleDetail, + onDelete: onDeleteSquareHole, + }), + ), + }, + { + kind: 'jump-hop', + buildItems: () => + jumpHopItems.map((item) => + mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, { + onOpen: onOpenJumpHopDetail, + onDelete: onDeleteJumpHop, + }), + ), + }, + { + kind: 'wooden-fish', + buildItems: () => + woodenFishItems.map((item) => + mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, { + onOpen: onOpenWoodenFishDetail, + onDelete: onDeleteWoodenFish, + }), + ), + }, + { + kind: 'puzzle', + buildItems: () => + puzzleItems.map((item) => + mapPuzzleWorkToShelfItem(item, canDeletePuzzle, { + onOpen: onOpenPuzzleDetail, + onDelete: onDeletePuzzle, + onClaimPointIncentive: onClaimPuzzlePointIncentive, + }), + ), + }, + { + kind: 'baby-object-match', + buildItems: () => + babyObjectMatchItems.map((item) => + mapBabyObjectMatchDraftToShelfItem( + item, + canDeleteBabyObjectMatch, + { + onOpen: onOpenBabyObjectMatchDetail, + onDelete: onDeleteBabyObjectMatch, + }, + ), + ), + }, + { + kind: 'bark-battle', + buildItems: () => + mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) => + mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, { + onOpen: onOpenBarkBattleDetail, + onDelete: onDeleteBarkBattle, + }), + ), + }, + { + kind: 'visual-novel', + buildItems: () => + visualNovelItems.map((item) => + mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, { + onOpen: onOpenVisualNovelDetail, + onDelete: onDeleteVisualNovel, + }), + ), + }, + ], + getItemState, + }); +} + +export function buildCreationWorkShelfItemsFromSources(params: { + sources: readonly CreationWorkShelfSourceAdapter[]; + getItemState?: ( + item: CreationWorkShelfItem, + ) => CreationWorkShelfRuntimeState | null; +}) { + const { sources, getItemState } = params; + const sourceItems = sources.reduce( + (items, source) => { + items.push(...source.buildItems()); + return items; + }, + [], + ); + + return sourceItems .map((item) => { const state = getItemState?.(item); const persistedIsGenerating = isPersistedCreationWorkGenerating(item); From 4a185ac8c2a767eee8c9757fed10ea957b18bdf7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 15:58:59 +0800 Subject: [PATCH 04/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20runtime?= =?UTF-8?q?=20client=20=E8=AF=B7=E6=B1=82=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...构】RuntimeClientFamily收口计划-2026-06-03.md | 32 +++++ .../match3d-runtime/match3dRuntimeClient.ts | 106 ++++++--------- src/services/runtimeRequest.test.ts | 88 ++++++++++++ src/services/runtimeRequest.ts | 62 +++++++++ .../squareHoleRuntimeClient.ts | 126 +++++++++--------- 7 files changed, 300 insertions(+), 124 deletions(-) create mode 100644 docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md create mode 100644 src/services/runtimeRequest.test.ts create mode 100644 src/services/runtimeRequest.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 6b02e60d..cadfeb83 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -40,6 +40,14 @@ - 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`。 +## 2026-06-03 Runtime Client Family 请求骨架收口 + +- 背景:Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options,新增玩法容易遗漏同一请求骨架。 +- 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。 +- 影响范围:`src/services/runtimeRequest.ts`、Match3D runtime client、SquareHole runtime client、后续 Puzzle / Jump Hop / Visual Novel / Bark Battle / Big Fish runtime client 迁移。 +- 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`。 + ## 2026-06-03 最近创作只复用创作模板入口 - 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。 diff --git a/docs/README.md b/docs/README.md index 5bb6fbd1..374f24a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,Hub 不再按玩法 `kind` 分发,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D 与 SquareHole 已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 diff --git a/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md new file mode 100644 index 00000000..4c055fc3 --- /dev/null +++ b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md @@ -0,0 +1,32 @@ +# 【前端架构】Runtime Client Family 收口计划 + +## 背景 + +多个小游戏 runtime client 都重复实现路径编码、JSON header / body、runtime guest token、认证影响策略和重试参数。重复逻辑分散在各玩法文件后,新增玩法容易遗漏 guest auth 或 retry 语义,也让测试必须逐玩法检查同一请求骨架。 + +## 决策 + +新增 `src/services/runtimeRequest.ts`,作为 Runtime Client Family 的请求 **Module**。其 **Interface** 包含: + +- `buildRuntimeApiPath(basePath, ...segments)`:统一对 runtime path segment 执行 `encodeURIComponent`。 +- `requestRuntimeJson(params)`:统一设置 method、JSON body、`Content-Type`、runtime guest `Authorization`、auth options 和 retry options。 + +`match3dRuntimeClient.ts` 与 `squareHoleRuntimeClient.ts` 已迁入此 **Module**,并保留原有导出函数名、错误文案、返回契约和重试常量。点击 / 投入等玩法专属返回映射仍留在各自 client 内,避免把领域规则塞进通用请求 **Implementation**。 + +## 约定 + +- Runtime client 不再手写 `encodeURIComponent` 拼 path;应优先使用 `buildRuntimeApiPath`。 +- Runtime JSON 请求不再手写 `Content-Type`、guest `Authorization` 和 `buildRuntimeGuestAuthOptions` 合并;应优先使用 `requestRuntimeJson`。 +- 玩法专属 payload 归一化、返回值适配和中文错误文案仍属于各玩法 client。 +- 每迁移一个 client,必须保留原导出函数名与原调用方契约。 + +## 后续深化 + +下一批可迁移 Puzzle、Jump Hop、Visual Novel、Bark Battle 与 Big Fish runtime client。迁移顺序以测试覆盖和请求形状接近度为准,优先迁移已有 guest launch 或 client 单测覆盖的函数。 + +## 验证 + +- `npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/src/services/match3d-runtime/match3dRuntimeClient.ts b/src/services/match3d-runtime/match3dRuntimeClient.ts index f1b0b5ec..721306c5 100644 --- a/src/services/match3d-runtime/match3dRuntimeClient.ts +++ b/src/services/match3d-runtime/match3dRuntimeClient.ts @@ -10,14 +10,11 @@ import type { } from '../../../packages/shared/src/contracts/match3dRuntime'; import { type ApiRetryOptions, - requestJson, } from '../apiClient'; -import { - buildRuntimeGuestAuthOptions, - buildRuntimeGuestHeaders, - type RuntimeGuestRequestOptions, -} from '../runtimeGuestAuth'; +import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth'; +import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; +const MATCH3D_RUNTIME_API_BASE = '/api/runtime/match3d'; const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = { maxRetries: 1, baseDelayMs: 120, @@ -74,39 +71,30 @@ export function startMatch3DRun( profileId: string, options: Match3DRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); const payload: StartMatch3DRunRequest = { profileId, itemTypeCountOverride: options.itemTypeCountOverride ?? null, }; - return requestJson( - `/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`, - { - method: 'POST', - headers: buildRuntimeGuestHeaders(options, { - 'Content-Type': 'application/json', - }), - body: JSON.stringify(payload), - }, - '启动抓大鹅玩法失败', - { - retry: MATCH3D_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'works', profileId, 'runs'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '启动抓大鹅玩法失败', + retry: MATCH3D_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } /** * 读取抓大鹅运行态快照。 */ export function getMatch3DRun(runId: string) { - return requestJson( - `/api/runtime/match3d/runs/${encodeURIComponent(runId)}`, - { method: 'GET' }, - '读取抓大鹅运行快照失败', - { retry: MATCH3D_RUNTIME_READ_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId), + fallbackMessage: '读取抓大鹅运行快照失败', + retry: MATCH3D_RUNTIME_READ_RETRY, + }); } /** @@ -116,19 +104,16 @@ export async function clickMatch3DItem( runId: string, payload: Match3DClickItemRequest, ) { - const response = await requestJson( - `/api/runtime/match3d/runs/${encodeURIComponent(runId)}/click`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...payload, - runId: payload.runId ?? runId, - }), + const response = await requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'click'), + method: 'POST', + jsonBody: { + ...payload, + runId: payload.runId ?? runId, }, - '确认抓大鹅点击失败', - { retry: MATCH3D_RUNTIME_WRITE_RETRY }, - ); + fallbackMessage: '确认抓大鹅点击失败', + retry: MATCH3D_RUNTIME_WRITE_RETRY, + }); return mapClickConfirmation(payload, response.confirmation); } @@ -142,40 +127,37 @@ export function stopMatch3DRun( clientActionId: `match3d-stop-${Date.now()}`, }, ) { - return requestJson( - `/api/runtime/match3d/runs/${encodeURIComponent(runId)}/stop`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - '停止抓大鹅玩法失败', - { retry: MATCH3D_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'stop'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '停止抓大鹅玩法失败', + retry: MATCH3D_RUNTIME_WRITE_RETRY, + }); } /** * 基于当前 run 重开一局。 */ export function restartMatch3DRun(runId: string) { - return requestJson( - `/api/runtime/match3d/runs/${encodeURIComponent(runId)}/restart`, - { method: 'POST' }, - '重新开始抓大鹅玩法失败', - { retry: MATCH3D_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'restart'), + method: 'POST', + fallbackMessage: '重新开始抓大鹅玩法失败', + retry: MATCH3D_RUNTIME_WRITE_RETRY, + }); } /** * 前端倒计时归零后通知后端确认失败状态。 */ export function finishMatch3DTimeUp(runId: string) { - return requestJson( - `/api/runtime/match3d/runs/${encodeURIComponent(runId)}/time-up`, - { method: 'POST' }, - '同步抓大鹅倒计时失败', - { retry: MATCH3D_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'time-up'), + method: 'POST', + fallbackMessage: '同步抓大鹅倒计时失败', + retry: MATCH3D_RUNTIME_WRITE_RETRY, + }); } export const match3dRuntimeClient = { diff --git a/src/services/runtimeRequest.test.ts b/src/services/runtimeRequest.test.ts new file mode 100644 index 00000000..a771f5f1 --- /dev/null +++ b/src/services/runtimeRequest.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiClientMocks = vi.hoisted(() => ({ + requestJson: vi.fn(), +})); + +vi.mock('./apiClient', async () => { + const actual = + await vi.importActual('./apiClient'); + return { + ...actual, + requestJson: apiClientMocks.requestJson, + }; +}); + +import { + buildRuntimeApiPath, + requestRuntimeJson, +} from './runtimeRequest'; + +describe('runtimeRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiClientMocks.requestJson.mockResolvedValue({ ok: true }); + }); + + it('builds encoded runtime api paths', () => { + expect(buildRuntimeApiPath('/api/runtime/demo/', 'work/a b', 'run/1')).toBe( + '/api/runtime/demo/work%2Fa%20b/run%2F1', + ); + }); + + it('sends json runtime requests with guest auth and retry options', async () => { + const retry = { maxRetries: 1, retryUnsafeMethods: true }; + + await requestRuntimeJson({ + url: '/api/runtime/demo/runs', + method: 'POST', + jsonBody: { profileId: 'profile-1' }, + fallbackMessage: '启动失败', + retry, + requestOptions: { + runtimeGuestToken: 'runtime-guest-token', + }, + }); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/runtime/demo/runs', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer runtime-guest-token', + }, + body: JSON.stringify({ profileId: 'profile-1' }), + }, + '启动失败', + { + retry, + authImpact: undefined, + skipAuth: true, + skipRefresh: true, + notifyAuthStateChange: undefined, + clearAuthOnUnauthorized: undefined, + }, + ); + }); + + it('omits empty headers and body for plain runtime reads', async () => { + await requestRuntimeJson({ + url: '/api/runtime/demo/runs/run-1', + fallbackMessage: '读取失败', + }); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/runtime/demo/runs/run-1', + { method: 'GET' }, + '读取失败', + { + authImpact: undefined, + skipAuth: undefined, + skipRefresh: undefined, + notifyAuthStateChange: undefined, + clearAuthOnUnauthorized: undefined, + }, + ); + }); +}); diff --git a/src/services/runtimeRequest.ts b/src/services/runtimeRequest.ts new file mode 100644 index 00000000..8a0465f4 --- /dev/null +++ b/src/services/runtimeRequest.ts @@ -0,0 +1,62 @@ +import { + type ApiRetryOptions, + requestJson, +} from './apiClient'; +import { + buildRuntimeGuestAuthOptions, + buildRuntimeGuestHeaders, + type RuntimeGuestRequestOptions, +} from './runtimeGuestAuth'; + +export type RuntimeJsonRequestParams = { + url: string; + method?: string; + jsonBody?: unknown; + headers?: Record; + fallbackMessage: string; + retry?: ApiRetryOptions; + requestOptions?: RuntimeGuestRequestOptions; +}; + +export function buildRuntimeApiPath( + basePath: string, + ...segments: string[] +) { + const normalizedBasePath = basePath.endsWith('/') + ? basePath.slice(0, -1) + : basePath; + return [ + normalizedBasePath, + ...segments.map((segment) => encodeURIComponent(segment)), + ].join('/'); +} + +export function requestRuntimeJson(params: RuntimeJsonRequestParams) { + const { + fallbackMessage, + headers = {}, + jsonBody, + method = 'GET', + requestOptions = {}, + retry, + url, + } = params; + const hasJsonBody = jsonBody !== undefined; + const requestHeaders = buildRuntimeGuestHeaders(requestOptions, { + ...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}), + ...headers, + }); + const init: RequestInit = { + method, + ...(Object.keys(requestHeaders).length > 0 + ? { headers: requestHeaders } + : {}), + ...(hasJsonBody ? { body: JSON.stringify(jsonBody) } : {}), + }; + const authOptions = buildRuntimeGuestAuthOptions(requestOptions); + + return requestJson(url, init, fallbackMessage, { + ...(retry ? { retry } : {}), + ...authOptions, + }); +} diff --git a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts index 083c9dec..e26d7907 100644 --- a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts +++ b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts @@ -6,14 +6,11 @@ import type { } from '../../../packages/shared/src/contracts/squareHoleRuntime'; import { type ApiRetryOptions, - requestJson, } from '../apiClient'; -import { - buildRuntimeGuestAuthOptions, - buildRuntimeGuestHeaders, - type RuntimeGuestRequestOptions, -} from '../runtimeGuestAuth'; +import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth'; +import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; +const SQUARE_HOLE_RUNTIME_API_BASE = '/api/runtime/square-hole'; const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = { maxRetries: 1, baseDelayMs: 120, @@ -34,34 +31,30 @@ export function startSquareHoleRun( profileId: string, options: SquareHoleRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`, - { - method: 'POST', - headers: buildRuntimeGuestHeaders(options, { - 'Content-Type': 'application/json', - }), - body: JSON.stringify({ profileId }), - }, - '启动方洞挑战失败', - { - retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + SQUARE_HOLE_RUNTIME_API_BASE, + 'works', + profileId, + 'runs', + ), + method: 'POST', + jsonBody: { profileId }, + fallbackMessage: '启动方洞挑战失败', + retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } /** * 读取方洞挑战运行态快照。 */ export function getSquareHoleRun(runId: string) { - return requestJson( - `/api/runtime/square-hole/runs/${encodeURIComponent(runId)}`, - { method: 'GET' }, - '读取方洞挑战运行快照失败', - { retry: SQUARE_HOLE_RUNTIME_READ_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId), + fallbackMessage: '读取方洞挑战运行快照失败', + retry: SQUARE_HOLE_RUNTIME_READ_RETRY, + }); } /** @@ -71,19 +64,21 @@ export function dropSquareHoleShape( runId: string, payload: DropSquareHoleShapeRequest, ) { - return requestJson( - `/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/drop`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...payload, - runId: payload.runId ?? runId, - }), + return requestRuntimeJson({ + url: buildRuntimeApiPath( + SQUARE_HOLE_RUNTIME_API_BASE, + 'runs', + runId, + 'drop', + ), + method: 'POST', + jsonBody: { + ...payload, + runId: payload.runId ?? runId, }, - '确认方洞挑战投入失败', - { retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY }, - ); + fallbackMessage: '确认方洞挑战投入失败', + retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + }); } /** @@ -95,40 +90,47 @@ export function stopSquareHoleRun( clientActionId: `square-hole-stop-${Date.now()}`, }, ) { - return requestJson( - `/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/stop`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - '停止方洞挑战失败', - { retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId, 'stop'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '停止方洞挑战失败', + retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + }); } /** * 基于当前 run 重开一局。 */ export function restartSquareHoleRun(runId: string) { - return requestJson( - `/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/restart`, - { method: 'POST' }, - '重新开始方洞挑战失败', - { retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + SQUARE_HOLE_RUNTIME_API_BASE, + 'runs', + runId, + 'restart', + ), + method: 'POST', + fallbackMessage: '重新开始方洞挑战失败', + retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + }); } /** * 前端倒计时归零后通知后端确认失败状态。 */ export function finishSquareHoleTimeUp(runId: string) { - return requestJson( - `/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/time-up`, - { method: 'POST' }, - '同步方洞挑战倒计时失败', - { retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + SQUARE_HOLE_RUNTIME_API_BASE, + 'runs', + runId, + 'time-up', + ), + method: 'POST', + fallbackMessage: '同步方洞挑战倒计时失败', + retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + }); } export const squareHoleRuntimeClient = { From e9534baacefd85b33194e087b94dbda79689aaf4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 16:23:11 +0800 Subject: [PATCH 05/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E4=BD=9C=E5=93=81=20ViewModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...】PublicGalleryViewModel收口计划-2026-06-03.md | 38 ++ src/components/rpg-entry/RpgEntryHomeView.tsx | 438 +----------------- .../rpgEntryPublicGalleryViewModel.test.ts | 327 +++++++++++++ .../rpgEntryPublicGalleryViewModel.ts | 433 +++++++++++++++++ 6 files changed, 833 insertions(+), 413 deletions(-) create mode 100644 docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md create mode 100644 src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts create mode 100644 src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index cadfeb83..9c280b36 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -48,6 +48,14 @@ - 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`。 +## 2026-06-03 Public Gallery ViewModel 收口 + +- 背景:`RpgEntryHomeView.tsx` 巨型页面内混合了公开作品分类、跨来源去重、搜索归一化、作品号匹配、时间戳解析和排序规则,新增玩法时页面与 ViewModel 规则容易纠缠。 +- 决策:新增 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,把 `buildPublicGalleryCardKey`、`buildPublicCategoryGroups`、`getPlatformPublicEntries`、`getAllPlatformPublicEntries`、`getPlatformSearchableWorkIds`、`filterPlatformWorkSearchResults`、`isExactPublicWorkCodeSearch`、`filterTodayPublishedEntries`、公开卡片指标 getter、`buildPlatformRankingEntries`、`getPlatformRankingMetricValue`、`getPlatformCategoryKindFilter`、`matchesPlatformCategoryKindFilter`、`sortPlatformCategoryEntries`、`getPlatformCategoryPrimaryMetric`、`parsePlatformEntryTimestamp` 和 `getPlatformWorldTimestamp` 收口为公开作品 ViewModel Interface。公开作品 key 复用平台入口身份规则,补齐 jump-hop / wooden-fish 等玩法区分。 +- 影响范围:RPG 首页公开作品发现、分类、搜索、排行数据准备,以及后续新增玩法公开卡片接入。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`。 + ## 2026-06-03 最近创作只复用创作模板入口 - 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。 diff --git a/docs/README.md b/docs/README.md index 374f24a9..39054240 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D 与 SquareHole 已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +公开作品分类、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 diff --git a/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md new file mode 100644 index 00000000..814cb807 --- /dev/null +++ b/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md @@ -0,0 +1,38 @@ +# 【前端架构】Public Gallery ViewModel 收口计划 + +## 背景 + +`RpgEntryHomeView.tsx` 同时承担首页、发现、分类、排行、搜索和公开作品卡片渲染。公开作品的 category 分组、跨来源去重、搜索归一化、作品号匹配、时间戳解析和列表排序原本都放在页面巨型 **Implementation** 中,导致公开作品规则与 JSX 交错,新增玩法时难以判断该改页面、卡片还是平台入口规则。 + +## 决策 + +新增 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,作为公开作品 ViewModel **Module**。该 **Module** 的 **Interface** 收口为: + +- `buildPublicGalleryCardKey(entry)`:复用平台公开作品身份规则,补齐 jump-hop / wooden-fish 等玩法 key。 +- `buildPublicCategoryGroups(featuredEntries, latestEntries)`:统一去重、标签兜底和分类排序。 +- `getPlatformPublicEntries(featuredEntries, latestEntries)` / `getAllPlatformPublicEntries(featuredEntries, latestEntries)`:统一公开作品合并规则。 +- `getPlatformSearchableWorkIds(entry)`、`filterPlatformWorkSearchResults(entries, keyword)` 与 `isExactPublicWorkCodeSearch(entries, keyword)`:统一搜索归一化、compact code 匹配和排序。 +- `parsePlatformEntryTimestamp(value)` / `getPlatformWorldTimestamp(entry)`:统一兼容 ISO 与后端 seconds.microsZ 时间戳。 +- `filterTodayPublishedEntries(entries)`:统一“今日游戏”本地自然日筛选。 +- `getPlatformWorldLikeCount(entry)` / `getPlatformWorldPlayCount(entry)` / `getPlatformWorldRemixCount(entry)`、`buildPlatformRankingEntries(entries, tab)` 与 `getPlatformRankingMetricValue(entry, tab)`:统一公开卡片指标读取、排行 Tab 排序与取值。 +- `getPlatformCategoryKindFilter(entry)`、`matchesPlatformCategoryKindFilter(entry, kindFilter)`、`sortPlatformCategoryEntries(entries, sortMode)` 与 `getPlatformCategoryPrimaryMetric(entry)`:统一分类频道的玩法过滤、排序和主指标展示。 + +`RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留渲染、事件处理和账号状态。公开作品规则的 **Locality** 转移到 ViewModel **Module** 与其测试,页面不再持有这批纯规则。 + +## 约定 + +- 公开作品身份 key 与平台入口推荐流保持一致,优先复用 `platformPublicGalleryFlow`。 +- 搜索应同时匹配作品号、`profileId`、`workId`、标题、作者、摘要和副标题。 +- 搜索排序先看标题前缀,再看作品号 compact 前缀,最后按发布时间 / 更新时间倒序。 +- 时间解析必须保留后端 `seconds.microsZ` 兼容。 + +## 后续深化 + +下一步可把移动 / 桌面 discover feed 的数据准备继续迁入 ViewModel,但卡片 JSX 与交互状态仍留页面内。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 6d5a5a8d..1511bb8f 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -14,9 +14,9 @@ import { Gamepad2, GitFork, Heart, + Loader2, LogIn, MessageCircle, - Loader2, Palette, Pencil, Plus, @@ -135,6 +135,27 @@ import { import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; +import { + buildPlatformRankingEntries, + buildPublicCategoryGroups, + buildPublicGalleryCardKey, + filterPlatformWorkSearchResults, + filterTodayPublishedEntries, + getAllPlatformPublicEntries, + getPlatformCategoryPrimaryMetric, + getPlatformPublicEntries, + getPlatformRankingMetricValue, + getPlatformSearchableWorkIds, + getPlatformWorldLikeCount, + getPlatformWorldPlayCount, + getPlatformWorldRemixCount, + isExactPublicWorkCodeSearch, + matchesPlatformCategoryKindFilter, + type PlatformCategoryKindFilter, + type PlatformCategorySortMode, + type PlatformRankingTab, + sortPlatformCategoryEntries, +} from './rpgEntryPublicGalleryViewModel'; import { buildPlatformWorldDisplayTags, describePlatformThemeLabel, @@ -151,9 +172,8 @@ import { isVisualNovelGalleryEntry, isWoodenFishGalleryEntry, type PlatformPublicGalleryCard, - type PlatformWorldCardLike, - resolvePlatformWorkAuthorDisplayName, resolvePlatformPublicWorkCode, + resolvePlatformWorkAuthorDisplayName, resolvePlatformWorldCoverImage, resolvePlatformWorldCoverSlides, resolvePlatformWorldFallbackCoverImage, @@ -361,17 +381,6 @@ type DiscoverChannel = | 'category' | 'ranking' | 'edutainment'; -type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like'; -type PlatformCategoryKindFilter = - | 'all' - | 'puzzle' - | 'match3d' - | 'square-hole' - | 'visual-novel' - | 'bark-battle' - | 'big-fish' - | 'custom-world'; -type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like'; const COMMUNITY_QR_CODES = [ { @@ -410,6 +419,8 @@ const PLATFORM_CATEGORY_KIND_FILTERS: Array<{ { id: 'visual-novel', label: '视觉' }, { id: 'bark-battle', label: '汪汪' }, { id: 'big-fish', label: '大鱼' }, + { id: 'jump-hop', label: '跳跃' }, + { id: 'wooden-fish', label: '木鱼' }, { id: 'custom-world', label: 'RPG' }, ]; const PLATFORM_CATEGORY_SORT_OPTIONS: Array<{ @@ -1639,186 +1650,6 @@ function PlatformCategoryFilterDialog({ ); } -function buildPublicCategoryGroups( - featuredEntries: PlatformPublicGalleryCard[], - latestEntries: PlatformPublicGalleryCard[], -) { - const publicEntryMap = new Map(); - - filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach( - (entry) => { - publicEntryMap.set(buildPublicGalleryCardKey(entry), entry); - }, - ); - - const categoryMap = new Map(); - Array.from(publicEntryMap.values()).forEach((entry) => { - const tags = buildPlatformWorldDisplayTags(entry, 3); - const normalizedTags = tags.length > 0 ? tags : ['回响']; - - normalizedTags.forEach((tag) => { - const entries = categoryMap.get(tag) ?? []; - entries.push(entry); - categoryMap.set(tag, entries); - }); - }); - - return Array.from(categoryMap.entries()) - .map(([tag, entries]) => ({ tag, entries })) - .sort((left, right) => { - if (right.entries.length !== left.entries.length) { - return right.entries.length - left.entries.length; - } - - return left.tag.localeCompare(right.tag, 'zh-CN'); - }); -} - -function getPlatformPublicEntries( - featuredEntries: PlatformPublicGalleryCard[], - latestEntries: PlatformPublicGalleryCard[], -) { - const entryMap = new Map(); - - filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach( - (entry) => { - entryMap.set(buildPublicGalleryCardKey(entry), entry); - }, - ); - - return Array.from(entryMap.values()); -} - -function getAllPlatformPublicEntries( - featuredEntries: PlatformPublicGalleryCard[], - latestEntries: PlatformPublicGalleryCard[], -) { - const entryMap = new Map(); - - [...featuredEntries, ...latestEntries].forEach((entry) => { - entryMap.set(buildPublicGalleryCardKey(entry), entry); - }); - - return Array.from(entryMap.values()); -} - -function normalizePlatformSearchText(value: string | null | undefined) { - return (value ?? '').trim().toLocaleLowerCase('zh-CN'); -} - -function normalizePlatformCompactSearchText(value: string | null | undefined) { - return normalizePlatformSearchText(value).replace(/[\s_-]+/gu, ''); -} - -function getPlatformSearchableWorkIds(entry: PlatformPublicGalleryCard) { - const ids = [entry.publicWorkCode, entry.profileId]; - if ('workId' in entry) { - ids.push(entry.workId); - } - - return ids.filter((value): value is string => Boolean(value?.trim())); -} - -function buildPlatformWorkSearchText(entry: PlatformPublicGalleryCard) { - return [ - ...getPlatformSearchableWorkIds(entry), - entry.worldName, - entry.authorDisplayName, - entry.summaryText, - entry.subtitle, - ].join(' '); -} - -function matchesPlatformWorkSearch( - entry: PlatformPublicGalleryCard, - keyword: string, -) { - const normalizedKeyword = normalizePlatformSearchText(keyword); - const compactKeyword = normalizePlatformCompactSearchText(keyword); - if (!normalizedKeyword) { - return false; - } - - const normalizedSearchText = normalizePlatformSearchText( - buildPlatformWorkSearchText(entry), - ); - if (normalizedSearchText.includes(normalizedKeyword)) { - return true; - } - - return ( - Boolean(compactKeyword) && - normalizePlatformCompactSearchText( - buildPlatformWorkSearchText(entry), - ).includes(compactKeyword) - ); -} - -function filterPlatformWorkSearchResults( - entries: PlatformPublicGalleryCard[], - keyword: string, -) { - return entries - .filter((entry) => matchesPlatformWorkSearch(entry, keyword)) - .sort((left, right) => { - const leftCode = getPlatformSearchableWorkIds(left)[0] ?? ''; - const rightCode = getPlatformSearchableWorkIds(right)[0] ?? ''; - const normalizedKeyword = normalizePlatformSearchText(keyword); - const leftNameStarts = normalizePlatformSearchText( - left.worldName, - ).startsWith(normalizedKeyword); - const rightNameStarts = normalizePlatformSearchText( - right.worldName, - ).startsWith(normalizedKeyword); - if (leftNameStarts !== rightNameStarts) { - return leftNameStarts ? -1 : 1; - } - - const leftCodeStarts = normalizePlatformCompactSearchText( - leftCode, - ).startsWith(normalizePlatformCompactSearchText(keyword)); - const rightCodeStarts = normalizePlatformCompactSearchText( - rightCode, - ).startsWith(normalizePlatformCompactSearchText(keyword)); - if (leftCodeStarts !== rightCodeStarts) { - return leftCodeStarts ? -1 : 1; - } - - return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left); - }); -} - -function isExactPublicWorkCodeSearch( - entries: PlatformPublicGalleryCard[], - keyword: string, -) { - const normalizedKeyword = normalizePlatformSearchText(keyword); - return entries.some( - (entry) => - Boolean(entry.publicWorkCode?.trim()) && - normalizePlatformSearchText(entry.publicWorkCode) === normalizedKeyword, - ); -} - -function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) { - const kind = isBigFishGalleryEntry(entry) - ? 'big-fish' - : isPuzzleGalleryEntry(entry) - ? 'puzzle' - : isMatch3DGalleryEntry(entry) - ? 'match3d' - : isSquareHoleGalleryEntry(entry) - ? 'square-hole' - : isVisualNovelGalleryEntry(entry) - ? 'visual-novel' - : isBarkBattleGalleryEntry(entry) - ? 'bark-battle' - : isEdutainmentGalleryEntry(entry) - ? `edutainment:${entry.templateId}` - : 'rpg'; - return `${kind}:${entry.ownerUserId}:${entry.profileId}`; -} - function PlatformWorkSearchResults({ keyword, entries, @@ -1950,225 +1781,6 @@ function getPublicAuthorAvatarLabel(authorDisplayName: string) { return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩'; } -function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) { - return Math.max(0, Math.round(entry.likeCount ?? 0)); -} - -function getPlatformWorldPlayCount(entry: PlatformWorldCardLike) { - return Math.max( - 0, - Math.round(('playCount' in entry && entry.playCount) || 0), - ); -} - -function getPlatformWorldRemixCount(entry: PlatformWorldCardLike) { - return Math.max( - 0, - Math.round(('remixCount' in entry && entry.remixCount) || 0), - ); -} - -function getPlatformWorldRecentPlayCount(entry: PlatformWorldCardLike) { - return Math.max( - 0, - Math.round(('recentPlayCount7d' in entry && entry.recentPlayCount7d) || 0), - ); -} - -function getPlatformWorldTimestamp(entry: PlatformWorldCardLike) { - const rawTime = entry.publishedAt ?? entry.updatedAt; - return parsePlatformEntryTimestamp(rawTime); -} - -// 首页“今日游戏”只看作品首次发布时间,按玩家浏览器本地自然日判断。 -function parsePlatformEntryTimestamp(value: string | null | undefined) { - if (!value) { - return 0; - } - - const normalized = value.trim(); - const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u); - if (numericTimestamp?.[1]) { - const rawTimestamp = Number(numericTimestamp[1]); - if (Number.isFinite(rawTimestamp)) { - const absoluteTimestamp = Math.abs(rawTimestamp); - const timestampMs = - absoluteTimestamp >= 1_000_000_000_000_000 - ? rawTimestamp / 1000 - : absoluteTimestamp >= 1_000_000_000_000 - ? rawTimestamp - : absoluteTimestamp >= 1_000_000_000 - ? rawTimestamp * 1000 - : Number.NaN; - return Number.isNaN(timestampMs) ? 0 : timestampMs; - } - } - - const timestamp = new Date(normalized).getTime(); - return Number.isNaN(timestamp) ? 0 : timestamp; -} - -function isSameLocalCalendarDay(left: Date, right: Date) { - return ( - left.getFullYear() === right.getFullYear() && - left.getMonth() === right.getMonth() && - left.getDate() === right.getDate() - ); -} - -function isPublishedToday(entry: PlatformPublicGalleryCard, now = new Date()) { - const publishedAtTimestamp = parsePlatformEntryTimestamp(entry.publishedAt); - if (publishedAtTimestamp <= 0) { - return false; - } - - const publishedAt = new Date(publishedAtTimestamp); - return isSameLocalCalendarDay(publishedAt, now); -} - -function filterTodayPublishedEntries(entries: PlatformPublicGalleryCard[]) { - const now = new Date(); - return entries.filter((entry) => isPublishedToday(entry, now)); -} - -function sortEntriesByMetric( - entries: PlatformPublicGalleryCard[], - getMetric: (entry: PlatformPublicGalleryCard) => number, -) { - return [...entries].sort((left, right) => { - const metricDiff = getMetric(right) - getMetric(left); - if (metricDiff !== 0) { - return metricDiff; - } - - return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left); - }); -} - -function buildPlatformRankingEntries( - entries: PlatformPublicGalleryCard[], - tab: PlatformRankingTab, -) { - if (tab === 'hot') { - return sortEntriesByMetric(entries, getPlatformWorldPlayCount); - } - - if (tab === 'remix') { - return sortEntriesByMetric(entries, getPlatformWorldRemixCount); - } - - if (tab === 'like') { - return sortEntriesByMetric(entries, getPlatformWorldLikeCount); - } - - return sortEntriesByMetric(entries, getPlatformWorldRecentPlayCount); -} - -function getPlatformRankingMetricValue( - entry: PlatformPublicGalleryCard, - tab: PlatformRankingTab, -) { - if (tab === 'remix') { - return getPlatformWorldRemixCount(entry); - } - - if (tab === 'like') { - return getPlatformWorldLikeCount(entry); - } - - if (tab === 'new') { - return getPlatformWorldRecentPlayCount(entry); - } - - return getPlatformWorldPlayCount(entry); -} - -function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) { - // 分类频道只使用公开读模型已经返回的指标做前端排序,不在 UI 层伪造评分数据。 - return ( - getPlatformWorldPlayCount(entry) + - getPlatformWorldRemixCount(entry) + - getPlatformWorldLikeCount(entry) + - getPlatformWorldRecentPlayCount(entry) - ); -} - -function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) { - if (isPuzzleGalleryEntry(entry)) { - return 'puzzle'; - } - - if (isMatch3DGalleryEntry(entry)) { - return 'match3d'; - } - - if (isSquareHoleGalleryEntry(entry)) { - return 'square-hole'; - } - - if (isVisualNovelGalleryEntry(entry)) { - return 'visual-novel'; - } - - if (isBarkBattleGalleryEntry(entry)) { - return 'bark-battle'; - } - - if (isBigFishGalleryEntry(entry)) { - return 'big-fish'; - } - - return 'custom-world'; -} - -function matchesPlatformCategoryKindFilter( - entry: PlatformPublicGalleryCard, - kindFilter: PlatformCategoryKindFilter, -) { - return ( - kindFilter === 'all' || getPlatformCategoryKindFilter(entry) === kindFilter - ); -} - -function sortPlatformCategoryEntries( - entries: PlatformPublicGalleryCard[], - sortMode: PlatformCategorySortMode, -) { - return [...entries].sort((left, right) => { - if (sortMode === 'latest') { - return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left); - } - - const metricDiff = - sortMode === 'play' - ? getPlatformWorldPlayCount(right) - getPlatformWorldPlayCount(left) - : sortMode === 'like' - ? getPlatformWorldLikeCount(right) - getPlatformWorldLikeCount(left) - : getPlatformCategoryCompositeScore(right) - - getPlatformCategoryCompositeScore(left); - - if (metricDiff !== 0) { - return metricDiff; - } - - return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left); - }); -} - -function getPlatformCategoryPrimaryMetric(entry: PlatformPublicGalleryCard) { - const likeCount = getPlatformWorldLikeCount(entry); - if (likeCount > 0) { - return { label: '点赞', value: likeCount }; - } - - const recentPlayCount = getPlatformWorldRecentPlayCount(entry); - if (recentPlayCount > 0) { - return { label: '近7日', value: recentPlayCount }; - } - - return { label: '游玩', value: getPlatformWorldPlayCount(entry) }; -} - function formatCompactCount(value: number) { const normalizedValue = Math.max(0, Math.round(value)); if (normalizedValue >= 100000000) { diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts new file mode 100644 index 00000000..729ca058 --- /dev/null +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts @@ -0,0 +1,327 @@ +import { expect, test } from 'vitest'; + +import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import { + buildPlatformRankingEntries, + buildPublicCategoryGroups, + buildPublicGalleryCardKey, + filterPlatformWorkSearchResults, + filterTodayPublishedEntries, + getPlatformCategoryKindFilter, + getPlatformCategoryPrimaryMetric, + getPlatformPublicEntries, + getPlatformRankingMetricValue, + matchesPlatformCategoryKindFilter, + parsePlatformEntryTimestamp, + sortPlatformCategoryEntries, +} from './rpgEntryPublicGalleryViewModel'; +import type { + PlatformJumpHopGalleryCard, + PlatformPuzzleGalleryCard, + PlatformWoodenFishGalleryCard, +} from './rpgEntryWorldPresentation'; + +function buildPuzzleEntry( + overrides: Partial = {}, +): PlatformPuzzleGalleryCard { + return { + sourceType: 'puzzle', + workId: 'puzzle-work', + profileId: 'shared-profile', + publicWorkCode: 'PZ-SHARED', + ownerUserId: 'user-1', + authorDisplayName: '拼图作者', + worldName: '星桥拼图', + subtitle: '拼图副标题', + summaryText: '星桥机关摘要', + coverImageSrc: null, + themeTags: ['星桥', '机关'], + visibility: 'published', + publishedAt: '2026-05-01T00:00:00.000Z', + updatedAt: '2026-05-01T00:00:00.000Z', + ...overrides, + }; +} + +function buildJumpHopEntry( + overrides: Partial = {}, +): PlatformJumpHopGalleryCard { + return { + sourceType: 'jump-hop', + workId: 'jump-hop-work', + profileId: 'shared-profile', + publicWorkCode: 'JH-SHARED', + ownerUserId: 'user-1', + authorDisplayName: '跳一跳作者', + worldName: '星桥跳一跳', + subtitle: '跳一跳副标题', + summaryText: '跳一跳摘要', + coverImageSrc: null, + themeTags: ['跳跃'], + visibility: 'published', + publishedAt: '2026-05-02T00:00:00.000Z', + updatedAt: '2026-05-02T00:00:00.000Z', + ...overrides, + }; +} + +function buildWoodenFishEntry( + overrides: Partial = {}, +): PlatformWoodenFishGalleryCard { + return { + sourceType: 'wooden-fish', + workId: 'wooden-fish-work', + profileId: 'shared-profile', + publicWorkCode: 'WF-SHARED', + ownerUserId: 'user-1', + authorDisplayName: '木鱼作者', + worldName: '星桥木鱼', + subtitle: '木鱼副标题', + summaryText: '木鱼摘要', + coverImageSrc: null, + themeTags: ['敲木鱼'], + visibility: 'published', + publishedAt: '2026-05-03T00:00:00.000Z', + updatedAt: '2026-05-03T00:00:00.000Z', + ...overrides, + }; +} + +function buildRpgEntry( + overrides: Partial = {}, +): CustomWorldGalleryCard { + return { + ownerUserId: 'user-1', + profileId: 'shared-profile', + publicWorkCode: 'CW-SHARED', + authorPublicUserCode: null, + visibility: 'published', + publishedAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + authorDisplayName: 'RPG 作者', + worldName: '星桥 RPG', + subtitle: 'RPG 副标题', + summaryText: 'RPG 摘要', + coverImageSrc: null, + themeMode: 'martial', + playableNpcCount: 1, + landmarkCount: 1, + ...overrides, + }; +} + +test('public gallery ViewModel keeps play kinds distinct in card keys', () => { + expect(buildPublicGalleryCardKey(buildPuzzleEntry())).toBe( + 'puzzle:user-1:shared-profile', + ); + expect(buildPublicGalleryCardKey(buildJumpHopEntry())).toBe( + 'jump-hop:user-1:shared-profile', + ); + expect(buildPublicGalleryCardKey(buildWoodenFishEntry())).toBe( + 'wooden-fish:user-1:shared-profile', + ); + expect(buildPublicGalleryCardKey(buildRpgEntry())).toBe( + 'rpg:user-1:shared-profile', + ); +}); + +test('public gallery ViewModel dedupes merged public entries by latest source', () => { + const oldPuzzle = buildPuzzleEntry({ + worldName: '旧拼图', + updatedAt: '2026-05-01T00:00:00.000Z', + }); + const latestPuzzle = buildPuzzleEntry({ + worldName: '新拼图', + updatedAt: '2026-05-02T00:00:00.000Z', + }); + + expect(getPlatformPublicEntries([oldPuzzle], [latestPuzzle])).toEqual([ + latestPuzzle, + ]); + const categoryGroups = buildPublicCategoryGroups([oldPuzzle], [latestPuzzle]); + + expect(categoryGroups.find((group) => group.tag === '星桥')).toEqual({ + tag: '星桥', + entries: [latestPuzzle], + }); +}); + +test('public gallery ViewModel searches compact work codes and ranks name prefix first', () => { + const nameMatch = buildPuzzleEntry({ + profileId: 'name-match', + publicWorkCode: 'PZ-OLDER', + worldName: '星桥拼图', + updatedAt: '2026-05-01T00:00:00.000Z', + }); + const codeMatch = buildPuzzleEntry({ + profileId: 'code-match', + publicWorkCode: 'PZ-XING-QIAO', + worldName: '海雾机关', + updatedAt: '2026-05-03T00:00:00.000Z', + }); + const jumpHopCodeMatch = buildJumpHopEntry({ + profileId: 'jump-code-match', + publicWorkCode: 'JH-XING-QIAO', + worldName: '海雾跳跃', + }); + const woodenFishCodeMatch = buildWoodenFishEntry({ + profileId: 'wooden-code-match', + publicWorkCode: 'WF-DEEP-CALM', + worldName: '静心木鱼', + }); + + expect(filterPlatformWorkSearchResults([codeMatch, nameMatch], '星桥')).toEqual( + [nameMatch, codeMatch], + ); + expect(filterPlatformWorkSearchResults([codeMatch], 'pz xing_qiao')).toEqual([ + codeMatch, + ]); + expect( + filterPlatformWorkSearchResults([jumpHopCodeMatch], 'jh xing-qiao'), + ).toEqual([jumpHopCodeMatch]); + expect( + filterPlatformWorkSearchResults([woodenFishCodeMatch], 'wf deep_calm'), + ).toEqual([woodenFishCodeMatch]); +}); + +test('public gallery ViewModel keeps source kinds behind one category filter seam', () => { + const jumpHopEntry = buildJumpHopEntry(); + const woodenFishEntry = buildWoodenFishEntry(); + const rpgEntry = buildRpgEntry(); + + expect(getPlatformCategoryKindFilter(jumpHopEntry)).toBe('jump-hop'); + expect(getPlatformCategoryKindFilter(woodenFishEntry)).toBe('wooden-fish'); + expect(getPlatformCategoryKindFilter(rpgEntry)).toBe('custom-world'); + expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'jump-hop')).toBe( + true, + ); + expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'wooden-fish')).toBe( + true, + ); + expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'custom-world')).toBe( + false, + ); + expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'custom-world')).toBe( + false, + ); +}); + +test('public gallery ViewModel ranks entries by selected metric', () => { + const playWinner = buildJumpHopEntry({ + profileId: 'play-winner', + playCount: 100, + remixCount: 1, + likeCount: 1, + recentPlayCount7d: 1, + }); + const remixWinner = buildPuzzleEntry({ + profileId: 'remix-winner', + playCount: 2, + remixCount: 50, + likeCount: 2, + recentPlayCount7d: 2, + }); + const recentWinner = buildPuzzleEntry({ + profileId: 'recent-winner', + playCount: 3, + remixCount: 3, + likeCount: 3, + recentPlayCount7d: 30, + }); + const likeWinner = buildWoodenFishEntry({ + profileId: 'like-winner', + playCount: 4, + remixCount: 4, + likeCount: 40, + recentPlayCount7d: 4, + }); + const entries = [recentWinner, remixWinner, likeWinner, playWinner]; + + expect(buildPlatformRankingEntries(entries, 'hot')[0]).toBe(playWinner); + expect(buildPlatformRankingEntries(entries, 'remix')[0]).toBe(remixWinner); + expect(buildPlatformRankingEntries(entries, 'new')[0]).toBe(recentWinner); + expect(buildPlatformRankingEntries(entries, 'like')[0]).toBe(likeWinner); + expect(getPlatformRankingMetricValue(likeWinner, 'like')).toBe(40); +}); + +test('public gallery ViewModel sorts category entries and exposes primary metric', () => { + const latestEntry = buildWoodenFishEntry({ + profileId: 'latest', + playCount: 1, + likeCount: 0, + recentPlayCount7d: 0, + publishedAt: '2026-05-05T00:00:00.000Z', + updatedAt: '2026-05-05T00:00:00.000Z', + }); + const playEntry = buildJumpHopEntry({ + profileId: 'play', + playCount: 100, + likeCount: 0, + recentPlayCount7d: 0, + publishedAt: '2026-05-03T00:00:00.000Z', + updatedAt: '2026-05-03T00:00:00.000Z', + }); + const likeEntry = buildPuzzleEntry({ + profileId: 'like', + playCount: 1, + likeCount: 20, + recentPlayCount7d: 0, + publishedAt: '2026-05-02T00:00:00.000Z', + updatedAt: '2026-05-02T00:00:00.000Z', + }); + const compositeEntry = buildPuzzleEntry({ + profileId: 'composite', + playCount: 30, + remixCount: 30, + likeCount: 30, + recentPlayCount7d: 30, + publishedAt: '2026-05-01T00:00:00.000Z', + updatedAt: '2026-05-01T00:00:00.000Z', + }); + const entries = [likeEntry, latestEntry, compositeEntry, playEntry]; + + expect(sortPlatformCategoryEntries(entries, 'latest')[0]).toBe(latestEntry); + expect(sortPlatformCategoryEntries(entries, 'play')[0]).toBe(playEntry); + expect(sortPlatformCategoryEntries(entries, 'like')[0]).toBe(compositeEntry); + expect(sortPlatformCategoryEntries(entries, 'composite')[0]).toBe( + compositeEntry, + ); + expect(getPlatformCategoryPrimaryMetric(likeEntry)).toEqual({ + label: '点赞', + value: 20, + }); + expect( + getPlatformCategoryPrimaryMetric( + buildPuzzleEntry({ likeCount: 0, recentPlayCount7d: 8, playCount: 2 }), + ), + ).toEqual({ label: '近7日', value: 8 }); +}); + +test('public gallery ViewModel filters entries published on the local day', () => { + const now = new Date(2026, 5, 3, 12); + const todayEntry = buildPuzzleEntry({ + profileId: 'today', + publishedAt: new Date(2026, 5, 3, 8).toISOString(), + }); + const yesterdayEntry = buildPuzzleEntry({ + profileId: 'yesterday', + publishedAt: new Date(2026, 5, 2, 8).toISOString(), + }); + const unpublishedEntry = buildPuzzleEntry({ + profileId: 'unpublished', + publishedAt: null, + }); + + expect( + filterTodayPublishedEntries( + [yesterdayEntry, todayEntry, unpublishedEntry], + now, + ), + ).toEqual([todayEntry]); +}); + +test('public gallery ViewModel parses backend numeric timestamps', () => { + expect(parsePlatformEntryTimestamp('1778457601.234567Z')).toBe( + 1778457601234.567, + ); +}); diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts new file mode 100644 index 00000000..a59221f5 --- /dev/null +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts @@ -0,0 +1,433 @@ +import { filterGeneralPublicWorks } from '../platform-entry/platformEdutainmentVisibility'; +import { getPlatformPublicGalleryEntryKey } from '../platform-entry/platformPublicGalleryFlow'; +import { + buildPlatformWorldDisplayTags, + isBarkBattleGalleryEntry, + isBigFishGalleryEntry, + isJumpHopGalleryEntry, + isMatch3DGalleryEntry, + isPuzzleGalleryEntry, + isSquareHoleGalleryEntry, + isVisualNovelGalleryEntry, + isWoodenFishGalleryEntry, + type PlatformPublicGalleryCard, + type PlatformWorldCardLike, +} from './rpgEntryWorldPresentation'; + +export type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like'; +export type PlatformCategoryKindFilter = + | 'all' + | 'puzzle' + | 'match3d' + | 'square-hole' + | 'visual-novel' + | 'bark-battle' + | 'big-fish' + | 'jump-hop' + | 'wooden-fish' + | 'custom-world'; +export type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like'; + +export type PlatformPublicCategoryGroup = { + tag: string; + entries: PlatformPublicGalleryCard[]; +}; + +export function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) { + return getPlatformPublicGalleryEntryKey(entry); +} + +export function buildPublicCategoryGroups( + featuredEntries: PlatformPublicGalleryCard[], + latestEntries: PlatformPublicGalleryCard[], +): PlatformPublicCategoryGroup[] { + const publicEntryMap = new Map(); + + filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach( + (entry) => { + publicEntryMap.set(buildPublicGalleryCardKey(entry), entry); + }, + ); + + const categoryMap = new Map(); + Array.from(publicEntryMap.values()).forEach((entry) => { + const tags = buildPlatformWorldDisplayTags(entry, 3); + const normalizedTags = tags.length > 0 ? tags : ['回响']; + + normalizedTags.forEach((tag) => { + const entries = categoryMap.get(tag) ?? []; + entries.push(entry); + categoryMap.set(tag, entries); + }); + }); + + return Array.from(categoryMap.entries()) + .map(([tag, entries]) => ({ tag, entries })) + .sort((left, right) => { + if (right.entries.length !== left.entries.length) { + return right.entries.length - left.entries.length; + } + + return left.tag.localeCompare(right.tag, 'zh-CN'); + }); +} + +export function getPlatformPublicEntries( + featuredEntries: PlatformPublicGalleryCard[], + latestEntries: PlatformPublicGalleryCard[], +) { + const entryMap = new Map(); + + filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach( + (entry) => { + entryMap.set(buildPublicGalleryCardKey(entry), entry); + }, + ); + + return Array.from(entryMap.values()); +} + +export function getAllPlatformPublicEntries( + featuredEntries: PlatformPublicGalleryCard[], + latestEntries: PlatformPublicGalleryCard[], +) { + const entryMap = new Map(); + + [...featuredEntries, ...latestEntries].forEach((entry) => { + entryMap.set(buildPublicGalleryCardKey(entry), entry); + }); + + return Array.from(entryMap.values()); +} + +function normalizePlatformSearchText(value: string | null | undefined) { + return (value ?? '').trim().toLocaleLowerCase('zh-CN'); +} + +function normalizePlatformCompactSearchText(value: string | null | undefined) { + return normalizePlatformSearchText(value).replace(/[\s_-]+/gu, ''); +} + +export function getPlatformSearchableWorkIds( + entry: PlatformPublicGalleryCard, +) { + const ids = [entry.publicWorkCode, entry.profileId]; + if ('workId' in entry) { + ids.push(entry.workId); + } + + return ids.filter((value): value is string => Boolean(value?.trim())); +} + +function buildPlatformWorkSearchText(entry: PlatformPublicGalleryCard) { + return [ + ...getPlatformSearchableWorkIds(entry), + entry.worldName, + entry.authorDisplayName, + entry.summaryText, + entry.subtitle, + ].join(' '); +} + +function matchesPlatformWorkSearch( + entry: PlatformPublicGalleryCard, + keyword: string, +) { + const normalizedKeyword = normalizePlatformSearchText(keyword); + const compactKeyword = normalizePlatformCompactSearchText(keyword); + if (!normalizedKeyword) { + return false; + } + + const normalizedSearchText = normalizePlatformSearchText( + buildPlatformWorkSearchText(entry), + ); + if (normalizedSearchText.includes(normalizedKeyword)) { + return true; + } + + return ( + Boolean(compactKeyword) && + normalizePlatformCompactSearchText( + buildPlatformWorkSearchText(entry), + ).includes(compactKeyword) + ); +} + +export function filterPlatformWorkSearchResults( + entries: PlatformPublicGalleryCard[], + keyword: string, +) { + return entries + .filter((entry) => matchesPlatformWorkSearch(entry, keyword)) + .sort((left, right) => { + const leftCode = getPlatformSearchableWorkIds(left)[0] ?? ''; + const rightCode = getPlatformSearchableWorkIds(right)[0] ?? ''; + const normalizedKeyword = normalizePlatformSearchText(keyword); + const leftNameStarts = normalizePlatformSearchText( + left.worldName, + ).startsWith(normalizedKeyword); + const rightNameStarts = normalizePlatformSearchText( + right.worldName, + ).startsWith(normalizedKeyword); + if (leftNameStarts !== rightNameStarts) { + return leftNameStarts ? -1 : 1; + } + + const compactKeyword = normalizePlatformCompactSearchText(keyword); + const leftCodeStarts = + normalizePlatformCompactSearchText(leftCode).startsWith(compactKeyword); + const rightCodeStarts = + normalizePlatformCompactSearchText(rightCode).startsWith( + compactKeyword, + ); + if (leftCodeStarts !== rightCodeStarts) { + return leftCodeStarts ? -1 : 1; + } + + return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left); + }); +} + +export function isExactPublicWorkCodeSearch( + entries: PlatformPublicGalleryCard[], + keyword: string, +) { + const normalizedKeyword = normalizePlatformSearchText(keyword); + return entries.some( + (entry) => + Boolean(entry.publicWorkCode?.trim()) && + normalizePlatformSearchText(entry.publicWorkCode) === normalizedKeyword, + ); +} + +export function getPlatformWorldTimestamp(entry: PlatformWorldCardLike) { + const rawTime = entry.publishedAt ?? entry.updatedAt; + return parsePlatformEntryTimestamp(rawTime); +} + +function isSameLocalCalendarDay(left: Date, right: Date) { + return ( + left.getFullYear() === right.getFullYear() && + left.getMonth() === right.getMonth() && + left.getDate() === right.getDate() + ); +} + +function isPlatformEntryPublishedToday( + entry: PlatformPublicGalleryCard, + now = new Date(), +) { + const publishedAtTimestamp = parsePlatformEntryTimestamp(entry.publishedAt); + if (publishedAtTimestamp <= 0) { + return false; + } + + return isSameLocalCalendarDay(new Date(publishedAtTimestamp), now); +} + +export function filterTodayPublishedEntries( + entries: PlatformPublicGalleryCard[], + now = new Date(), +) { + return entries.filter((entry) => isPlatformEntryPublishedToday(entry, now)); +} + +export function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) { + return Math.max(0, Math.round(('likeCount' in entry && entry.likeCount) || 0)); +} + +export function getPlatformWorldPlayCount(entry: PlatformWorldCardLike) { + return Math.max(0, Math.round(('playCount' in entry && entry.playCount) || 0)); +} + +export function getPlatformWorldRemixCount(entry: PlatformWorldCardLike) { + return Math.max( + 0, + Math.round(('remixCount' in entry && entry.remixCount) || 0), + ); +} + +function getPlatformWorldRecentPlayCount(entry: PlatformWorldCardLike) { + return Math.max( + 0, + Math.round(('recentPlayCount7d' in entry && entry.recentPlayCount7d) || 0), + ); +} + +function sortEntriesByMetric( + entries: PlatformPublicGalleryCard[], + getMetric: (entry: PlatformPublicGalleryCard) => number, +) { + return [...entries].sort((left, right) => { + const metricDiff = getMetric(right) - getMetric(left); + if (metricDiff !== 0) { + return metricDiff; + } + + return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left); + }); +} + +export function buildPlatformRankingEntries( + entries: PlatformPublicGalleryCard[], + tab: PlatformRankingTab, +) { + if (tab === 'hot') { + return sortEntriesByMetric(entries, getPlatformWorldPlayCount); + } + + if (tab === 'remix') { + return sortEntriesByMetric(entries, getPlatformWorldRemixCount); + } + + if (tab === 'like') { + return sortEntriesByMetric(entries, getPlatformWorldLikeCount); + } + + return sortEntriesByMetric(entries, getPlatformWorldRecentPlayCount); +} + +export function getPlatformRankingMetricValue( + entry: PlatformPublicGalleryCard, + tab: PlatformRankingTab, +) { + if (tab === 'remix') { + return getPlatformWorldRemixCount(entry); + } + + if (tab === 'like') { + return getPlatformWorldLikeCount(entry); + } + + if (tab === 'new') { + return getPlatformWorldRecentPlayCount(entry); + } + + return getPlatformWorldPlayCount(entry); +} + +function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) { + // 分类频道只使用公开读模型已经返回的指标做前端排序,不在 UI 层伪造评分数据。 + return ( + getPlatformWorldPlayCount(entry) + + getPlatformWorldRemixCount(entry) + + getPlatformWorldLikeCount(entry) + + getPlatformWorldRecentPlayCount(entry) + ); +} + +export function getPlatformCategoryKindFilter( + entry: PlatformPublicGalleryCard, +): Exclude { + if (isPuzzleGalleryEntry(entry)) { + return 'puzzle'; + } + + if (isMatch3DGalleryEntry(entry)) { + return 'match3d'; + } + + if (isSquareHoleGalleryEntry(entry)) { + return 'square-hole'; + } + + if (isVisualNovelGalleryEntry(entry)) { + return 'visual-novel'; + } + + if (isBarkBattleGalleryEntry(entry)) { + return 'bark-battle'; + } + + if (isBigFishGalleryEntry(entry)) { + return 'big-fish'; + } + + if (isJumpHopGalleryEntry(entry)) { + return 'jump-hop'; + } + + if (isWoodenFishGalleryEntry(entry)) { + return 'wooden-fish'; + } + + return 'custom-world'; +} + +export function matchesPlatformCategoryKindFilter( + entry: PlatformPublicGalleryCard, + kindFilter: PlatformCategoryKindFilter, +) { + return ( + kindFilter === 'all' || getPlatformCategoryKindFilter(entry) === kindFilter + ); +} + +export function sortPlatformCategoryEntries( + entries: PlatformPublicGalleryCard[], + sortMode: PlatformCategorySortMode, +) { + return [...entries].sort((left, right) => { + if (sortMode === 'latest') { + return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left); + } + + const metricDiff = + sortMode === 'play' + ? getPlatformWorldPlayCount(right) - getPlatformWorldPlayCount(left) + : sortMode === 'like' + ? getPlatformWorldLikeCount(right) - getPlatformWorldLikeCount(left) + : getPlatformCategoryCompositeScore(right) - + getPlatformCategoryCompositeScore(left); + + if (metricDiff !== 0) { + return metricDiff; + } + + return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left); + }); +} + +export function getPlatformCategoryPrimaryMetric( + entry: PlatformPublicGalleryCard, +) { + const likeCount = getPlatformWorldLikeCount(entry); + if (likeCount > 0) { + return { label: '点赞', value: likeCount }; + } + + const recentPlayCount = getPlatformWorldRecentPlayCount(entry); + if (recentPlayCount > 0) { + return { label: '近7日', value: recentPlayCount }; + } + + return { label: '游玩', value: getPlatformWorldPlayCount(entry) }; +} + +export function parsePlatformEntryTimestamp(value: string | null | undefined) { + if (!value) { + return 0; + } + + const normalized = value.trim(); + const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u); + if (numericTimestamp?.[1]) { + const rawTimestamp = Number(numericTimestamp[1]); + if (Number.isFinite(rawTimestamp)) { + const absoluteTimestamp = Math.abs(rawTimestamp); + const timestampMs = + absoluteTimestamp >= 1_000_000_000_000_000 + ? rawTimestamp / 1000 + : absoluteTimestamp >= 1_000_000_000_000 + ? rawTimestamp + : absoluteTimestamp >= 1_000_000_000 + ? rawTimestamp * 1000 + : Number.NaN; + return Number.isNaN(timestampMs) ? 0 : timestampMs; + } + } + + const timestamp = new Date(normalized).getTime(); + return Number.isNaN(timestamp) ? 0 : timestamp; +} From ab49c32e33f2f8360d2fd3c8a2d10b0e36116c7f Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 16:35:01 +0800 Subject: [PATCH 06/67] =?UTF-8?q?refactor:=20=E8=BF=81=E7=A7=BB=E5=A4=A7?= =?UTF-8?q?=E9=B1=BC=E4=B8=8E=E6=B1=AA=E6=B1=AA=20runtime=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 3 +- docs/README.md | 2 +- ...构】RuntimeClientFamily收口计划-2026-06-03.md | 4 +- .../barkBattleRuntimeClient.ts | 113 ++++++++---------- .../big-fish-runtime/bigFishRuntimeClient.ts | 102 +++++++--------- .../recommendedRuntimeGuestLaunch.test.ts | 2 +- 6 files changed, 99 insertions(+), 127 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 9c280b36..4e2aec4d 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -44,7 +44,8 @@ - 背景:Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options,新增玩法容易遗漏同一请求骨架。 - 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。 -- 影响范围:`src/services/runtimeRequest.ts`、Match3D runtime client、SquareHole runtime client、后续 Puzzle / Jump Hop / Visual Novel / Bark Battle / Big Fish runtime client 迁移。 +- 追加决策:Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId`、`runId`)仍留在各玩法 client,通用 Module 只承接请求骨架。 +- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle runtime client、后续 Puzzle / Jump Hop / Visual Novel runtime client 迁移。 - 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index 39054240..36579f3f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,7 +43,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,Hub 不再按玩法 `kind` 分发,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D 与 SquareHole 已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish 与 Bark Battle 已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 公开作品分类、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md index 4c055fc3..51ef3431 100644 --- a/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md @@ -11,7 +11,7 @@ - `buildRuntimeApiPath(basePath, ...segments)`:统一对 runtime path segment 执行 `encodeURIComponent`。 - `requestRuntimeJson(params)`:统一设置 method、JSON body、`Content-Type`、runtime guest `Authorization`、auth options 和 retry options。 -`match3dRuntimeClient.ts` 与 `squareHoleRuntimeClient.ts` 已迁入此 **Module**,并保留原有导出函数名、错误文案、返回契约和重试常量。点击 / 投入等玩法专属返回映射仍留在各自 client 内,避免把领域规则塞进通用请求 **Implementation**。 +`match3dRuntimeClient.ts`、`squareHoleRuntimeClient.ts`、`bigFishRuntimeClient.ts` 与 `barkBattleRuntimeClient.ts` 已迁入此 **Module**,并保留原有导出函数名、错误文案、返回契约和重试常量。点击 / 投入 / 成绩提交等玩法专属 payload 归一化仍留在各自 client 内,避免把领域规则塞进通用请求 **Implementation**。 ## 约定 @@ -22,7 +22,7 @@ ## 后续深化 -下一批可迁移 Puzzle、Jump Hop、Visual Novel、Bark Battle 与 Big Fish runtime client。迁移顺序以测试覆盖和请求形状接近度为准,优先迁移已有 guest launch 或 client 单测覆盖的函数。 +下一批可迁移 Puzzle、Jump Hop 与 Visual Novel runtime client。迁移顺序以测试覆盖和请求形状接近度为准,优先迁移已有 guest launch 或 client 单测覆盖的函数。 ## 验证 diff --git a/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts b/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts index 211cfdad..c9fe5d91 100644 --- a/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts +++ b/src/services/bark-battle-runtime/barkBattleRuntimeClient.ts @@ -5,15 +5,11 @@ import type { BarkBattleRunStartResponse, BarkBattleRuntimeConfig, } from '../../../packages/shared/src/contracts/barkBattle'; -import { - type ApiRetryOptions, - requestJson, -} from '../apiClient'; -import { - buildRuntimeGuestAuthOptions, - buildRuntimeGuestHeaders, - type RuntimeGuestRequestOptions, -} from '../runtimeGuestAuth'; +import { type ApiRetryOptions } from '../apiClient'; +import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth'; +import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; + +const BARK_BATTLE_RUNTIME_API_BASE = '/api/runtime/bark-battle'; const BARK_BATTLE_RUNTIME_READ_RETRY: ApiRetryOptions = { maxRetries: 1, @@ -34,16 +30,17 @@ export function getBarkBattleRuntimeConfig( workId: string, options: BarkBattleRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/config`, - { method: 'GET', headers: buildRuntimeGuestHeaders(options) }, - '读取汪汪声浪大作战配置失败', - { - retry: BARK_BATTLE_RUNTIME_READ_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + BARK_BATTLE_RUNTIME_API_BASE, + 'works', + workId, + 'config', + ), + fallbackMessage: '读取汪汪声浪大作战配置失败', + retry: BARK_BATTLE_RUNTIME_READ_RETRY, + requestOptions: options, + }); } export function startBarkBattleRun( @@ -51,39 +48,34 @@ export function startBarkBattleRun( payload: Partial = {}, options: BarkBattleRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/runs`, - { - method: 'POST', - headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }), - body: JSON.stringify({ - ...payload, - workId: payload.workId ?? workId, - }), + return requestRuntimeJson({ + url: buildRuntimeApiPath( + BARK_BATTLE_RUNTIME_API_BASE, + 'works', + workId, + 'runs', + ), + method: 'POST', + jsonBody: { + ...payload, + workId: payload.workId ?? workId, }, - '启动汪汪声浪大作战正式局失败', - { - retry: BARK_BATTLE_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + fallbackMessage: '启动汪汪声浪大作战正式局失败', + retry: BARK_BATTLE_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } export function getBarkBattleRun( runId: string, options: BarkBattleRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}`, - { method: 'GET', headers: buildRuntimeGuestHeaders(options) }, - '读取汪汪声浪大作战单局失败', - { - retry: BARK_BATTLE_RUNTIME_READ_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(BARK_BATTLE_RUNTIME_API_BASE, 'runs', runId), + fallbackMessage: '读取汪汪声浪大作战单局失败', + retry: BARK_BATTLE_RUNTIME_READ_RETRY, + requestOptions: options, + }); } export function finishBarkBattleRun( @@ -91,21 +83,20 @@ export function finishBarkBattleRun( payload: BarkBattleRunFinishRequest, options: BarkBattleRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}/finish`, - { - method: 'POST', - headers: buildRuntimeGuestHeaders(options, { 'Content-Type': 'application/json' }), - body: JSON.stringify({ - ...payload, - runId: payload.runId ?? runId, - }), + return requestRuntimeJson({ + url: buildRuntimeApiPath( + BARK_BATTLE_RUNTIME_API_BASE, + 'runs', + runId, + 'finish', + ), + method: 'POST', + jsonBody: { + ...payload, + runId: payload.runId ?? runId, }, - '提交汪汪声浪大作战成绩失败', - { - retry: BARK_BATTLE_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + fallbackMessage: '提交汪汪声浪大作战成绩失败', + retry: BARK_BATTLE_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } diff --git a/src/services/big-fish-runtime/bigFishRuntimeClient.ts b/src/services/big-fish-runtime/bigFishRuntimeClient.ts index 16b02528..39407713 100644 --- a/src/services/big-fish-runtime/bigFishRuntimeClient.ts +++ b/src/services/big-fish-runtime/bigFishRuntimeClient.ts @@ -4,16 +4,11 @@ import type { SubmitBigFishInputRequest, } from '../../../packages/shared/src/contracts/bigFish'; import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; -import { - type ApiRetryOptions, - requestJson, -} from '../apiClient'; -import { - buildRuntimeGuestAuthOptions, - buildRuntimeGuestHeaders, - type RuntimeGuestRequestOptions, -} from '../runtimeGuestAuth'; +import { type ApiRetryOptions } from '../apiClient'; +import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth'; +import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; +const BIG_FISH_RUNTIME_API_BASE = '/api/runtime/big-fish'; const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = { maxRetries: 1, baseDelayMs: 120, @@ -30,51 +25,44 @@ export function recordBigFishPlay( payload: RecordBigFishPlayRequest, options: BigFishRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`, - { - method: 'POST', - headers: buildRuntimeGuestHeaders(options, { - 'Content-Type': 'application/json', - }), - body: JSON.stringify(payload), - }, - '记录大鱼吃小鱼游玩失败', - { - retry: BIG_FISH_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + BIG_FISH_RUNTIME_API_BASE, + 'sessions', + sessionId, + 'play', + ), + method: 'POST', + jsonBody: payload, + fallbackMessage: '记录大鱼吃小鱼游玩失败', + retry: BIG_FISH_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } export function startBigFishRun( sessionId: string, options: BigFishRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`, - { - method: 'POST', - headers: buildRuntimeGuestHeaders(options), - }, - '启动大鱼吃小鱼玩法失败', - { - retry: BIG_FISH_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + BIG_FISH_RUNTIME_API_BASE, + 'sessions', + sessionId, + 'runs', + ), + method: 'POST', + fallbackMessage: '启动大鱼吃小鱼玩法失败', + retry: BIG_FISH_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } export function getBigFishRun(runId: string) { - return requestJson( - `/api/runtime/big-fish/runs/${encodeURIComponent(runId)}`, - { - method: 'GET', - }, - '读取大鱼吃小鱼玩法失败', - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(BIG_FISH_RUNTIME_API_BASE, 'runs', runId), + fallbackMessage: '读取大鱼吃小鱼玩法失败', + }); } export function submitBigFishInput( @@ -82,20 +70,12 @@ export function submitBigFishInput( payload: SubmitBigFishInputRequest, options: BigFishRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `/api/runtime/big-fish/runs/${encodeURIComponent(runId)}/input`, - { - method: 'POST', - headers: buildRuntimeGuestHeaders(options, { - 'Content-Type': 'application/json', - }), - body: JSON.stringify(payload), - }, - '同步大鱼吃小鱼输入失败', - { - retry: BIG_FISH_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(BIG_FISH_RUNTIME_API_BASE, 'runs', runId, 'input'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '同步大鱼吃小鱼输入失败', + retry: BIG_FISH_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } diff --git a/src/services/recommendedRuntimeGuestLaunch.test.ts b/src/services/recommendedRuntimeGuestLaunch.test.ts index eb5307c0..514f00c2 100644 --- a/src/services/recommendedRuntimeGuestLaunch.test.ts +++ b/src/services/recommendedRuntimeGuestLaunch.test.ts @@ -13,8 +13,8 @@ vi.mock('./apiClient', async () => { }; }); -import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient'; import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient'; +import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient'; import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient'; import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient'; import { From 3783f0d2afb23bd5d4fa0bb596c5c2ab88ef4e87 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 16:42:18 +0800 Subject: [PATCH 07/67] =?UTF-8?q?refactor:=20=E8=BF=81=E7=A7=BB=E6=8B=BC?= =?UTF-8?q?=E5=9B=BE=E4=B8=8E=E8=B7=B3=E8=B7=83=20runtime=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 3 +- docs/README.md | 2 +- ...构】RuntimeClientFamily收口计划-2026-06-03.md | 4 +- .../jump-hop/jumpHopClient.runtime.test.ts | 108 +++++++++++++ src/services/jump-hop/jumpHopClient.ts | 83 ++++------ .../puzzleRuntimeClient.test.ts | 92 +++++++++++ .../puzzle-runtime/puzzleRuntimeClient.ts | 144 ++++++------------ 7 files changed, 284 insertions(+), 152 deletions(-) create mode 100644 src/services/jump-hop/jumpHopClient.runtime.test.ts create mode 100644 src/services/puzzle-runtime/puzzleRuntimeClient.test.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 4e2aec4d..752aec27 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -45,7 +45,8 @@ - 背景:Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options,新增玩法容易遗漏同一请求骨架。 - 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。 - 追加决策:Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId`、`runId`)仍留在各玩法 client,通用 Module 只承接请求骨架。 -- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle runtime client、后续 Puzzle / Jump Hop / Visual Novel runtime client 迁移。 +- 追加决策:Puzzle 的 start / get / swap / drag / next-level / leaderboard 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`;Puzzle `pause` 与 `props` 仍保留原账号态 auth options,不直接接入 runtime guest auth。 +- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop runtime client、后续 Visual Novel runtime client 迁移。 - 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index 36579f3f..175f634a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,7 +43,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,Hub 不再按玩法 `kind` 分发,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish 与 Bark Battle 已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求与 Jump Hop 正式 run 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 公开作品分类、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md index 51ef3431..5f334e3d 100644 --- a/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md @@ -11,7 +11,7 @@ - `buildRuntimeApiPath(basePath, ...segments)`:统一对 runtime path segment 执行 `encodeURIComponent`。 - `requestRuntimeJson(params)`:统一设置 method、JSON body、`Content-Type`、runtime guest `Authorization`、auth options 和 retry options。 -`match3dRuntimeClient.ts`、`squareHoleRuntimeClient.ts`、`bigFishRuntimeClient.ts` 与 `barkBattleRuntimeClient.ts` 已迁入此 **Module**,并保留原有导出函数名、错误文案、返回契约和重试常量。点击 / 投入 / 成绩提交等玩法专属 payload 归一化仍留在各自 client 内,避免把领域规则塞进通用请求 **Implementation**。 +`match3dRuntimeClient.ts`、`squareHoleRuntimeClient.ts`、`bigFishRuntimeClient.ts`、`barkBattleRuntimeClient.ts`、`puzzleRuntimeClient.ts` 的公开 / 推荐运行态请求,以及 `jumpHopClient.ts` 的正式 run 请求已迁入此 **Module**,并保留原有导出函数名、错误文案、返回契约和重试常量。点击 / 投入 / 成绩提交等玩法专属 payload 归一化仍留在各自 client 内,避免把领域规则塞进通用请求 **Implementation**。 ## 约定 @@ -22,7 +22,7 @@ ## 后续深化 -下一批可迁移 Puzzle、Jump Hop 与 Visual Novel runtime client。迁移顺序以测试覆盖和请求形状接近度为准,优先迁移已有 guest launch 或 client 单测覆盖的函数。 +下一批可迁移 Visual Novel runtime client,并评估 Puzzle `pause` / `props` 是否应继续保留账号态 auth options。迁移顺序以测试覆盖和请求形状接近度为准,优先迁移已有 guest launch 或 client 单测覆盖的函数。 ## 验证 diff --git a/src/services/jump-hop/jumpHopClient.runtime.test.ts b/src/services/jump-hop/jumpHopClient.runtime.test.ts new file mode 100644 index 00000000..fa534194 --- /dev/null +++ b/src/services/jump-hop/jumpHopClient.runtime.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiClientMocks = vi.hoisted(() => ({ + requestJson: vi.fn(), +})); + +vi.mock('../apiClient', async () => { + const actual = + await vi.importActual('../apiClient'); + return { + ...actual, + requestJson: apiClientMocks.requestJson, + }; +}); + +import { + restartJumpHopRuntimeRun, + startJumpHopRuntimeRun, + submitJumpHopJump, +} from './jumpHopClient'; + +describe('jumpHopClient runtime requests', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(Date, 'now').mockReturnValue(1780000000000); + apiClientMocks.requestJson.mockResolvedValue({ runId: 'run-1' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts runs through the shared runtime request skeleton', async () => { + await startJumpHopRuntimeRun('profile/1', { + runtimeGuestToken: 'runtime-guest-token', + }); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/runtime/jump-hop/runs', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer runtime-guest-token', + }, + body: JSON.stringify({ profileId: 'profile/1' }), + }), + '启动跳一跳运行态失败', + expect.objectContaining({ + skipAuth: true, + skipRefresh: true, + }), + ); + }); + + it('submits jump input with a generated client event id', async () => { + await submitJumpHopJump( + 'run/1', + { chargeMs: 320 }, + { runtimeGuestToken: 'runtime-guest-token' }, + ); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/runtime/jump-hop/runs/run%2F1/jump', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer runtime-guest-token', + }, + body: JSON.stringify({ + chargeMs: 320, + clientEventId: 'jump-run/1-1780000000000', + }), + }), + '提交跳一跳起跳失败', + expect.objectContaining({ + skipAuth: true, + skipRefresh: true, + }), + ); + }); + + it('restarts runs with the same guest auth request skeleton', async () => { + await restartJumpHopRuntimeRun('run/1', { + runtimeGuestToken: 'runtime-guest-token', + }); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/runtime/jump-hop/runs/run%2F1/restart', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer runtime-guest-token', + }, + body: JSON.stringify({ + clientActionId: 'restart-run/1-1780000000000', + }), + }), + '重新开始跳一跳失败', + expect.objectContaining({ + skipAuth: true, + skipRefresh: true, + }), + ); + }); +}); diff --git a/src/services/jump-hop/jumpHopClient.ts b/src/services/jump-hop/jumpHopClient.ts index d1e7fe13..ac4a6a50 100644 --- a/src/services/jump-hop/jumpHopClient.ts +++ b/src/services/jump-hop/jumpHopClient.ts @@ -12,8 +12,8 @@ import type { JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, - JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, + JumpHopWorksResponse, JumpHopWorkSummaryResponse, } from '../../../packages/shared/src/contracts/jumpHop'; import { @@ -21,11 +21,8 @@ import { requestJson, } from '../apiClient'; import { createCreationAgentClient } from '../creation-agent'; -import { - buildRuntimeGuestAuthOptions, - buildRuntimeGuestHeaders, - type RuntimeGuestRequestOptions, -} from '../runtimeGuestAuth'; +import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth'; +import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions'; const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works'; @@ -51,8 +48,8 @@ export type { JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, - JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, + JumpHopWorksResponse, }; export type CreateJumpHopSessionRequest = { themeText: string; @@ -234,22 +231,13 @@ export async function startJumpHopRuntimeRun( profileId: string, options: JumpHopRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `${JUMP_HOP_RUNTIME_API_BASE}/runs`, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...buildRuntimeGuestHeaders(options), - }, - body: JSON.stringify({ profileId }), - }, - '启动跳一跳运行态失败', - { - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(JUMP_HOP_RUNTIME_API_BASE, 'runs'), + method: 'POST', + jsonBody: { profileId }, + fallbackMessage: '启动跳一跳运行态失败', + requestOptions: options, + }); } export async function submitJumpHopJump( @@ -257,47 +245,38 @@ export async function submitJumpHopJump( payload: { chargeMs: number }, options: JumpHopRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); const requestPayload = { chargeMs: payload.chargeMs, clientEventId: `jump-${runId}-${Date.now()}`, }; - return requestJson( - `${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/jump`, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...buildRuntimeGuestHeaders(options), - }, - body: JSON.stringify(requestPayload), - }, - '提交跳一跳起跳失败', - requestOptions, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(JUMP_HOP_RUNTIME_API_BASE, 'runs', runId, 'jump'), + method: 'POST', + jsonBody: requestPayload, + fallbackMessage: '提交跳一跳起跳失败', + requestOptions: options, + }); } export async function restartJumpHopRuntimeRun( runId: string, options: JumpHopRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `${JUMP_HOP_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/restart`, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...buildRuntimeGuestHeaders(options), - }, - body: JSON.stringify({ - clientActionId: `restart-${runId}-${Date.now()}`, - }), + return requestRuntimeJson({ + url: buildRuntimeApiPath( + JUMP_HOP_RUNTIME_API_BASE, + 'runs', + runId, + 'restart', + ), + method: 'POST', + jsonBody: { + clientActionId: `restart-${runId}-${Date.now()}`, }, - '重新开始跳一跳失败', - requestOptions, - ); + fallbackMessage: '重新开始跳一跳失败', + requestOptions: options, + }); } export const jumpHopClient = { diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.test.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.test.ts new file mode 100644 index 00000000..68f3accc --- /dev/null +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiClientMocks = vi.hoisted(() => ({ + requestJson: vi.fn(), +})); + +vi.mock('../apiClient', async () => { + const actual = + await vi.importActual('../apiClient'); + return { + ...actual, + requestJson: apiClientMocks.requestJson, + }; +}); + +import { + getPuzzleRun, + swapPuzzlePieces, + updatePuzzleRunPause, +} from './puzzleRuntimeClient'; + +describe('puzzleRuntimeClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiClientMocks.requestJson.mockResolvedValue({ runId: 'run-1' }); + }); + + it('reads runs through the shared encoded runtime path', async () => { + await getPuzzleRun('run/1'); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/runtime/puzzle/runs/run%2F1', + { method: 'GET' }, + '读取拼图运行快照失败', + expect.objectContaining({ + retry: expect.objectContaining({ maxRetries: 1 }), + }), + ); + }); + + it('submits puzzle swaps through the shared json request skeleton', async () => { + await swapPuzzlePieces('run/1', { + firstPieceId: 'piece-a', + secondPieceId: 'piece-b', + }); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/runtime/puzzle/runs/run%2F1/swap', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + firstPieceId: 'piece-a', + secondPieceId: 'piece-b', + }), + }), + '交换拼图块失败', + expect.objectContaining({ + retry: expect.objectContaining({ retryUnsafeMethods: true }), + }), + ); + }); + + it('keeps pause requests on account auth options instead of guest auth', async () => { + await updatePuzzleRunPause( + 'run/1', + { paused: true }, + { + authImpact: 'local', + runtimeGuestToken: 'runtime-guest-token', + skipRefresh: true, + }, + ); + + const [, init, , options] = apiClientMocks.requestJson.mock.calls[0]; + expect(init).toEqual( + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paused: true }), + }), + ); + expect(init.headers).not.toHaveProperty('Authorization'); + expect(options).toEqual( + expect.objectContaining({ + authImpact: 'local', + skipRefresh: true, + }), + ); + expect(options).not.toMatchObject({ skipAuth: true }); + }); +}); diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts index be9393ef..501d6a9e 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts @@ -1,6 +1,6 @@ import type { - DragPuzzlePieceRequest, AdvancePuzzleNextLevelRequest, + DragPuzzlePieceRequest, PuzzleRunResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, @@ -12,11 +12,8 @@ import { type ApiRetryOptions, requestJson, } from '../apiClient'; -import { - buildRuntimeGuestAuthOptions, - buildRuntimeGuestHeaders, - type RuntimeGuestRequestOptions, -} from '../runtimeGuestAuth'; +import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth'; +import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs'; const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = { @@ -42,38 +39,25 @@ export async function startPuzzleRun( payload: StartPuzzleRunRequest, options: PuzzleRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - PUZZLE_RUNTIME_API_BASE, - { - method: 'POST', - headers: buildRuntimeGuestHeaders(options, { - 'Content-Type': 'application/json', - }), - body: JSON.stringify(payload), - }, - '启动拼图玩法失败', - { - retry: PUZZLE_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: PUZZLE_RUNTIME_API_BASE, + method: 'POST', + jsonBody: payload, + fallbackMessage: '启动拼图玩法失败', + retry: PUZZLE_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } /** * 读取拼图运行态快照。 */ export async function getPuzzleRun(runId: string) { - return requestJson( - `${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}`, - { - method: 'GET', - }, - '读取拼图运行快照失败', - { - retry: PUZZLE_RUNTIME_READ_RETRY, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId), + fallbackMessage: '读取拼图运行快照失败', + retry: PUZZLE_RUNTIME_READ_RETRY, + }); } /** @@ -83,18 +67,13 @@ export async function swapPuzzlePieces( runId: string, payload: SwapPuzzlePiecesRequest, ) { - return requestJson( - `${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/swap`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - '交换拼图块失败', - { - retry: PUZZLE_RUNTIME_WRITE_RETRY, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'swap'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '交换拼图块失败', + retry: PUZZLE_RUNTIME_WRITE_RETRY, + }); } /** @@ -104,18 +83,13 @@ export async function dragPuzzlePieceOrGroup( runId: string, payload: DragPuzzlePieceRequest, ) { - return requestJson( - `${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - '拖动拼图块失败', - { - retry: PUZZLE_RUNTIME_WRITE_RETRY, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'drag'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '拖动拼图块失败', + retry: PUZZLE_RUNTIME_WRITE_RETRY, + }); } /** @@ -126,7 +100,6 @@ export async function advancePuzzleNextLevel( payload: AdvancePuzzleNextLevelRequest = {}, options: PuzzleRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); const targetProfileId = payload.targetProfileId?.trim() ?? ''; const preferSimilarWork = payload.preferSimilarWork === true; const requestPayload = { @@ -134,27 +107,14 @@ export async function advancePuzzleNextLevel( ...(preferSimilarWork ? { preferSimilarWork: true } : {}), }; const hasRequestPayload = Object.keys(requestPayload).length > 0; - return requestJson( - `${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`, - { - method: 'POST', - ...(hasRequestPayload - ? { - headers: buildRuntimeGuestHeaders(options, { - 'Content-Type': 'application/json', - }), - body: JSON.stringify(requestPayload), - } - : { - headers: buildRuntimeGuestHeaders(options), - }), - }, - '进入下一关失败', - { - retry: PUZZLE_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'next-level'), + method: 'POST', + ...(hasRequestPayload ? { jsonBody: requestPayload } : {}), + fallbackMessage: '进入下一关失败', + retry: PUZZLE_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } /** @@ -165,22 +125,14 @@ export async function submitPuzzleLeaderboard( payload: SubmitPuzzleLeaderboardRequest, options: PuzzleRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`, - { - method: 'POST', - headers: buildRuntimeGuestHeaders(options, { - 'Content-Type': 'application/json', - }), - body: JSON.stringify(payload), - }, - '提交拼图排行榜失败', - { - retry: PUZZLE_RUNTIME_LEADERBOARD_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'leaderboard'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '提交拼图排行榜失败', + retry: PUZZLE_RUNTIME_LEADERBOARD_RETRY, + requestOptions: options, + }); } /** @@ -192,7 +144,7 @@ export async function updatePuzzleRunPause( options: PuzzleRuntimeRequestOptions = {}, ) { return requestJson( - `${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/pause`, + buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'pause'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -218,7 +170,7 @@ export async function usePuzzleRuntimeProp( options: PuzzleRuntimeRequestOptions = {}, ) { return requestJson( - `${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/props`, + buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'props'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, From 06fabd3eab08da89349f6a98874dcda15bba9d42 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 16:49:54 +0800 Subject: [PATCH 08/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E4=BB=BB=E5=8A=A1=20ViewModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...构】ProfileTaskViewModel收口计划-2026-06-03.md | 29 ++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 85 ++---------- .../rpgEntryProfileTaskViewModel.test.ts | 127 ++++++++++++++++++ .../rpg-entry/rpgEntryProfileTaskViewModel.ts | 107 +++++++++++++++ 6 files changed, 283 insertions(+), 75 deletions(-) create mode 100644 docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md create mode 100644 src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts create mode 100644 src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 752aec27..7aefb3a2 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -58,6 +58,14 @@ - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`。 +## 2026-06-03 Profile Task ViewModel 收口 + +- 背景:`RpgEntryHomeView.tsx` 同时持有每日任务卡片和任务中心弹窗的任务选择、进度 clamp、奖励兜底、状态标签和按钮文案,导致任务展示规则和 JSX 缠在一起。 +- 决策:新增 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,把 `selectProfileTaskCenterTasks`、`selectProfileTaskCardTask`、`buildProfileTaskCardSummary`、`buildProfileTaskProgressLabel`、`getProfileTaskStatusLabel` 和 `getProfileTaskClaimButtonLabel` 收口为每日任务 ViewModel Interface。任务中心仍只展示一条 claimable / incomplete 优先任务,任务卡按可操作、claimed、非 disabled 的顺序兜底。 +- 影响范围:RPG 首页“每日任务”卡片、任务中心弹窗、后续任务状态和任务展示文案调整。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md`。 + ## 2026-06-03 最近创作只复用创作模板入口 - 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。 diff --git a/docs/README.md b/docs/README.md index 175f634a..26f8e492 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 公开作品分类、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 diff --git a/docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md new file mode 100644 index 00000000..88193054 --- /dev/null +++ b/docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md @@ -0,0 +1,29 @@ +# 【前端架构】Profile Task ViewModel 收口计划 + +## 背景 + +`RpgEntryHomeView.tsx` 的“每日任务”卡片与任务弹窗共用同一批展示规则:任务优先级、可领取 / 未完成选择、进度 clamp、奖励兜底、状态标签和按钮文案。原先这些规则散在巨型页面 **Implementation** 中,UI JSX 既要渲染,又要知道任务状态排序和兜底口径。 + +## 决策 + +新增 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,作为每日任务展示模型 **Module**。该 **Module** 的 **Interface** 收口为: + +- `selectProfileTaskCenterTasks(tasks)`:统一任务中心只展示一条可操作任务,按 claimable / incomplete 优先级并保持原始顺序。 +- `selectProfileTaskCardTask(tasks)`:统一任务卡兜底顺序,先可操作,再 claimed,再非 disabled。 +- `buildProfileTaskCardSummary(center)`:统一任务卡的奖励、阈值、进度百分比与动作文案。 +- `buildProfileTaskProgressLabel(task)`、`getProfileTaskStatusLabel(status)`、`getProfileTaskClaimButtonLabel(task, isClaiming)`:统一任务弹窗中的进度、状态和按钮文案。 + +`RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留弹窗、按钮和点击处理。每日任务展示规则的 **Locality** 转移到 ViewModel **Module** 与纯测试,后续新增任务状态或修改展示优先级不再穿透 UI。 + +## 约定 + +- 任务中心只露出当前最需要用户处理的一条任务。 +- 任务进度必须按 `0..threshold` clamp,避免异常后端进度撑破卡片进度条。 +- `pause` / `claim` 等副作用仍留在页面和后端 client;ViewModel 只做展示派生。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 1511bb8f..812c595a 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -71,7 +71,6 @@ import type { ProfileReferralInviteCenterResponse, ProfileSaveArchiveSummary, ProfileTaskCenterResponse, - ProfileTaskItem, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, WechatMiniProgramPayParams, @@ -135,6 +134,13 @@ import { import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; +import { + buildProfileTaskCardSummary, + buildProfileTaskProgressLabel, + getProfileTaskClaimButtonLabel, + getProfileTaskStatusLabel, + selectProfileTaskCenterTasks, +} from './rpgEntryProfileTaskViewModel'; import { buildPlatformRankingEntries, buildPublicCategoryGroups, @@ -277,66 +283,8 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; -const PROFILE_TASK_STATUS_PRIORITY_RANK: Record< - ProfileTaskItem['status'], - number -> = { - claimable: 2, - incomplete: 1, - disabled: 0, - claimed: -1, -}; -const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10; const PROFILE_QR_SCAN_INTERVAL_MS = 360; -function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) { - return tasks - .map((task, index) => ({ task, index })) - .filter( - ({ task }) => - task.status === 'claimable' || task.status === 'incomplete', - ) - .sort( - (left, right) => - PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] - - PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] || - left.index - right.index, - ) - .slice(0, 1) - .map(({ task }) => task); -} - -function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) { - return ( - selectProfileTaskCenterTasks(tasks)[0] ?? - tasks.find((task) => task.status === 'claimed') ?? - tasks.find((task) => task.status !== 'disabled') ?? - null - ); -} - -function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) { - const task = selectProfileTaskCardTask(center?.tasks ?? []); - const threshold = Math.max(1, task?.threshold ?? 1); - const progressCount = Math.min(task?.progressCount ?? 0, threshold); - const rewardPoints = - task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS; - const actionLabel = - task?.status === 'claimable' - ? '领取' - : task?.status === 'claimed' - ? '已完成' - : '去完成'; - - return { - actionLabel, - progressCount, - progressPercent: Math.round((progressCount / threshold) * 100), - rewardPoints, - threshold, - }; -} - type ProfileReferralPanel = 'invite' | 'redeem' | 'community'; type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives'; type BarcodeDetectorLike = { @@ -2861,13 +2809,6 @@ function WalletLedgerModal({ ); } -const PROFILE_TASK_STATUS_LABELS: Record = { - incomplete: '未完成', - claimable: '可领取', - claimed: '已领取', - disabled: '已停用', -}; - function ProfileTaskCenterModal({ center, isLoading, @@ -2947,7 +2888,7 @@ function ProfileTaskCenterModal({ {tasks.map((task) => { const isClaimable = task.status === 'claimable'; const isClaiming = claimingTaskId === task.taskId; - const progressLabel = `${Math.min(task.progressCount, task.threshold)}/${task.threshold}`; + const progressLabel = buildProfileTaskProgressLabel(task); return (
- {PROFILE_TASK_STATUS_LABELS[task.status]} + {getProfileTaskStatusLabel(task.status)}
@@ -2978,13 +2919,7 @@ function ProfileTaskCenterModal({ onClick={() => onClaim(task.taskId)} className="platform-primary-button mt-3 w-full rounded-2xl px-4 py-2.5 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50" > - {isClaiming - ? '领取中' - : task.status === 'claimed' - ? '已领取' - : isClaimable - ? '领取' - : '未完成'} + {getProfileTaskClaimButtonLabel(task, isClaiming)} ); diff --git a/src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts b/src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts new file mode 100644 index 00000000..b71ddb97 --- /dev/null +++ b/src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts @@ -0,0 +1,127 @@ +import { expect, test } from 'vitest'; + +import type { + ProfileTaskCenterResponse, + ProfileTaskItem, +} from '../../../packages/shared/src/contracts/runtime'; +import { + buildProfileTaskCardSummary, + buildProfileTaskProgressLabel, + getProfileTaskClaimButtonLabel, + getProfileTaskStatusLabel, + selectProfileTaskCardTask, + selectProfileTaskCenterTasks, +} from './rpgEntryProfileTaskViewModel'; + +function buildTask( + overrides: Partial = {}, +): ProfileTaskItem { + return { + taskId: 'task-1', + title: '游玩一次', + description: '完成一次游戏', + eventKey: 'work_play_start', + cycle: 'daily', + threshold: 1, + progressCount: 0, + rewardPoints: 10, + status: 'incomplete', + dayKey: 20260603, + claimedAt: null, + updatedAt: '2026-06-03T00:00:00.000Z', + ...overrides, + }; +} + +function buildCenter( + tasks: ProfileTaskItem[], +): ProfileTaskCenterResponse { + return { + dayKey: 20260603, + walletBalance: 12, + tasks, + updatedAt: '2026-06-03T00:00:00.000Z', + }; +} + +test('profile task ViewModel selects one actionable task by status priority and original order', () => { + const firstIncomplete = buildTask({ + taskId: 'incomplete-1', + status: 'incomplete', + }); + const secondIncomplete = buildTask({ + taskId: 'incomplete-2', + status: 'incomplete', + }); + const claimable = buildTask({ + taskId: 'claimable-1', + status: 'claimable', + }); + + expect( + selectProfileTaskCenterTasks([ + firstIncomplete, + secondIncomplete, + claimable, + ]), + ).toEqual([claimable]); + expect(selectProfileTaskCenterTasks([firstIncomplete, secondIncomplete])).toEqual( + [firstIncomplete], + ); +}); + +test('profile task ViewModel falls back from card task to claimed and enabled tasks', () => { + const claimed = buildTask({ taskId: 'claimed-1', status: 'claimed' }); + const disabled = buildTask({ taskId: 'disabled-1', status: 'disabled' }); + const incomplete = buildTask({ + taskId: 'incomplete-1', + status: 'incomplete', + }); + + expect(selectProfileTaskCardTask([disabled, claimed])).toBe(claimed); + expect(selectProfileTaskCardTask([disabled, incomplete])).toBe(incomplete); + expect(selectProfileTaskCardTask([disabled])).toBeNull(); +}); + +test('profile task ViewModel builds card summary with reward fallback and clamped progress', () => { + expect(buildProfileTaskCardSummary(null)).toEqual({ + actionLabel: '去完成', + progressCount: 0, + progressPercent: 0, + rewardPoints: 10, + threshold: 1, + }); + + expect( + buildProfileTaskCardSummary( + buildCenter([ + buildTask({ + progressCount: 5, + rewardPoints: 25, + status: 'claimable', + threshold: 3, + }), + ]), + ), + ).toEqual({ + actionLabel: '领取', + progressCount: 3, + progressPercent: 100, + rewardPoints: 25, + threshold: 3, + }); +}); + +test('profile task ViewModel exposes task labels for the modal', () => { + const task = buildTask({ progressCount: -1, threshold: 0 }); + + expect(getProfileTaskStatusLabel('claimable')).toBe('可领取'); + expect(buildProfileTaskProgressLabel(task)).toBe('0/1'); + expect(getProfileTaskClaimButtonLabel(task, true)).toBe('领取中'); + expect(getProfileTaskClaimButtonLabel(buildTask({ status: 'claimed' }), false)).toBe( + '已领取', + ); + expect( + getProfileTaskClaimButtonLabel(buildTask({ status: 'claimable' }), false), + ).toBe('领取'); +}); diff --git a/src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts b/src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts new file mode 100644 index 00000000..5987b671 --- /dev/null +++ b/src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts @@ -0,0 +1,107 @@ +import type { + ProfileTaskCenterResponse, + ProfileTaskItem, +} from '../../../packages/shared/src/contracts/runtime'; + +const PROFILE_TASK_STATUS_PRIORITY_RANK: Record< + ProfileTaskItem['status'], + number +> = { + claimable: 2, + incomplete: 1, + disabled: 0, + claimed: -1, +}; +const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10; +const PROFILE_TASK_STATUS_LABELS: Record = { + incomplete: '未完成', + claimable: '可领取', + claimed: '已领取', + disabled: '已停用', +}; + +export type ProfileTaskCardSummary = { + actionLabel: string; + progressCount: number; + progressPercent: number; + rewardPoints: number; + threshold: number; +}; + +export function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) { + return tasks + .map((task, index) => ({ task, index })) + .filter( + ({ task }) => + task.status === 'claimable' || task.status === 'incomplete', + ) + .sort( + (left, right) => + PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] - + PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] || + left.index - right.index, + ) + .slice(0, 1) + .map(({ task }) => task); +} + +export function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) { + return ( + selectProfileTaskCenterTasks(tasks)[0] ?? + tasks.find((task) => task.status === 'claimed') ?? + tasks.find((task) => task.status !== 'disabled') ?? + null + ); +} + +export function getProfileTaskStatusLabel(status: ProfileTaskItem['status']) { + return PROFILE_TASK_STATUS_LABELS[status]; +} + +export function buildProfileTaskProgressLabel(task: ProfileTaskItem) { + const threshold = Math.max(1, task.threshold); + const progressCount = Math.min(Math.max(0, task.progressCount), threshold); + return `${progressCount}/${threshold}`; +} + +export function getProfileTaskClaimButtonLabel( + task: ProfileTaskItem, + isClaiming: boolean, +) { + if (isClaiming) { + return '领取中'; + } + + if (task.status === 'claimed') { + return '已领取'; + } + + return task.status === 'claimable' ? '领取' : '未完成'; +} + +export function buildProfileTaskCardSummary( + center: ProfileTaskCenterResponse | null, +): ProfileTaskCardSummary { + const task = selectProfileTaskCardTask(center?.tasks ?? []); + const threshold = Math.max(1, task?.threshold ?? 1); + const progressCount = Math.min( + Math.max(0, task?.progressCount ?? 0), + threshold, + ); + const rewardPoints = + task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS; + const actionLabel = + task?.status === 'claimable' + ? '领取' + : task?.status === 'claimed' + ? '已完成' + : '去完成'; + + return { + actionLabel, + progressCount, + progressPercent: Math.round((progressCount / threshold) * 100), + rewardPoints, + threshold, + }; +} From 4f59a0e791fc596265067af54d0a952ce8164e7e Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 17:08:38 +0800 Subject: [PATCH 09/67] =?UTF-8?q?refactor:=20=E8=BF=81=E7=A7=BB=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E5=B0=8F=E8=AF=B4=E4=B8=8E=E6=9C=A8=E9=B1=BC=20runtim?= =?UTF-8?q?e=20=E8=AF=B7=E6=B1=82=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 3 +- docs/README.md | 2 +- ...构】RuntimeClientFamily收口计划-2026-06-03.md | 4 +- .../visualNovelRuntimeClient.test.ts | 36 ++++++- .../visualNovelRuntimeClient.ts | 87 +++++++++-------- .../wooden-fish/woodenFishClient.test.ts | 89 ++++++++++++++++- src/services/wooden-fish/woodenFishClient.ts | 96 ++++++++----------- 7 files changed, 214 insertions(+), 103 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7aefb3a2..6ed5ece1 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -46,7 +46,8 @@ - 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。 - 追加决策:Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId`、`runId`)仍留在各玩法 client,通用 Module 只承接请求骨架。 - 追加决策:Puzzle 的 start / get / swap / drag / next-level / leaderboard 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`;Puzzle `pause` 与 `props` 仍保留原账号态 auth options,不直接接入 runtime guest auth。 -- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop runtime client、后续 Visual Novel runtime client 迁移。 +- 追加决策:Wooden Fish 的 start / checkpoint / finish 与 Visual Novel 的 gallery / run / history / regenerate JSON 请求也迁入 `runtimeRequest.ts`;Wooden Fish 的 `clientEventId` 生成仍留在木鱼 client,Visual Novel start 因 `timeoutMs`、SSE 因流式 `fetchWithApiAuth` 仍暂留原实现。 +- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop / Wooden Fish / Visual Novel runtime client。 - 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index 26f8e492..25f41526 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,7 +43,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,Hub 不再按玩法 `kind` 分发,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求与 Jump Hop 正式 run 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 公开作品分类、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md index 5f334e3d..c1cb759d 100644 --- a/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md @@ -11,7 +11,7 @@ - `buildRuntimeApiPath(basePath, ...segments)`:统一对 runtime path segment 执行 `encodeURIComponent`。 - `requestRuntimeJson(params)`:统一设置 method、JSON body、`Content-Type`、runtime guest `Authorization`、auth options 和 retry options。 -`match3dRuntimeClient.ts`、`squareHoleRuntimeClient.ts`、`bigFishRuntimeClient.ts`、`barkBattleRuntimeClient.ts`、`puzzleRuntimeClient.ts` 的公开 / 推荐运行态请求,以及 `jumpHopClient.ts` 的正式 run 请求已迁入此 **Module**,并保留原有导出函数名、错误文案、返回契约和重试常量。点击 / 投入 / 成绩提交等玩法专属 payload 归一化仍留在各自 client 内,避免把领域规则塞进通用请求 **Implementation**。 +`match3dRuntimeClient.ts`、`squareHoleRuntimeClient.ts`、`bigFishRuntimeClient.ts`、`barkBattleRuntimeClient.ts`、`puzzleRuntimeClient.ts` 的公开 / 推荐运行态请求、`jumpHopClient.ts` 与 `woodenFishClient.ts` 的正式 run 请求,以及 `visualNovelRuntimeClient.ts` 的公开列表、run 读取、history 读取和 regenerate JSON 请求已迁入此 **Module**,并保留原有导出函数名、错误文案、返回契约和重试常量。点击 / 投入 / 成绩提交等玩法专属 payload 归一化仍留在各自 client 内,避免把领域规则塞进通用请求 **Implementation**。 ## 约定 @@ -22,7 +22,7 @@ ## 后续深化 -下一批可迁移 Visual Novel runtime client,并评估 Puzzle `pause` / `props` 是否应继续保留账号态 auth options。迁移顺序以测试覆盖和请求形状接近度为准,优先迁移已有 guest launch 或 client 单测覆盖的函数。 +下一批可评估是否扩展 `requestRuntimeJson` 支持 `timeoutMs` / `signal`,再迁移 Visual Novel start 请求;Visual Novel SSE、平台存档、平台 checkpoint,以及 Puzzle `pause` / `props` 继续保留各自现有 auth / stream 语义,暂不纳入通用 JSON helper。 ## 验证 diff --git a/src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts b/src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts index c226b663..64e1cff4 100644 --- a/src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts +++ b/src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts @@ -10,10 +10,12 @@ vi.mock('../apiClient', () => ({ requestJson: requestJsonMock, })); +import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel'; import { buildVisualNovelRuntimeCheckpoint, buildVisualNovelSaveArchiveState, - type VisualNovelRuntimeStreamOptions, + getVisualNovelHistory, + getVisualNovelRun, listVisualNovelGallery, listVisualNovelSaveArchives, putVisualNovelRuntimeSnapshot, @@ -21,8 +23,8 @@ import { resumeVisualNovelSaveArchive, startVisualNovelRun, streamVisualNovelRuntimeAction, + type VisualNovelRuntimeStreamOptions, } from './visualNovelRuntimeClient'; -import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel'; function createMockRun( overrides: Partial = {}, @@ -108,6 +110,32 @@ test('startVisualNovelRun uses the visual novel runtime work route', async () => ); }); +test('getVisualNovelRun and getVisualNovelHistory use encoded runtime run routes', async () => { + requestJsonMock + .mockResolvedValueOnce({ run: createMockRun() }) + .mockResolvedValueOnce({ entries: [] }); + + await getVisualNovelRun('vn/run-1'); + await getVisualNovelHistory('vn/run-1'); + + expect(requestJsonMock.mock.calls[0]).toEqual([ + '/api/runtime/visual-novel/runs/vn%2Frun-1', + expect.objectContaining({ method: 'GET' }), + '读取视觉小说运行快照失败', + expect.objectContaining({ + retry: expect.objectContaining({ maxRetries: 1 }), + }), + ]); + expect(requestJsonMock.mock.calls[1]).toEqual([ + '/api/runtime/visual-novel/runs/vn%2Frun-1/history', + expect.objectContaining({ method: 'GET' }), + '读取视觉小说历史失败', + expect.objectContaining({ + retry: expect.objectContaining({ maxRetries: 1 }), + }), + ]); +}); + test('streamVisualNovelRuntimeAction posts to the SSE action stream route', async () => { const response = createSseResponse( [ @@ -146,6 +174,10 @@ test('streamVisualNovelRuntimeAction posts to the SSE action stream route', asyn }), signal: undefined, }), + expect.objectContaining({ + skipAuth: undefined, + skipRefresh: undefined, + }), ); expect(result).toMatchObject({ runId: 'vn-run-route-1' }); }); diff --git a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts index 8f29ffe4..527d165b 100644 --- a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts +++ b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts @@ -23,12 +23,13 @@ import { fetchWithApiAuth, requestJson, } from '../apiClient'; -import { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse'; import { buildRuntimeGuestAuthOptions, buildRuntimeGuestHeaders, type RuntimeGuestRequestOptions, } from '../runtimeGuestAuth'; +import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; +import { readVisualNovelRuntimeRunFromSse } from './visualNovelRuntimeSse'; const VISUAL_NOVEL_RUNTIME_API_BASE = '/api/runtime/visual-novel'; const VISUAL_NOVEL_RUNTIME_READ_RETRY: ApiRetryOptions = { @@ -57,17 +58,13 @@ export type VisualNovelSaveArchiveResumeResponse = >; export async function listVisualNovelGallery() { - return requestJson( - `${VISUAL_NOVEL_RUNTIME_API_BASE}/gallery`, - { method: 'GET' }, - '读取视觉小说公开作品列表失败', - { - retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, - // 中文注释:公开广场是游客可读入口,避免未登录态先触发 refresh 再读取公开列表。 - skipAuth: true, - skipRefresh: true, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(VISUAL_NOVEL_RUNTIME_API_BASE, 'gallery'), + fallbackMessage: '读取视觉小说公开作品列表失败', + retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, + // 中文注释:公开广场是游客可读入口,避免未登录态先触发 refresh 再读取公开列表。 + requestOptions: { skipAuth: true, skipRefresh: true }, + }); } function buildJsonInit(method: 'POST' | 'PUT', payload: unknown): RequestInit { @@ -117,7 +114,12 @@ export async function startVisualNovelRun( ) { const requestOptions = buildRuntimeGuestAuthOptions(options); return requestJson( - `${VISUAL_NOVEL_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/runs`, + buildRuntimeApiPath( + VISUAL_NOVEL_RUNTIME_API_BASE, + 'works', + profileId, + 'runs', + ), { ...buildJsonInit('POST', payload), headers: buildRuntimeGuestHeaders(options, { @@ -134,25 +136,24 @@ export async function startVisualNovelRun( } export async function getVisualNovelRun(runId: string) { - return requestJson( - `${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}`, - { method: 'GET' }, - '读取视觉小说运行快照失败', - { - retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(VISUAL_NOVEL_RUNTIME_API_BASE, 'runs', runId), + fallbackMessage: '读取视觉小说运行快照失败', + retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, + }); } export async function getVisualNovelHistory(runId: string) { - return requestJson( - `${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/history`, - { method: 'GET' }, - '读取视觉小说历史失败', - { - retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + VISUAL_NOVEL_RUNTIME_API_BASE, + 'runs', + runId, + 'history', + ), + fallbackMessage: '读取视觉小说历史失败', + retry: VISUAL_NOVEL_RUNTIME_READ_RETRY, + }); } export async function streamVisualNovelRuntimeAction( @@ -161,7 +162,13 @@ export async function streamVisualNovelRuntimeAction( options: VisualNovelRuntimeStreamOptions = {}, ) { const response = await openVisualNovelRuntimeSsePost( - `${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/actions/stream`, + buildRuntimeApiPath( + VISUAL_NOVEL_RUNTIME_API_BASE, + 'runs', + runId, + 'actions', + 'stream', + ), payload, '推进视觉小说失败', options.signal, @@ -179,14 +186,18 @@ export async function regenerateVisualNovelRun( runId: string, payload: VisualNovelRegenerateRequest, ) { - return requestJson( - `${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/regenerate`, - buildJsonInit('POST', payload), - '重生成视觉小说历史失败', - { - retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + VISUAL_NOVEL_RUNTIME_API_BASE, + 'runs', + runId, + 'regenerate', + ), + method: 'POST', + jsonBody: payload, + fallbackMessage: '重生成视觉小说历史失败', + retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY, + }); } export async function listVisualNovelSaveArchives(profileId?: string | null) { diff --git a/src/services/wooden-fish/woodenFishClient.test.ts b/src/services/wooden-fish/woodenFishClient.test.ts index aef88dee..aea47b59 100644 --- a/src/services/wooden-fish/woodenFishClient.test.ts +++ b/src/services/wooden-fish/woodenFishClient.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; const requestJsonMock = vi.hoisted(() => vi.fn()); @@ -27,6 +27,10 @@ beforeEach(() => { requestJsonMock.mockReset(); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + test('wooden fish creation keeps image2 generation requests alive long enough', async () => { await import('./woodenFishClient'); @@ -50,3 +54,86 @@ test('wooden fish list works uses creation works endpoint', async () => { '读取敲木鱼作品列表失败', ); }); + +test('wooden fish start run uses runtime guest json skeleton', async () => { + const { woodenFishClient } = await import('./woodenFishClient'); + requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } }); + + await woodenFishClient.startRun('profile/1', { + runtimeGuestToken: 'runtime-guest-token', + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/wooden-fish/runs', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer runtime-guest-token', + }, + body: JSON.stringify({ profileId: 'profile/1' }), + }), + '启动敲木鱼运行态失败', + expect.objectContaining({ + retry: expect.objectContaining({ retryUnsafeMethods: true }), + skipAuth: true, + skipRefresh: true, + }), + ); +}); + +test('wooden fish checkpoint run keeps client event id local to the client', async () => { + const { woodenFishClient } = await import('./woodenFishClient'); + vi.spyOn(Date, 'now').mockReturnValue(1780000000000); + requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } }); + + await woodenFishClient.checkpointRun( + 'run/1', + { + totalTapCount: 12, + wordCounters: [{ text: '功德', count: 3 }], + }, + { runtimeGuestToken: 'runtime-guest-token' }, + ); + + const [, init] = requestJsonMock.mock.calls[0]; + const body = JSON.parse(init.body); + expect(requestJsonMock.mock.calls[0][0]).toBe( + '/api/runtime/wooden-fish/runs/run%2F1/checkpoint', + ); + expect(body).toEqual({ + totalTapCount: 12, + wordCounters: [{ text: '功德', count: 3 }], + clientEventId: 'checkpoint-run/1-1780000000000', + }); + expect(body).not.toHaveProperty('runId'); + expect(body).not.toHaveProperty('checkpointAtMs'); +}); + +test('wooden fish finish run keeps finish event id local to the client', async () => { + const { woodenFishClient } = await import('./woodenFishClient'); + vi.spyOn(Date, 'now').mockReturnValue(1780000000001); + requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } }); + + await woodenFishClient.finishRun( + 'run/1', + { + totalTapCount: 18, + wordCounters: [{ text: '清净', count: 2 }], + }, + { runtimeGuestToken: 'runtime-guest-token' }, + ); + + const [, init] = requestJsonMock.mock.calls[0]; + const body = JSON.parse(init.body); + expect(requestJsonMock.mock.calls[0][0]).toBe( + '/api/runtime/wooden-fish/runs/run%2F1/finish', + ); + expect(body).toEqual({ + totalTapCount: 18, + wordCounters: [{ text: '清净', count: 2 }], + clientEventId: 'finish-run/1-1780000000001', + }); + expect(body).not.toHaveProperty('runId'); + expect(body).not.toHaveProperty('finishedAtMs'); +}); diff --git a/src/services/wooden-fish/woodenFishClient.ts b/src/services/wooden-fish/woodenFishClient.ts index f6f31005..8d5efd68 100644 --- a/src/services/wooden-fish/woodenFishClient.ts +++ b/src/services/wooden-fish/woodenFishClient.ts @@ -13,17 +13,14 @@ import type { WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkProfileResponse, - WoodenFishWorksResponse, WoodenFishWorkspaceCreateRequest, + WoodenFishWorksResponse, WoodenFishWorkSummaryResponse, } from '../../../packages/shared/src/contracts/woodenFish'; import { type ApiRetryOptions, requestJson } from '../apiClient'; import { createCreationAgentClient } from '../creation-agent'; -import { - buildRuntimeGuestAuthOptions, - buildRuntimeGuestHeaders, - type RuntimeGuestRequestOptions, -} from '../runtimeGuestAuth'; +import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth'; +import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; const WOODEN_FISH_API_BASE = '/api/creation/wooden-fish/sessions'; const WOODEN_FISH_WORKS_API_BASE = '/api/creation/wooden-fish/works'; @@ -58,8 +55,8 @@ export type { WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkProfileResponse, - WoodenFishWorksResponse, WoodenFishWorkspaceCreateRequest, + WoodenFishWorksResponse, }; export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest; export type WoodenFishSessionSnapshot = WoodenFishSessionSnapshotResponse; @@ -237,23 +234,14 @@ export async function startWoodenFishRuntimeRun( profileId: string, options: WoodenFishRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `${WOODEN_FISH_RUNTIME_API_BASE}/runs`, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...buildRuntimeGuestHeaders(options), - }, - body: JSON.stringify({ profileId }), - }, - '启动敲木鱼运行态失败', - { - retry: WOODEN_FISH_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(WOODEN_FISH_RUNTIME_API_BASE, 'runs'), + method: 'POST', + jsonBody: { profileId }, + fallbackMessage: '启动敲木鱼运行态失败', + retry: WOODEN_FISH_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } export async function checkpointWoodenFishRun( @@ -261,28 +249,24 @@ export async function checkpointWoodenFishRun( payload: Omit, options: WoodenFishRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); const requestPayload: WoodenFishCheckpointRunRequest = { ...payload, clientEventId: `checkpoint-${runId}-${Date.now()}`, }; - return requestJson( - `${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/checkpoint`, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...buildRuntimeGuestHeaders(options), - }, - body: JSON.stringify(requestPayload), - }, - '保存敲木鱼进度失败', - { - retry: WOODEN_FISH_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + WOODEN_FISH_RUNTIME_API_BASE, + 'runs', + runId, + 'checkpoint', + ), + method: 'POST', + jsonBody: requestPayload, + fallbackMessage: '保存敲木鱼进度失败', + retry: WOODEN_FISH_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } export async function finishWoodenFishRun( @@ -290,28 +274,24 @@ export async function finishWoodenFishRun( payload: Omit, options: WoodenFishRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); const requestPayload: WoodenFishFinishRunRequest = { ...payload, clientEventId: `finish-${runId}-${Date.now()}`, }; - return requestJson( - `${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/finish`, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...buildRuntimeGuestHeaders(options), - }, - body: JSON.stringify(requestPayload), - }, - '结束敲木鱼运行失败', - { - retry: WOODEN_FISH_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + WOODEN_FISH_RUNTIME_API_BASE, + 'runs', + runId, + 'finish', + ), + method: 'POST', + jsonBody: requestPayload, + fallbackMessage: '结束敲木鱼运行失败', + retry: WOODEN_FISH_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } export const woodenFishClient = { From a17894203321963938c6da6727369d6a56ea8b41 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 17:30:28 +0800 Subject: [PATCH 10/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E6=95=B0=E6=8D=AE=E5=B1=95=E7=A4=BA=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...ofileDashboardPresentation收口计划-2026-06-03.md | 30 ++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 98 +++++-------------- ...gEntryProfileDashboardPresentation.test.ts | 89 +++++++++++++++++ .../rpgEntryProfileDashboardPresentation.ts | 95 ++++++++++++++++++ 6 files changed, 250 insertions(+), 72 deletions(-) create mode 100644 docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md create mode 100644 src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts create mode 100644 src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 6ed5ece1..fb0bfb2f 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1233,6 +1233,14 @@ - 验证方式:工作台首屏不再出现标题 / 简介 / 标签输入;结果页修改后点试玩或发布会先写回当前作品信息。 - 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-03 Profile Dashboard Presentation 收口 + +- 背景:`RpgEntryHomeView.tsx` 同时承载个人数据卡、钱包 chip 与“玩过”弹窗,计数压缩、累计时长、单作品时长、玩法标签和作品号兜底散在页面 Implementation 内,修改展示口径时缺少稳定测试面。 +- 决策:新增 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts` 作为个人数据展示 Module,Interface 收口为 `buildProfileDashboardPresentation`、计数 / 时长格式化和“玩过”列表标签 / 作品号格式化函数;页面只消费结果并保留 UI 编排与点击处理。 +- 影响范围:RPG 首页“我的数据”卡片、移动端 / 桌面端钱包 chip、个人数据弹窗与“玩过”列表。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md`。 + ## 2026-05-26 前端不外露图片模型名 - 背景:拼图与相关结果页、生成进度和错误提示里直接显示 `gpt-image-2`、`gemini-3.1-flash-image-preview`、`image-2` 等名称,会把内部模型路由暴露给普通用户。 diff --git a/docs/README.md b/docs/README.md index 25f41526..8f2a22f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +个人数据卡、钱包 chip 与“玩过”弹窗的计数、时长、作品类型和作品号展示收口到 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,规则见 [【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileDashboardPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 diff --git a/docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md b/docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md new file mode 100644 index 00000000..9f41bbba --- /dev/null +++ b/docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md @@ -0,0 +1,30 @@ +# 【前端架构】Profile Dashboard Presentation 收口计划 + +## 背景 + +`RpgEntryHomeView.tsx` 的“我的数据”、钱包 chip 和“玩过”弹窗共用一批展示规则:泥点数量压缩、累计时长固定小时展示、单作品游玩时长压缩、作品类型标签和作品 ID 兜底。原先这些规则散在页面 **Implementation** 内,导致格式口径只能靠 UI 集成测试间接保护。 + +## 决策 + +新增 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,作为个人数据展示 **Module**。该 **Module** 的 **Interface** 收口为: + +- `buildProfileDashboardPresentation(dashboard)`:统一生成钱包余额、钱包文案、累计时长文案和已玩数量文案。 +- `formatDashboardCount(value)`:统一泥点和计数压缩规则。 +- `formatTotalPlayTimeHours(playTimeMs)`:统一“累计游戏时长”固定小时口径。 +- `formatCompactPlayTime(playTimeMs)`:统一“玩过”单作品紧凑时长。 +- `formatPlayedWorkType(value)` 与 `formatPlayedWorkId(work)`:统一“玩过”列表里的玩法标签和作品号兜底。 + +`RpgEntryHomeView.tsx` 只消费这些 presentation 函数,保留卡片、弹窗和点击处理。个人数据展示规则的 **Locality** 转移到该 **Module** 与纯测试,后续修改计数、时长或作品类型标签不再穿透页面 JSX。 + +## 约定 + +- `formatDashboardCount` 与公开作品卡片的 `formatCompactCount` 不合并,二者展示口径不同。 +- “累计游戏时长”固定以小时展示,避免个人数据卡在分钟 / 天之间跳动。 +- “玩过”列表当前仍按历史契约用 `profileId || worldKey` 展示作品号;若后端未来下发 `publicWorkCode`,应在此 **Module** 改口径。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 812c595a..35618a4b 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -134,6 +134,13 @@ import { import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; +import { + buildProfileDashboardPresentation, + formatCompactPlayTime, + formatPlayedWorkId, + formatPlayedWorkType, + formatTotalPlayTimeHours, +} from './rpgEntryProfileDashboardPresentation'; import { buildProfileTaskCardSummary, buildProfileTaskProgressLabel, @@ -1758,46 +1765,6 @@ function formatSnapshotTime(value: string | null | undefined) { }); } -function formatCompactPlayTime(playTimeMs: number) { - const totalMinutes = Math.max(0, Math.floor(playTimeMs / 60000)); - const days = totalMinutes / 1440; - - if (days >= 10) { - return `${Math.floor(days)}天`; - } - if (days >= 1) { - return `${days.toFixed(days >= 3 ? 0 : 1)}天`; - } - - const hours = totalMinutes / 60; - if (hours >= 1) { - return `${hours.toFixed(hours >= 10 ? 0 : 1)}小时`; - } - - return `${Math.max(0, totalMinutes)}分`; -} - -// “游戏时长”固定使用小时,避免短时长切到分钟或长时长切到天。 -function formatTotalPlayTimeHours(playTimeMs: number) { - const roundedHours = Math.max(0, Math.round(playTimeMs / 360000) / 10); - - return `${roundedHours.toLocaleString('zh-CN', { - maximumFractionDigits: 1, - })}小时`; -} - -function formatDashboardCount(value: number) { - const normalizedValue = Math.max(0, Math.round(value)); - if (normalizedValue >= 100000000) { - return `${(normalizedValue / 100000000).toFixed(1)}亿`; - } - if (normalizedValue >= 10000) { - return `${(normalizedValue / 10000).toFixed(1)}万`; - } - - return normalizedValue.toLocaleString('zh-CN'); -} - function normalizeProfileInviteQueryCode(value: string | null | undefined) { return (value ?? '') .trim() @@ -1816,27 +1783,6 @@ function readProfileInviteCodeFromLocationSearch(search: string) { return ''; } -function formatPlayedWorkType(value: string | null | undefined) { - const normalizedValue = (value ?? '').toLowerCase(); - if (normalizedValue === 'puzzle') { - return '拼图'; - } - if (normalizedValue === 'match3d' || normalizedValue === 'match_3d') { - return '抓鹅'; - } - if (normalizedValue === 'square-hole' || normalizedValue === 'square_hole') { - return '方洞'; - } - if (normalizedValue === 'big_fish' || normalizedValue === 'big-fish') { - return '大鱼'; - } - return 'RPG'; -} - -function formatPlayedWorkId(work: ProfilePlayedWorkSummary) { - return work.profileId?.trim() || work.worldKey; -} - function buildPublicUserCode(user: AuthUser | null | undefined) { if (user?.publicUserCode?.trim()) { return user.publicUserCode.trim(); @@ -3839,11 +3785,11 @@ export function RpgEntryHomeView({ const activeLegalDocument = activeLegalDocumentId ? getLegalDocument(activeLegalDocumentId) : null; - const remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0; - const totalPlayTime = formatTotalPlayTimeHours( - profileDashboard?.totalPlayTimeMs ?? 0, + const profileDashboardPresentation = useMemo( + () => buildProfileDashboardPresentation(profileDashboard), + [profileDashboard], ); - const playedWorkCount = profileDashboard?.playedWorldCount ?? 0; + const remainingNarrativeCoins = profileDashboardPresentation.walletBalance; const profileTaskCardSummary = useMemo( () => buildProfileTaskCardSummary(taskCenter), [taskCenter], @@ -5960,7 +5906,7 @@ export function RpgEntryHomeView({ - {formatDashboardCount(remainingNarrativeCoins)}泥点 + + {profileDashboardPresentation.walletBalanceWithUnitLabel} + ) : !isAuthenticated ? ( ) : null} - {formatCompactCount(likeCount)} + {formatPlatformCompactCount(likeCount)} ); @@ -1711,51 +1709,10 @@ async function getPublicWorkAuthorSummary( return null; } -function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) { - if (isBigFishGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('大鱼吃小鱼'); - } - if (isPuzzleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('拼图'); - } - if (isMatch3DGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('抓大鹅'); - } - if (isSquareHoleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('方洞挑战'); - } - if (isJumpHopGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('跳一跳'); - } - if (isWoodenFishGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('敲木鱼'); - } - if (isVisualNovelGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('视觉小说'); - } - if (isBarkBattleGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag('汪汪声浪'); - } - if (isEdutainmentGalleryEntry(entry)) { - return formatPlatformWorkDisplayTag(entry.templateName); - } - return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode)); -} function getPublicAuthorAvatarLabel(authorDisplayName: string) { return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩'; } -function formatCompactCount(value: number) { - const normalizedValue = Math.max(0, Math.round(value)); - if (normalizedValue >= 100000000) { - return `${(normalizedValue / 100000000).toFixed(1)}亿`; - } - if (normalizedValue >= 10000) { - return `${(normalizedValue / 10000).toFixed(1)}万`; - } - return `${normalizedValue}`; -} - function formatSnapshotTime(value: string | null | undefined) { if (!value) { return '刚刚保存'; @@ -6041,7 +5998,7 @@ export function RpgEntryHomeView({ {leadPublicEntry - ? describePublicGalleryCardKind(leadPublicEntry) + ? describePlatformPublicWorkKind(leadPublicEntry) : '作品'} diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts index 765d4436..ba1361b1 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts @@ -3,8 +3,10 @@ import { expect, test } from 'vitest'; import { buildPlatformWorldDisplayTags, buildPuzzleWorkCoverSlides, + describePlatformPublicWorkKind, EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + formatPlatformCompactCount, formatPlatformWorkDisplayName, formatPlatformWorkDisplayTags, formatPlatformWorldTime, @@ -16,10 +18,11 @@ import { mapBarkBattleWorkToPlatformGalleryCard, mapVisualNovelWorkToPlatformGalleryCard, mapWoodenFishWorkToPlatformGalleryCard, + type PlatformBigFishGalleryCard, type PlatformEdutainmentGalleryCard, type PlatformPuzzleGalleryCard, - resolvePlatformWorkAuthorDisplayName, resolvePlatformPublicWorkCode, + resolvePlatformWorkAuthorDisplayName, resolvePlatformWorldFallbackCoverImage, } from './rpgEntryWorldPresentation'; @@ -53,6 +56,48 @@ test('platform work display text limits names and tags by character count', () = ).toEqual(['超长机关', '星桥']); }); +test('platform public work presentation formats compact counts and kind labels', () => { + const puzzleCard: PlatformPuzzleGalleryCard = { + sourceType: 'puzzle', + workId: 'puzzle-work-kind', + profileId: 'puzzle-profile-kind', + publicWorkCode: 'PZ-KIND', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + worldName: '机关拼图', + subtitle: '拼图关卡', + summaryText: '公开作品', + coverImageSrc: null, + themeTags: ['拼图'], + visibility: 'published', + publishedAt: '2026-05-18T00:00:00.000Z', + updatedAt: '2026-05-18T00:00:00.000Z', + }; + const bigFishCard: PlatformBigFishGalleryCard = { + sourceType: 'big-fish', + workId: 'big-fish-work-kind', + profileId: 'big-fish-profile-kind', + publicWorkCode: 'BF-KIND', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + worldName: '大鱼海湾', + subtitle: '大鱼关卡', + summaryText: '公开作品', + coverImageSrc: null, + themeTags: ['大鱼'], + visibility: 'published', + publishedAt: '2026-05-18T00:00:00.000Z', + updatedAt: '2026-05-18T00:00:00.000Z', + }; + + expect(formatPlatformCompactCount(-1)).toBe('0'); + expect(formatPlatformCompactCount(9999)).toBe('9999'); + expect(formatPlatformCompactCount(10000)).toBe('1.0万'); + expect(formatPlatformCompactCount(100000000)).toBe('1.0亿'); + expect(describePlatformPublicWorkKind(puzzleCard)).toBe('拼图'); + expect(describePlatformPublicWorkKind(bigFishCard)).toBe('大鱼吃小'); +}); + test('platform public cards use play type reference images as cover fallback', () => { const puzzleCard: PlatformPuzzleGalleryCard = { sourceType: 'puzzle', diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index ff4b511e..245b955c 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -1,5 +1,5 @@ -import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth'; +import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; @@ -863,6 +863,52 @@ export function formatPlatformWorkDisplayTags( ].slice(0, limit); } +export function formatPlatformCompactCount(value: number) { + const normalizedValue = Math.max(0, Math.round(value)); + if (normalizedValue >= 100000000) { + return `${(normalizedValue / 100000000).toFixed(1)}亿`; + } + if (normalizedValue >= 10000) { + return `${(normalizedValue / 10000).toFixed(1)}万`; + } + + return `${normalizedValue}`; +} + +export function describePlatformPublicWorkKind( + entry: PlatformPublicGalleryCard, +) { + if (isBigFishGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('大鱼吃小鱼'); + } + if (isPuzzleGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('拼图'); + } + if (isMatch3DGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('抓大鹅'); + } + if (isSquareHoleGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('方洞挑战'); + } + if (isJumpHopGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('跳一跳'); + } + if (isWoodenFishGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('敲木鱼'); + } + if (isVisualNovelGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('视觉小说'); + } + if (isBarkBattleGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag('汪汪声浪'); + } + if (isEdutainmentGalleryEntry(entry)) { + return formatPlatformWorkDisplayTag(entry.templateName); + } + + return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode)); +} + export function resolvePlatformWorkAuthorDisplayName( entry: PlatformPublicGalleryCard, authorSummary?: PublicUserSummary | null, From 685560ec07268bc91cd05303c8bda9a9b9eeebb3 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 18:37:34 +0800 Subject: [PATCH 14/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=8E=92?= =?UTF-8?q?=E8=A1=8C=E9=85=8D=E7=BD=AE=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 +++ docs/README.md | 2 + ...架构】RankingViewModel收口计划-2026-06-03.md | 32 ++++++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 62 +++++------------- .../rpgEntryPublicGalleryViewModel.test.ts | 25 ++++++++ .../rpgEntryPublicGalleryViewModel.ts | 63 +++++++++++++++++++ 6 files changed, 144 insertions(+), 48 deletions(-) create mode 100644 docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 12c85716..b4b810f9 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1249,6 +1249,14 @@ - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md`。 +## 2026-06-03 Ranking ViewModel 收口 + +- 背景:排行 tab 的文案、metric label 与空态文案在 `RpgEntryHomeView.tsx`,排序和 metric value 在 `rpgEntryPublicGalleryViewModel.ts`,同一 `PlatformRankingTab` 的 Interface 分散且页面需要类型断言取 active config。 +- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口 `DEFAULT_PLATFORM_RANKING_TAB`、`PLATFORM_RANKING_TABS`、`getPlatformRankingTabConfig` 与 `getPlatformRankingMetric`;页面仅保留 active tab 状态和渲染。 +- 影响范围:发现页排行频道 tab 顺序、tab 文案、空态文案、排行项指标 label/value。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "bottom category tab becomes ranking and switches ranking metrics|ranking"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 92dbefdd..b286166d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,6 +51,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 推荐 feed 的公开作品去重、普通内容过滤、active 窗口与上一条 / 下一条回环选择也收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RecommendFeedViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +排行频道的默认 tab、tab 文案、空态文案、排序字段与指标 label/value 收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RankingViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RankingViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + 每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 个人数据卡、钱包 chip 与“玩过”弹窗的计数、时长、作品类型和作品号展示收口到 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,规则见 [【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileDashboardPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md new file mode 100644 index 00000000..049faea6 --- /dev/null +++ b/docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md @@ -0,0 +1,32 @@ +# 【前端架构】Ranking ViewModel 收口计划 + +## 背景 + +平台发现页排行频道以 `PlatformRankingTab` 决定 tab 文案、空态文案、排序字段和指标展示。原先排序与指标取值在 `rpgEntryPublicGalleryViewModel.ts`,而 tab label、metric label 与 empty text 留在 `RpgEntryHomeView.tsx`,页面还用类型断言寻找 active config,导致同一个排行语义的 **Interface** 分散。 + +## 决策 + +在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口排行 **Interface**: + +- `DEFAULT_PLATFORM_RANKING_TAB` 与 `PLATFORM_RANKING_TABS`:统一 tab 顺序、tab label、metric label 与空态文案。 +- `getPlatformRankingTabConfig(tab)`:统一 active tab 配置兜底。 +- `getPlatformRankingMetric(entry, tab)`:统一 metric label 与 value,避免 label/value 漂移。 +- `buildPlatformRankingEntries(entries, tab)` 继续承载排序规则。 + +`RpgEntryHomeView.tsx` 只保留 active tab 状态、点击与渲染,不再理解“热门榜=游玩值”“新品榜=近 7 日值”等映射。排行规则的 **Locality** 收口到 PublicGallery ViewModel。 + +## 约定 + +- 默认排行 tab 保持 `hot`。 +- tab 顺序保持“热门榜 / 改造榜 / 新品榜 / 点赞榜”。 +- 排序口径保持:`hot=playCount`、`remix=remixCount`、`new=recentPlayCount7d`、`like=likeCount`。 +- “新品榜”仍按近 7 日游玩数排序,不改为发布时间排序。 +- 页面层继续保留最多显示 30 条的展示限制。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "bottom category tab becomes ranking and switches ranking metrics|ranking"` +- 针对变更文件执行 ESLint +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index c273bf00..3bb1eb3d 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -160,20 +160,24 @@ import { buildPublicCategoryGroups, buildPublicGalleryCardKey, dedupePlatformPublicGalleryEntries, + DEFAULT_PLATFORM_RANKING_TAB, filterPlatformWorkSearchResults, filterTodayPublishedEntries, getAllPlatformPublicEntries, getPlatformCategoryPrimaryMetric, getPlatformPublicEntries, - getPlatformRankingMetricValue, + getPlatformRankingMetric, + getPlatformRankingTabConfig, getPlatformSearchableWorkIds, getPlatformWorldLikeCount, getPlatformWorldPlayCount, getPlatformWorldRemixCount, isExactPublicWorkCodeSearch, matchesPlatformCategoryKindFilter, + PLATFORM_RANKING_TABS, type PlatformCategoryKindFilter, type PlatformCategorySortMode, + type PlatformRankingMetric, type PlatformRankingTab, selectPlatformRecommendFeedWindow, sortPlatformCategoryEntries, @@ -405,37 +409,6 @@ const CHILD_MOTION_DEMO_DEFAULT_CARD = { summary: '站位、招手和左右手活动。', }; -const PLATFORM_RANKING_TABS: Array<{ - id: PlatformRankingTab; - label: string; - metricLabel: string; - emptyText: string; -}> = [ - { - id: 'hot', - label: '热门榜', - metricLabel: '游玩', - emptyText: '公开广场暂时还没有热门作品。', - }, - { - id: 'remix', - label: '改造榜', - metricLabel: '改造', - emptyText: '公开广场暂时还没有改造作品。', - }, - { - id: 'new', - label: '新品榜', - metricLabel: '近7日', - emptyText: '近 7 日暂时还没有新品。', - }, - { - id: 'like', - label: '点赞榜', - metricLabel: '点赞', - emptyText: '公开广场暂时还没有点赞作品。', - }, -]; function ResolvedAssetBackdrop({ src, fallbackSrc, @@ -1362,14 +1335,12 @@ function DesktopTrendingItem({ function PlatformRankingItem({ entry, rank, - metricLabel, - metricValue, + metric, onClick, }: { entry: PlatformPublicGalleryCard; rank: number; - metricLabel: string; - metricValue: number; + metric: PlatformRankingMetric; onClick: () => void; }) { const coverImage = resolvePlatformWorldCoverImage(entry); @@ -1405,9 +1376,9 @@ function PlatformRankingItem({
- {formatPlatformCompactCount(metricValue)} + {formatPlatformCompactCount(metric.value)} - {metricLabel} + {metric.label} · {describePlatformPublicWorkKind(entry)}
@@ -3567,8 +3538,9 @@ export function RpgEntryHomeView({ const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState< Record >({}); - const [activeRankingTab, setActiveRankingTab] = - useState('hot'); + const [activeRankingTab, setActiveRankingTab] = useState( + DEFAULT_PLATFORM_RANKING_TAB, + ); const [visitedTabs, setVisitedTabs] = useState>( () => new Set([activeTab]), ); @@ -4794,9 +4766,7 @@ export function RpgEntryHomeView({ activeTab, mobileFeedCarouselEnabled, ]); - const activeRankingConfig = PLATFORM_RANKING_TABS.find( - (tab) => tab.id === activeRankingTab, - ) as (typeof PLATFORM_RANKING_TABS)[number]; + const activeRankingConfig = getPlatformRankingTabConfig(activeRankingTab); const rankingEntries = useMemo( () => buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30), @@ -5037,11 +5007,7 @@ export function RpgEntryHomeView({ key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`} entry={entry} rank={index + 1} - metricLabel={activeRankingConfig.metricLabel} - metricValue={getPlatformRankingMetricValue( - entry, - activeRankingTab, - )} + metric={getPlatformRankingMetric(entry, activeRankingTab)} onClick={() => onOpenGalleryDetail(entry)} /> ))} diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts index 6ad55bcb..146fa079 100644 --- a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts @@ -7,14 +7,18 @@ import { buildPublicCategoryGroups, buildPublicGalleryCardKey, dedupePlatformPublicGalleryEntries, + DEFAULT_PLATFORM_RANKING_TAB, filterPlatformWorkSearchResults, filterTodayPublishedEntries, getPlatformCategoryKindFilter, getPlatformCategoryPrimaryMetric, getPlatformPublicEntries, + getPlatformRankingMetric, getPlatformRankingMetricValue, + getPlatformRankingTabConfig, matchesPlatformCategoryKindFilter, parsePlatformEntryTimestamp, + PLATFORM_RANKING_TABS, selectAdjacentPlatformRecommendEntry, selectPlatformRecommendFeedWindow, sortPlatformCategoryEntries, @@ -335,11 +339,32 @@ test('public gallery ViewModel ranks entries by selected metric', () => { }); const entries = [recentWinner, remixWinner, likeWinner, playWinner]; + expect(DEFAULT_PLATFORM_RANKING_TAB).toBe('hot'); + expect(PLATFORM_RANKING_TABS.map((tab) => tab.label)).toEqual([ + '热门榜', + '改造榜', + '新品榜', + '点赞榜', + ]); + expect(getPlatformRankingTabConfig('new')).toEqual({ + id: 'new', + label: '新品榜', + metricLabel: '近7日', + emptyText: '近 7 日暂时还没有新品。', + }); expect(buildPlatformRankingEntries(entries, 'hot')[0]).toBe(playWinner); expect(buildPlatformRankingEntries(entries, 'remix')[0]).toBe(remixWinner); expect(buildPlatformRankingEntries(entries, 'new')[0]).toBe(recentWinner); expect(buildPlatformRankingEntries(entries, 'like')[0]).toBe(likeWinner); expect(getPlatformRankingMetricValue(likeWinner, 'like')).toBe(40); + expect(getPlatformRankingMetric(recentWinner, 'new')).toEqual({ + label: '近7日', + value: 30, + }); + expect(getPlatformRankingMetric(playWinner, 'hot')).toEqual({ + label: '游玩', + value: 100, + }); }); test('public gallery ViewModel sorts category entries and exposes primary metric', () => { diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts index ceca4d6d..0ff23cb1 100644 --- a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts @@ -15,6 +15,16 @@ import { } from './rpgEntryWorldPresentation'; export type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like'; +export type PlatformRankingTabConfig = { + emptyText: string; + id: PlatformRankingTab; + label: string; + metricLabel: string; +}; +export type PlatformRankingMetric = { + label: string; + value: number; +}; export type PlatformCategoryKindFilter = | 'all' | 'puzzle' @@ -33,6 +43,40 @@ export type PlatformPublicCategoryGroup = { entries: PlatformPublicGalleryCard[]; }; +export const DEFAULT_PLATFORM_RANKING_TAB: PlatformRankingTab = 'hot'; + +export const PLATFORM_RANKING_TABS: PlatformRankingTabConfig[] = [ + { + id: 'hot', + label: '热门榜', + metricLabel: '游玩', + emptyText: '公开广场暂时还没有热门作品。', + }, + { + id: 'remix', + label: '改造榜', + metricLabel: '改造', + emptyText: '公开广场暂时还没有改造作品。', + }, + { + id: 'new', + label: '新品榜', + metricLabel: '近7日', + emptyText: '近 7 日暂时还没有新品。', + }, + { + id: 'like', + label: '点赞榜', + metricLabel: '点赞', + emptyText: '公开广场暂时还没有点赞作品。', + }, +]; + +const DEFAULT_PLATFORM_RANKING_CONFIG = + PLATFORM_RANKING_TABS.find( + (config) => config.id === DEFAULT_PLATFORM_RANKING_TAB, + ) ?? PLATFORM_RANKING_TABS[0]!; + export type PlatformRecommendFeedWindow = { activeEntry: PlatformPublicGalleryCard | null; activeEntryKey: string | null; @@ -372,6 +416,15 @@ export function buildPlatformRankingEntries( return sortEntriesByMetric(entries, getPlatformWorldRecentPlayCount); } +export function getPlatformRankingTabConfig( + tab: PlatformRankingTab, +): PlatformRankingTabConfig { + return ( + PLATFORM_RANKING_TABS.find((config) => config.id === tab) ?? + DEFAULT_PLATFORM_RANKING_CONFIG + ); +} + export function getPlatformRankingMetricValue( entry: PlatformPublicGalleryCard, tab: PlatformRankingTab, @@ -391,6 +444,16 @@ export function getPlatformRankingMetricValue( return getPlatformWorldPlayCount(entry); } +export function getPlatformRankingMetric( + entry: PlatformPublicGalleryCard, + tab: PlatformRankingTab, +): PlatformRankingMetric { + return { + label: getPlatformRankingTabConfig(tab).metricLabel, + value: getPlatformRankingMetricValue(entry, tab), + }; +} + function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) { // 分类频道只使用公开读模型已经返回的指标做前端排序,不在 UI 层伪造评分数据。 return ( From a0efb14e848cfc39c1dfa7dece0673fc49295714 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 18:50:32 +0800 Subject: [PATCH 15/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E9=85=8D=E7=BD=AE=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 +- ...】PublicGalleryViewModel收口计划-2026-06-03.md | 2 + src/components/rpg-entry/RpgEntryHomeView.tsx | 59 ++++---------- .../rpgEntryPublicGalleryViewModel.test.ts | 55 +++++++++++++ .../rpgEntryPublicGalleryViewModel.ts | 77 +++++++++++++++++++ 6 files changed, 159 insertions(+), 44 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b4b810f9..74ff2d23 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1257,6 +1257,14 @@ - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "bottom category tab becomes ranking and switches ranking metrics|ranking"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md`。 +## 2026-06-03 Category Option ViewModel 收口 + +- 背景:分类频道的筛选选项、排序选项、默认值、active label fallback 和排序循环仍留在 `RpgEntryHomeView.tsx` 页面 Implementation 内,而玩法过滤、排序和主指标已经在 `rpgEntryPublicGalleryViewModel.ts`,同一分类 Interface 被拆成两处。 +- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口 `DEFAULT_PLATFORM_CATEGORY_KIND_FILTER`、`DEFAULT_PLATFORM_CATEGORY_SORT_MODE`、`PLATFORM_CATEGORY_KIND_FILTERS`、`PLATFORM_CATEGORY_SORT_OPTIONS`、`getPlatformCategoryKindFilterOption`、`getPlatformCategorySortOption` 与 `getNextPlatformCategorySortMode`;页面仅保留当前筛选 / 排序状态和渲染。 +- 影响范围:发现页分类频道筛选弹窗、筛选按钮 label、排序按钮 label 与排序循环。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "category"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index b286166d..aceaeae2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,7 +45,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -公开作品分类、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +公开作品分类选项、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 公开作品的玩法类型 label 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md index 814cb807..57c1de53 100644 --- a/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md @@ -15,6 +15,7 @@ - `parsePlatformEntryTimestamp(value)` / `getPlatformWorldTimestamp(entry)`:统一兼容 ISO 与后端 seconds.microsZ 时间戳。 - `filterTodayPublishedEntries(entries)`:统一“今日游戏”本地自然日筛选。 - `getPlatformWorldLikeCount(entry)` / `getPlatformWorldPlayCount(entry)` / `getPlatformWorldRemixCount(entry)`、`buildPlatformRankingEntries(entries, tab)` 与 `getPlatformRankingMetricValue(entry, tab)`:统一公开卡片指标读取、排行 Tab 排序与取值。 +- `DEFAULT_PLATFORM_CATEGORY_KIND_FILTER`、`DEFAULT_PLATFORM_CATEGORY_SORT_MODE`、`PLATFORM_CATEGORY_KIND_FILTERS`、`PLATFORM_CATEGORY_SORT_OPTIONS`、`getPlatformCategoryKindFilterOption(kindFilter)`、`getPlatformCategorySortOption(sortMode)` 与 `getNextPlatformCategorySortMode(sortMode)`:统一分类频道的筛选 / 排序选项、默认值、label 兜底和排序循环。 - `getPlatformCategoryKindFilter(entry)`、`matchesPlatformCategoryKindFilter(entry, kindFilter)`、`sortPlatformCategoryEntries(entries, sortMode)` 与 `getPlatformCategoryPrimaryMetric(entry)`:统一分类频道的玩法过滤、排序和主指标展示。 `RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留渲染、事件处理和账号状态。公开作品规则的 **Locality** 转移到 ViewModel **Module** 与其测试,页面不再持有这批纯规则。 @@ -25,6 +26,7 @@ - 搜索应同时匹配作品号、`profileId`、`workId`、标题、作者、摘要和副标题。 - 搜索排序先看标题前缀,再看作品号 compact 前缀,最后按发布时间 / 更新时间倒序。 - 时间解析必须保留后端 `seconds.microsZ` 兼容。 +- 分类筛选与排序的选项顺序、默认值、中文 label 和“综合 -> 最新 -> 游玩 -> 点赞 -> 综合”循环属于 ViewModel **Interface**;页面只能消费该 **Interface**,不得在 `RpgEntryHomeView.tsx` 复写数组或 fallback 文案。 ## 后续深化 diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 3bb1eb3d..cc5b4509 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -160,11 +160,16 @@ import { buildPublicCategoryGroups, buildPublicGalleryCardKey, dedupePlatformPublicGalleryEntries, + DEFAULT_PLATFORM_CATEGORY_KIND_FILTER, + DEFAULT_PLATFORM_CATEGORY_SORT_MODE, DEFAULT_PLATFORM_RANKING_TAB, filterPlatformWorkSearchResults, filterTodayPublishedEntries, getAllPlatformPublicEntries, + getNextPlatformCategorySortMode, + getPlatformCategoryKindFilterOption, getPlatformCategoryPrimaryMetric, + getPlatformCategorySortOption, getPlatformPublicEntries, getPlatformRankingMetric, getPlatformRankingTabConfig, @@ -174,6 +179,8 @@ import { getPlatformWorldRemixCount, isExactPublicWorkCodeSearch, matchesPlatformCategoryKindFilter, + PLATFORM_CATEGORY_KIND_FILTERS, + PLATFORM_CATEGORY_SORT_OPTIONS, PLATFORM_RANKING_TABS, type PlatformCategoryKindFilter, type PlatformCategorySortMode, @@ -374,30 +381,6 @@ const EDUTAINMENT_DISCOVER_CHANNEL = { id: 'edutainment', label: EDUTAINMENT_WORK_TAG, } as const; -const PLATFORM_CATEGORY_KIND_FILTERS: Array<{ - id: PlatformCategoryKindFilter; - label: string; -}> = [ - { id: 'all', label: '全部' }, - { id: 'puzzle', label: '拼图' }, - { id: 'match3d', label: '抓鹅' }, - { id: 'square-hole', label: '方洞' }, - { id: 'visual-novel', label: '视觉' }, - { id: 'bark-battle', label: '汪汪' }, - { id: 'big-fish', label: '大鱼' }, - { id: 'jump-hop', label: '跳跃' }, - { id: 'wooden-fish', label: '木鱼' }, - { id: 'custom-world', label: 'RPG' }, -]; -const PLATFORM_CATEGORY_SORT_OPTIONS: Array<{ - id: PlatformCategorySortMode; - label: string; -}> = [ - { id: 'composite', label: '综合' }, - { id: 'latest', label: '最新' }, - { id: 'play', label: '游玩' }, - { id: 'like', label: '点赞' }, -]; const BABY_LOVE_DRAWING_DEFAULT_CARD = { title: '宝贝爱画', subtitle: '空白画板', @@ -3522,9 +3505,11 @@ export function RpgEntryHomeView({ null, ); const [categoryKindFilter, setCategoryKindFilter] = - useState('all'); + useState( + DEFAULT_PLATFORM_CATEGORY_KIND_FILTER, + ); const [categorySortMode, setCategorySortMode] = - useState('composite'); + useState(DEFAULT_PLATFORM_CATEGORY_SORT_MODE); const [isCategoryFilterPanelOpen, setIsCategoryFilterPanelOpen] = useState(false); const [discoverChannel, setDiscoverChannel] = @@ -3674,15 +3659,12 @@ export function RpgEntryHomeView({ }, [activeCategoryGroup, categoryKindFilter, categorySortMode]); const activeCategoryRawCount = activeCategoryGroup?.entries.length ?? 0; const activeCategoryFilterLabel = - PLATFORM_CATEGORY_KIND_FILTERS.find( - (option) => option.id === categoryKindFilter, - )?.label ?? '全部'; + getPlatformCategoryKindFilterOption(categoryKindFilter).label; const activeCategorySortLabel = - PLATFORM_CATEGORY_SORT_OPTIONS.find( - (option) => option.id === categorySortMode, - )?.label ?? '综合'; + getPlatformCategorySortOption(categorySortMode).label; const activeCategoryFilterCount = activeCategoryEntries.length; - const categoryFilterApplied = categoryKindFilter !== 'all'; + const categoryFilterApplied = + categoryKindFilter !== DEFAULT_PLATFORM_CATEGORY_KIND_FILTER; const visibleTabs = useMemo( () => isAuthenticated @@ -4633,16 +4615,7 @@ export function RpgEntryHomeView({ submitWorkSearch(mobileSearchKeyword); }; const cycleCategorySortMode = () => { - const currentIndex = PLATFORM_CATEGORY_SORT_OPTIONS.findIndex( - (option) => option.id === categorySortMode, - ); - const nextIndex = - currentIndex >= 0 - ? (currentIndex + 1) % PLATFORM_CATEGORY_SORT_OPTIONS.length - : 0; - setCategorySortMode( - PLATFORM_CATEGORY_SORT_OPTIONS[nextIndex]?.id ?? 'composite', - ); + setCategorySortMode(getNextPlatformCategorySortMode(categorySortMode)); }; const desktopHeroEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? myEntries[0] ?? null; diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts index 146fa079..3071ce05 100644 --- a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts @@ -7,18 +7,27 @@ import { buildPublicCategoryGroups, buildPublicGalleryCardKey, dedupePlatformPublicGalleryEntries, + DEFAULT_PLATFORM_CATEGORY_KIND_FILTER, + DEFAULT_PLATFORM_CATEGORY_SORT_MODE, DEFAULT_PLATFORM_RANKING_TAB, filterPlatformWorkSearchResults, filterTodayPublishedEntries, + getNextPlatformCategorySortMode, getPlatformCategoryKindFilter, + getPlatformCategoryKindFilterOption, getPlatformCategoryPrimaryMetric, + getPlatformCategorySortOption, getPlatformPublicEntries, getPlatformRankingMetric, getPlatformRankingMetricValue, getPlatformRankingTabConfig, matchesPlatformCategoryKindFilter, parsePlatformEntryTimestamp, + PLATFORM_CATEGORY_KIND_FILTERS, + PLATFORM_CATEGORY_SORT_OPTIONS, PLATFORM_RANKING_TABS, + type PlatformCategoryKindFilter, + type PlatformCategorySortMode, selectAdjacentPlatformRecommendEntry, selectPlatformRecommendFeedWindow, sortPlatformCategoryEntries, @@ -308,6 +317,52 @@ test('public gallery ViewModel keeps source kinds behind one category filter sea ); }); +test('public gallery ViewModel exposes category filter and sort option interface', () => { + expect(DEFAULT_PLATFORM_CATEGORY_KIND_FILTER).toBe('all'); + expect(DEFAULT_PLATFORM_CATEGORY_SORT_MODE).toBe('composite'); + expect(PLATFORM_CATEGORY_KIND_FILTERS.map((option) => option.label)).toEqual([ + '全部', + '拼图', + '抓鹅', + '方洞', + '视觉', + '汪汪', + '大鱼', + '跳跃', + '木鱼', + 'RPG', + ]); + expect(PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => option.label)).toEqual([ + '综合', + '最新', + '游玩', + '点赞', + ]); + expect(getPlatformCategoryKindFilterOption('match3d')).toEqual({ + id: 'match3d', + label: '抓鹅', + }); + expect(getPlatformCategorySortOption('latest')).toEqual({ + id: 'latest', + label: '最新', + }); + expect( + getPlatformCategoryKindFilterOption( + 'unknown' as PlatformCategoryKindFilter, + ), + ).toEqual({ id: 'all', label: '全部' }); + expect( + getPlatformCategorySortOption('unknown' as PlatformCategorySortMode), + ).toEqual({ id: 'composite', label: '综合' }); + expect(getNextPlatformCategorySortMode('composite')).toBe('latest'); + expect(getNextPlatformCategorySortMode('latest')).toBe('play'); + expect(getNextPlatformCategorySortMode('play')).toBe('like'); + expect(getNextPlatformCategorySortMode('like')).toBe('composite'); + expect( + getNextPlatformCategorySortMode('unknown' as PlatformCategorySortMode), + ).toBe('composite'); +}); + test('public gallery ViewModel ranks entries by selected metric', () => { const playWinner = buildJumpHopEntry({ profileId: 'play-winner', diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts index 0ff23cb1..6c2a4e85 100644 --- a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts @@ -37,6 +37,14 @@ export type PlatformCategoryKindFilter = | 'wooden-fish' | 'custom-world'; export type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like'; +export type PlatformCategoryKindFilterOption = { + id: PlatformCategoryKindFilter; + label: string; +}; +export type PlatformCategorySortOption = { + id: PlatformCategorySortMode; + label: string; +}; export type PlatformPublicCategoryGroup = { tag: string; @@ -44,6 +52,10 @@ export type PlatformPublicCategoryGroup = { }; export const DEFAULT_PLATFORM_RANKING_TAB: PlatformRankingTab = 'hot'; +export const DEFAULT_PLATFORM_CATEGORY_KIND_FILTER: PlatformCategoryKindFilter = + 'all'; +export const DEFAULT_PLATFORM_CATEGORY_SORT_MODE: PlatformCategorySortMode = + 'composite'; export const PLATFORM_RANKING_TABS: PlatformRankingTabConfig[] = [ { @@ -77,6 +89,36 @@ const DEFAULT_PLATFORM_RANKING_CONFIG = (config) => config.id === DEFAULT_PLATFORM_RANKING_TAB, ) ?? PLATFORM_RANKING_TABS[0]!; +export const PLATFORM_CATEGORY_KIND_FILTERS: PlatformCategoryKindFilterOption[] = + [ + { id: 'all', label: '全部' }, + { id: 'puzzle', label: '拼图' }, + { id: 'match3d', label: '抓鹅' }, + { id: 'square-hole', label: '方洞' }, + { id: 'visual-novel', label: '视觉' }, + { id: 'bark-battle', label: '汪汪' }, + { id: 'big-fish', label: '大鱼' }, + { id: 'jump-hop', label: '跳跃' }, + { id: 'wooden-fish', label: '木鱼' }, + { id: 'custom-world', label: 'RPG' }, + ]; + +export const PLATFORM_CATEGORY_SORT_OPTIONS: PlatformCategorySortOption[] = [ + { id: 'composite', label: '综合' }, + { id: 'latest', label: '最新' }, + { id: 'play', label: '游玩' }, + { id: 'like', label: '点赞' }, +]; + +const DEFAULT_PLATFORM_CATEGORY_KIND_FILTER_OPTION = + PLATFORM_CATEGORY_KIND_FILTERS.find( + (option) => option.id === DEFAULT_PLATFORM_CATEGORY_KIND_FILTER, + ) ?? PLATFORM_CATEGORY_KIND_FILTERS[0]!; +const DEFAULT_PLATFORM_CATEGORY_SORT_OPTION = + PLATFORM_CATEGORY_SORT_OPTIONS.find( + (option) => option.id === DEFAULT_PLATFORM_CATEGORY_SORT_MODE, + ) ?? PLATFORM_CATEGORY_SORT_OPTIONS[0]!; + export type PlatformRecommendFeedWindow = { activeEntry: PlatformPublicGalleryCard | null; activeEntryKey: string | null; @@ -552,6 +594,41 @@ export function getPlatformCategoryPrimaryMetric( return { label: '游玩', value: getPlatformWorldPlayCount(entry) }; } +export function getPlatformCategoryKindFilterOption( + kindFilter: PlatformCategoryKindFilter, +): PlatformCategoryKindFilterOption { + return ( + PLATFORM_CATEGORY_KIND_FILTERS.find((option) => option.id === kindFilter) ?? + DEFAULT_PLATFORM_CATEGORY_KIND_FILTER_OPTION + ); +} + +export function getPlatformCategorySortOption( + sortMode: PlatformCategorySortMode, +): PlatformCategorySortOption { + return ( + PLATFORM_CATEGORY_SORT_OPTIONS.find((option) => option.id === sortMode) ?? + DEFAULT_PLATFORM_CATEGORY_SORT_OPTION + ); +} + +export function getNextPlatformCategorySortMode( + sortMode: PlatformCategorySortMode, +): PlatformCategorySortMode { + const currentIndex = PLATFORM_CATEGORY_SORT_OPTIONS.findIndex( + (option) => option.id === sortMode, + ); + const nextIndex = + currentIndex >= 0 + ? (currentIndex + 1) % PLATFORM_CATEGORY_SORT_OPTIONS.length + : 0; + + return ( + PLATFORM_CATEGORY_SORT_OPTIONS[nextIndex]?.id ?? + DEFAULT_PLATFORM_CATEGORY_SORT_MODE + ); +} + export function parsePlatformEntryTimestamp(value: string | null | undefined) { if (!value) { return 0; From 0b71b79e7acf0ae63213fbd06815cae95e0cbd60 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 19:05:00 +0800 Subject: [PATCH 16/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E4=BD=9C=E8=80=85=E5=B1=95=E7=A4=BA=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 4 +- docs/README.md | 2 +- ...】PublicWorkPresentation收口计划-2026-06-03.md | 7 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 82 ++++++++----------- .../rpgEntryWorldPresentation.test.ts | 54 ++++++++++++ .../rpg-entry/rpgEntryWorldPresentation.ts | 36 ++++++++ 6 files changed, 133 insertions(+), 52 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 74ff2d23..32626c48 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1268,8 +1268,8 @@ ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 -- 决策:在 `src/components/rpg-entry/rpgEntryWorldPresentation.ts` 追加单作品展示 Interface:`describePlatformPublicWorkKind` 与 `formatPlatformCompactCount`;页面删除本地实现。集合筛选、排序和指标选择继续留在 `rpgEntryPublicGalleryViewModel.ts`。 -- 影响范围:公开作品卡片 aria label、推荐点赞 / 改造文案、排行数值、分类主指标、搜索结果和桌面 hero 玩法 label。 +- 决策:在 `src/components/rpg-entry/rpgEntryWorldPresentation.ts` 追加单作品展示 Interface:`describePlatformPublicWorkKind`、`formatPlatformCompactCount`、`resolvePlatformPublicWorkAuthorLookup` 与 `formatPlatformPublicAuthorAvatarLabel`;页面删除本地玩法类型、紧凑计数、公开作者 lookup 和头像首字实现。集合筛选、排序和指标选择继续留在 `rpgEntryPublicGalleryViewModel.ts`。 +- 影响范围:公开作品卡片 aria label、推荐点赞 / 改造文案、排行数值、分类主指标、搜索结果、桌面 hero 玩法 label、公开作者摘要缓存 key 与无头像首字兜底。 - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|ranking|category"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index aceaeae2..8f3b7c0e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,7 +47,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 公开作品分类选项、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -公开作品的玩法类型 label 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +公开作品的玩法类型 label、公开作者 lookup 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 推荐 feed 的公开作品去重、普通内容过滤、active 窗口与上一条 / 下一条回环选择也收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RecommendFeedViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md b/docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md index 4feea2e9..9d1d1d78 100644 --- a/docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md @@ -2,7 +2,7 @@ ## 背景 -`RpgEntryHomeView.tsx` 的作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用公开作品玩法类型 label 与紧凑计数格式。原先 `describePublicGalleryCardKind` 与 `formatCompactCount` 放在页面 **Implementation** 内,导致新增玩法或调整数字展示时需要穿过多段 JSX。 +`RpgEntryHomeView.tsx` 的作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用公开作品玩法类型 label 与紧凑计数格式。原先 `describePublicGalleryCardKind` 与 `formatCompactCount` 放在页面 **Implementation** 内,导致新增玩法或调整数字展示时需要穿过多段 JSX。公开作者 lookup key 与头像首字也曾由页面手写,页面既要知道公开作品作者来源优先级,又要知道 `code:` / `id:` 前缀约定。 ## 决策 @@ -10,13 +10,16 @@ - `describePlatformPublicWorkKind(entry)`:统一公开作品玩法类型 label,并继续复用 `formatPlatformWorkDisplayTag` 的 4 字截断口径。 - `formatPlatformCompactCount(value)`:统一游玩、改造、点赞、排行和分类指标的紧凑数字展示。 +- `resolvePlatformPublicWorkAuthorLookup(entry)`:统一公开作者查询 lookup,优先使用 `authorPublicUserCode`,否则回退 `ownerUserId`,并用结构化 `{ key, source, value }` 避免页面复写前缀规则。 +- `formatPlatformPublicAuthorAvatarLabel(authorDisplayName)`:统一公开作者头像无图时的首字兜底。 -`RpgEntryHomeView.tsx` 删除本地类型 label 与紧凑计数 **Implementation**,仅消费 `rpgEntryWorldPresentation.ts`。集合筛选、排序和指标选择仍留在 `rpgEntryPublicGalleryViewModel.ts`,避免单作品展示 **Module** 与集合 **Module** 混杂。 +`RpgEntryHomeView.tsx` 删除本地类型 label、紧凑计数、公开作者 lookup 与头像首字 **Implementation**,仅消费 `rpgEntryWorldPresentation.ts`。认证请求、缓存和失败兜底仍留页面侧 Adapter;集合筛选、排序和指标选择仍留在 `rpgEntryPublicGalleryViewModel.ts`,避免单作品展示 **Module** 与集合 **Module** 混杂。 ## 约定 - 紧凑计数保留既有口径:`10000` 显示 `1.0万`,`100000000` 显示 `1.0亿`,一万以下不加千分位。 - 玩法类型 label 继续遵循 4 字展示限制,例如“大鱼吃小鱼”外显为“大鱼吃小”。 +- 公开作者 lookup 的 `key` 只用于缓存索引;真正调用公开用户 Adapter 时以 `source` 和 `value` 分发,页面不得解析 `code:` / `id:` 前缀。 - 本次不迁移排行 metric label / value 配对;该规则属于集合排序 **Module** 的后续切片。 ## 验证 diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index cc5b4509..0ce8e13f 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -194,6 +194,7 @@ import { describePlatformPublicWorkKind, describePlatformThemeLabel, formatPlatformCompactCount, + formatPlatformPublicAuthorAvatarLabel, formatPlatformWorkDisplayName, formatPlatformWorkDisplayTag, formatPlatformWorldTime, @@ -203,6 +204,8 @@ import { isPuzzleGalleryEntry, isVisualNovelGalleryEntry, type PlatformPublicGalleryCard, + type PlatformPublicWorkAuthorLookup, + resolvePlatformPublicWorkAuthorLookup, resolvePlatformPublicWorkCode, resolvePlatformWorkAuthorDisplayName, resolvePlatformWorldCoverImage, @@ -597,7 +600,7 @@ function WorldCard({ entry, authorSummary, ); - const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName); + const authorAvatarLabel = formatPlatformPublicAuthorAvatarLabel(authorName); const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; const cardLabel = `${entry.worldName},${typeLabel},${formatPlatformCompactCount(playCount)}游玩,${formatPlatformCompactCount(remixCount)}改造,${formatPlatformCompactCount(likeCount)}点赞`; const coverStats = [ @@ -966,7 +969,7 @@ function RecommendRuntimeMeta({ entry, authorSummary, ); - const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName); + const authorAvatarLabel = formatPlatformPublicAuthorAvatarLabel(authorName); const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; const displayName = formatPlatformWorkDisplayName(entry.worldName); const stopActionPointer = (event: PointerEvent) => { @@ -1637,36 +1640,20 @@ function PlatformWorkSearchResults({ ); } -function buildPublicWorkAuthorLookupKey(entry: PlatformPublicGalleryCard) { - if ('authorPublicUserCode' in entry) { - const authorPublicUserCode = entry.authorPublicUserCode?.trim(); - if (authorPublicUserCode) { - return `code:${authorPublicUserCode}`; - } - } - - const ownerUserId = entry.ownerUserId.trim(); - return ownerUserId ? `id:${ownerUserId}` : null; -} - async function getPublicWorkAuthorSummary( - authorLookupKey: string, + authorLookup: PlatformPublicWorkAuthorLookup, ): Promise { - if (authorLookupKey.startsWith('code:')) { - return getPublicAuthUserByCode(authorLookupKey.slice('code:'.length)); + if (authorLookup.source === 'publicUserCode') { + return getPublicAuthUserByCode(authorLookup.value); } - if (authorLookupKey.startsWith('id:')) { - return getPublicAuthUserById(authorLookupKey.slice('id:'.length)); + if (authorLookup.source === 'ownerUserId') { + return getPublicAuthUserById(authorLookup.value); } return null; } -function getPublicAuthorAvatarLabel(authorDisplayName: string) { - return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩'; -} - function formatSnapshotTime(value: string | null | undefined) { if (!value) { return '刚刚保存'; @@ -3619,25 +3606,25 @@ export function RpgEntryHomeView({ ); const getPublicEntryAuthorAvatarUrl = useCallback( (entry: PlatformPublicGalleryCard) => { - const authorLookupKey = buildPublicWorkAuthorLookupKey(entry); - if (!authorLookupKey) { + const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry); + if (!authorLookup) { return null; } return ( - publicAuthorSummariesByKey[authorLookupKey]?.avatarUrl?.trim() || null + publicAuthorSummariesByKey[authorLookup.key]?.avatarUrl?.trim() || null ); }, [publicAuthorSummariesByKey], ); const getPublicEntryAuthorSummary = useCallback( (entry: PlatformPublicGalleryCard) => { - const authorLookupKey = buildPublicWorkAuthorLookupKey(entry); - if (!authorLookupKey) { + const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry); + if (!authorLookup) { return null; } - return publicAuthorSummariesByKey[authorLookupKey] ?? null; + return publicAuthorSummariesByKey[authorLookup.key] ?? null; }, [publicAuthorSummariesByKey], ); @@ -3775,37 +3762,38 @@ export function RpgEntryHomeView({ }, [categoryGroups, selectedCategoryTag]); useEffect(() => { - const missingAuthorKeys = [ - ...new Set( - publicEntries - .map(buildPublicWorkAuthorLookupKey) - .filter((key): key is string => Boolean(key)), - ), - ].filter( - (key) => - !(key in publicAuthorSummariesByKey) && - !pendingPublicAuthorKeysRef.current.has(key), + const authorLookupsByKey = new Map(); + publicEntries.forEach((entry) => { + const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry); + if (authorLookup) { + authorLookupsByKey.set(authorLookup.key, authorLookup); + } + }); + const missingAuthorLookups = Array.from(authorLookupsByKey.values()).filter( + (authorLookup) => + !(authorLookup.key in publicAuthorSummariesByKey) && + !pendingPublicAuthorKeysRef.current.has(authorLookup.key), ); - if (missingAuthorKeys.length === 0) { + if (missingAuthorLookups.length === 0) { return undefined; } let cancelled = false; - missingAuthorKeys.forEach((key) => { - pendingPublicAuthorKeysRef.current.add(key); + missingAuthorLookups.forEach((authorLookup) => { + pendingPublicAuthorKeysRef.current.add(authorLookup.key); }); // 中文注释:头像来自公开用户摘要,失败时缓存空值,避免首页滚动时反复打公开用户接口。 void Promise.all( - missingAuthorKeys.map(async (authorLookupKey) => { + missingAuthorLookups.map(async (authorLookup) => { try { - const author = await getPublicWorkAuthorSummary(authorLookupKey); - return [authorLookupKey, author] as const; + const author = await getPublicWorkAuthorSummary(authorLookup); + return [authorLookup.key, author] as const; } catch { - return [authorLookupKey, null] as const; + return [authorLookup.key, null] as const; } finally { - pendingPublicAuthorKeysRef.current.delete(authorLookupKey); + pendingPublicAuthorKeysRef.current.delete(authorLookup.key); } }), ).then((results) => { diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts index ba1361b1..980f8a29 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts @@ -7,6 +7,7 @@ import { EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, formatPlatformCompactCount, + formatPlatformPublicAuthorAvatarLabel, formatPlatformWorkDisplayName, formatPlatformWorkDisplayTags, formatPlatformWorldTime, @@ -18,9 +19,11 @@ import { mapBarkBattleWorkToPlatformGalleryCard, mapVisualNovelWorkToPlatformGalleryCard, mapWoodenFishWorkToPlatformGalleryCard, + type PlatformBarkBattleGalleryCard, type PlatformBigFishGalleryCard, type PlatformEdutainmentGalleryCard, type PlatformPuzzleGalleryCard, + resolvePlatformPublicWorkAuthorLookup, resolvePlatformPublicWorkCode, resolvePlatformWorkAuthorDisplayName, resolvePlatformWorldFallbackCoverImage, @@ -312,6 +315,57 @@ test('public work author display hides phone masks and public user codes on card expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('玩家'); }); +test('public work author lookup keeps public user code priority and avatar labels', () => { + const barkBattleCard: PlatformBarkBattleGalleryCard = { + sourceType: 'bark-battle', + workId: 'bark-battle-work-author', + profileId: 'bark-battle-profile-author', + sourceSessionId: null, + publicWorkCode: 'BB-AUTHOR', + ownerUserId: 'user-author-id', + authorPublicUserCode: ' SY-00012345 ', + authorDisplayName: '声浪玩家', + worldName: '声浪擂台', + subtitle: '汪汪声浪', + summaryText: '公开作品', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + themeTags: ['声浪'], + themeMode: 'martial', + playableNpcCount: 0, + landmarkCount: 0, + visibility: 'published', + publishedAt: '2026-05-22T00:00:00.000Z', + updatedAt: '2026-05-22T00:00:00.000Z', + }; + + expect(resolvePlatformPublicWorkAuthorLookup(barkBattleCard)).toEqual({ + key: 'code:SY-00012345', + source: 'publicUserCode', + value: 'SY-00012345', + }); + expect( + resolvePlatformPublicWorkAuthorLookup({ + ...barkBattleCard, + authorPublicUserCode: ' ', + }), + ).toEqual({ + key: 'id:user-author-id', + source: 'ownerUserId', + value: 'user-author-id', + }); + expect( + resolvePlatformPublicWorkAuthorLookup({ + ...barkBattleCard, + authorPublicUserCode: null, + ownerUserId: ' ', + }), + ).toBeNull(); + expect(formatPlatformPublicAuthorAvatarLabel(' 声浪玩家')).toBe('声'); + expect(formatPlatformPublicAuthorAvatarLabel('')).toBe('玩'); +}); + test('keeps baby object match public card code and template label intact', () => { const card: PlatformEdutainmentGalleryCard = { sourceType: 'edutainment', diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index 245b955c..63a3f670 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -300,6 +300,12 @@ export type PlatformPublicGalleryCard = | PlatformBarkBattleGalleryCard | PlatformEdutainmentGalleryCard; +export type PlatformPublicWorkAuthorLookup = { + key: string; + source: 'publicUserCode' | 'ownerUserId'; + value: string; +}; + export function isLibraryWorldEntry( entry: PlatformWorldCardLike, ): entry is CustomWorldLibraryEntry { @@ -923,6 +929,36 @@ export function resolvePlatformWorkAuthorDisplayName( return displayName || entryAuthorName || '玩家'; } +export function resolvePlatformPublicWorkAuthorLookup( + entry: PlatformPublicGalleryCard, +): PlatformPublicWorkAuthorLookup | null { + if ('authorPublicUserCode' in entry) { + const authorPublicUserCode = entry.authorPublicUserCode?.trim(); + if (authorPublicUserCode) { + return { + key: `code:${authorPublicUserCode}`, + source: 'publicUserCode', + value: authorPublicUserCode, + }; + } + } + + const ownerUserId = entry.ownerUserId.trim(); + return ownerUserId + ? { + key: `id:${ownerUserId}`, + source: 'ownerUserId', + value: ownerUserId, + } + : null; +} + +export function formatPlatformPublicAuthorAvatarLabel( + authorDisplayName: string, +) { + return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩'; +} + function normalizePlatformPublicAuthorName(value: string | null | undefined) { const normalized = value?.trim() ?? ''; if (!normalized || normalized === 'null' || normalized === 'undefined') { From 69167da8d051a693dea823a57f98f70378db6c6a Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 19:21:07 +0800 Subject: [PATCH 17/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=8A=93?= =?UTF-8?q?=E5=A4=A7=E9=B9=85=E8=BF=90=E8=A1=8C=E8=B5=84=E6=96=99=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...】Match3DRuntimeProfile收口计划-2026-06-03.md | 34 ++ .../PlatformEntryFlowShellImpl.tsx | 326 +----------------- .../platformMatch3DRuntimeProfile.test.ts | 268 ++++++++++++++ .../platformMatch3DRuntimeProfile.ts | 326 ++++++++++++++++++ 6 files changed, 651 insertions(+), 313 deletions(-) create mode 100644 docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md create mode 100644 src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts create mode 100644 src/components/platform-entry/platformMatch3DRuntimeProfile.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 32626c48..4ac34916 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1265,6 +1265,14 @@ - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "category"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`。 +## 2026-06-03 Match3D Runtime Profile 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内仍直接承载抓大鹅公开详情转 work、session draft 转 profile、生成背景资产提升、runtime active profile 选择和 run / profile / public detail 素材优先级,平台壳需要理解抓大鹅生成素材内部结构。 +- 决策:新增 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts` 作为抓大鹅 runtime profile Module,Interface 收口 `mapPublicWorkDetailToMatch3DWork`、`buildMatch3DProfileFromSession`、`normalizeMatch3DWorkForRuntimeUi`、`mapMatch3DWorksForRuntimeUi`、`promoteMatch3DGeneratedBackgroundAsset`、`hasMatch3DRuntimeAsset`、`hasMatch3DRuntimeBackgroundAsset`、`resolveActiveMatch3DRuntimeProfile` 与 runtime item/background/backgroundImage 解析函数;平台壳只保留启动 run、预加载、路由、错误和 state 编排。 +- 影响范围:抓大鹅作品架、公开详情试玩、推荐 runtime、正式 runtime 与草稿结果页试玩前素材规范化。 +- 验证方式:`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "match3d|抓大鹅"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 8f3b7c0e..b4d20565 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +抓大鹅 runtime profile 的公开详情转 work、session draft 转 profile、生成背景资产提升和 run/profile/public detail 素材优先级收口到 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91Match3DRuntimeProfile%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + 公开作品分类选项、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 公开作品的玩法类型 label、公开作者 lookup 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md b/docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md new file mode 100644 index 00000000..a39d835b --- /dev/null +++ b/docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md @@ -0,0 +1,34 @@ +# 【前端架构】Match3D Runtime Profile 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 同时编排抓大鹅创作、作品详情、推荐 runtime 和正式 runtime。运行态启动前的 profile 规范化、公开详情转 work、生成背景资产提升、run / profile / public detail 优先级和 runtime 素材选择原本都在平台壳 **Implementation** 内,导致平台壳必须理解抓大鹅生成素材的内部结构。 + +## 决策 + +新增 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,作为抓大鹅 runtime profile **Module**。该 **Module** 的 **Interface** 收口为: + +- `mapPublicWorkDetailToMatch3DWork(entry)`:把公开作品详情映射为可启动 runtime 的 Match3D work,并补齐生成背景资产。 +- `buildMatch3DProfileFromSession(session)`:从创作 session draft 生成 runtime profile。 +- `normalizeMatch3DWorkForRuntimeUi(profile)` / `mapMatch3DWorksForRuntimeUi(profiles)`:统一作品列表进入 UI / runtime 前的素材规范化。 +- `promoteMatch3DGeneratedBackgroundAsset(profile)`:从 `generatedBackgroundAsset` 或 `generatedItemAssets[].backgroundAsset` 提升背景图、对象 key 与 prompt。 +- `hasMatch3DRuntimeAsset(profile.generatedItemAssets)` / `hasMatch3DRuntimeBackgroundAsset(profile)`:统一判断 runtime 是否具备物品与背景素材。 +- `resolveActiveMatch3DRuntimeProfile(run, runtimeProfile, profile)`:按 run 的 `profileId` 选择当前 profile,避免切屏时误用旧草稿。 +- `resolveMatch3DRuntimeGeneratedItemAssets(...)`、`resolveMatch3DRuntimeGeneratedBackgroundAsset(...)`、`resolveMatch3DRuntimeBackgroundImageSrc(...)`:统一 run / profile / public detail 的素材优先级。 + +`PlatformEntryFlowShellImpl.tsx` 只保留启动 run、预加载、路由、错误和 state 编排;抓大鹅素材规则集中到该 **Module**,提升 **Locality** 与测试 **Leverage**。 + +## 约定 + +- 公开详情补 runtime 素材时,只有 `profileId` 与 run 匹配才优先使用公开详情;错配时不得污染当前 run。 +- 当前启动时拿到的 `runtimeProfile` 优先于旧草稿 profile;若 run 指向旧草稿 profile,才使用草稿 profile。 +- 背景资产提升不得覆盖已有显式 `backgroundImageSrc` / `backgroundImageObjectKey` / `generatedBackgroundAsset`,只补缺。 +- 本 **Module** 只放纯 profile / asset 规则,不引入启动 run、预加载、URL、状态机或 UI 副作用。 + +## 验证 + +- `npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "match3d|抓大鹅"` +- `npm run typecheck` +- `npm run check:encoding` +- 针对新 Module 与测试执行 ESLint;`PlatformEntryFlowShellImpl.tsx` 保留既有 hook dependency warnings,不在本切片扩大处理。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index e7ee35c6..48e7019c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -49,7 +49,6 @@ import type { } from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { - Match3DGeneratedBackgroundAsset, Match3DGeneratedItemAsset, Match3DWorkProfile, Match3DWorkSummary, @@ -202,7 +201,6 @@ import { listMatch3DWorks, } from '../../services/match3d-works'; import { - hasMatch3DGeneratedImageAsset, mergeMatch3DGeneratedItemAssetsForRuntime, normalizeMatch3DGeneratedItemAssetsForRuntime, preloadMatch3DGeneratedRuntimeAssets, @@ -442,6 +440,19 @@ import { type PlatformErrorDialogPayload, } from './PlatformErrorDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; +import { + buildMatch3DProfileFromSession, + hasMatch3DRuntimeAsset, + hasMatch3DRuntimeBackgroundAsset, + mapMatch3DWorksForRuntimeUi, + mapPublicWorkDetailToMatch3DWork, + normalizeMatch3DWorkForRuntimeUi, + promoteMatch3DGeneratedBackgroundAsset, + resolveActiveMatch3DRuntimeProfile, + resolveMatch3DRuntimeBackgroundImageSrc, + resolveMatch3DRuntimeGeneratedBackgroundAsset, + resolveMatch3DRuntimeGeneratedItemAssets, +} from './platformMatch3DRuntimeProfile'; import { getPlatformPublicGalleryEntryKey, getPlatformRecommendRuntimeKind, @@ -811,317 +822,6 @@ function mapVisualNovelWorkDetailToSession( }; } -function mapPublicWorkDetailToMatch3DWork( - entry: PlatformPublicGalleryCard, -): Match3DWorkSummary | null { - if (!isMatch3DGalleryEntry(entry)) { - return null; - } - - return promoteMatch3DGeneratedBackgroundAsset({ - workId: entry.workId, - profileId: entry.profileId, - ownerUserId: entry.ownerUserId, - sourceSessionId: - 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' - ? entry.sourceSessionId - : null, - gameName: entry.worldName, - themeText: entry.themeTags[0] ?? '经典消除', - summary: entry.summaryText, - tags: entry.themeTags, - coverImageSrc: entry.coverImageSrc, - referenceImageSrc: null, - clearCount: 12, - difficulty: 4, - publicationStatus: 'published', - playCount: entry.playCount ?? 0, - updatedAt: entry.updatedAt, - publishedAt: entry.publishedAt, - publishReady: true, - backgroundPrompt: entry.backgroundPrompt ?? null, - backgroundImageSrc: entry.backgroundImageSrc ?? null, - backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null, - generatedBackgroundAsset: - entry.generatedBackgroundAsset ?? - entry.generatedItemAssets - ?.map((asset) => asset.backgroundAsset ?? null) - .find(Boolean) ?? - null, - generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime( - entry.generatedItemAssets ?? [], - ), - }); -} - -function findMatch3DGeneratedBackgroundAsset( - generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined, -): Match3DGeneratedBackgroundAsset | null { - return ( - generatedItemAssets - ?.map((asset) => asset.backgroundAsset ?? null) - .find(Boolean) ?? null - ); -} - -function promoteMatch3DGeneratedBackgroundAsset< - T extends Pick< - Match3DWorkSummary, - | 'backgroundPrompt' - | 'backgroundImageSrc' - | 'backgroundImageObjectKey' - | 'generatedBackgroundAsset' - | 'generatedItemAssets' - >, ->(profile: T): T { - const backgroundAsset = - profile.generatedBackgroundAsset ?? - findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets); - if (!backgroundAsset) { - return profile; - } - - return { - ...profile, - backgroundPrompt: - profile.backgroundPrompt ?? backgroundAsset.prompt ?? null, - backgroundImageSrc: - profile.backgroundImageSrc ?? - backgroundAsset.imageSrc ?? - backgroundAsset.imageObjectKey ?? - null, - backgroundImageObjectKey: - profile.backgroundImageObjectKey ?? - backgroundAsset.imageObjectKey ?? - backgroundAsset.imageSrc ?? - null, - generatedBackgroundAsset: - profile.generatedBackgroundAsset ?? backgroundAsset, - }; -} - -function normalizeMatch3DWorkForRuntimeUi( - profile: T, -): T { - return promoteMatch3DGeneratedBackgroundAsset({ - ...profile, - generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime( - profile.generatedItemAssets, - ), - }); -} - -function mapMatch3DWorksForRuntimeUi( - profiles: readonly T[], -): T[] { - return profiles.map(normalizeMatch3DWorkForRuntimeUi); -} - -function buildMatch3DProfileFromSession( - session: Match3DAgentSessionSnapshot | null, -): Match3DWorkProfile | null { - const draft = session?.draft; - if (!session || !draft?.profileId) { - return null; - } - - const now = session.updatedAt || new Date().toISOString(); - const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime( - draft.generatedItemAssets, - ); - return promoteMatch3DGeneratedBackgroundAsset({ - workId: draft.profileId, - profileId: draft.profileId, - ownerUserId: 'current-user', - sourceSessionId: session.sessionId, - gameName: draft.gameName, - themeText: draft.themeText, - summary: draft.summary ?? draft.summaryText ?? '', - tags: draft.tags, - coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null, - referenceImageSrc: draft.referenceImageSrc ?? null, - clearCount: draft.clearCount, - difficulty: draft.difficulty, - publicationStatus: 'draft', - playCount: 0, - updatedAt: now, - publishedAt: null, - publishReady: Boolean(draft.publishReady), - backgroundPrompt: draft.backgroundPrompt ?? null, - backgroundImageSrc: draft.backgroundImageSrc ?? null, - backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null, - generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null, - generatedItemAssets, - }); -} - -function hasMatch3DRuntimeAsset( - assets: readonly Match3DGeneratedItemAsset[] | null | undefined, -) { - return hasMatch3DGeneratedImageAsset(assets); -} - -function hasMatch3DRuntimeBackgroundAsset( - profile: Pick< - Match3DWorkSummary, - | 'backgroundImageSrc' - | 'backgroundImageObjectKey' - | 'generatedBackgroundAsset' - | 'generatedItemAssets' - >, -) { - return Boolean( - profile.backgroundImageSrc?.trim() || - profile.backgroundImageObjectKey?.trim() || - profile.generatedBackgroundAsset?.imageSrc?.trim() || - profile.generatedBackgroundAsset?.imageObjectKey?.trim() || - profile.generatedBackgroundAsset?.containerImageSrc?.trim() || - profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() || - profile.generatedItemAssets?.some( - (asset) => - asset.backgroundAsset?.imageSrc?.trim() || - asset.backgroundAsset?.imageObjectKey?.trim() || - asset.backgroundAsset?.containerImageSrc?.trim() || - asset.backgroundAsset?.containerImageObjectKey?.trim(), - ), - ); -} - -function resolveMatch3DRuntimeGeneratedItemAssets( - run: Match3DRunSnapshot | null, - profile: Match3DWorkProfile | null, - publicWorkDetail: PlatformPublicGalleryCard | null, -) { - const runProfileId = run?.profileId?.trim() ?? ''; - const profileAssets = profile?.generatedItemAssets ?? []; - const publicDetailAssets = - publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) - ? (publicWorkDetail.generatedItemAssets ?? []) - : []; - - if (runProfileId && profile?.profileId === runProfileId) { - if (hasMatch3DRuntimeAsset(profileAssets)) { - return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); - } - - if ( - publicWorkDetail && - isMatch3DGalleryEntry(publicWorkDetail) && - publicWorkDetail.profileId === runProfileId - ) { - return hasMatch3DRuntimeAsset(publicDetailAssets) - ? mergeMatch3DGeneratedItemAssetsForRuntime( - publicDetailAssets, - profileAssets, - ) - : normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); - } - - return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); - } - - if ( - runProfileId && - publicWorkDetail && - isMatch3DGalleryEntry(publicWorkDetail) && - publicWorkDetail.profileId === runProfileId - ) { - return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets); - } - - if (hasMatch3DRuntimeAsset(profileAssets)) { - return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); - } - return publicDetailAssets.length > 0 - ? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets) - : normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); -} - -function resolveMatch3DRuntimeGeneratedBackgroundAsset( - run: Match3DRunSnapshot | null, - profile: Match3DWorkProfile | null, - publicWorkDetail: PlatformPublicGalleryCard | null, -) { - const runProfileId = run?.profileId?.trim() ?? ''; - const profileBackground = profile - ? (promoteMatch3DGeneratedBackgroundAsset(profile) - .generatedBackgroundAsset ?? null) - : null; - const publicBackground = - publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) - ? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail) - .generatedBackgroundAsset ?? null) - : null; - - if (runProfileId && profile?.profileId === runProfileId) { - return profileBackground ?? publicBackground; - } - if ( - runProfileId && - publicWorkDetail && - isMatch3DGalleryEntry(publicWorkDetail) && - publicWorkDetail.profileId === runProfileId - ) { - return publicBackground ?? profileBackground; - } - return profileBackground ?? publicBackground; -} - -function resolveActiveMatch3DRuntimeProfile( - run: Match3DRunSnapshot | null, - runtimeProfile: Match3DWorkProfile | null, - profile: Match3DWorkProfile | null, -) { - const runProfileId = run?.profileId?.trim() ?? ''; - if (runProfileId && runtimeProfile?.profileId === runProfileId) { - return runtimeProfile; - } - if (runProfileId && profile?.profileId === runProfileId) { - return profile; - } - return runtimeProfile ?? profile; -} - -function resolveMatch3DRuntimeBackgroundImageSrc( - run: Match3DRunSnapshot | null, - profile: Match3DWorkProfile | null, - publicWorkDetail: PlatformPublicGalleryCard | null, -) { - const runProfileId = run?.profileId?.trim() ?? ''; - const resolvedProfile = profile - ? promoteMatch3DGeneratedBackgroundAsset(profile) - : null; - const resolvedPublicWork = - publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) - ? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail) - : null; - const profileBackground = - resolvedProfile?.backgroundImageSrc?.trim() || - resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() || - resolvedProfile?.backgroundImageObjectKey?.trim() || - resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() || - ''; - const publicBackground = - resolvedPublicWork?.backgroundImageSrc?.trim() || - resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() || - resolvedPublicWork?.backgroundImageObjectKey?.trim() || - resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() || - ''; - - if (runProfileId && profile?.profileId === runProfileId) { - return profileBackground || publicBackground || null; - } - if ( - runProfileId && - publicWorkDetail && - isMatch3DGalleryEntry(publicWorkDetail) && - publicWorkDetail.profileId === runProfileId - ) { - return publicBackground || profileBackground || null; - } - return profileBackground || publicBackground || null; -} - function resolveMatch3DGenerationStateFromAssets( current: MiniGameDraftGenerationState | null, assets: readonly Match3DGeneratedItemAsset[] | null | undefined, diff --git a/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts b/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts new file mode 100644 index 00000000..56e98a95 --- /dev/null +++ b/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts @@ -0,0 +1,268 @@ +import { expect, test } from 'vitest'; + +import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; +import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; +import type { + Match3DGeneratedBackgroundAsset, + Match3DGeneratedItemAsset, + Match3DWorkProfile, +} from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PlatformMatch3DGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; +import { + buildMatch3DProfileFromSession, + mapPublicWorkDetailToMatch3DWork, + resolveActiveMatch3DRuntimeProfile, + resolveMatch3DRuntimeBackgroundImageSrc, + resolveMatch3DRuntimeGeneratedBackgroundAsset, + resolveMatch3DRuntimeGeneratedItemAssets, +} from './platformMatch3DRuntimeProfile'; + +function buildBackgroundAsset( + overrides: Partial = {}, +): Match3DGeneratedBackgroundAsset { + return { + prompt: '森林棋盘', + imageSrc: '/generated/match3d/background.png', + imageObjectKey: null, + status: 'ready', + ...overrides, + }; +} + +function buildItemAsset( + overrides: Partial = {}, +): Match3DGeneratedItemAsset { + return { + itemId: 'item-1', + itemName: '蘑菇', + imageSrc: '/generated/match3d/item.png', + imageObjectKey: null, + status: 'image_ready', + ...overrides, + }; +} + +function buildProfile( + overrides: Partial = {}, +): Match3DWorkProfile { + return { + workId: 'match3d-work-1', + profileId: 'match3d-profile-1', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session-1', + gameName: '森林抓鹅', + themeText: '森林', + summary: '找出蘑菇。', + tags: ['森林', '蘑菇'], + coverImageSrc: '/cover.png', + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'published', + playCount: 1, + updatedAt: '2026-05-20T00:00:00.000Z', + publishedAt: '2026-05-20T00:00:00.000Z', + publishReady: true, + backgroundPrompt: null, + backgroundImageSrc: null, + backgroundImageObjectKey: null, + generatedBackgroundAsset: null, + generatedItemAssets: [buildItemAsset()], + ...overrides, + }; +} + +function buildRun(overrides: Partial = {}): Match3DRunSnapshot { + return { + runId: 'match3d-run-1', + profileId: 'match3d-profile-1', + status: 'running', + snapshotVersion: 1, + startedAtMs: 1000, + durationLimitMs: 60000, + remainingMs: 55000, + clearCount: 12, + totalItemCount: 12, + clearedItemCount: 0, + items: [], + traySlots: [], + ...overrides, + }; +} + +function buildPublicWork( + overrides: Partial = {}, +): PlatformMatch3DGalleryCard { + return { + sourceType: 'match3d', + workId: 'match3d-work-1', + profileId: 'match3d-profile-1', + sourceSessionId: 'match3d-session-1', + publicWorkCode: 'M3D-00000001', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + worldName: '森林抓鹅', + subtitle: '抓大鹅', + summaryText: '找出蘑菇。', + coverImageSrc: '/cover.png', + backgroundPrompt: null, + backgroundImageSrc: null, + backgroundImageObjectKey: null, + generatedBackgroundAsset: null, + generatedItemAssets: [buildItemAsset()], + themeTags: ['森林', '蘑菇'], + visibility: 'published', + publishedAt: '2026-05-20T00:00:00.000Z', + updatedAt: '2026-05-20T00:00:00.000Z', + ...overrides, + }; +} + +test('Match3D runtime profile maps public detail and promotes item background asset', () => { + const backgroundAsset = buildBackgroundAsset({ + imageSrc: '/generated/match3d/background-from-item.png', + imageObjectKey: 'oss/background-from-item.png', + }); + const work = mapPublicWorkDetailToMatch3DWork( + buildPublicWork({ + generatedBackgroundAsset: null, + backgroundImageSrc: null, + generatedItemAssets: [ + buildItemAsset({ + backgroundAsset, + }), + ], + }), + ); + + expect(work?.generatedBackgroundAsset).toEqual(backgroundAsset); + expect(work?.backgroundImageSrc).toBe( + '/generated/match3d/background-from-item.png', + ); + expect(work?.backgroundImageObjectKey).toBe('oss/background-from-item.png'); +}); + +test('Match3D runtime profile builds draft profile from session snapshot', () => { + const backgroundAsset = buildBackgroundAsset({ + imageSrc: '/generated/match3d/draft-background.png', + }); + const session: Match3DAgentSessionSnapshot = { + sessionId: 'match3d-session-draft', + currentTurn: 2, + progressPercent: 100, + stage: 'draft_compiled', + anchorPack: { + theme: { key: 'theme', label: '主题', value: '森林', status: 'confirmed' }, + clearCount: { + key: 'clearCount', + label: '消除数', + value: '12', + status: 'confirmed', + }, + difficulty: { + key: 'difficulty', + label: '难度', + value: '4', + status: 'confirmed', + }, + }, + messages: [], + lastAssistantReply: null, + updatedAt: '2026-05-21T00:00:00.000Z', + draft: { + profileId: 'match3d-draft-profile', + gameName: '草稿抓鹅', + themeText: '森林', + summaryText: '草稿摘要', + tags: ['森林'], + coverImageSrc: null, + referenceImageSrc: '/reference.png', + clearCount: 12, + difficulty: 4, + publishReady: true, + generatedItemAssets: [ + buildItemAsset({ + backgroundAsset, + }), + ], + }, + }; + + const profile = buildMatch3DProfileFromSession(session); + + expect(profile?.profileId).toBe('match3d-draft-profile'); + expect(profile?.sourceSessionId).toBe('match3d-session-draft'); + expect(profile?.publicationStatus).toBe('draft'); + expect(profile?.coverImageSrc).toBe('/reference.png'); + expect(profile?.generatedBackgroundAsset).toEqual(backgroundAsset); + expect(profile?.backgroundImageSrc).toBe( + '/generated/match3d/draft-background.png', + ); +}); + +test('Match3D runtime profile selects active profile by run profile id', () => { + const runtimeProfile = buildProfile({ + profileId: 'runtime-profile', + gameName: '运行态抓鹅', + }); + const draftProfile = buildProfile({ + profileId: 'draft-profile', + gameName: '旧草稿抓鹅', + }); + + expect( + resolveActiveMatch3DRuntimeProfile( + buildRun({ profileId: 'runtime-profile' }), + runtimeProfile, + draftProfile, + ), + ).toBe(runtimeProfile); + expect( + resolveActiveMatch3DRuntimeProfile( + buildRun({ profileId: 'draft-profile' }), + runtimeProfile, + draftProfile, + ), + ).toBe(draftProfile); +}); + +test('Match3D runtime profile resolves generated assets from matching public detail', () => { + const staleProfile = buildProfile({ + profileId: 'stale-profile', + generatedBackgroundAsset: buildBackgroundAsset({ + imageSrc: '/generated/match3d/stale-background.png', + }), + generatedItemAssets: [ + buildItemAsset({ + itemId: 'stale-item', + imageSrc: '/generated/match3d/stale-item.png', + }), + ], + }); + const publicBackground = buildBackgroundAsset({ + imageSrc: '/generated/match3d/public-background.png', + }); + const publicWork = buildPublicWork({ + profileId: 'public-profile', + generatedBackgroundAsset: publicBackground, + generatedItemAssets: [ + buildItemAsset({ + itemId: 'public-item', + imageSrc: '/generated/match3d/public-item.png', + }), + ], + }); + const run = buildRun({ profileId: 'public-profile' }); + + expect( + resolveMatch3DRuntimeGeneratedItemAssets(run, staleProfile, publicWork).some( + (asset) => asset.imageSrc === '/generated/match3d/public-item.png', + ), + ).toBe(true); + expect( + resolveMatch3DRuntimeGeneratedBackgroundAsset(run, staleProfile, publicWork), + ).toEqual(publicBackground); + expect(resolveMatch3DRuntimeBackgroundImageSrc(run, staleProfile, publicWork)).toBe( + '/generated/match3d/public-background.png', + ); +}); diff --git a/src/components/platform-entry/platformMatch3DRuntimeProfile.ts b/src/components/platform-entry/platformMatch3DRuntimeProfile.ts new file mode 100644 index 00000000..4f0325ce --- /dev/null +++ b/src/components/platform-entry/platformMatch3DRuntimeProfile.ts @@ -0,0 +1,326 @@ +import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; +import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; +import type { + Match3DGeneratedBackgroundAsset, + Match3DGeneratedItemAsset, + Match3DWorkProfile, + Match3DWorkSummary, +} from '../../../packages/shared/src/contracts/match3dWorks'; +import { + hasMatch3DGeneratedImageAsset, + mergeMatch3DGeneratedItemAssetsForRuntime, + normalizeMatch3DGeneratedItemAssetsForRuntime, +} from '../../services/match3dGeneratedModelCache'; +import { + isMatch3DGalleryEntry, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; + +export function mapPublicWorkDetailToMatch3DWork( + entry: PlatformPublicGalleryCard, +): Match3DWorkSummary | null { + if (!isMatch3DGalleryEntry(entry)) { + return null; + } + + return promoteMatch3DGeneratedBackgroundAsset({ + workId: entry.workId, + profileId: entry.profileId, + ownerUserId: entry.ownerUserId, + sourceSessionId: + 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' + ? entry.sourceSessionId + : null, + gameName: entry.worldName, + themeText: entry.themeTags[0] ?? '经典消除', + summary: entry.summaryText, + tags: entry.themeTags, + coverImageSrc: entry.coverImageSrc, + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'published', + playCount: entry.playCount ?? 0, + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + publishReady: true, + backgroundPrompt: entry.backgroundPrompt ?? null, + backgroundImageSrc: entry.backgroundImageSrc ?? null, + backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null, + generatedBackgroundAsset: + entry.generatedBackgroundAsset ?? + findMatch3DGeneratedBackgroundAsset(entry.generatedItemAssets) ?? + null, + generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime( + entry.generatedItemAssets ?? [], + ), + }); +} + +export function findMatch3DGeneratedBackgroundAsset( + generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined, +): Match3DGeneratedBackgroundAsset | null { + return ( + generatedItemAssets + ?.map((asset) => asset.backgroundAsset ?? null) + .find(Boolean) ?? null + ); +} + +export function promoteMatch3DGeneratedBackgroundAsset< + T extends Pick< + Match3DWorkSummary, + | 'backgroundPrompt' + | 'backgroundImageSrc' + | 'backgroundImageObjectKey' + | 'generatedBackgroundAsset' + | 'generatedItemAssets' + >, +>(profile: T): T { + const backgroundAsset = + profile.generatedBackgroundAsset ?? + findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets); + if (!backgroundAsset) { + return profile; + } + + return { + ...profile, + backgroundPrompt: + profile.backgroundPrompt ?? backgroundAsset.prompt ?? null, + backgroundImageSrc: + profile.backgroundImageSrc ?? + backgroundAsset.imageSrc ?? + backgroundAsset.imageObjectKey ?? + null, + backgroundImageObjectKey: + profile.backgroundImageObjectKey ?? + backgroundAsset.imageObjectKey ?? + backgroundAsset.imageSrc ?? + null, + generatedBackgroundAsset: + profile.generatedBackgroundAsset ?? backgroundAsset, + }; +} + +export function normalizeMatch3DWorkForRuntimeUi( + profile: T, +): T { + return promoteMatch3DGeneratedBackgroundAsset({ + ...profile, + generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime( + profile.generatedItemAssets, + ), + }); +} + +export function mapMatch3DWorksForRuntimeUi( + profiles: readonly T[], +): T[] { + return profiles.map(normalizeMatch3DWorkForRuntimeUi); +} + +export function buildMatch3DProfileFromSession( + session: Match3DAgentSessionSnapshot | null, +): Match3DWorkProfile | null { + const draft = session?.draft; + if (!session || !draft?.profileId) { + return null; + } + + const now = session.updatedAt || new Date().toISOString(); + const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime( + draft.generatedItemAssets, + ); + return promoteMatch3DGeneratedBackgroundAsset({ + workId: draft.profileId, + profileId: draft.profileId, + ownerUserId: 'current-user', + sourceSessionId: session.sessionId, + gameName: draft.gameName, + themeText: draft.themeText, + summary: draft.summary ?? draft.summaryText ?? '', + tags: draft.tags, + coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null, + referenceImageSrc: draft.referenceImageSrc ?? null, + clearCount: draft.clearCount, + difficulty: draft.difficulty, + publicationStatus: 'draft', + playCount: 0, + updatedAt: now, + publishedAt: null, + publishReady: Boolean(draft.publishReady), + backgroundPrompt: draft.backgroundPrompt ?? null, + backgroundImageSrc: draft.backgroundImageSrc ?? null, + backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null, + generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null, + generatedItemAssets, + }); +} + +export function hasMatch3DRuntimeAsset( + assets: readonly Match3DGeneratedItemAsset[] | null | undefined, +) { + return hasMatch3DGeneratedImageAsset(assets); +} + +export function hasMatch3DRuntimeBackgroundAsset( + profile: Pick< + Match3DWorkSummary, + | 'backgroundImageSrc' + | 'backgroundImageObjectKey' + | 'generatedBackgroundAsset' + | 'generatedItemAssets' + >, +) { + return Boolean( + profile.backgroundImageSrc?.trim() || + profile.backgroundImageObjectKey?.trim() || + profile.generatedBackgroundAsset?.imageSrc?.trim() || + profile.generatedBackgroundAsset?.imageObjectKey?.trim() || + profile.generatedBackgroundAsset?.containerImageSrc?.trim() || + profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() || + profile.generatedItemAssets?.some( + (asset) => + asset.backgroundAsset?.imageSrc?.trim() || + asset.backgroundAsset?.imageObjectKey?.trim() || + asset.backgroundAsset?.containerImageSrc?.trim() || + asset.backgroundAsset?.containerImageObjectKey?.trim(), + ), + ); +} + +export function resolveMatch3DRuntimeGeneratedItemAssets( + run: Match3DRunSnapshot | null, + profile: Match3DWorkProfile | null, + publicWorkDetail: PlatformPublicGalleryCard | null, +) { + const runProfileId = run?.profileId?.trim() ?? ''; + const profileAssets = profile?.generatedItemAssets ?? []; + const publicDetailAssets = + publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) + ? (publicWorkDetail.generatedItemAssets ?? []) + : []; + + if (runProfileId && profile?.profileId === runProfileId) { + if (hasMatch3DRuntimeAsset(profileAssets)) { + return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); + } + + if ( + publicWorkDetail && + isMatch3DGalleryEntry(publicWorkDetail) && + publicWorkDetail.profileId === runProfileId + ) { + return hasMatch3DRuntimeAsset(publicDetailAssets) + ? mergeMatch3DGeneratedItemAssetsForRuntime( + publicDetailAssets, + profileAssets, + ) + : normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); + } + + return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); + } + + if ( + runProfileId && + publicWorkDetail && + isMatch3DGalleryEntry(publicWorkDetail) && + publicWorkDetail.profileId === runProfileId + ) { + return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets); + } + + if (hasMatch3DRuntimeAsset(profileAssets)) { + return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); + } + return publicDetailAssets.length > 0 + ? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets) + : normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); +} + +export function resolveMatch3DRuntimeGeneratedBackgroundAsset( + run: Match3DRunSnapshot | null, + profile: Match3DWorkProfile | null, + publicWorkDetail: PlatformPublicGalleryCard | null, +) { + const runProfileId = run?.profileId?.trim() ?? ''; + const profileBackground = profile + ? (promoteMatch3DGeneratedBackgroundAsset(profile) + .generatedBackgroundAsset ?? null) + : null; + const publicBackground = + publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) + ? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail) + .generatedBackgroundAsset ?? null) + : null; + + if (runProfileId && profile?.profileId === runProfileId) { + return profileBackground ?? publicBackground; + } + if ( + runProfileId && + publicWorkDetail && + isMatch3DGalleryEntry(publicWorkDetail) && + publicWorkDetail.profileId === runProfileId + ) { + return publicBackground ?? profileBackground; + } + return profileBackground ?? publicBackground; +} + +export function resolveActiveMatch3DRuntimeProfile( + run: Match3DRunSnapshot | null, + runtimeProfile: Match3DWorkProfile | null, + profile: Match3DWorkProfile | null, +) { + const runProfileId = run?.profileId?.trim() ?? ''; + if (runProfileId && runtimeProfile?.profileId === runProfileId) { + return runtimeProfile; + } + if (runProfileId && profile?.profileId === runProfileId) { + return profile; + } + return runtimeProfile ?? profile; +} + +export function resolveMatch3DRuntimeBackgroundImageSrc( + run: Match3DRunSnapshot | null, + profile: Match3DWorkProfile | null, + publicWorkDetail: PlatformPublicGalleryCard | null, +) { + const runProfileId = run?.profileId?.trim() ?? ''; + const resolvedProfile = profile + ? promoteMatch3DGeneratedBackgroundAsset(profile) + : null; + const resolvedPublicWork = + publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) + ? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail) + : null; + const profileBackground = + resolvedProfile?.backgroundImageSrc?.trim() || + resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() || + resolvedProfile?.backgroundImageObjectKey?.trim() || + resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() || + ''; + const publicBackground = + resolvedPublicWork?.backgroundImageSrc?.trim() || + resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() || + resolvedPublicWork?.backgroundImageObjectKey?.trim() || + resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() || + ''; + + if (runProfileId && profile?.profileId === runProfileId) { + return profileBackground || publicBackground || null; + } + if ( + runProfileId && + publicWorkDetail && + isMatch3DGalleryEntry(publicWorkDetail) && + publicWorkDetail.profileId === runProfileId + ) { + return publicBackground || profileBackground || null; + } + return profileBackground || publicBackground || null; +} From fe2f8a66e667bcdf3e60d9ad36cb1affb2e6b6b0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 20:11:25 +0800 Subject: [PATCH 18/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E7=94=9F=E6=88=90=E4=BD=9C=E5=93=81=E6=9E=B6=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...DraftGenerationShelfModel收口计划-2026-06-03.md | 36 + .../PlatformEntryFlowShellImpl.tsx | 883 ++---------------- .../platformDraftGenerationShelfModel.test.ts | 189 ++++ .../platformDraftGenerationShelfModel.ts | 860 +++++++++++++++++ 6 files changed, 1180 insertions(+), 798 deletions(-) create mode 100644 docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md create mode 100644 src/components/platform-entry/platformDraftGenerationShelfModel.test.ts create mode 100644 src/components/platform-entry/platformDraftGenerationShelfModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 4ac34916..a0230b47 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1273,6 +1273,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "match3d|抓大鹅"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md`。 +## 2026-06-03 Draft Generation Shelf Model 收口 + +- 背景:平台壳内散落创作生成 notice key、pending 作品架占位、失败文案覆盖、拼图稳定 ID、持久化 generating/failed 判断与草稿 Tab 未读点,新增或调整玩法时需要在多处理解 `workId` / `profileId` / `sourceSessionId` / `draftId` 形状。 +- 决策:新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf Module,Interface 收口 `collectDraftNoticeKeys`、`getGenerationNoticeShelfKeys`、`createPendingDraftShelfState`、各玩法 `buildPending*Works`、`buildCreationWorkShelfRuntimeState`、`collectVisibleDraftNoticeKeys`、`hasUnreadDraftGenerationUpdates`、拼图稳定 ID 与持久化状态判断;`PlatformEntryFlowShellImpl.tsx` 仅作为 React state、网络刷新、路由和弹窗副作用 Adapter。 +- 影响范围:创作中心草稿 Tab 未读点、作品架生成中遮罩、失败草稿摘要、pending 草稿占位、拼图 / 抓大鹅生成恢复和各玩法生成完成通知。 +- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index b4d20565..b6eefb52 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,Hub 不再按玩法 `kind` 分发,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 抓大鹅 runtime profile 的公开详情转 work、session draft 转 profile、生成背景资产提升和 run/profile/public detail 素材优先级收口到 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91Match3DRuntimeProfile%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md new file mode 100644 index 00000000..c19c6a3c --- /dev/null +++ b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md @@ -0,0 +1,36 @@ +# 【前端架构】Draft Generation Shelf Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 同时承载创作生成状态、草稿 Tab 未读点、pending 作品架占位、失败文案覆盖和跨玩法 notice key。拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、汪汪声浪、大鱼吃小鱼和宝贝识物各有不同的 `workId` / `profileId` / `sourceSessionId` / `draftId`,这些规则散在平台壳 **Implementation** 内,导致调用方必须理解每种玩法的草稿身份形状。 + +该 **Interface** 过浅:页面看似只关心“生成中 / 已完成未读 / 失败”,却要知道多 ID 去重、pending 草稿去重、失败摘要、拼图空标题兜底和持久化 generating 覆盖规则。 + +## 决策 + +新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf **Module**。其 **Interface** 收口为: + +- `collectDraftNoticeKeys(kind, ids)` / `getGenerationNoticeShelfKeys(item)`:统一把玩法草稿身份映射为 notice key。 +- `createPendingDraftShelfState(...)` 与 `buildPending*Works(...)`:统一把本地 pending 生成状态映射成作品架占位,并避免与后端已有草稿重复。 +- `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。 +- `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。 +- `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)`、`isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、启动生成、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总和作品架 runtime state 规则。 + +## 约定 + +- 新玩法若需进入草稿生成通知,必须在此 **Module** 补 notice key、pending 占位和 visible key 映射,避免在平台壳里新增散落 switch。 +- pending 作品只用于本地生成任务尚未被后端作品架返回时的临时展示;一旦后端已有同一 `sourceSessionId` / `profileId` / `workId`,pending 占位必须让位。 +- 失败 notice 优先级高于持久化 generating,且可通过 pending metadata 提供更具体 summary;否则回退玩法默认失败摘要。 +- 已有封面的拼图草稿即使局部关卡仍在后台生成,也不得被整卡遮罩为不可打开的生成中状态。 +- 本 **Module** 不做网络请求、路由切换、弹窗副作用或 React state 写入,只保留纯 **Implementation**,以提高 **Depth**、**Leverage** 与 **Locality**。 + +## 验证 + +- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts` +- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"` +- 针对新 **Module** 与测试执行 ESLint;`PlatformEntryFlowShellImpl.tsx` 保留既有 hook dependency warnings,不在本切片扩大处理。 +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 48e7019c..4e45d4b9 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -405,6 +405,37 @@ import { mergeBarkBattleWorkSummary, shouldPreserveLocalBarkBattleWorkOnRefresh, } from './barkBattleWorkCache'; +import { + buildCreationWorkShelfRuntimeState, + buildDraftCompletionDialogSource, + buildDraftFailedShelfSummary, + buildPendingBarkBattleWorks, + buildPendingBigFishWorks, + buildPendingJumpHopWorks, + buildPendingMatch3DWorks, + buildPendingPuzzleWorks, + buildPendingSquareHoleWorks, + buildPendingVisualNovelWorks, + buildPendingWoodenFishWorks, + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, + collectDraftNoticeKeys, + collectVisibleDraftNoticeKeys, + createPendingDraftShelfState, + type DraftGenerationNoticeMap, + type DraftGenerationNoticeStatus, + getDraftGenerationNotice, + getGenerationNoticeShelfKeys, + hasDraftGenerationNoticeStatus, + hasUnreadDraftGenerationUpdates, + hasUnreadReadyDraftGenerationNotice, + isPersistedDraftFailed, + isPersistedDraftGenerating, + normalizeDraftNoticeId, + type PendingDraftShelfKind, + type PendingDraftShelfMap, + type PendingDraftShelfMetadata, +} from './platformDraftGenerationShelfModel'; import { canExposePublicWork, EDUTAINMENT_HIDDEN_MESSAGE, @@ -474,28 +505,7 @@ type AgentResultPublishGateView = { blockers: string[]; publishReady: boolean; }; -type DraftGenerationNoticeStatus = 'generating' | 'ready' | 'failed'; -type DraftGenerationNotice = { - status: DraftGenerationNoticeStatus; - seen: boolean; - completedAtMs?: number; - message?: string; -}; -type DraftGenerationNoticeMap = Record; type CreationWorkShelfKind = CreationWorkShelfItem['kind']; -type PendingDraftShelfState = { - status: DraftGenerationNoticeStatus; - seen: boolean; - updatedAt: string; - title?: string; - summary?: string; -}; -type PendingDraftShelfMap = Partial< - Record< - Exclude, - Record - > ->; type CreationFlowReturnTarget = 'create' | 'draft-shelf'; type Match3DBackgroundCompileTask = { session: Match3DAgentSessionSnapshot; @@ -1352,28 +1362,6 @@ function buildAgentResultPublishGateView( }; } -function buildPuzzleResultProfileId(sessionId: string | null | undefined) { - const normalizedSessionId = sessionId?.trim(); - if (!normalizedSessionId) { - return null; - } - const stableSuffix = normalizedSessionId.startsWith('puzzle-session-') - ? normalizedSessionId.slice('puzzle-session-'.length) - : normalizedSessionId; - return `puzzle-profile-${stableSuffix}`; -} - -function buildPuzzleResultWorkId(sessionId: string | null | undefined) { - const normalizedSessionId = sessionId?.trim(); - if (!normalizedSessionId) { - return null; - } - const stableSuffix = normalizedSessionId.startsWith('puzzle-session-') - ? normalizedSessionId.slice('puzzle-session-'.length) - : normalizedSessionId; - return `puzzle-work-${stableSuffix}`; -} - function buildPuzzleSessionIdFromProfileId( profileId: string | null | undefined, ) { @@ -1690,62 +1678,6 @@ function buildBabyObjectMatchCreationUrlState( }; } -function buildDraftNoticeKey(kind: CreationWorkShelfKind, id: string) { - return `${kind}:${id}`; -} - -function collectDraftNoticeKeys( - kind: CreationWorkShelfKind, - ids: Array, -) { - const keys = new Set(); - for (const id of ids) { - const normalizedId = id?.trim(); - if (normalizedId) { - keys.add(buildDraftNoticeKey(kind, normalizedId)); - } - } - return Array.from(keys); -} - -function normalizeDraftNoticeId(id: string | null | undefined) { - return id?.trim() || null; -} - -function normalizePendingDraftShelfLookupId( - kind: Exclude, - id: string | null | undefined, -) { - const normalizedId = normalizeDraftNoticeId(id); - if (!normalizedId) { - return null; - } - - const noticePrefix = `${kind}:`; - if (!normalizedId.startsWith(noticePrefix)) { - return normalizedId; - } - - return normalizeDraftNoticeId(normalizedId.slice(noticePrefix.length)); -} - -function createPendingDraftShelfState( - status: DraftGenerationNoticeStatus, - seen = false, - updatedAt = new Date().toISOString(), - metadata?: { title?: string | null; summary?: string | null }, -): PendingDraftShelfState { - const title = metadata?.title?.trim(); - const summary = metadata?.summary?.trim(); - return { - status, - seen, - updatedAt, - ...(title ? { title } : {}), - ...(summary ? { summary } : {}), - }; -} - function normalizePlatformErrorMessage(message: string | null | undefined) { const normalized = message?.trim(); return normalized ? normalized : null; @@ -1756,51 +1688,10 @@ function formatPlatformErrorSource(label: string, id?: string | null) { return normalizedId ? `${label} ${normalizedId}` : label; } -function formatPlatformTaskCompletionSource(label: string, id?: string | null) { - const normalizedId = id?.trim(); - return normalizedId ? `${label} ${normalizedId}` : label; -} - function isBackgroundGenerationStillRunningMessage(message: string) { return /仍在后台处理|后台仍在处理|仍在生成|后台生成/u.test(message); } -function buildDraftFailedShelfSummary(kind: CreationWorkShelfKind) { - switch (kind) { - case 'puzzle': - return '拼图草稿生成失败,可重新打开处理。'; - case 'match3d': - return '玩法素材生成失败,可重新打开处理。'; - case 'big-fish': - return '草稿生成失败,可重新打开处理。'; - case 'square-hole': - return '挑战素材生成失败,可重新打开处理。'; - case 'jump-hop': - return '跳一跳玩法草稿生成失败,可重新打开处理。'; - case 'wooden-fish': - return '敲木鱼草稿生成失败,可重新打开处理。'; - case 'visual-novel': - return '视觉小说草稿生成失败,可重新打开处理。'; - case 'bark-battle': - return '声浪竞技素材生成失败,可重新打开处理。'; - case 'baby-object-match': - return '宝贝识物草稿生成失败,可重新打开处理。'; - default: - return '草稿生成失败,可重新打开处理。'; - } -} - -function isDraftShelfSummaryPlaceholder(value: string | null | undefined) { - const normalized = value?.trim(); - if (!normalized) { - return true; - } - - return /^(正在生成|.*生成失败,可重新打开处理。$|未填写作品描述$)/u.test( - normalized, - ); -} - function buildPlatformErrorDialogDismissKey( error: (PlatformErrorDialogPayload & { key: string }) | null, ) { @@ -1820,53 +1711,6 @@ function buildPlatformTaskCompletionDialogDismissKey( : null; } -function pickDraftCompletionDialogSourceId( - ids: Array, -) { - const normalizedIds = ids - .map((id) => id?.trim() ?? '') - .filter((id) => Boolean(id)); - return ( - normalizedIds.find((id) => /session/i.test(id)) ?? - normalizedIds.find((id) => /work/i.test(id)) ?? - normalizedIds.find((id) => /draft/i.test(id)) ?? - normalizedIds.find((id) => /run/i.test(id)) ?? - normalizedIds.find((id) => /profile/i.test(id)) ?? - normalizedIds[0] ?? - null - ); -} - -function buildDraftCompletionDialogSource( - kind: CreationWorkShelfKind, - ids: Array, -): string { - const sourceId = pickDraftCompletionDialogSourceId(ids); - switch (kind) { - case 'rpg': - return formatPlatformTaskCompletionSource('RPG 草稿', sourceId); - case 'big-fish': - return formatPlatformTaskCompletionSource('大鱼吃小鱼草稿', sourceId); - case 'match3d': - return formatPlatformTaskCompletionSource('抓大鹅草稿', sourceId); - case 'square-hole': - return formatPlatformTaskCompletionSource('方洞挑战草稿', sourceId); - case 'jump-hop': - return formatPlatformTaskCompletionSource('跳一跳草稿', sourceId); - case 'wooden-fish': - return formatPlatformTaskCompletionSource('敲木鱼草稿', sourceId); - case 'puzzle': - return formatPlatformTaskCompletionSource('拼图草稿', sourceId); - case 'visual-novel': - return formatPlatformTaskCompletionSource('视觉小说草稿', sourceId); - case 'bark-battle': - return formatPlatformTaskCompletionSource('汪汪声浪草稿', sourceId); - case 'baby-object-match': - return formatPlatformTaskCompletionSource('宝贝识物草稿', sourceId); - } - return formatPlatformTaskCompletionSource('创作草稿', sourceId); -} - /** 为恢复的小游戏草稿重建生成态,保留后端开始时间作为进度事实源。 */ function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, @@ -2166,80 +2010,6 @@ function hasRecoverableGeneratedPuzzleDraft( ); } -function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] { - switch (item.source.kind) { - case 'rpg': - return collectDraftNoticeKeys('rpg', [ - item.id, - item.source.item.workId, - item.source.item.sessionId, - item.source.item.profileId, - ]); - case 'big-fish': - return collectDraftNoticeKeys('big-fish', [ - item.id, - item.source.item.workId, - item.source.item.sourceSessionId, - ]); - case 'match3d': - return collectDraftNoticeKeys('match3d', [ - item.id, - item.source.item.workId, - item.source.item.profileId, - item.source.item.sourceSessionId, - ]); - case 'square-hole': - return collectDraftNoticeKeys('square-hole', [ - item.id, - item.source.item.workId, - item.source.item.profileId, - item.source.item.sourceSessionId, - ]); - case 'jump-hop': - return collectDraftNoticeKeys('jump-hop', [ - item.id, - item.source.item.workId, - item.source.item.profileId, - item.source.item.sourceSessionId, - ]); - case 'puzzle': - return collectDraftNoticeKeys('puzzle', [ - item.id, - item.source.item.workId, - item.source.item.profileId, - item.source.item.sourceSessionId, - buildPuzzleResultWorkId(item.source.item.sourceSessionId), - buildPuzzleResultProfileId(item.source.item.sourceSessionId), - ]); - case 'visual-novel': - return collectDraftNoticeKeys('visual-novel', [ - item.id, - item.source.item.profileId, - ]); - case 'baby-object-match': - return collectDraftNoticeKeys('baby-object-match', [ - item.id, - item.source.item.profileId, - item.source.item.draftId, - ]); - case 'bark-battle': - return collectDraftNoticeKeys('bark-battle', [ - item.id, - item.source.item.workId, - item.source.item.draftId, - ]); - case 'wooden-fish': - return collectDraftNoticeKeys('wooden-fish', [ - item.id, - item.source.item.workId, - item.source.item.profileId, - item.source.item.sourceSessionId, - ]); - default: - return []; - } -} - function isMiniGameDraftReady(state: MiniGameDraftGenerationState | null) { return state?.phase === 'ready'; } @@ -2248,15 +2018,6 @@ function isMiniGameDraftGenerating(state: MiniGameDraftGenerationState | null) { return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed'); } -function isPersistedDraftGenerating(value: string | null | undefined) { - return value?.trim() === 'generating'; -} - -function isPersistedDraftFailed(value: string | null | undefined) { - const normalized = value?.trim(); - return normalized === 'failed' || normalized === 'partial_failed'; -} - function resolveProfileWalletBalance( dashboard: { walletBalance?: number | null } | null | undefined, ) { @@ -2311,347 +2072,6 @@ function reconcileProfileWalletLocalDeltaWithServerDashboard( return Math.max(0, normalizedDelta - reflectedCredit); } -function buildPendingBigFishWorks( - pending: Record | undefined, - existingItems: readonly BigFishWorkSummary[], -): BigFishWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => { - const isFailed = state.status === 'failed'; - return { - workId: `big-fish-work-${sessionId}`, - sourceSessionId: sessionId, - ownerUserId: '', - authorDisplayName: '', - title: '大鱼吃小鱼草稿', - subtitle: isFailed ? '生成失败待重试' : '草稿生成中', - summary: isFailed - ? '草稿生成失败,可重新打开处理。' - : '正在生成玩法草稿。', - coverImageSrc: null, - status: 'draft', - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - levelCount: 0, - levelMainImageReadyCount: 0, - levelMotionReadyCount: 0, - backgroundReady: false, - playCount: 0, - remixCount: 0, - likeCount: 0, - }; - }); -} - -function buildPendingJumpHopWorks( - pending: Record | undefined, - existingItems: readonly JumpHopWorkSummaryResponse[], -): JumpHopWorkSummaryResponse[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => { - const generationStatus = - state.status === 'failed' - ? 'failed' - : state.status === 'generating' - ? 'generating' - : 'ready'; - return { - runtimeKind: 'jump-hop', - workId: `jump-hop-work-${sessionId}`, - profileId: `jump-hop-profile-${sessionId}`, - ownerUserId: '', - sourceSessionId: sessionId, - workTitle: '跳一跳草稿', - workDescription: - state.status === 'failed' - ? '跳一跳玩法草稿生成失败,可重新打开处理。' - : '正在生成跳一跳玩法草稿。', - themeTags: [], - difficulty: 'standard', - stylePreset: 'minimal-blocks', - coverImageSrc: null, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus, - }; - }); -} - -function buildPendingWoodenFishWorks( - pending: Record | undefined, - existingItems: readonly WoodenFishWorkSummaryResponse[], -): WoodenFishWorkSummaryResponse[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => { - const generationStatus = - state.status === 'failed' - ? 'failed' - : state.status === 'generating' - ? 'generating' - : 'ready'; - return { - runtimeKind: 'wooden-fish', - workId: `wooden-fish-work-${sessionId}`, - profileId: sessionId, - ownerUserId: '', - sourceSessionId: sessionId, - workTitle: '敲木鱼草稿', - workDescription: - state.status === 'failed' - ? '敲木鱼草稿生成失败,可重新打开处理。' - : '正在生成敲木鱼草稿。', - themeTags: ['敲木鱼'], - coverImageSrc: null, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus, - }; - }); -} - -function buildPendingMatch3DWorks( - pending: Record | undefined, - existingItems: readonly Match3DWorkSummary[], -): Match3DWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => { - const themeText = state.summary?.trim() || state.title?.trim() || ''; - const fallbackSummary = - state.status === 'failed' - ? '玩法素材生成失败,可重新打开处理。' - : '正在生成玩法素材。'; - return { - workId: `match3d-work-${sessionId}`, - profileId: sessionId, - ownerUserId: '', - sourceSessionId: sessionId, - gameName: '抓大鹅草稿', - themeText, - summary: themeText || fallbackSummary, - tags: [], - coverImageSrc: null, - referenceImageSrc: null, - clearCount: 0, - difficulty: 0, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus: - state.status === 'failed' - ? 'failed' - : state.status === 'generating' - ? 'generating' - : 'ready', - generatedItemAssets: [], - }; - }); -} - -function buildPendingSquareHoleWorks( - pending: Record | undefined, - existingItems: readonly SquareHoleWorkSummary[], -): SquareHoleWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => ({ - workId: `square-hole-work-${sessionId}`, - profileId: sessionId, - ownerUserId: '', - sourceSessionId: sessionId, - gameName: '方洞挑战草稿', - themeText: '', - twistRule: '', - summary: - state.status === 'failed' - ? '挑战素材生成失败,可重新打开处理。' - : '正在生成挑战素材。', - tags: [], - coverImageSrc: null, - backgroundPrompt: '', - backgroundImageSrc: null, - shapeOptions: [], - holeOptions: [], - shapeCount: 0, - difficulty: 0, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - })); -} - -function buildPendingPuzzleWorks( - pending: Record | undefined, - existingItems: readonly PuzzleWorkSummary[], -): PuzzleWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => { - const profileId = - buildPuzzleResultProfileId(sessionId) ?? `puzzle-profile-${sessionId}`; - const title = state.title?.trim() || '拼图草稿'; - const summary = - state.summary?.trim() || - (state.status === 'failed' - ? '拼图草稿生成失败,可重新打开处理。' - : '正在生成拼图草稿。'); - return { - workId: - buildPuzzleResultWorkId(sessionId) ?? `puzzle-work-${sessionId}`, - profileId, - ownerUserId: '', - sourceSessionId: sessionId, - authorDisplayName: '', - workTitle: title, - workDescription: summary, - levelName: title, - summary, - themeTags: [], - coverImageSrc: null, - coverAssetId: null, - publicationStatus: 'draft', - updatedAt: state.updatedAt, - publishedAt: null, - playCount: 0, - remixCount: 0, - likeCount: 0, - publishReady: false, - generationStatus: - state.status === 'generating' - ? 'generating' - : state.status === 'failed' - ? 'failed' - : 'ready', - levels: [], - }; - }); -} - -function buildPendingVisualNovelWorks( - pending: Record | undefined, - existingItems: readonly VisualNovelWorkSummary[], -): VisualNovelWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([profileId]) => - existingItems.every((item) => item.profileId !== profileId), - ) - .map(([profileId, state]) => ({ - runtimeKind: 'visual-novel', - profileId, - ownerUserId: '', - title: '视觉小说草稿', - description: - state.status === 'failed' - ? '视觉小说草稿生成失败,可重新打开处理。' - : '正在生成视觉小说草稿。', - coverImageSrc: null, - tags: [], - publishStatus: 'draft', - publishReady: false, - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - })); -} - -function buildPendingBarkBattleWorks( - pending: Record | undefined, - existingItems: readonly BarkBattleWorkSummary[], -): BarkBattleWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([id]) => - existingItems.every((item) => item.workId !== id && item.draftId !== id), - ) - .map(([id, state]) => ({ - workId: id, - draftId: id, - ownerUserId: '', - authorDisplayName: '', - title: '汪汪声浪草稿', - summary: - state.status === 'failed' - ? '声浪竞技素材生成失败,可重新打开处理。' - : '正在生成声浪竞技素材。', - themeDescription: '', - playerImageDescription: '', - opponentImageDescription: '', - onomatopoeia: [], - playerCharacterImageSrc: null, - opponentCharacterImageSrc: null, - uiBackgroundImageSrc: null, - difficultyPreset: 'normal', - status: 'draft', - generationStatus: - state.status === 'generating' - ? 'pending_assets' - : state.status === 'failed' - ? 'partial_failed' - : 'ready', - publishReady: false, - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - })); -} - function buildPuzzleCompileActionFromFormPayload( payload: CreatePuzzleAgentSessionRequest | null, ): PuzzleAgentActionRequest { @@ -3525,11 +2945,11 @@ export function PlatformEntryFlowShellImpl({ const updatePendingDraftShelfItem = useCallback( ( - kind: Exclude, + kind: PendingDraftShelfKind, id: string | null | undefined, status: DraftGenerationNoticeStatus, seen = false, - metadata?: { title?: string | null; summary?: string | null }, + metadata?: PendingDraftShelfMetadata, ) => { const normalizedId = normalizeDraftNoticeId(id); if (!normalizedId) { @@ -3555,10 +2975,7 @@ export function PlatformEntryFlowShellImpl({ [], ); const clearPendingDraftShelfItem = useCallback( - ( - kind: Exclude, - id: string | null | undefined, - ) => { + (kind: PendingDraftShelfKind, id: string | null | undefined) => { const normalizedId = normalizeDraftNoticeId(id); if (!normalizedId) { return; @@ -3638,36 +3055,6 @@ export function PlatformEntryFlowShellImpl({ }); }, []); - const getDraftGenerationNotice = useCallback( - (keys: string[]) => { - for (const key of keys) { - const notice = draftGenerationNotices[key]; - if (notice) { - return notice; - } - } - return null; - }, - [draftGenerationNotices], - ); - const getPendingDraftShelfState = useCallback( - (kind: Exclude, keys: string[]) => { - const entries = pendingDraftShelfItems[kind]; - if (!entries) { - return null; - } - - for (const key of keys) { - const normalizedKey = normalizePendingDraftShelfLookupId(kind, key); - const pending = normalizedKey ? entries[normalizedKey] : null; - if (pending) { - return pending; - } - } - return null; - }, - [pendingDraftShelfItems], - ); const markDraftGenerating = useCallback( (kind: CreationWorkShelfKind, ids: Array) => { setPendingPlatformTaskCompletionDialog(null); @@ -3739,9 +3126,9 @@ export function PlatformEntryFlowShellImpl({ ); const markPendingDraftGenerating = useCallback( ( - kind: Exclude, + kind: PendingDraftShelfKind, id: string | null | undefined, - metadata?: { title?: string | null; summary?: string | null }, + metadata?: PendingDraftShelfMetadata, ) => { setPendingPlatformTaskCompletionDialog(null); setPendingPlatformTaskFailureDialog(null); @@ -3755,7 +3142,7 @@ export function PlatformEntryFlowShellImpl({ ); const markPendingDraftReady = useCallback( ( - kind: Exclude, + kind: PendingDraftShelfKind, id: string | null | undefined, viewedImmediately: boolean, ) => { @@ -3764,10 +3151,7 @@ export function PlatformEntryFlowShellImpl({ [updatePendingDraftShelfItem], ); const markPendingDraftFailed = useCallback( - ( - kind: Exclude, - id: string | null | undefined, - ) => { + (kind: PendingDraftShelfKind, id: string | null | undefined) => { updatePendingDraftShelfItem(kind, id, 'failed', false); }, [updatePendingDraftShelfItem], @@ -3944,28 +3328,23 @@ export function PlatformEntryFlowShellImpl({ ); }, []); const isDraftNoticeGenerating = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => { - return collectDraftNoticeKeys(kind, ids).some( - (key) => draftGenerationNotices[key]?.status === 'generating', - ); - }, + (kind: CreationWorkShelfKind, ids: Array) => + hasDraftGenerationNoticeStatus( + draftGenerationNotices, + kind, + ids, + 'generating', + ), [draftGenerationNotices], ); const isDraftNoticeFailed = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => { - return collectDraftNoticeKeys(kind, ids).some( - (key) => draftGenerationNotices[key]?.status === 'failed', - ); - }, + (kind: CreationWorkShelfKind, ids: Array) => + hasDraftGenerationNoticeStatus(draftGenerationNotices, kind, ids, 'failed'), [draftGenerationNotices], ); const isDraftNoticeReadyUnread = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => { - return collectDraftNoticeKeys(kind, ids).some((key) => { - const notice = draftGenerationNotices[key]; - return notice?.status === 'ready' && !notice.seen; - }); - }, + (kind: CreationWorkShelfKind, ids: Array) => + hasUnreadReadyDraftGenerationNotice(draftGenerationNotices, kind, ids), [draftGenerationNotices], ); const ensureEnoughDraftGenerationPointsFromServer = useCallback( @@ -4806,128 +4185,30 @@ export function PlatformEntryFlowShellImpl({ [barkBattleWorks, pendingDraftShelfItems], ); const getCreationWorkShelfState = useCallback( - (item: CreationWorkShelfItem) => { - const noticeKeys = getGenerationNoticeShelfKeys(item); - const notice = getDraftGenerationNotice(noticeKeys); - if (notice?.status === 'failed') { - const failedSummary = buildDraftFailedShelfSummary(item.source.kind); - const pending = - item.source.kind === 'rpg' - ? null - : getPendingDraftShelfState(item.source.kind, noticeKeys); - const pendingSummary = pending?.summary?.trim(); - return { - isGenerating: false, - hasGenerationFailure: true, - generationFailureSummary: failedSummary, - hasUnreadUpdate: false, - suppressPersistedGenerating: true, - titleOverride: - item.source.kind === 'puzzle' && - item.status === 'draft' && - !item.source.item.workTitle?.trim() - ? '拼图草稿' - : undefined, - summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) - ? (pendingSummary ?? failedSummary) - : undefined, - }; - } - if ( - item.source.kind === 'puzzle' && - isPersistedDraftFailed(item.source.item.generationStatus) - ) { - const failedSummary = buildDraftFailedShelfSummary('puzzle'); - return { - isGenerating: false, - hasGenerationFailure: true, - generationFailureSummary: failedSummary, - hasUnreadUpdate: false, - suppressPersistedGenerating: true, - titleOverride: - item.status === 'draft' && !item.source.item.workTitle?.trim() - ? '拼图草稿' - : undefined, - summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) - ? failedSummary - : undefined, - }; - } - const isNoticeGenerating = - notice?.status === 'generating' && - (item.source.kind !== 'puzzle' || - !resolvePuzzleWorkCoverImageSrc(item.source.item)); - return { - isGenerating: isNoticeGenerating || item.isGenerating === true, - hasUnreadUpdate: notice?.status === 'ready' && !notice.seen, - }; - }, - [getDraftGenerationNotice, getPendingDraftShelfState], + (item: CreationWorkShelfItem) => + buildCreationWorkShelfRuntimeState({ + item, + notices: draftGenerationNotices, + pendingShelfItems: pendingDraftShelfItems, + }), + [draftGenerationNotices, pendingDraftShelfItems], ); const visibleDraftNoticeKeys = useMemo( - () => [ - ...creationHubItems.flatMap((item) => - collectDraftNoticeKeys('rpg', [ - item.workId, - item.sessionId, - item.profileId, - ]), - ), - ...bigFishShelfItems.flatMap((item) => - collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]), - ), - ...jumpHopShelfItems.flatMap((item) => - collectDraftNoticeKeys('jump-hop', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ), - ...woodenFishShelfItems.flatMap((item) => - collectDraftNoticeKeys('wooden-fish', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ), - ...match3dShelfItems.flatMap((item) => - collectDraftNoticeKeys('match3d', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ), - ...(isSquareHoleCreationVisible - ? squareHoleShelfItems.flatMap((item) => - collectDraftNoticeKeys('square-hole', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ) - : []), - ...puzzleShelfItems.flatMap((item) => - collectDraftNoticeKeys('puzzle', [ - item.workId, - item.profileId, - item.sourceSessionId, - buildPuzzleResultWorkId(item.sourceSessionId), - buildPuzzleResultProfileId(item.sourceSessionId), - ]), - ), - ...visualNovelShelfItems.flatMap((item) => - collectDraftNoticeKeys('visual-novel', [item.profileId]), - ), - ...barkBattleShelfItems.flatMap((item) => - collectDraftNoticeKeys('bark-battle', [item.workId, item.draftId]), - ), - ...babyObjectMatchDrafts.flatMap((item) => - collectDraftNoticeKeys('baby-object-match', [ - item.profileId, - item.draftId, - ]), - ), - ], + () => + collectVisibleDraftNoticeKeys({ + rpgItems: creationHubItems, + bigFishItems: bigFishShelfItems, + jumpHopItems: jumpHopShelfItems, + woodenFishItems: woodenFishShelfItems, + match3dItems: match3dShelfItems, + squareHoleItems: isSquareHoleCreationVisible + ? squareHoleShelfItems + : [], + puzzleItems: puzzleShelfItems, + visualNovelItems: visualNovelShelfItems, + barkBattleItems: barkBattleShelfItems, + babyObjectMatchItems: babyObjectMatchDrafts, + }), [ babyObjectMatchDrafts, barkBattleShelfItems, @@ -4944,10 +4225,10 @@ export function PlatformEntryFlowShellImpl({ ); const hasUnreadDraftUpdates = useMemo( () => - visibleDraftNoticeKeys.some((key) => { - const notice = draftGenerationNotices[key]; - return notice?.status === 'ready' && !notice.seen; - }), + hasUnreadDraftGenerationUpdates( + draftGenerationNotices, + visibleDraftNoticeKeys, + ), [draftGenerationNotices, visibleDraftNoticeKeys], ); const resultViewError = @@ -12656,7 +11937,10 @@ export function PlatformEntryFlowShellImpl({ buildPuzzleResultWorkId(item.sourceSessionId), buildPuzzleResultProfileId(item.sourceSessionId), ]); - const failedNotice = getDraftGenerationNotice(noticeKeys); + const failedNotice = getDraftGenerationNotice( + draftGenerationNotices, + noticeKeys, + ); const isPersistedFailed = isPersistedDraftFailed(item.generationStatus); const hasGeneratingNotice = isDraftNoticeGenerating('puzzle', [ item.workId, @@ -12882,8 +12166,8 @@ export function PlatformEntryFlowShellImpl({ }, [ enterCreateTab, + draftGenerationNotices, getPuzzleBackgroundCompileTask, - getDraftGenerationNotice, isDraftNoticeFailed, isDraftNoticeGenerating, markDraftNoticeSeen, @@ -12930,7 +12214,10 @@ export function PlatformEntryFlowShellImpl({ return; } - const failedNotice = getDraftGenerationNotice(noticeKeys); + const failedNotice = getDraftGenerationNotice( + draftGenerationNotices, + noticeKeys, + ); const hasFailedNotice = isDraftNoticeFailed('match3d', [ item.workId, item.profileId, @@ -13149,8 +12436,8 @@ export function PlatformEntryFlowShellImpl({ }, [ enterCreateTab, + draftGenerationNotices, getMatch3DBackgroundCompileTask, - getDraftGenerationNotice, isDraftNoticeFailed, isDraftNoticeGenerating, isDraftNoticeReadyUnread, diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts new file mode 100644 index 00000000..25c70f45 --- /dev/null +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from 'vitest'; + +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf'; +import { + buildCreationWorkShelfRuntimeState, + buildPendingPuzzleWorks, + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, + collectVisibleDraftNoticeKeys, + createPendingDraftShelfState, + type DraftGenerationNoticeMap, + getGenerationNoticeShelfKeys, + hasUnreadDraftGenerationUpdates, +} from './platformDraftGenerationShelfModel'; + +describe('platformDraftGenerationShelfModel', () => { + test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => { + const pending = buildPendingPuzzleWorks( + { + 'puzzle-session-ocean': createPendingDraftShelfState( + 'failed', + false, + '2026-06-03T08:00:00.000Z', + ), + }, + [], + ); + + expect(pending).toHaveLength(1); + expect(pending[0]).toMatchObject({ + workId: 'puzzle-work-ocean', + profileId: 'puzzle-profile-ocean', + sourceSessionId: 'puzzle-session-ocean', + workTitle: '拼图草稿', + summary: '拼图草稿生成失败,可重新打开处理。', + generationStatus: 'failed', + }); + }); + + test('buildPendingPuzzleWorks skips pending item when backend shelf already has the session', () => { + const pending = buildPendingPuzzleWorks( + { + 'puzzle-session-ocean': createPendingDraftShelfState( + 'generating', + false, + '2026-06-03T08:00:00.000Z', + ), + }, + [buildPuzzleWork({ sourceSessionId: 'puzzle-session-ocean' })], + ); + + expect(pending).toEqual([]); + }); + + test('buildCreationWorkShelfRuntimeState lets failure notice override persisted generating puzzle copy', () => { + const [item] = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [ + buildPuzzleWork({ + workId: 'puzzle-work-empty', + profileId: 'puzzle-profile-empty', + sourceSessionId: 'puzzle-session-empty', + workTitle: '', + workDescription: '', + levelName: '', + summary: '正在生成拼图草稿。', + generationStatus: 'generating', + }), + ], + }); + expect(item).toBeTruthy(); + + const noticeKeys = getGenerationNoticeShelfKeys(item!); + const notices = Object.fromEntries( + noticeKeys.map((key) => [ + key, + { status: 'failed', seen: false }, + ]), + ) as DraftGenerationNoticeMap; + + const state = buildCreationWorkShelfRuntimeState({ + item: item!, + notices, + pendingShelfItems: { + puzzle: { + 'puzzle-session-empty': createPendingDraftShelfState( + 'failed', + false, + '2026-06-03T08:00:00.000Z', + { summary: '图片生成超时,可重新打开处理。' }, + ), + }, + }, + }); + + expect(state).toMatchObject({ + isGenerating: false, + hasGenerationFailure: true, + generationFailureSummary: '拼图草稿生成失败,可重新打开处理。', + hasUnreadUpdate: false, + suppressPersistedGenerating: true, + titleOverride: '拼图草稿', + summaryOverride: '图片生成超时,可重新打开处理。', + }); + }); + + test('collectVisibleDraftNoticeKeys and hasUnreadDraftGenerationUpdates share unread dot rule', () => { + const puzzle = buildPuzzleWork({ + workId: 'puzzle-work-ocean', + profileId: 'puzzle-profile-ocean', + sourceSessionId: 'puzzle-session-ocean', + }); + const visibleKeys = collectVisibleDraftNoticeKeys({ + rpgItems: [], + bigFishItems: [], + jumpHopItems: [], + woodenFishItems: [], + match3dItems: [], + squareHoleItems: [], + puzzleItems: [puzzle], + visualNovelItems: [], + barkBattleItems: [], + babyObjectMatchItems: [], + }); + + expect(visibleKeys).toContain('puzzle:puzzle-work-ocean'); + expect(visibleKeys).toContain('puzzle:puzzle-profile-ocean'); + expect(visibleKeys).toContain('puzzle:puzzle-session-ocean'); + expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe( + 'puzzle-work-ocean', + ); + expect(buildPuzzleResultProfileId('puzzle-session-ocean')).toBe( + 'puzzle-profile-ocean', + ); + + expect( + hasUnreadDraftGenerationUpdates( + { + 'puzzle:puzzle-profile-ocean': { + status: 'ready', + seen: false, + }, + }, + visibleKeys, + ), + ).toBe(true); + expect( + hasUnreadDraftGenerationUpdates( + { + 'puzzle:puzzle-profile-ocean': { + status: 'ready', + seen: true, + }, + }, + visibleKeys, + ), + ).toBe(false); + }); +}); + +function buildPuzzleWork( + overrides: Partial = {}, +): PuzzleWorkSummary { + return { + workId: 'puzzle-work-base', + profileId: 'puzzle-profile-base', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-base', + authorDisplayName: '测试作者', + workTitle: '潮雾拼图', + workDescription: '潮雾港口拼图。', + levelName: '潮雾拼图', + summary: '潮雾港口拼图。', + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-06-03T08:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + levels: [], + ...overrides, + }; +} diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.ts new file mode 100644 index 00000000..314b7f61 --- /dev/null +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.ts @@ -0,0 +1,860 @@ +import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; +import { + type CreationWorkShelfItem, + type CreationWorkShelfKind, + type CreationWorkShelfRuntimeState, + resolvePuzzleWorkCoverImageSrc, +} from '../custom-world-home/creationWorkShelf'; + +export type DraftGenerationNoticeStatus = 'generating' | 'ready' | 'failed'; + +export type DraftGenerationNotice = { + status: DraftGenerationNoticeStatus; + seen: boolean; + completedAtMs?: number; + message?: string; +}; + +export type DraftGenerationNoticeMap = Record; + +export type PendingDraftShelfState = { + status: DraftGenerationNoticeStatus; + seen: boolean; + updatedAt: string; + title?: string; + summary?: string; +}; + +export type PendingDraftShelfKind = Exclude; + +export type PendingDraftShelfMap = Partial< + Record> +>; + +export type PendingDraftShelfMetadata = { + title?: string | null; + summary?: string | null; +}; + +export type PlatformDraftGenerationVisibleShelfSources = { + rpgItems: readonly CustomWorldWorkSummary[]; + bigFishItems: readonly BigFishWorkSummary[]; + jumpHopItems: readonly JumpHopWorkSummaryResponse[]; + woodenFishItems: readonly WoodenFishWorkSummaryResponse[]; + match3dItems: readonly Match3DWorkSummary[]; + squareHoleItems: readonly SquareHoleWorkSummary[]; + puzzleItems: readonly PuzzleWorkSummary[]; + visualNovelItems: readonly VisualNovelWorkSummary[]; + barkBattleItems: readonly BarkBattleWorkSummary[]; + babyObjectMatchItems: readonly BabyObjectMatchDraft[]; +}; + +export function buildPuzzleResultProfileId( + sessionId: string | null | undefined, +) { + const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId); + return stableSuffix ? `puzzle-profile-${stableSuffix}` : null; +} + +export function buildPuzzleResultWorkId( + sessionId: string | null | undefined, +) { + const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId); + return stableSuffix ? `puzzle-work-${stableSuffix}` : null; +} + +export function buildDraftNoticeKey( + kind: CreationWorkShelfKind, + id: string, +) { + return `${kind}:${id}`; +} + +export function collectDraftNoticeKeys( + kind: CreationWorkShelfKind, + ids: Array, +) { + const keys = new Set(); + for (const id of ids) { + const normalizedId = id?.trim(); + if (normalizedId) { + keys.add(buildDraftNoticeKey(kind, normalizedId)); + } + } + return Array.from(keys); +} + +export function normalizeDraftNoticeId(id: string | null | undefined) { + return id?.trim() || null; +} + +export function normalizePendingDraftShelfLookupId( + kind: PendingDraftShelfKind, + id: string | null | undefined, +) { + const normalizedId = normalizeDraftNoticeId(id); + if (!normalizedId) { + return null; + } + + const noticePrefix = `${kind}:`; + if (!normalizedId.startsWith(noticePrefix)) { + return normalizedId; + } + + return normalizeDraftNoticeId(normalizedId.slice(noticePrefix.length)); +} + +export function createPendingDraftShelfState( + status: DraftGenerationNoticeStatus, + seen = false, + updatedAt = new Date().toISOString(), + metadata?: PendingDraftShelfMetadata, +): PendingDraftShelfState { + const title = metadata?.title?.trim(); + const summary = metadata?.summary?.trim(); + return { + status, + seen, + updatedAt, + ...(title ? { title } : {}), + ...(summary ? { summary } : {}), + }; +} + +export function buildDraftFailedShelfSummary(kind: CreationWorkShelfKind) { + switch (kind) { + case 'puzzle': + return '拼图草稿生成失败,可重新打开处理。'; + case 'match3d': + return '玩法素材生成失败,可重新打开处理。'; + case 'big-fish': + return '草稿生成失败,可重新打开处理。'; + case 'square-hole': + return '挑战素材生成失败,可重新打开处理。'; + case 'jump-hop': + return '跳一跳玩法草稿生成失败,可重新打开处理。'; + case 'wooden-fish': + return '敲木鱼草稿生成失败,可重新打开处理。'; + case 'visual-novel': + return '视觉小说草稿生成失败,可重新打开处理。'; + case 'bark-battle': + return '声浪竞技素材生成失败,可重新打开处理。'; + case 'baby-object-match': + return '宝贝识物草稿生成失败,可重新打开处理。'; + default: + return '草稿生成失败,可重新打开处理。'; + } +} + +export function buildDraftCompletionDialogSource( + kind: CreationWorkShelfKind, + ids: Array, +): string { + const sourceId = pickDraftCompletionDialogSourceId(ids); + switch (kind) { + case 'rpg': + return formatDraftTaskCompletionSource('RPG 草稿', sourceId); + case 'big-fish': + return formatDraftTaskCompletionSource('大鱼吃小鱼草稿', sourceId); + case 'match3d': + return formatDraftTaskCompletionSource('抓大鹅草稿', sourceId); + case 'square-hole': + return formatDraftTaskCompletionSource('方洞挑战草稿', sourceId); + case 'jump-hop': + return formatDraftTaskCompletionSource('跳一跳草稿', sourceId); + case 'wooden-fish': + return formatDraftTaskCompletionSource('敲木鱼草稿', sourceId); + case 'puzzle': + return formatDraftTaskCompletionSource('拼图草稿', sourceId); + case 'visual-novel': + return formatDraftTaskCompletionSource('视觉小说草稿', sourceId); + case 'bark-battle': + return formatDraftTaskCompletionSource('汪汪声浪草稿', sourceId); + case 'baby-object-match': + return formatDraftTaskCompletionSource('宝贝识物草稿', sourceId); + } + return formatDraftTaskCompletionSource('创作草稿', sourceId); +} + +export function isDraftShelfSummaryPlaceholder( + value: string | null | undefined, +) { + const normalized = value?.trim(); + if (!normalized) { + return true; + } + + return /^(正在生成|.*生成失败,可重新打开处理。$|未填写作品描述$)/u.test( + normalized, + ); +} + +export function isPersistedDraftGenerating(value: string | null | undefined) { + return value?.trim() === 'generating'; +} + +export function isPersistedDraftFailed(value: string | null | undefined) { + const normalized = value?.trim(); + return normalized === 'failed' || normalized === 'partial_failed'; +} + +export function getGenerationNoticeShelfKeys( + item: CreationWorkShelfItem, +): string[] { + switch (item.source.kind) { + case 'rpg': + return collectDraftNoticeKeys('rpg', [ + item.id, + item.source.item.workId, + item.source.item.sessionId, + item.source.item.profileId, + ]); + case 'big-fish': + return collectDraftNoticeKeys('big-fish', [ + item.id, + item.source.item.workId, + item.source.item.sourceSessionId, + ]); + case 'match3d': + return collectDraftNoticeKeys('match3d', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); + case 'square-hole': + return collectDraftNoticeKeys('square-hole', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); + case 'jump-hop': + return collectDraftNoticeKeys('jump-hop', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); + case 'puzzle': + return collectDraftNoticeKeys('puzzle', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + buildPuzzleResultWorkId(item.source.item.sourceSessionId), + buildPuzzleResultProfileId(item.source.item.sourceSessionId), + ]); + case 'visual-novel': + return collectDraftNoticeKeys('visual-novel', [ + item.id, + item.source.item.profileId, + ]); + case 'baby-object-match': + return collectDraftNoticeKeys('baby-object-match', [ + item.id, + item.source.item.profileId, + item.source.item.draftId, + ]); + case 'bark-battle': + return collectDraftNoticeKeys('bark-battle', [ + item.id, + item.source.item.workId, + item.source.item.draftId, + ]); + case 'wooden-fish': + return collectDraftNoticeKeys('wooden-fish', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); + default: + return []; + } +} + +export function getDraftGenerationNotice( + notices: DraftGenerationNoticeMap, + keys: readonly string[], +) { + for (const key of keys) { + const notice = notices[key]; + if (notice) { + return notice; + } + } + return null; +} + +export function getPendingDraftShelfState( + pendingShelfItems: PendingDraftShelfMap, + kind: PendingDraftShelfKind, + keys: readonly string[], +) { + const entries = pendingShelfItems[kind]; + if (!entries) { + return null; + } + + for (const key of keys) { + const normalizedKey = normalizePendingDraftShelfLookupId(kind, key); + const pending = normalizedKey ? entries[normalizedKey] : null; + if (pending) { + return pending; + } + } + return null; +} + +export function hasDraftGenerationNoticeStatus( + notices: DraftGenerationNoticeMap, + kind: CreationWorkShelfKind, + ids: Array, + status: DraftGenerationNoticeStatus, +) { + return collectDraftNoticeKeys(kind, ids).some( + (key) => notices[key]?.status === status, + ); +} + +export function hasUnreadReadyDraftGenerationNotice( + notices: DraftGenerationNoticeMap, + kind: CreationWorkShelfKind, + ids: Array, +) { + return collectDraftNoticeKeys(kind, ids).some((key) => { + const notice = notices[key]; + return notice?.status === 'ready' && !notice.seen; + }); +} + +export function buildCreationWorkShelfRuntimeState(params: { + item: CreationWorkShelfItem; + notices: DraftGenerationNoticeMap; + pendingShelfItems: PendingDraftShelfMap; +}): CreationWorkShelfRuntimeState { + const { item, notices, pendingShelfItems } = params; + const noticeKeys = getGenerationNoticeShelfKeys(item); + const notice = getDraftGenerationNotice(notices, noticeKeys); + + if (notice?.status === 'failed') { + const failedSummary = buildDraftFailedShelfSummary(item.source.kind); + const pending = + item.source.kind === 'rpg' + ? null + : getPendingDraftShelfState( + pendingShelfItems, + item.source.kind, + noticeKeys, + ); + const pendingSummary = pending?.summary?.trim(); + return { + isGenerating: false, + hasGenerationFailure: true, + generationFailureSummary: failedSummary, + hasUnreadUpdate: false, + suppressPersistedGenerating: true, + titleOverride: + item.source.kind === 'puzzle' && + item.status === 'draft' && + !item.source.item.workTitle?.trim() + ? '拼图草稿' + : undefined, + summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) + ? (pendingSummary ?? failedSummary) + : undefined, + }; + } + + if ( + item.source.kind === 'puzzle' && + isPersistedDraftFailed(item.source.item.generationStatus) + ) { + const failedSummary = buildDraftFailedShelfSummary('puzzle'); + return { + isGenerating: false, + hasGenerationFailure: true, + generationFailureSummary: failedSummary, + hasUnreadUpdate: false, + suppressPersistedGenerating: true, + titleOverride: + item.status === 'draft' && !item.source.item.workTitle?.trim() + ? '拼图草稿' + : undefined, + summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) + ? failedSummary + : undefined, + }; + } + + const isNoticeGenerating = + notice?.status === 'generating' && + (item.source.kind !== 'puzzle' || + !resolvePuzzleWorkCoverImageSrc(item.source.item)); + return { + isGenerating: isNoticeGenerating || item.isGenerating === true, + hasUnreadUpdate: notice?.status === 'ready' && !notice.seen, + }; +} + +export function collectVisibleDraftNoticeKeys( + sources: PlatformDraftGenerationVisibleShelfSources, +) { + return [ + ...sources.rpgItems.flatMap((item) => + collectDraftNoticeKeys('rpg', [ + item.workId, + item.sessionId, + item.profileId, + ]), + ), + ...sources.bigFishItems.flatMap((item) => + collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]), + ), + ...sources.jumpHopItems.flatMap((item) => + collectDraftNoticeKeys('jump-hop', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), + ...sources.woodenFishItems.flatMap((item) => + collectDraftNoticeKeys('wooden-fish', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), + ...sources.match3dItems.flatMap((item) => + collectDraftNoticeKeys('match3d', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), + ...sources.squareHoleItems.flatMap((item) => + collectDraftNoticeKeys('square-hole', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), + ...sources.puzzleItems.flatMap((item) => + collectDraftNoticeKeys('puzzle', [ + item.workId, + item.profileId, + item.sourceSessionId, + buildPuzzleResultWorkId(item.sourceSessionId), + buildPuzzleResultProfileId(item.sourceSessionId), + ]), + ), + ...sources.visualNovelItems.flatMap((item) => + collectDraftNoticeKeys('visual-novel', [item.profileId]), + ), + ...sources.barkBattleItems.flatMap((item) => + collectDraftNoticeKeys('bark-battle', [item.workId, item.draftId]), + ), + ...sources.babyObjectMatchItems.flatMap((item) => + collectDraftNoticeKeys('baby-object-match', [ + item.profileId, + item.draftId, + ]), + ), + ]; +} + +export function hasUnreadDraftGenerationUpdates( + notices: DraftGenerationNoticeMap, + visibleKeys: readonly string[], +) { + return visibleKeys.some((key) => { + const notice = notices[key]; + return notice?.status === 'ready' && !notice.seen; + }); +} + +export function buildPendingBigFishWorks( + pending: Record | undefined, + existingItems: readonly BigFishWorkSummary[], +): BigFishWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => { + const isFailed = state.status === 'failed'; + return { + workId: `big-fish-work-${sessionId}`, + sourceSessionId: sessionId, + ownerUserId: '', + authorDisplayName: '', + title: '大鱼吃小鱼草稿', + subtitle: isFailed ? '生成失败待重试' : '草稿生成中', + summary: isFailed + ? '草稿生成失败,可重新打开处理。' + : '正在生成玩法草稿。', + coverImageSrc: null, + status: 'draft', + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + levelCount: 0, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + playCount: 0, + remixCount: 0, + likeCount: 0, + }; + }); +} + +export function buildPendingJumpHopWorks( + pending: Record | undefined, + existingItems: readonly JumpHopWorkSummaryResponse[], +): JumpHopWorkSummaryResponse[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => { + const generationStatus = + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready'; + return { + runtimeKind: 'jump-hop', + workId: `jump-hop-work-${sessionId}`, + profileId: `jump-hop-profile-${sessionId}`, + ownerUserId: '', + sourceSessionId: sessionId, + workTitle: '跳一跳草稿', + workDescription: + state.status === 'failed' + ? '跳一跳玩法草稿生成失败,可重新打开处理。' + : '正在生成跳一跳玩法草稿。', + themeTags: [], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus, + }; + }); +} + +export function buildPendingWoodenFishWorks( + pending: Record | undefined, + existingItems: readonly WoodenFishWorkSummaryResponse[], +): WoodenFishWorkSummaryResponse[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => { + const generationStatus = + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready'; + return { + runtimeKind: 'wooden-fish', + workId: `wooden-fish-work-${sessionId}`, + profileId: sessionId, + ownerUserId: '', + sourceSessionId: sessionId, + workTitle: '敲木鱼草稿', + workDescription: + state.status === 'failed' + ? '敲木鱼草稿生成失败,可重新打开处理。' + : '正在生成敲木鱼草稿。', + themeTags: ['敲木鱼'], + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus, + }; + }); +} + +export function buildPendingMatch3DWorks( + pending: Record | undefined, + existingItems: readonly Match3DWorkSummary[], +): Match3DWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => { + const themeText = state.summary?.trim() || state.title?.trim() || ''; + const fallbackSummary = + state.status === 'failed' + ? '玩法素材生成失败,可重新打开处理。' + : '正在生成玩法素材。'; + return { + workId: `match3d-work-${sessionId}`, + profileId: sessionId, + ownerUserId: '', + sourceSessionId: sessionId, + gameName: '抓大鹅草稿', + themeText, + summary: themeText || fallbackSummary, + tags: [], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 0, + difficulty: 0, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus: + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready', + generatedItemAssets: [], + }; + }); +} + +export function buildPendingSquareHoleWorks( + pending: Record | undefined, + existingItems: readonly SquareHoleWorkSummary[], +): SquareHoleWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => ({ + workId: `square-hole-work-${sessionId}`, + profileId: sessionId, + ownerUserId: '', + sourceSessionId: sessionId, + gameName: '方洞挑战草稿', + themeText: '', + twistRule: '', + summary: + state.status === 'failed' + ? '挑战素材生成失败,可重新打开处理。' + : '正在生成挑战素材。', + tags: [], + coverImageSrc: null, + backgroundPrompt: '', + backgroundImageSrc: null, + shapeOptions: [], + holeOptions: [], + shapeCount: 0, + difficulty: 0, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + })); +} + +export function buildPendingPuzzleWorks( + pending: Record | undefined, + existingItems: readonly PuzzleWorkSummary[], +): PuzzleWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => { + const profileId = + buildPuzzleResultProfileId(sessionId) ?? `puzzle-profile-${sessionId}`; + const title = state.title?.trim() || '拼图草稿'; + const summary = + state.summary?.trim() || + (state.status === 'failed' + ? '拼图草稿生成失败,可重新打开处理。' + : '正在生成拼图草稿。'); + return { + workId: + buildPuzzleResultWorkId(sessionId) ?? `puzzle-work-${sessionId}`, + profileId, + ownerUserId: '', + sourceSessionId: sessionId, + authorDisplayName: '', + workTitle: title, + workDescription: summary, + levelName: title, + summary, + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: state.updatedAt, + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + generationStatus: + state.status === 'generating' + ? 'generating' + : state.status === 'failed' + ? 'failed' + : 'ready', + levels: [], + }; + }); +} + +export function buildPendingVisualNovelWorks( + pending: Record | undefined, + existingItems: readonly VisualNovelWorkSummary[], +): VisualNovelWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([profileId]) => + existingItems.every((item) => item.profileId !== profileId), + ) + .map(([profileId, state]) => ({ + runtimeKind: 'visual-novel', + profileId, + ownerUserId: '', + title: '视觉小说草稿', + description: + state.status === 'failed' + ? '视觉小说草稿生成失败,可重新打开处理。' + : '正在生成视觉小说草稿。', + coverImageSrc: null, + tags: [], + publishStatus: 'draft', + publishReady: false, + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + })); +} + +export function buildPendingBarkBattleWorks( + pending: Record | undefined, + existingItems: readonly BarkBattleWorkSummary[], +): BarkBattleWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([id]) => + existingItems.every((item) => item.workId !== id && item.draftId !== id), + ) + .map(([id, state]) => ({ + workId: id, + draftId: id, + ownerUserId: '', + authorDisplayName: '', + title: '汪汪声浪草稿', + summary: + state.status === 'failed' + ? '声浪竞技素材生成失败,可重新打开处理。' + : '正在生成声浪竞技素材。', + themeDescription: '', + playerImageDescription: '', + opponentImageDescription: '', + onomatopoeia: [], + playerCharacterImageSrc: null, + opponentCharacterImageSrc: null, + uiBackgroundImageSrc: null, + difficultyPreset: 'normal', + status: 'draft', + generationStatus: + state.status === 'generating' + ? 'pending_assets' + : state.status === 'failed' + ? 'partial_failed' + : 'ready', + publishReady: false, + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + })); +} + +function resolvePuzzleSessionStableSuffix( + sessionId: string | null | undefined, +) { + const normalizedSessionId = sessionId?.trim(); + if (!normalizedSessionId) { + return null; + } + return normalizedSessionId.startsWith('puzzle-session-') + ? normalizedSessionId.slice('puzzle-session-'.length) + : normalizedSessionId; +} + +function pickDraftCompletionDialogSourceId( + ids: Array, +) { + const normalizedIds = ids + .map((id) => id?.trim() ?? '') + .filter((id) => Boolean(id)); + return ( + normalizedIds.find((id) => /session/i.test(id)) ?? + normalizedIds.find((id) => /work/i.test(id)) ?? + normalizedIds.find((id) => /draft/i.test(id)) ?? + normalizedIds.find((id) => /run/i.test(id)) ?? + normalizedIds.find((id) => /profile/i.test(id)) ?? + normalizedIds[0] ?? + null + ); +} + +function formatDraftTaskCompletionSource(label: string, id?: string | null) { + const normalizedId = id?.trim(); + return normalizedId ? `${label} ${normalizedId}` : label; +} From 30ead590e2242fb49c0eb88a404362ea0975a83f Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 20:46:39 +0800 Subject: [PATCH 19/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E6=81=A2=E5=A4=8DURL=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...】CreationUrlStateModel收口计划-2026-06-03.md | 36 +++ .../PlatformEntryFlowShellImpl.tsx | 213 ++--------------- .../platformCreationUrlStateModel.test.ts | 224 ++++++++++++++++++ .../platformCreationUrlStateModel.ts | 202 ++++++++++++++++ .../platformDraftGenerationShelfModel.ts | 35 +-- .../platformPuzzleIdentityModel.test.ts | 31 +++ .../platformPuzzleIdentityModel.ts | 36 +++ 9 files changed, 569 insertions(+), 218 deletions(-) create mode 100644 docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md create mode 100644 src/components/platform-entry/platformCreationUrlStateModel.test.ts create mode 100644 src/components/platform-entry/platformCreationUrlStateModel.ts create mode 100644 src/components/platform-entry/platformPuzzleIdentityModel.test.ts create mode 100644 src/components/platform-entry/platformPuzzleIdentityModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index a0230b47..f081cdcd 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1281,6 +1281,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 +## 2026-06-03 Creation URL State Model 收口 + +- 背景:平台壳内散落各玩法创作恢复 URL 的 `sessionId` / `profileId` / `draftId` / `workId` 组装、空值归一化、拼图 runtime query key 与拼图稳定身份互推,导致刷新恢复规则缺少稳定测试面。 +- 决策:新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module,Interface 收口各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、URL state 非空判断和 runtime state key;新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,`platformDraftGenerationShelfModel.ts` 仅 re-export 旧入口以保持兼容。`PlatformEntryFlowShellImpl.tsx` 只保留路由、URL 写入和网络副作用 Adapter。 +- 影响范围:创作流程刷新恢复、拼图草稿 / 发布 runtime 深链、作品架打开试玩、跳一跳 / 敲木鱼 work-backed 恢复、Bark Battle / 宝贝识物本地草稿恢复。 +- 验证方式:`npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts`、`npm run test -- src/services/creationUrlState.test.ts`、`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index b6eefb52..32f3133f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +平台入口创作恢复 URL 私有 query、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 + 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 抓大鹅 runtime profile 的公开详情转 work、session draft 转 profile、生成背景资产提升和 run/profile/public detail 素材优先级收口到 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91Match3DRuntimeProfile%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md new file mode 100644 index 00000000..7c9c9d3f --- /dev/null +++ b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md @@ -0,0 +1,36 @@ +# CreationUrlStateModel 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾直接承载多玩法创作恢复 URL 的拼装规则:`sessionId`、`profileId`、`draftId`、`workId` 的优先级、拼图草稿 runtime query、以及空值归一化散在壳层 Implementation 内。平台壳因此需要理解各玩法快照结构,新增玩法或修复刷新恢复时缺少稳定测试面。 + +## 决策 + +- 新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module。 +- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、`normalizeCreationUrlValue`、`hasCreationUrlStateValue`、`hasPuzzleRuntimeUrlStateValue` 与 `buildPuzzleRuntimeUrlStateKey`。 +- 新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,统一 `puzzle-session-*`、`puzzle-profile-*`、`puzzle-work-*` 的互推规则。 +- `PlatformEntryFlowShellImpl.tsx` 保留 React state、路由、登录门禁、网络请求和 URL 写入副作用 Adapter;不再在壳层内定义各玩法 URL 状态构造函数。 + +## Interface 约束 + +- 创作恢复私有 query 只使用 `sessionId`、`profileId`、`draftId`、`workId`;不得新增说明性 query 字段。 +- 空字符串、全空白字符串统一视为 `null`,避免刷新恢复时写入无效私有参数。 +- work-backed 玩法优先使用后端 work summary 的公开 `workId` / `profileId`;仅缺失时才回退 session draft。 +- 拼图 runtime query 独立使用 `mode`、`runtimeSessionId`、`runtimeProfileId`、`runtimeLevelId`、`publicWorkCode`,不与创作恢复 query 混写。 +- 拼图 draft runtime 若没有 `sourceSessionId`,只允许从 `puzzle-profile-*` 反推出 `puzzle-session-*`。 + +## Depth / Leverage / Locality + +- **Depth**:调用方只传玩法快照或作品摘要,即可得到规范化 URL state;各玩法字段优先级藏在 Module Implementation 内。 +- **Leverage**:新增或调整玩法恢复规则时,优先补 Module Interface 测试,再接壳层 Adapter。 +- **Locality**:恢复 query、拼图 runtime query 和拼图稳定身份规则集中在两个小 Module,避免散落在页面壳、作品架和 runtime 打开逻辑中。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts` +- `npm run test -- src/services/creationUrlState.test.ts` +- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts` +- `npx eslint src/components/platform-entry/platformCreationUrlStateModel.ts src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts --max-warnings 0` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx src/components/platform-entry/platformDraftGenerationShelfModel.ts --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 4e45d4b9..6a27ebf7 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -158,7 +158,6 @@ import { } from '../../services/creationEntryConfigService'; import { clearCreationUrlState, - type CreationUrlState, isCreationRestorePath, readCreationUrlState, writeCreationUrlState, @@ -405,6 +404,23 @@ import { mergeBarkBattleWorkSummary, shouldPreserveLocalBarkBattleWorkOnRefresh, } from './barkBattleWorkCache'; +import { + buildBabyObjectMatchCreationUrlState, + buildBarkBattleCreationUrlState, + buildBigFishCreationUrlState, + buildJumpHopCreationUrlState, + buildMatch3DCreationUrlState, + buildPuzzleCreationUrlState, + buildPuzzleDraftRuntimeUrlState, + buildPuzzlePublishedRuntimeUrlState, + buildPuzzleRuntimeUrlStateKey, + buildSquareHoleCreationUrlState, + buildVisualNovelCreationUrlState, + buildWoodenFishCreationUrlState, + hasCreationUrlStateValue, + hasPuzzleRuntimeUrlStateValue, + normalizeCreationUrlValue, +} from './platformCreationUrlStateModel'; import { buildCreationWorkShelfRuntimeState, buildDraftCompletionDialogSource, @@ -417,8 +433,6 @@ import { buildPendingSquareHoleWorks, buildPendingVisualNovelWorks, buildPendingWoodenFishWorks, - buildPuzzleResultProfileId, - buildPuzzleResultWorkId, collectDraftNoticeKeys, collectVisibleDraftNoticeKeys, createPendingDraftShelfState, @@ -491,6 +505,10 @@ import { mergePlatformPublicGalleryEntries, type RecommendRuntimeKind, } from './platformPublicGalleryFlow'; +import { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, +} from './platformPuzzleIdentityModel'; import { PlatformTaskCompletionDialog, type PlatformTaskCompletionDialogPayload, @@ -1362,131 +1380,6 @@ function buildAgentResultPublishGateView( }; } -function buildPuzzleSessionIdFromProfileId( - profileId: string | null | undefined, -) { - const normalizedProfileId = profileId?.trim(); - if (!normalizedProfileId?.startsWith('puzzle-profile-')) { - return null; - } - - const stableSuffix = normalizedProfileId.slice('puzzle-profile-'.length); - return stableSuffix ? `puzzle-session-${stableSuffix}` : null; -} - -function normalizeCreationUrlValue(value: string | null | undefined) { - return value?.trim() || null; -} - -function hasCreationUrlStateValue(state: CreationUrlState) { - return Boolean( - normalizeCreationUrlValue(state.sessionId) || - normalizeCreationUrlValue(state.profileId) || - normalizeCreationUrlValue(state.draftId) || - normalizeCreationUrlValue(state.workId), - ); -} - -function hasPuzzleRuntimeUrlStateValue(state: PuzzleRuntimeUrlState) { - return Boolean( - normalizeCreationUrlValue(state.runtimeSessionId) || - normalizeCreationUrlValue(state.runtimeProfileId) || - normalizeCreationUrlValue(state.runtimeLevelId) || - normalizeCreationUrlValue(state.publicWorkCode) || - normalizeCreationUrlValue(state.mode), - ); -} - -function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) { - return [ - normalizeCreationUrlValue(state.mode), - normalizeCreationUrlValue(state.runtimeSessionId), - normalizeCreationUrlValue(state.runtimeProfileId), - normalizeCreationUrlValue(state.runtimeLevelId), - normalizeCreationUrlValue(state.publicWorkCode), - ].join('|'); -} - -function buildBigFishCreationUrlState( - session: BigFishSessionSnapshotResponse | null, -): CreationUrlState { - const sessionId = normalizeCreationUrlValue(session?.sessionId); - return { - sessionId, - workId: sessionId ? `big-fish-work-${sessionId}` : null, - }; -} - -function buildMatch3DCreationUrlState( - session: Match3DAgentSessionSnapshot | null, -): CreationUrlState { - const sessionId = normalizeCreationUrlValue(session?.sessionId); - const profileId = normalizeCreationUrlValue( - session?.draft?.profileId ?? session?.publishedProfileId, - ); - return { - sessionId, - profileId, - workId: profileId, - }; -} - -function buildSquareHoleCreationUrlState( - session: SquareHoleSessionSnapshot | null, -): CreationUrlState { - const sessionId = normalizeCreationUrlValue(session?.sessionId); - const profileId = normalizeCreationUrlValue( - session?.draft?.profileId ?? session?.publishedProfileId, - ); - return { - sessionId, - profileId, - workId: profileId, - }; -} - -function buildPuzzleCreationUrlState( - session: PuzzleAgentSessionSnapshot | null, -): CreationUrlState { - const sessionId = normalizeCreationUrlValue(session?.sessionId); - const profileId = normalizeCreationUrlValue( - session?.publishedProfileId ?? buildPuzzleResultProfileId(sessionId), - ); - return { - sessionId, - profileId, - workId: sessionId ? buildPuzzleResultWorkId(sessionId) : null, - }; -} - -function buildPuzzleDraftRuntimeUrlState( - item: PuzzleWorkSummary, - levelId?: string | null, -): PuzzleRuntimeUrlState { - const runtimeSessionId = - normalizeCreationUrlValue(item.sourceSessionId) ?? - buildPuzzleSessionIdFromProfileId(item.profileId); - - return { - mode: 'draft', - runtimeSessionId, - runtimeProfileId: normalizeCreationUrlValue(item.profileId), - runtimeLevelId: normalizeCreationUrlValue(levelId), - }; -} - -function buildPuzzlePublishedRuntimeUrlState( - item: PuzzleWorkSummary, - levelId?: string | null, -): PuzzleRuntimeUrlState { - return { - mode: 'published', - runtimeProfileId: normalizeCreationUrlValue(item.profileId), - runtimeLevelId: normalizeCreationUrlValue(levelId), - publicWorkCode: buildPuzzlePublicWorkCode(item.profileId), - }; -} - function openPuzzleRuntimeStage( setSelectionStage: (stage: SelectionStage) => void, state: PuzzleRuntimeUrlState, @@ -1531,33 +1424,6 @@ function buildPuzzleRuntimeWorkFromSession( }; } -function buildVisualNovelCreationUrlState( - session: VisualNovelAgentSessionSnapshot | null, -): CreationUrlState { - const sessionId = normalizeCreationUrlValue(session?.sessionId); - const profileId = normalizeCreationUrlValue(session?.draft?.profileId); - return { - sessionId, - profileId, - workId: profileId ?? sessionId, - }; -} - -function buildJumpHopCreationUrlState(params: { - session?: JumpHopSessionSnapshotResponse | null; - work?: JumpHopWorkProfileResponse | null; -}): CreationUrlState { - const sessionId = normalizeCreationUrlValue(params.session?.sessionId); - const profileId = normalizeCreationUrlValue( - params.work?.summary.profileId ?? params.session?.draft?.profileId, - ); - return { - sessionId, - profileId, - workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId), - }; -} - function buildJumpHopPendingSession( item: JumpHopWorkSummaryResponse, ): JumpHopSessionSnapshotResponse { @@ -1591,23 +1457,6 @@ function buildJumpHopPendingSession( }; } -function buildWoodenFishCreationUrlState(params: { - session?: WoodenFishSessionSnapshotResponse | null; - work?: WoodenFishWorkProfileResponse | null; -}): CreationUrlState { - const sessionId = normalizeCreationUrlValue(params.session?.sessionId); - const profileId = normalizeCreationUrlValue( - params.work?.summary.profileId ?? params.session?.draft?.profileId, - ); - const draftId = profileId ?? sessionId; - return { - sessionId, - profileId, - draftId, - workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId), - }; -} - function buildWoodenFishSessionFromWorkDetail( work: WoodenFishWorkProfileResponse, fallbackItem?: WoodenFishWorkSummaryResponse | null, @@ -1658,26 +1507,6 @@ function buildWoodenFishPendingSession( }; } -function buildBarkBattleCreationUrlState( - draft: BarkBattleDraftConfig | null, -): CreationUrlState { - return { - draftId: normalizeCreationUrlValue(draft?.draftId), - workId: normalizeCreationUrlValue(draft?.workId ?? draft?.draftId), - }; -} - -function buildBabyObjectMatchCreationUrlState( - draft: BabyObjectMatchDraft | null, -): CreationUrlState { - const profileId = normalizeCreationUrlValue(draft?.profileId); - return { - profileId, - draftId: normalizeCreationUrlValue(draft?.draftId), - workId: profileId, - }; -} - function normalizePlatformErrorMessage(message: string | null | undefined) { const normalized = message?.trim(); return normalized ? normalized : null; diff --git a/src/components/platform-entry/platformCreationUrlStateModel.test.ts b/src/components/platform-entry/platformCreationUrlStateModel.test.ts new file mode 100644 index 00000000..54cf53ef --- /dev/null +++ b/src/components/platform-entry/platformCreationUrlStateModel.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from 'vitest'; + +import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent'; +import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel'; +import type { + JumpHopSessionSnapshotResponse, + JumpHopWorkProfileResponse, +} from '../../services/jump-hop/jumpHopClient'; +import type { + WoodenFishSessionSnapshotResponse, + WoodenFishWorkProfileResponse, +} from '../../services/wooden-fish/woodenFishClient'; +import { + buildBabyObjectMatchCreationUrlState, + buildBarkBattleCreationUrlState, + buildBigFishCreationUrlState, + buildJumpHopCreationUrlState, + buildMatch3DCreationUrlState, + buildPuzzleCreationUrlState, + buildPuzzleDraftRuntimeUrlState, + buildPuzzlePublishedRuntimeUrlState, + buildPuzzleRuntimeUrlStateKey, + buildSquareHoleCreationUrlState, + buildVisualNovelCreationUrlState, + buildWoodenFishCreationUrlState, + hasCreationUrlStateValue, + hasPuzzleRuntimeUrlStateValue, + normalizeCreationUrlValue, +} from './platformCreationUrlStateModel'; + +describe('platformCreationUrlStateModel', () => { + test('normalizes private creation url state values', () => { + expect(normalizeCreationUrlValue(' session-1 ')).toBe('session-1'); + expect(normalizeCreationUrlValue(' ')).toBeNull(); + expect( + hasCreationUrlStateValue({ + sessionId: ' ', + profileId: null, + draftId: undefined, + workId: 'work-1', + }), + ).toBe(true); + expect(hasCreationUrlStateValue({})).toBe(false); + }); + + test('builds creation restore state for core session based plays', () => { + expect( + buildBigFishCreationUrlState({ + sessionId: ' big-fish-session-1 ', + } as BigFishSessionSnapshotResponse), + ).toEqual({ + sessionId: 'big-fish-session-1', + workId: 'big-fish-work-big-fish-session-1', + }); + + expect( + buildMatch3DCreationUrlState({ + sessionId: 'match3d-session-1', + draft: { profileId: 'match3d-profile-draft' }, + } as Match3DAgentSessionSnapshot), + ).toEqual({ + sessionId: 'match3d-session-1', + profileId: 'match3d-profile-draft', + workId: 'match3d-profile-draft', + }); + + expect( + buildSquareHoleCreationUrlState({ + sessionId: 'square-session-1', + publishedProfileId: 'square-profile-published', + } as SquareHoleSessionSnapshot), + ).toEqual({ + sessionId: 'square-session-1', + profileId: 'square-profile-published', + workId: 'square-profile-published', + }); + + expect( + buildVisualNovelCreationUrlState({ + sessionId: 'visual-session-1', + draft: { profileId: 'visual-profile-1' }, + } as VisualNovelAgentSessionSnapshot), + ).toEqual({ + sessionId: 'visual-session-1', + profileId: 'visual-profile-1', + workId: 'visual-profile-1', + }); + }); + + test('builds puzzle creation and runtime query state', () => { + expect( + buildPuzzleCreationUrlState({ + sessionId: 'puzzle-session-ocean', + } as PuzzleAgentSessionSnapshot), + ).toEqual({ + sessionId: 'puzzle-session-ocean', + profileId: 'puzzle-profile-ocean', + workId: 'puzzle-work-ocean', + }); + + const draftRuntime = buildPuzzleDraftRuntimeUrlState( + buildPuzzleWork({ + profileId: 'puzzle-profile-ocean', + sourceSessionId: null, + }), + 'level-2', + ); + expect(draftRuntime).toEqual({ + mode: 'draft', + runtimeSessionId: 'puzzle-session-ocean', + runtimeProfileId: 'puzzle-profile-ocean', + runtimeLevelId: 'level-2', + }); + expect(hasPuzzleRuntimeUrlStateValue(draftRuntime)).toBe(true); + expect(buildPuzzleRuntimeUrlStateKey(draftRuntime)).toBe( + 'draft|puzzle-session-ocean|puzzle-profile-ocean|level-2|', + ); + + const publishedRuntime = buildPuzzlePublishedRuntimeUrlState( + buildPuzzleWork({ profileId: 'puzzle-profile-ocean' }), + ); + expect(publishedRuntime.mode).toBe('published'); + expect(publishedRuntime.runtimeProfileId).toBe('puzzle-profile-ocean'); + expect(publishedRuntime.publicWorkCode).toMatch(/^PZ-/u); + }); + + test('builds creation state for work backed plays with work id priority', () => { + expect( + buildJumpHopCreationUrlState({ + session: { + sessionId: 'jump-session-1', + draft: { profileId: 'jump-profile-draft' }, + } as JumpHopSessionSnapshotResponse, + work: { + summary: { + profileId: 'jump-profile-work', + workId: 'jump-work-1', + }, + } as JumpHopWorkProfileResponse, + }), + ).toEqual({ + sessionId: 'jump-session-1', + profileId: 'jump-profile-work', + workId: 'jump-work-1', + }); + + expect( + buildWoodenFishCreationUrlState({ + session: { + sessionId: 'wood-session-1', + draft: { profileId: 'wood-profile-draft' }, + } as WoodenFishSessionSnapshotResponse, + work: { + summary: { + profileId: 'wood-profile-work', + workId: 'wood-work-1', + }, + } as WoodenFishWorkProfileResponse, + }), + ).toEqual({ + sessionId: 'wood-session-1', + profileId: 'wood-profile-work', + draftId: 'wood-profile-work', + workId: 'wood-work-1', + }); + }); + + test('builds creation state for draft backed local plays', () => { + expect( + buildBarkBattleCreationUrlState({ + draftId: 'bark-draft-1', + workId: 'bark-work-1', + } as BarkBattleDraftConfig), + ).toEqual({ + draftId: 'bark-draft-1', + workId: 'bark-work-1', + }); + + expect( + buildBabyObjectMatchCreationUrlState({ + draftId: 'baby-draft-1', + profileId: 'baby-profile-1', + } as BabyObjectMatchDraft), + ).toEqual({ + profileId: 'baby-profile-1', + draftId: 'baby-draft-1', + workId: 'baby-profile-1', + }); + }); +}); + +function buildPuzzleWork( + overrides: Partial = {}, +): PuzzleWorkSummary { + return { + workId: 'puzzle-work-base', + profileId: 'puzzle-profile-base', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-base', + authorDisplayName: '测试作者', + workTitle: '潮雾拼图', + workDescription: '潮雾港口拼图。', + levelName: '潮雾拼图', + summary: '潮雾港口拼图。', + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-06-03T08:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + levels: [], + ...overrides, + }; +} diff --git a/src/components/platform-entry/platformCreationUrlStateModel.ts b/src/components/platform-entry/platformCreationUrlStateModel.ts new file mode 100644 index 00000000..6ce46fa8 --- /dev/null +++ b/src/components/platform-entry/platformCreationUrlStateModel.ts @@ -0,0 +1,202 @@ +import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent'; +import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel'; +import type { CreationUrlState } from '../../services/creationUrlState'; +import type { + JumpHopSessionSnapshotResponse, + JumpHopWorkProfileResponse, +} from '../../services/jump-hop/jumpHopClient'; +import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode'; +import type { PuzzleRuntimeUrlState } from '../../services/puzzleRuntimeUrlState'; +import type { + WoodenFishSessionSnapshotResponse, + WoodenFishWorkProfileResponse, +} from '../../services/wooden-fish/woodenFishClient'; +import { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, + buildPuzzleSessionIdFromProfileId, +} from './platformPuzzleIdentityModel'; + +/** 平台创作恢复 URL 私有 query 的纯模型,调用方只需传入玩法快照。 */ +export function normalizeCreationUrlValue(value: string | null | undefined) { + return value?.trim() || null; +} + +export function hasCreationUrlStateValue(state: CreationUrlState) { + return Boolean( + normalizeCreationUrlValue(state.sessionId) || + normalizeCreationUrlValue(state.profileId) || + normalizeCreationUrlValue(state.draftId) || + normalizeCreationUrlValue(state.workId), + ); +} + +export function hasPuzzleRuntimeUrlStateValue(state: PuzzleRuntimeUrlState) { + return Boolean( + normalizeCreationUrlValue(state.runtimeSessionId) || + normalizeCreationUrlValue(state.runtimeProfileId) || + normalizeCreationUrlValue(state.runtimeLevelId) || + normalizeCreationUrlValue(state.publicWorkCode) || + normalizeCreationUrlValue(state.mode), + ); +} + +export function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) { + return [ + normalizeCreationUrlValue(state.mode), + normalizeCreationUrlValue(state.runtimeSessionId), + normalizeCreationUrlValue(state.runtimeProfileId), + normalizeCreationUrlValue(state.runtimeLevelId), + normalizeCreationUrlValue(state.publicWorkCode), + ].join('|'); +} + +export function buildBigFishCreationUrlState( + session: BigFishSessionSnapshotResponse | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + return { + sessionId, + workId: sessionId ? `big-fish-work-${sessionId}` : null, + }; +} + +export function buildMatch3DCreationUrlState( + session: Match3DAgentSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue( + session?.draft?.profileId ?? session?.publishedProfileId, + ); + return { + sessionId, + profileId, + workId: profileId, + }; +} + +export function buildSquareHoleCreationUrlState( + session: SquareHoleSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue( + session?.draft?.profileId ?? session?.publishedProfileId, + ); + return { + sessionId, + profileId, + workId: profileId, + }; +} + +export function buildPuzzleCreationUrlState( + session: PuzzleAgentSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue( + session?.publishedProfileId ?? buildPuzzleResultProfileId(sessionId), + ); + return { + sessionId, + profileId, + workId: sessionId ? buildPuzzleResultWorkId(sessionId) : null, + }; +} + +export function buildPuzzleDraftRuntimeUrlState( + item: PuzzleWorkSummary, + levelId?: string | null, +): PuzzleRuntimeUrlState { + const runtimeSessionId = + normalizeCreationUrlValue(item.sourceSessionId) ?? + buildPuzzleSessionIdFromProfileId(item.profileId); + + return { + mode: 'draft', + runtimeSessionId, + runtimeProfileId: normalizeCreationUrlValue(item.profileId), + runtimeLevelId: normalizeCreationUrlValue(levelId), + }; +} + +export function buildPuzzlePublishedRuntimeUrlState( + item: PuzzleWorkSummary, + levelId?: string | null, +): PuzzleRuntimeUrlState { + return { + mode: 'published', + runtimeProfileId: normalizeCreationUrlValue(item.profileId), + runtimeLevelId: normalizeCreationUrlValue(levelId), + publicWorkCode: buildPuzzlePublicWorkCode(item.profileId), + }; +} + +export function buildVisualNovelCreationUrlState( + session: VisualNovelAgentSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue(session?.draft?.profileId); + return { + sessionId, + profileId, + workId: profileId ?? sessionId, + }; +} + +export function buildJumpHopCreationUrlState(params: { + session?: JumpHopSessionSnapshotResponse | null; + work?: JumpHopWorkProfileResponse | null; +}): CreationUrlState { + const sessionId = normalizeCreationUrlValue(params.session?.sessionId); + const profileId = normalizeCreationUrlValue( + params.work?.summary.profileId ?? params.session?.draft?.profileId, + ); + return { + sessionId, + profileId, + workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId), + }; +} + +export function buildWoodenFishCreationUrlState(params: { + session?: WoodenFishSessionSnapshotResponse | null; + work?: WoodenFishWorkProfileResponse | null; +}): CreationUrlState { + const sessionId = normalizeCreationUrlValue(params.session?.sessionId); + const profileId = normalizeCreationUrlValue( + params.work?.summary.profileId ?? params.session?.draft?.profileId, + ); + const draftId = profileId ?? sessionId; + return { + sessionId, + profileId, + draftId, + workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId), + }; +} + +export function buildBarkBattleCreationUrlState( + draft: BarkBattleDraftConfig | null, +): CreationUrlState { + return { + draftId: normalizeCreationUrlValue(draft?.draftId), + workId: normalizeCreationUrlValue(draft?.workId ?? draft?.draftId), + }; +} + +export function buildBabyObjectMatchCreationUrlState( + draft: BabyObjectMatchDraft | null, +): CreationUrlState { + const profileId = normalizeCreationUrlValue(draft?.profileId); + return { + profileId, + draftId: normalizeCreationUrlValue(draft?.draftId), + workId: profileId, + }; +} diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.ts index 314b7f61..b257cbef 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.ts @@ -14,6 +14,15 @@ import { type CreationWorkShelfRuntimeState, resolvePuzzleWorkCoverImageSrc, } from '../custom-world-home/creationWorkShelf'; +import { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, +} from './platformPuzzleIdentityModel'; + +export { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, +} from './platformPuzzleIdentityModel'; export type DraftGenerationNoticeStatus = 'generating' | 'ready' | 'failed'; @@ -58,20 +67,6 @@ export type PlatformDraftGenerationVisibleShelfSources = { babyObjectMatchItems: readonly BabyObjectMatchDraft[]; }; -export function buildPuzzleResultProfileId( - sessionId: string | null | undefined, -) { - const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId); - return stableSuffix ? `puzzle-profile-${stableSuffix}` : null; -} - -export function buildPuzzleResultWorkId( - sessionId: string | null | undefined, -) { - const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId); - return stableSuffix ? `puzzle-work-${stableSuffix}` : null; -} - export function buildDraftNoticeKey( kind: CreationWorkShelfKind, id: string, @@ -825,18 +820,6 @@ export function buildPendingBarkBattleWorks( })); } -function resolvePuzzleSessionStableSuffix( - sessionId: string | null | undefined, -) { - const normalizedSessionId = sessionId?.trim(); - if (!normalizedSessionId) { - return null; - } - return normalizedSessionId.startsWith('puzzle-session-') - ? normalizedSessionId.slice('puzzle-session-'.length) - : normalizedSessionId; -} - function pickDraftCompletionDialogSourceId( ids: Array, ) { diff --git a/src/components/platform-entry/platformPuzzleIdentityModel.test.ts b/src/components/platform-entry/platformPuzzleIdentityModel.test.ts new file mode 100644 index 00000000..5d5319b0 --- /dev/null +++ b/src/components/platform-entry/platformPuzzleIdentityModel.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest'; + +import { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, + buildPuzzleSessionIdFromProfileId, +} from './platformPuzzleIdentityModel'; + +describe('platformPuzzleIdentityModel', () => { + test('builds stable puzzle result identities from a session id', () => { + expect(buildPuzzleResultProfileId(' puzzle-session-ocean ')).toBe( + 'puzzle-profile-ocean', + ); + expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe( + 'puzzle-work-ocean', + ); + }); + + test('keeps legacy suffix inputs usable', () => { + expect(buildPuzzleResultProfileId('ocean')).toBe('puzzle-profile-ocean'); + expect(buildPuzzleResultWorkId('ocean')).toBe('puzzle-work-ocean'); + }); + + test('builds draft runtime session ids from profile ids', () => { + expect(buildPuzzleSessionIdFromProfileId(' puzzle-profile-ocean ')).toBe( + 'puzzle-session-ocean', + ); + expect(buildPuzzleSessionIdFromProfileId('puzzle-work-ocean')).toBeNull(); + expect(buildPuzzleSessionIdFromProfileId('puzzle-profile-')).toBeNull(); + }); +}); diff --git a/src/components/platform-entry/platformPuzzleIdentityModel.ts b/src/components/platform-entry/platformPuzzleIdentityModel.ts new file mode 100644 index 00000000..3d493578 --- /dev/null +++ b/src/components/platform-entry/platformPuzzleIdentityModel.ts @@ -0,0 +1,36 @@ +/** 收口拼图草稿在 session/profile/work 之间的稳定身份互推规则。 */ +export function buildPuzzleResultProfileId( + sessionId: string | null | undefined, +) { + const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId); + return stableSuffix ? `puzzle-profile-${stableSuffix}` : null; +} + +export function buildPuzzleResultWorkId(sessionId: string | null | undefined) { + const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId); + return stableSuffix ? `puzzle-work-${stableSuffix}` : null; +} + +export function buildPuzzleSessionIdFromProfileId( + profileId: string | null | undefined, +) { + const normalizedProfileId = profileId?.trim(); + if (!normalizedProfileId?.startsWith('puzzle-profile-')) { + return null; + } + + const stableSuffix = normalizedProfileId.slice('puzzle-profile-'.length); + return stableSuffix ? `puzzle-session-${stableSuffix}` : null; +} + +function resolvePuzzleSessionStableSuffix( + sessionId: string | null | undefined, +) { + const normalizedSessionId = sessionId?.trim(); + if (!normalizedSessionId) { + return null; + } + return normalizedSessionId.startsWith('puzzle-session-') + ? normalizedSessionId.slice('puzzle-session-'.length) + : normalizedSessionId; +} From c238ef9b4026000e48ccb3ef6d58dba4f6cf7e00 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 21:11:13 +0800 Subject: [PATCH 20/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E6=BB=91=E5=8A=A8=E5=8D=A1=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...】RecommendSwipeDeckModel收口计划-2026-06-03.md | 42 +++++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 57 +++++++----- .../rpgEntryRecommendSwipeDeckModel.test.ts | 88 +++++++++++++++++++ .../rpgEntryRecommendSwipeDeckModel.ts | 75 ++++++++++++++++ 6 files changed, 248 insertions(+), 24 deletions(-) create mode 100644 docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md create mode 100644 src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts create mode 100644 src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index f081cdcd..28337fc3 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1249,6 +1249,14 @@ - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md`。 +## 2026-06-03 Recommend Swipe Deck Model 收口 + +- 背景:移动端推荐首页 swipe deck 的拖拽阈值、offset clamp、commit 方向、rail class 和分享文案仍留在 `RpgEntryHomeView.tsx` 页面 Implementation 内,页面同时承载 DOM pointer 副作用和纯规则。 +- 决策:新增 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts` 作为 Recommend Swipe Deck Module,Interface 收口 `hasRecommendDragStarted`、`clampRecommendDragOffset`、`resolveRecommendDragCommitDirection`、`resolveRecommendCommitOffset`、`buildRecommendSwipeRailClassName`、`shouldAnimateRecommendSwipe` 与 `buildRecommendShareText`;页面仅保留 pointer capture、DOM 高度读取、动画 timer、clipboard 与 like/remix/open 副作用 Adapter。 +- 影响范围:移动端推荐首页 swipe 手势、上一条 / 下一条动画、推荐分享文案与未登录时的直接切换行为。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`、`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts -t "recommend"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md`。 + ## 2026-06-03 Ranking ViewModel 收口 - 背景:排行 tab 的文案、metric label 与空态文案在 `RpgEntryHomeView.tsx`,排序和 metric value 在 `rpgEntryPublicGalleryViewModel.ts`,同一 `PlatformRankingTab` 的 Interface 分散且页面需要类型断言取 active config。 diff --git a/docs/README.md b/docs/README.md index 32f3133f..af451bc9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 推荐 feed 的公开作品去重、普通内容过滤、active 窗口与上一条 / 下一条回环选择也收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RecommendFeedViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +移动端推荐首页 swipe deck 的拖拽阈值、offset clamp、commit 方向、rail class 和分享文案收口到 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts`,规则见 [【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md](./technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md)。 + 排行频道的默认 tab、tab 文案、空态文案、排序字段与指标 label/value 收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RankingViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RankingViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md b/docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md new file mode 100644 index 00000000..35d40059 --- /dev/null +++ b/docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md @@ -0,0 +1,42 @@ +# RecommendSwipeDeckModel 收口计划 + +## 背景 + +移动端推荐首页的纵向 swipe deck 曾把拖拽阈值、offset clamp、commit 方向、rail class 和分享文案直接放在 `RpgEntryHomeView.tsx` Implementation 内。页面因此同时理解 DOM pointer 副作用、动画副作用与推荐卡纯规则,后续调整手势阈值或分享文案时缺少稳定测试面。 + +## 决策 + +- 新增 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts` 作为 Recommend Swipe Deck Module。 +- Module Interface 收口: + - `hasRecommendDragStarted` + - `clampRecommendDragOffset` + - `resolveRecommendDragCommitDirection` + - `resolveRecommendCommitOffset` + - `buildRecommendSwipeRailClassName` + - `shouldAnimateRecommendSwipe` + - `buildRecommendShareText` +- `RpgEntryHomeView.tsx` 保留 pointer capture、DOM 高度读取、`setTimeout`、clipboard、like/remix/open 等副作用 Adapter;推荐卡纯规则不再散落在页面 Implementation 内。 + +## Interface 约束 + +- swipe 阈值、commit 动画时长和 drag fallback limit 只从 Module 导出,不在页面重复定义。 +- `deltaY < 0` 表示上滑进入下一条,返回方向 `1`;`deltaY > 0` 表示下滑进入上一条,返回方向 `-1`。 +- 未达到 commit 阈值时必须返回 `null`,页面 Adapter 只负责把 offset 归零。 +- rail class 仅由 `offsetY` 与 `commitDirection` 决定,CSS class 名保持现有命名。 +- 分享文案只使用公开作品名、作品号和详情 URL;公开作品码解析与复制副作用仍在页面 Adapter。 + +## Depth / Leverage / Locality + +- **Depth**:页面传入少量数值或公开作品身份,即可得到拖拽状态、提交方向、动画 class 和分享文案。 +- **Leverage**:调整推荐 swipe 体验时只需改 Module 与单测,交互测试仍护页面 Adapter。 +- **Locality**:pointer 事件生命周期与纯规则分离,推荐卡手势和分享规则集中到一个小 Module。 + +## 验收 + +- `npm run test -- src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"` +- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts -t "recommend"` +- `npx eslint src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts --max-warnings 0` +- `npx eslint src/components/rpg-entry/RpgEntryHomeView.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 0ce8e13f..f17271b9 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -189,6 +189,17 @@ import { selectPlatformRecommendFeedWindow, sortPlatformCategoryEntries, } from './rpgEntryPublicGalleryViewModel'; +import { + buildRecommendShareText, + buildRecommendSwipeRailClassName, + clampRecommendDragOffset, + hasRecommendDragStarted, + RECOMMEND_ENTRY_COMMIT_ANIMATION_MS, + type RecommendSwipeDirection, + resolveRecommendCommitOffset, + resolveRecommendDragCommitDirection, + shouldAnimateRecommendSwipe, +} from './rpgEntryRecommendSwipeDeckModel'; import { buildPlatformWorldDisplayTags, describePlatformPublicWorkKind, @@ -305,9 +316,6 @@ const AVATAR_OUTPUT_SIZE = 256; const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200; const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; -const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36; -const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180; -const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; @@ -4746,7 +4754,7 @@ export function RpgEntryHomeView({ const nextRecommendEntry = recommendFeedWindow.nextEntry; const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0); const [recommendDragCommitDirection, setRecommendDragCommitDirection] = - useState<1 | -1 | null>(null); + useState(null); const [recommendShareState, setRecommendShareState] = useState< 'idle' | 'copied' | 'failed' >('idle'); @@ -4759,7 +4767,7 @@ export function RpgEntryHomeView({ dragging: boolean; } | null>(null); const commitRecommendDrag = useCallback( - (direction: 1 | -1) => { + (direction: RecommendSwipeDirection) => { if (recommendDragCommitDirection) { return; } @@ -4767,9 +4775,8 @@ export function RpgEntryHomeView({ setRecommendDragCommitDirection(direction); const panelHeight = recommendCardStageRef.current?.getBoundingClientRect().height ?? 0; - const commitDistance = panelHeight > 0 ? panelHeight : window.innerHeight; setRecommendDragOffsetY( - direction === 1 ? -commitDistance : commitDistance, + resolveRecommendCommitOffset(direction, panelHeight, window.innerHeight), ); window.setTimeout(() => { if (direction === 1) { @@ -4818,9 +4825,7 @@ export function RpgEntryHomeView({ } const deltaY = event.clientY - drag.startY; - drag.dragging = - drag.dragging || - Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2; + drag.dragging = drag.dragging || hasRecommendDragStarted(deltaY); if (!drag.dragging) { return; } @@ -4828,9 +4833,7 @@ export function RpgEntryHomeView({ event.preventDefault(); const cardHeight = recommendCardStageRef.current?.getBoundingClientRect().height ?? 0; - const dragLimit = - cardHeight > 0 ? cardHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX; - setRecommendDragOffsetY(Math.max(-dragLimit, Math.min(dragLimit, deltaY))); + setRecommendDragOffsetY(clampRecommendDragOffset(deltaY, cardHeight)); }, []); const endRecommendDrag = useCallback( (event: PointerEvent) => { @@ -4842,12 +4845,13 @@ export function RpgEntryHomeView({ event.currentTarget.releasePointerCapture?.(drag.pointerId); recommendDragStartRef.current = null; const deltaY = event.clientY - drag.startY; - if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) { + const commitDirection = resolveRecommendDragCommitDirection(deltaY); + if (!commitDirection) { setRecommendDragOffsetY(0); return; } - commitRecommendDrag(deltaY < 0 ? 1 : -1); + commitRecommendDrag(commitDirection); }, [commitRecommendDrag], ); @@ -4865,16 +4869,17 @@ export function RpgEntryHomeView({ const recommendRailStyle = { transform: `translate3d(0, ${recommendDragOffsetY}px, 0)`, } satisfies CSSProperties; - const recommendRailClassName = recommendDragCommitDirection - ? 'platform-recommend-swipe-rail--committing' - : recommendDragOffsetY === 0 - ? 'platform-recommend-swipe-rail--settled' - : 'platform-recommend-swipe-rail--dragging'; + const recommendRailClassName = buildRecommendSwipeRailClassName({ + offsetY: recommendDragOffsetY, + commitDirection: recommendDragCommitDirection, + }); const selectNextRecommendEntry = useCallback(() => { if ( - isAuthenticated && - activeRecommendEntry && - recommendedFeedEntries.length > 1 + shouldAnimateRecommendSwipe({ + isAuthenticated, + hasActiveEntry: Boolean(activeRecommendEntry), + entryCount: recommendedFeedEntries.length, + }) ) { commitRecommendDrag(1); return; @@ -4908,7 +4913,11 @@ export function RpgEntryHomeView({ return; } - const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`; + const shareText = buildRecommendShareText({ + entry, + publicWorkCode, + detailUrl: buildPublicWorkDetailUrl(publicWorkCode), + }); void copyTextToClipboard(shareText).then((copied) => { setRecommendShareState(copied ? 'copied' : 'failed'); if (recommendShareResetTimerRef.current !== null) { diff --git a/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts b/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts new file mode 100644 index 00000000..b412a40c --- /dev/null +++ b/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'vitest'; + +import { + buildRecommendShareText, + buildRecommendSwipeRailClassName, + clampRecommendDragOffset, + hasRecommendDragStarted, + resolveRecommendCommitOffset, + resolveRecommendDragCommitDirection, + shouldAnimateRecommendSwipe, +} from './rpgEntryRecommendSwipeDeckModel'; +import type { PlatformPuzzleGalleryCard } from './rpgEntryWorldPresentation'; + +describe('rpgEntryRecommendSwipeDeckModel', () => { + test('detects drag start and clamps offset to the card stage', () => { + expect(hasRecommendDragStarted(17)).toBe(false); + expect(hasRecommendDragStarted(18)).toBe(true); + expect(clampRecommendDragOffset(240, 120)).toBe(120); + expect(clampRecommendDragOffset(-240, 120)).toBe(-120); + expect(clampRecommendDragOffset(240, 0)).toBe(160); + }); + + test('resolves commit direction and commit offset', () => { + expect(resolveRecommendDragCommitDirection(35)).toBeNull(); + expect(resolveRecommendDragCommitDirection(-36)).toBe(1); + expect(resolveRecommendDragCommitDirection(36)).toBe(-1); + expect(resolveRecommendCommitOffset(1, 320, 720)).toBe(-320); + expect(resolveRecommendCommitOffset(-1, 0, 720)).toBe(720); + }); + + test('builds rail class and animation guard state', () => { + expect( + buildRecommendSwipeRailClassName({ offsetY: 0, commitDirection: null }), + ).toBe('platform-recommend-swipe-rail--settled'); + expect( + buildRecommendSwipeRailClassName({ offsetY: 24, commitDirection: null }), + ).toBe('platform-recommend-swipe-rail--dragging'); + expect( + buildRecommendSwipeRailClassName({ offsetY: -320, commitDirection: 1 }), + ).toBe('platform-recommend-swipe-rail--committing'); + + expect( + shouldAnimateRecommendSwipe({ + isAuthenticated: true, + hasActiveEntry: true, + entryCount: 2, + }), + ).toBe(true); + expect( + shouldAnimateRecommendSwipe({ + isAuthenticated: true, + hasActiveEntry: true, + entryCount: 1, + }), + ).toBe(false); + }); + + test('builds recommend share text from public work identity', () => { + expect( + buildRecommendShareText({ + entry: buildPuzzleEntry(), + publicWorkCode: 'PZ-OCEAN', + detailUrl: 'https://example.test/works/detail?work=PZ-OCEAN', + }), + ).toBe( + '邀请你来玩《潮汐拼图》\n作品号:PZ-OCEAN\nhttps://example.test/works/detail?work=PZ-OCEAN', + ); + }); +}); + +function buildPuzzleEntry(): PlatformPuzzleGalleryCard { + return { + sourceType: 'puzzle', + workId: 'puzzle-work-ocean', + profileId: 'puzzle-profile-ocean', + publicWorkCode: 'PZ-OCEAN', + ownerUserId: 'user-1', + authorDisplayName: '拼图作者', + worldName: '潮汐拼图', + subtitle: '潮汐副标题', + summaryText: '潮汐摘要', + coverImageSrc: null, + themeTags: ['海潮'], + visibility: 'published', + publishedAt: '2026-06-03T08:00:00.000Z', + updatedAt: '2026-06-03T08:00:00.000Z', + }; +} diff --git a/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts b/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts new file mode 100644 index 00000000..f0db81cc --- /dev/null +++ b/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts @@ -0,0 +1,75 @@ +import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation'; + +export const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36; +export const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180; +export const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; + +export type RecommendSwipeDirection = 1 | -1; + +export type RecommendSwipeRailState = { + offsetY: number; + commitDirection: RecommendSwipeDirection | null; +}; + +/** 收口推荐卡纵向滑动的纯判定,页面只保留 pointer 与动画副作用。 */ +export function hasRecommendDragStarted(deltaY: number) { + return Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2; +} + +export function clampRecommendDragOffset( + deltaY: number, + stageHeight: number, +) { + const dragLimit = + stageHeight > 0 ? stageHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX; + return Math.max(-dragLimit, Math.min(dragLimit, deltaY)); +} + +export function resolveRecommendDragCommitDirection( + deltaY: number, +): RecommendSwipeDirection | null { + if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) { + return null; + } + + return deltaY < 0 ? 1 : -1; +} + +export function resolveRecommendCommitOffset( + direction: RecommendSwipeDirection, + stageHeight: number, + viewportHeight: number, +) { + const commitDistance = stageHeight > 0 ? stageHeight : viewportHeight; + return direction === 1 ? -commitDistance : commitDistance; +} + +export function buildRecommendSwipeRailClassName( + state: RecommendSwipeRailState, +) { + if (state.commitDirection) { + return 'platform-recommend-swipe-rail--committing'; + } + + return state.offsetY === 0 + ? 'platform-recommend-swipe-rail--settled' + : 'platform-recommend-swipe-rail--dragging'; +} + +export function shouldAnimateRecommendSwipe(params: { + isAuthenticated: boolean; + hasActiveEntry: boolean; + entryCount: number; +}) { + return ( + params.isAuthenticated && params.hasActiveEntry && params.entryCount > 1 + ); +} + +export function buildRecommendShareText(params: { + entry: PlatformPublicGalleryCard; + publicWorkCode: string; + detailUrl: string; +}) { + return `邀请你来玩《${params.entry.worldName}》\n作品号:${params.publicWorkCode}\n${params.detailUrl}`; +} From 3efbb6882cff2bb1eb1239524d095eae3c2b240c Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 21:36:06 +0800 Subject: [PATCH 21/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E4=BD=9C=E5=93=81=E6=9E=B6Hub=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 +- ...架构】WorkShelfModule收口计划-2026-06-03.md | 13 +- ...ustomWorldCreationHub.interaction.test.tsx | 2 +- .../CustomWorldCreationHub.test.tsx | 2 +- .../CustomWorldCreationHub.testAdapter.tsx | 128 ++++++++ .../CustomWorldCreationHub.tsx | 180 +---------- .../PlatformEntryFlowShellImpl.tsx | 290 ++++++++++-------- 8 files changed, 306 insertions(+), 319 deletions(-) create mode 100644 src/components/custom-world-home/CustomWorldCreationHub.testAdapter.tsx diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 28337fc3..c3aaa42e 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1289,6 +1289,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 +## 2026-06-03 Creation Hub Shelf Items Interface 收口 + +- 背景:`creationWorkShelf.ts` 已把各玩法作品映射为 `CreationWorkShelfItem.actions`,但 `CustomWorldCreationHub.tsx` 的生产 Interface 仍接收 raw items 与 open/delete/claim 回调列阵,新增玩法时 Hub props 继续膨胀。 +- 决策:`CustomWorldCreationHub.tsx` 生产 Interface 收敛为 `shelfItems: CreationWorkShelfItem[]` 与少量 UI 状态;`PlatformEntryFlowShellImpl.tsx` 在外层作为 Adapter 调用 `buildCreationWorkShelfItems` 注入完整 actions;Hub 测试改经 `CustomWorldCreationHub.testAdapter.tsx` 把旧 fixture 转成 shelf items,不让测试继续依赖旧浅 Interface。 +- 影响范围:创作 Tab / 草稿 Tab 作品架、RPG / 拼图 / 抓大鹅 / 方洞 / 跳一跳 / 敲木鱼 / 视觉小说 / Bark Battle / 宝贝识物作品打开、删除、生成态与拼图奖励领取。 +- 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts`、`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx`、`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、相关 FlowShell creation hub 交互片段、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`。 + ## 2026-06-03 Creation URL State Model 收口 - 背景:平台壳内散落各玩法创作恢复 URL 的 `sessionId` / `profileId` / `draftId` / `workId` 组装、空值归一化、拼图 runtime query key 与拼图稳定身份互推,导致刷新恢复规则缺少稳定测试面。 diff --git a/docs/README.md b/docs/README.md index af451bc9..ab1166ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,Hub 不再按玩法 `kind` 分发,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md index b6e954b9..5b1cfd6c 100644 --- a/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md @@ -2,7 +2,7 @@ ## 背景 -创作中心作品架需要同时展示 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物等作品。`creationWorkShelf.ts` 已经统一了卡片标题、摘要、封面、发布码、分享路径、指标、生成态和动作 Adapter,但 `CustomWorldCreationHub.tsx` 仍在点击作品卡时按玩法 `kind` 再写一遍打开逻辑,导致调用方仍须理解每种玩法。 +创作中心作品架需要同时展示 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物等作品。`creationWorkShelf.ts` 已经统一了卡片标题、摘要、封面、发布码、分享路径、指标、生成态和动作 Adapter。后续深化前,`CustomWorldCreationHub.tsx` 虽已不再按玩法 `kind` 分发点击,但生产调用仍向 Hub 传入多玩法 raw items 与 open/delete/claim 回调列阵,Hub Interface 仍偏 shallow。 ## 决策 @@ -10,20 +10,25 @@ `buildCreationWorkShelfItemsFromSources` 是作品架 source registry 的正式 **Interface**。每个玩法提供一个 `CreationWorkShelfSourceAdapter`,Adapter 负责把玩法数据、删除权限、打开动作和特殊动作映射为 `CreationWorkShelfItem[]`。registry 统一执行 flatten、运行态覆盖、持久化生成态兜底和更新时间排序。 +`CustomWorldCreationHub.tsx` 的生产 **Interface** 收敛为 `shelfItems: CreationWorkShelfItem[]` 加 `loading/error/onRetry/mode/recentWorkItems/onOpenShelfItem/deletingWorkId/claimingPuzzleProfileId` 等 UI 状态。平台壳 `PlatformEntryFlowShellImpl.tsx` 在外层作为 Adapter 调用 `buildCreationWorkShelfItems` 注入完整 open/delete/claim actions 后再传给 Hub;Hub 不再接触各玩法 raw items、删除权限布尔值或玩法专属打开回调。 + +测试文件通过 `CustomWorldCreationHub.testAdapter.tsx` 把旧 fixture 转成 `shelfItems`,避免测试继续强化生产 Hub 的旧浅 Interface。 + 此决策让 `creationWorkShelf.ts` 的 **Module** 更 deep: - **Implementation**:玩法差异、草稿 / 已发布分支、profileId 进入方式和回调绑定都留在 Work Shelf Adapter 内。 -- **Interface**:Hub 只需要 `CreationWorkShelfItem`;后续调用方也可只传 `CreationWorkShelfSourceAdapter[]`,不需要知道每种玩法的打开规则、状态覆盖和排序规则。 +- **Interface**:Hub 只需要 `CreationWorkShelfItem[]`;后续调用方也可只传 `CreationWorkShelfSourceAdapter[]`,不需要知道每种玩法的打开规则、状态覆盖和排序规则。 - **Leverage**:新增玩法时只补 shelf item 映射与 Adapter,Hub 不再新增 switch 分支。 - **Locality**:作品架点击行为、source flatten、运行态覆盖和排序错误集中在 `creationWorkShelf.ts` 与其测试里定位。 ## 后续深化 -`buildCreationWorkShelfItems` 仍保留旧长参数兼容入口,但其 **Implementation** 已改为组装 `CreationWorkShelfSourceAdapter[]` 后复用 `buildCreationWorkShelfItemsFromSources`。下一步可让 Hub / 平台壳逐步直接传入 source adapters,从而减少按玩法平铺的参数数量。删除、刷新和直达恢复也可沿同一 seam 收口。 +`buildCreationWorkShelfItems` 仍保留旧长参数兼容入口,但其 **Implementation** 已改为组装 `CreationWorkShelfSourceAdapter[]` 后复用 `buildCreationWorkShelfItemsFromSources`。下一步可让平台壳直接传入 source adapters,从而继续减少按玩法平铺的参数数量。`deletingWorkId` 与 `claimingPuzzleProfileId` 仍是 Hub UI 状态,可后续下沉到 shelf item/action busy state。 ## 验证 -- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx` +- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "creation hub published work can open detail view before deleting from detail page|creation hub published work enters existing detail view|creation hub published work card reveals delete action after card action reveal"` - `npm run typecheck` - `npm run check:encoding` - 针对变更文件执行 ESLint diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 61e0c605..20ac5d68 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -10,7 +10,7 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes'; -import { CustomWorldCreationHub } from './CustomWorldCreationHub'; +import { CustomWorldCreationHub } from './CustomWorldCreationHub.testAdapter'; const noopCreateType = () => {}; diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index 43d0ebea..0701a005 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -4,7 +4,7 @@ import { expect, test } from 'vitest'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes'; import { buildCreationWorkShelfItems } from './creationWorkShelf'; -import { CustomWorldCreationHub } from './CustomWorldCreationHub'; +import { CustomWorldCreationHub } from './CustomWorldCreationHub.testAdapter'; const noopCreateType = () => {}; const DAY_MS = 24 * 60 * 60 * 1000; diff --git a/src/components/custom-world-home/CustomWorldCreationHub.testAdapter.tsx b/src/components/custom-world-home/CustomWorldCreationHub.testAdapter.tsx new file mode 100644 index 00000000..b87d3732 --- /dev/null +++ b/src/components/custom-world-home/CustomWorldCreationHub.testAdapter.tsx @@ -0,0 +1,128 @@ +import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes'; +import { + buildCreationWorkShelfItems, + type CreationWorkShelfItem, +} from './creationWorkShelf'; +import { + CustomWorldCreationHub as CustomWorldCreationHubView, +} from './CustomWorldCreationHub'; + +type ShelfBuilderParams = Parameters[0]; +type HubViewProps = Parameters[0]; + +type LegacyCustomWorldCreationHubProps = Omit & + Partial< + Omit + > & { + shelfItems?: CreationWorkShelfItem[]; + items?: ShelfBuilderParams['rpgItems']; + bigFishItems?: ShelfBuilderParams['bigFishItems']; + puzzleItems?: ShelfBuilderParams['puzzleItems']; + onOpenDraft?: ShelfBuilderParams['onOpenRpgDraft']; + onEnterPublished?: ShelfBuilderParams['onEnterRpgPublished']; + onDeletePublished?: ShelfBuilderParams['onDeleteRpg'] | null; + getWorkState?: ShelfBuilderParams['getItemState']; + }; + +/** 测试用 Adapter:旧 fixture 先转成 shelfItems,生产 Hub Interface 保持窄面。 */ +export function CustomWorldCreationHub({ + shelfItems, + items = [], + rpgLibraryEntries = [], + bigFishItems = [], + match3dItems = [], + squareHoleItems = [], + jumpHopItems = [], + woodenFishItems = [], + puzzleItems = [], + babyObjectMatchItems = [], + barkBattleItems = [], + visualNovelItems = [], + onOpenDraft, + onEnterPublished, + onDeletePublished = null, + onOpenBigFishDetail, + onDeleteBigFish, + onOpenMatch3DDetail, + onDeleteMatch3D, + onOpenSquareHoleDetail, + onDeleteSquareHole, + onOpenJumpHopDetail, + onDeleteJumpHop, + onOpenWoodenFishDetail, + onDeleteWoodenFish, + onOpenPuzzleDetail, + onDeletePuzzle, + onClaimPuzzlePointIncentive, + onOpenBabyObjectMatchDetail, + onDeleteBabyObjectMatch, + onOpenBarkBattleDetail, + onDeleteBarkBattle, + onOpenVisualNovelDetail, + onDeleteVisualNovel, + getItemState, + getWorkState, + creationTypes, + ...props +}: LegacyCustomWorldCreationHubProps) { + const isSquareHoleCreationVisible = isPlatformCreationTypeVisible( + creationTypes, + 'square-hole', + ); + const resolvedShelfItems = + shelfItems ?? + buildCreationWorkShelfItems({ + rpgItems: items, + rpgLibraryEntries, + bigFishItems, + match3dItems, + squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [], + jumpHopItems, + woodenFishItems, + puzzleItems, + babyObjectMatchItems, + barkBattleItems, + visualNovelItems, + canDeleteRpg: Boolean(onDeletePublished), + canDeleteBigFish: Boolean(onDeleteBigFish), + canDeleteMatch3D: Boolean(onDeleteMatch3D), + canDeleteSquareHole: Boolean(onDeleteSquareHole), + canDeleteJumpHop: Boolean(onDeleteJumpHop), + canDeleteWoodenFish: Boolean(onDeleteWoodenFish), + canDeletePuzzle: Boolean(onDeletePuzzle), + canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch), + canDeleteBarkBattle: Boolean(onDeleteBarkBattle), + canDeleteVisualNovel: Boolean(onDeleteVisualNovel), + onOpenRpgDraft: onOpenDraft, + onEnterRpgPublished: onEnterPublished, + onDeleteRpg: onDeletePublished ?? undefined, + onOpenBigFishDetail, + onDeleteBigFish, + onOpenMatch3DDetail, + onDeleteMatch3D, + onOpenSquareHoleDetail, + onDeleteSquareHole, + onOpenJumpHopDetail, + onDeleteJumpHop, + onOpenWoodenFishDetail, + onDeleteWoodenFish, + onOpenPuzzleDetail, + onDeletePuzzle, + onClaimPuzzlePointIncentive, + onOpenBabyObjectMatchDetail, + onDeleteBabyObjectMatch, + onOpenBarkBattleDetail, + onDeleteBarkBattle, + onOpenVisualNovelDetail, + onDeleteVisualNovel, + getItemState: getItemState ?? getWorkState, + }); + + return ( + + ); +} diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index e4be3bd6..5243c00b 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -1,28 +1,13 @@ import { useEffect, useMemo, useState } from 'react'; -import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; -import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; -import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; -import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; -import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; -import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; -import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; -import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; -import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; -import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; -import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; -import type { CustomWorldProfile } from '../../types'; import type { PlatformCreationTypeCard, PlatformCreationTypeId, } from '../platform-entry/platformEntryCreationTypes'; -import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes'; import { - buildCreationWorkShelfItems, type CreationWorkShelfItem, type CreationWorkShelfMetricId, - type CreationWorkShelfRuntimeState, getCreationWorkShelfItemTime, } from './creationWorkShelf'; import { @@ -47,7 +32,7 @@ type WorkMetricSnapshot = Record< >; type CustomWorldCreationHubProps = { - items: CustomWorldWorkSummary[]; + shelfItems: CreationWorkShelfItem[]; loading: boolean; error: string | null; onRetry: () => void; @@ -55,45 +40,8 @@ type CustomWorldCreationHubProps = { entryConfig: CreationEntryConfig; creationTypes: readonly PlatformCreationTypeCard[]; onCreateType: (type: PlatformCreationTypeId) => void; - onOpenDraft: (item: CustomWorldWorkSummary) => void; - onEnterPublished: (profileId: string) => void; - onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null; deletingWorkId?: string | null; - rpgLibraryEntries?: CustomWorldLibraryEntry[]; - bigFishItems?: BigFishWorkSummary[]; - onOpenBigFishDetail?: (item: BigFishWorkSummary) => void; - onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null; - match3dItems?: Match3DWorkSummary[]; - onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void; - onDeleteMatch3D?: ((item: Match3DWorkSummary) => void) | null; - squareHoleItems?: SquareHoleWorkSummary[]; - onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void; - onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null; - jumpHopItems?: JumpHopWorkSummaryResponse[]; - onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void; - onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null; - woodenFishItems?: WoodenFishWorkSummaryResponse[]; - onOpenWoodenFishDetail?: - | ((item: WoodenFishWorkSummaryResponse) => void) - | null; - onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null; - puzzleItems?: PuzzleWorkSummary[]; - onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; - onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null; - onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null; claimingPuzzleProfileId?: string | null; - babyObjectMatchItems?: BabyObjectMatchDraft[]; - onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null; - onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null; - barkBattleItems?: BarkBattleWorkSummary[]; - onOpenBarkBattleDetail?: ((item: BarkBattleWorkSummary) => void) | null; - onDeleteBarkBattle?: ((item: BarkBattleWorkSummary) => void) | null; - visualNovelItems?: VisualNovelWorkSummary[]; - onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null; - onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null; - getWorkState?: ( - item: CreationWorkShelfItem, - ) => CreationWorkShelfRuntimeState | null; onOpenShelfItem?: (item: CreationWorkShelfItem) => void; // 中文注释:底部加号入口可传入后端作品架摘要,用于推导最近使用过的模板。 recentWorkItems?: CreationWorkShelfItem[]; @@ -165,7 +113,7 @@ function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) { /** 渲染底部加号创作入口页与草稿作品架,最近创作复用最近使用过的模板入口。 */ export function CustomWorldCreationHub({ - items, + shelfItems, loading, error, onRetry, @@ -173,138 +121,14 @@ export function CustomWorldCreationHub({ entryConfig, creationTypes, onCreateType, - onOpenDraft, - onEnterPublished, - onDeletePublished = null, deletingWorkId = null, - rpgLibraryEntries = [], - bigFishItems = [], - onOpenBigFishDetail, - onDeleteBigFish = null, - match3dItems = [], - onOpenMatch3DDetail, - onDeleteMatch3D = null, - squareHoleItems = [], - onOpenSquareHoleDetail, - onDeleteSquareHole = null, - jumpHopItems = [], - onOpenJumpHopDetail, - onDeleteJumpHop = null, - woodenFishItems = [], - onOpenWoodenFishDetail = null, - onDeleteWoodenFish = null, - puzzleItems = [], - onOpenPuzzleDetail, - onDeletePuzzle = null, - onClaimPuzzlePointIncentive = null, claimingPuzzleProfileId = null, - babyObjectMatchItems = [], - onOpenBabyObjectMatchDetail = null, - onDeleteBabyObjectMatch = null, - barkBattleItems = [], - onOpenBarkBattleDetail = null, - onDeleteBarkBattle = null, - visualNovelItems = [], - onOpenVisualNovelDetail = null, - onDeleteVisualNovel = null, - getWorkState, onOpenShelfItem, recentWorkItems: recentWorkSourceItems, mode = 'full', }: CustomWorldCreationHubProps) { const [activeFilter, setActiveFilter] = useState('all'); - const isSquareHoleCreationVisible = isPlatformCreationTypeVisible( - creationTypes, - 'square-hole', - ); - const shelfItems = useMemo( - () => - buildCreationWorkShelfItems({ - rpgItems: items, - rpgLibraryEntries, - bigFishItems, - match3dItems, - squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [], - jumpHopItems, - woodenFishItems, - puzzleItems, - babyObjectMatchItems, - barkBattleItems, - visualNovelItems, - canDeleteRpg: Boolean(onDeletePublished), - canDeleteBigFish: Boolean(onDeleteBigFish), - canDeleteMatch3D: Boolean(onDeleteMatch3D), - canDeleteSquareHole: - isSquareHoleCreationVisible && Boolean(onDeleteSquareHole), - canDeleteJumpHop: Boolean(onDeleteJumpHop), - canDeleteWoodenFish: Boolean(onDeleteWoodenFish), - canDeletePuzzle: Boolean(onDeletePuzzle), - canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch), - canDeleteBarkBattle: Boolean(onDeleteBarkBattle), - canDeleteVisualNovel: Boolean(onDeleteVisualNovel), - onOpenRpgDraft: onOpenDraft, - onEnterRpgPublished: onEnterPublished, - onDeleteRpg: onDeletePublished ?? undefined, - onOpenBigFishDetail, - onDeleteBigFish: onDeleteBigFish ?? undefined, - onOpenMatch3DDetail, - onDeleteMatch3D: onDeleteMatch3D ?? undefined, - onOpenSquareHoleDetail, - onDeleteSquareHole: onDeleteSquareHole ?? undefined, - onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined, - onDeleteJumpHop: onDeleteJumpHop ?? undefined, - onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined, - onDeleteWoodenFish: onDeleteWoodenFish ?? undefined, - onOpenPuzzleDetail, - onDeletePuzzle: onDeletePuzzle ?? undefined, - onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined, - onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined, - onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined, - onOpenBarkBattleDetail: onOpenBarkBattleDetail ?? undefined, - onDeleteBarkBattle: onDeleteBarkBattle ?? undefined, - onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined, - onDeleteVisualNovel: onDeleteVisualNovel ?? undefined, - getItemState: getWorkState, - }), - [ - bigFishItems, - isSquareHoleCreationVisible, - babyObjectMatchItems, - barkBattleItems, - items, - match3dItems, - squareHoleItems, - onDeleteBigFish, - onDeleteMatch3D, - onDeleteSquareHole, - onDeletePublished, - onDeletePuzzle, - onDeleteBabyObjectMatch, - onDeleteBarkBattle, - onDeleteVisualNovel, - onDeleteJumpHop, - onDeleteWoodenFish, - onClaimPuzzlePointIncentive, - onOpenBigFishDetail, - onOpenDraft, - onOpenMatch3DDetail, - onOpenBabyObjectMatchDetail, - onOpenBarkBattleDetail, - onOpenPuzzleDetail, - onOpenSquareHoleDetail, - onOpenVisualNovelDetail, - onOpenWoodenFishDetail, - onEnterPublished, - getWorkState, - puzzleItems, - rpgLibraryEntries, - onOpenJumpHopDetail, - jumpHopItems, - woodenFishItems, - visualNovelItems, - ], - ); const [metricSnapshot] = useState(() => readWorkMetricSnapshot(), ); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 6a27ebf7..7d15d87d 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -14733,6 +14733,161 @@ export function PlatformEntryFlowShellImpl({ selectionStage, ]); + const creationHubShelfItems = useMemo( + () => + buildCreationWorkShelfItems({ + rpgItems: creationHubItems, + rpgLibraryEntries: platformBootstrap.savedCustomWorldEntries, + bigFishItems: isBigFishCreationVisible ? bigFishShelfItems : [], + jumpHopItems: isJumpHopCreationVisible ? jumpHopShelfItems : [], + woodenFishItems: woodenFishShelfItems, + match3dItems: match3dShelfItems, + squareHoleItems: isSquareHoleCreationVisible ? squareHoleShelfItems : [], + puzzleItems: puzzleShelfItems, + babyObjectMatchItems: isBabyObjectMatchVisible + ? babyObjectMatchDrafts + : [], + barkBattleItems: barkBattleShelfItems, + visualNovelItems: visualNovelShelfItems, + canDeleteRpg: true, + canDeleteBigFish: isBigFishCreationVisible, + canDeleteMatch3D: true, + canDeleteSquareHole: isSquareHoleCreationVisible, + canDeletePuzzle: true, + canDeleteBabyObjectMatch: isBabyObjectMatchVisible, + canDeleteVisualNovel: true, + onOpenRpgDraft: (item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + void detailNavigation.handleOpenCreationWork(item); + }); + }, + onEnterRpgPublished: (profileId) => { + runProtectedAction(() => { + const matchedWork = creationHubItems.find( + (entry) => entry.profileId === profileId, + ); + if (!matchedWork) { + return; + } + markCreationFlowReturnToDraftShelf(); + void detailNavigation.handleOpenCreationWork(matchedWork); + }); + }, + onDeleteRpg: handleDeletePublishedWork, + onOpenBigFishDetail: isBigFishCreationVisible + ? (item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + void openBigFishDraft(item); + }); + } + : undefined, + onDeleteBigFish: isBigFishCreationVisible + ? handleDeleteBigFishWork + : undefined, + onOpenJumpHopDetail: isJumpHopCreationVisible + ? (item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + void openJumpHopDraft(item); + }); + } + : undefined, + onOpenWoodenFishDetail: (item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + void openWoodenFishDraft(item); + }); + }, + onOpenMatch3DDetail: (item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + void openMatch3DDraft(item); + }); + }, + onDeleteMatch3D: handleDeleteMatch3DWork, + onOpenSquareHoleDetail: isSquareHoleCreationVisible + ? (item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + void openSquareHoleDraft(item); + }); + } + : undefined, + onDeleteSquareHole: isSquareHoleCreationVisible + ? handleDeleteSquareHoleWork + : undefined, + onOpenPuzzleDetail: (item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + void openPuzzleDraft(item); + }); + }, + onDeletePuzzle: handleDeletePuzzleWork, + onClaimPuzzlePointIncentive: handleClaimPuzzlePointIncentive, + onOpenBabyObjectMatchDetail: (item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + openBabyObjectMatchDraft(item); + }); + }, + onDeleteBabyObjectMatch: handleDeleteBabyObjectMatchWork, + onOpenBarkBattleDetail: (item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + openBarkBattleDraft(item); + }); + }, + onOpenVisualNovelDetail: (item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + void openVisualNovelDraft(item); + }); + }, + onDeleteVisualNovel: handleDeleteVisualNovelWork, + getItemState: getCreationWorkShelfState, + }), + [ + barkBattleShelfItems, + babyObjectMatchDrafts, + bigFishShelfItems, + creationHubItems, + detailNavigation, + getCreationWorkShelfState, + handleClaimPuzzlePointIncentive, + handleDeleteBabyObjectMatchWork, + handleDeleteBigFishWork, + handleDeleteMatch3DWork, + handleDeletePublishedWork, + handleDeletePuzzleWork, + handleDeleteSquareHoleWork, + handleDeleteVisualNovelWork, + isBabyObjectMatchVisible, + isBigFishCreationVisible, + isJumpHopCreationVisible, + isSquareHoleCreationVisible, + jumpHopShelfItems, + markCreationFlowReturnToDraftShelf, + match3dShelfItems, + openBabyObjectMatchDraft, + openBarkBattleDraft, + openBigFishDraft, + openJumpHopDraft, + openMatch3DDraft, + openPuzzleDraft, + openSquareHoleDraft, + openVisualNovelDraft, + openWoodenFishDraft, + platformBootstrap.savedCustomWorldEntries, + puzzleShelfItems, + runProtectedAction, + squareHoleShelfItems, + visualNovelShelfItems, + woodenFishShelfItems, + ], + ); + // 中文注释:最近创作必须由真实作品架/后端草稿摘要决定,不能混入本地生成中占位。 const backendRecentCreationShelfItems = useMemo( () => @@ -14781,7 +14936,7 @@ export function PlatformEntryFlowShellImpl({ {creationEntryConfig ? ( { markDraftNoticeSeen(getGenerationNoticeShelfKeys(item)); }} - onOpenDraft={(item) => { - runProtectedAction(() => { - markCreationFlowReturnToDraftShelf(); - void detailNavigation.handleOpenCreationWork(item); - }); - }} - onEnterPublished={(profileId) => { - runProtectedAction(() => { - const matchedWork = creationHubItems.find( - (entry) => entry.profileId === profileId, - ); - if (!matchedWork) { - return; - } - markCreationFlowReturnToDraftShelf(); - void detailNavigation.handleOpenCreationWork(matchedWork); - }); - }} - onDeletePublished={(item) => { - handleDeletePublishedWork(item); - }} deletingWorkId={deletingCreationWorkId} - rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} - bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []} - jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []} - woodenFishItems={woodenFishShelfItems} - onOpenBigFishDetail={ - isBigFishCreationVisible - ? (item) => { - runProtectedAction(() => { - markCreationFlowReturnToDraftShelf(); - void openBigFishDraft(item); - }); - } - : undefined - } - onOpenJumpHopDetail={ - isJumpHopCreationVisible - ? (item) => { - runProtectedAction(() => { - markCreationFlowReturnToDraftShelf(); - void openJumpHopDraft(item); - }); - } - : undefined - } - onDeleteBigFish={ - isBigFishCreationVisible - ? (item) => { - handleDeleteBigFishWork(item); - } - : null - } - onDeleteJumpHop={null} - onOpenWoodenFishDetail={(item) => { - runProtectedAction(() => { - markCreationFlowReturnToDraftShelf(); - void openWoodenFishDraft(item); - }); - }} - onDeleteWoodenFish={null} - match3dItems={match3dShelfItems} - onOpenMatch3DDetail={(item) => { - runProtectedAction(() => { - markCreationFlowReturnToDraftShelf(); - void openMatch3DDraft(item); - }); - }} - onDeleteMatch3D={(item) => { - handleDeleteMatch3DWork(item); - }} - squareHoleItems={ - isSquareHoleCreationVisible ? squareHoleShelfItems : [] - } - onOpenSquareHoleDetail={ - isSquareHoleCreationVisible - ? (item) => { - runProtectedAction(() => { - markCreationFlowReturnToDraftShelf(); - void openSquareHoleDraft(item); - }); - } - : undefined - } - onDeleteSquareHole={ - isSquareHoleCreationVisible - ? (item) => { - handleDeleteSquareHoleWork(item); - } - : null - } - puzzleItems={puzzleShelfItems} - onOpenPuzzleDetail={(item) => { - runProtectedAction(() => { - markCreationFlowReturnToDraftShelf(); - void openPuzzleDraft(item); - }); - }} - onDeletePuzzle={(item) => { - handleDeletePuzzleWork(item); - }} - onClaimPuzzlePointIncentive={(item) => { - handleClaimPuzzlePointIncentive(item); - }} claimingPuzzleProfileId={claimingPuzzlePointIncentiveProfileId} - babyObjectMatchItems={ - isBabyObjectMatchVisible ? babyObjectMatchDrafts : [] - } - onOpenBabyObjectMatchDetail={(item) => { - runProtectedAction(() => { - markCreationFlowReturnToDraftShelf(); - openBabyObjectMatchDraft(item); - }); - }} - onDeleteBabyObjectMatch={(item) => { - handleDeleteBabyObjectMatchWork(item); - }} - barkBattleItems={barkBattleShelfItems} - onOpenBarkBattleDetail={(item) => { - runProtectedAction(() => { - markCreationFlowReturnToDraftShelf(); - openBarkBattleDraft(item); - }); - }} - visualNovelItems={visualNovelShelfItems} - onOpenVisualNovelDetail={(item) => { - runProtectedAction(() => { - markCreationFlowReturnToDraftShelf(); - void openVisualNovelDraft(item); - }); - }} - onDeleteVisualNovel={(item) => { - handleDeleteVisualNovelWork(item); - }} /> ) : null} From caac418e0e772ba1b32a3fb14f38e347327071ca Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 22:00:36 +0800 Subject: [PATCH 22/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=BC=B9=E7=AA=97=E7=8A=B6=E6=80=81=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...PlatformDialogStateModel收口计划-2026-06-03.md | 46 ++ .../PlatformEntryFlowShellImpl.tsx | 558 ++++++++---------- .../platformDialogStateModel.test.ts | 113 ++++ .../platformDialogStateModel.ts | 85 +++ 6 files changed, 501 insertions(+), 311 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md create mode 100644 src/components/platform-entry/platformDialogStateModel.test.ts create mode 100644 src/components/platform-entry/platformDialogStateModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index c3aaa42e..d1663fbf 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-03 平台入口弹窗状态规则收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 曾同时持有平台级错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key、后台生成 still-running 识别和任务完成文案,导致壳层 Interface 偏浅,测试面不稳定。 +- 决策:新增 `src/components/platform-entry/platformDialogStateModel.ts` 作为 Platform Dialog State Module,统一导出 `normalizePlatformDialogMessage`、`formatPlatformDialogSource`、`resolvePlatformErrorDialog`、dismiss key builder、`resolveActivePlatformDialog`、`isBackgroundGenerationStillRunningMessage` 和 `PLATFORM_TASK_COMPLETION_MESSAGE`。平台壳只汇总候选、持有 React state,并在关闭弹窗时作为 Adapter 清理对应副作用 setter。 +- 影响范围:平台入口错误弹窗、任务完成弹窗、后台生成仍在处理识别、草稿生成完成 / 失败通知。 +- 验证方式:`npm run test -- src/components/platform-entry/platformDialogStateModel.test.ts`、`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx`、相关壳层交互测试、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md`。 + ## 2026-06-03 前端 SSE 客户端传输层统一收口 - 背景:创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态等多个前端 client 曾各自手写 SSE 边界扫描、`TextDecoder` 解码、JSON 解析和流结束 flush,导致 CRLF / LF、UTF-8 尾部、多行 `data:` 和提前停止释放 reader 的处理容易漂移。 diff --git a/docs/README.md b/docs/README.md index ab1166ee..adec95d3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口创作恢复 URL 私有 query、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 +平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。 + 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 抓大鹅 runtime profile 的公开详情转 work、session draft 转 profile、生成背景资产提升和 run/profile/public detail 素材优先级收口到 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91Match3DRuntimeProfile%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md new file mode 100644 index 00000000..525d4669 --- /dev/null +++ b/docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md @@ -0,0 +1,46 @@ +# PlatformDialogStateModel 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾直接承载平台级错误 / 完成弹窗的纯状态规则:错误文案 trim、来源 label 与 id 拼接、后台生成仍在处理的识别、错误候选优先级、dismiss key 与生成完成文案都散在壳层 Implementation 内。壳层因此既要管理 React state 与副作用清理,又要记住弹窗判定细则;新增玩法错误或调整弹窗展示时缺少稳定测试面。 + +## 决策 + +- 新增 `src/components/platform-entry/platformDialogStateModel.ts` 作为 Platform Dialog State Module。 +- Module Interface 收口: + - `normalizePlatformDialogMessage` + - `formatPlatformDialogSource` + - `isBackgroundGenerationStillRunningMessage` + - `resolvePlatformErrorDialog` + - `buildPlatformErrorDialogDismissKey` + - `buildPlatformTaskCompletionDialogDismissKey` + - `resolveActivePlatformDialog` + - `PLATFORM_TASK_COMPLETION_MESSAGE` + - `PlatformErrorDialogState`、`PlatformTaskFailureDialogState` 与 `PlatformTaskCompletionDialogState` +- `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter:汇总各玩法候选、持有 React state、关闭弹窗时清理对应 setter。副作用清理不下沉到 Module,避免把大量壳层 setter 变成浅 Interface。 + +## Interface 约束 + +- 错误与完成弹窗文案先 trim;空字符串或全空白字符串统一视为 `null`。 +- 来源格式固定为 `label + 空格 + trimmed id`;缺 id 时只返回 label。 +- 平台错误候选按数组顺序取第一个有效文案;候选本身只描述 `key/source/message`。 +- 错误 dismiss key 固定为 `key:source:message`;完成 dismiss key 固定为 `key:source:message:completedAtMs`,缺完成时间时补 `0`。 +- `resolveActivePlatformDialog` 只根据当前弹窗 dismiss key 与已记录 dismiss key 决定是否隐藏,不修改底层错误或完成状态。 +- 任务完成弹窗文案统一使用 `PLATFORM_TASK_COMPLETION_MESSAGE`,不得在壳层重复写同一中文 literal。 +- `closePlatformErrorDialog` 保持在壳层 Adapter;它负责按错误来源清理 `creationEntryConfigError`、玩法 error、作品详情 error 等副作用状态,不属于纯状态 Module。 + +## Depth / Leverage / Locality + +- **Depth**:壳层传入候选和 dismiss 记录,即可得到当前平台弹窗状态;文案归一、来源格式和 dismiss 规则藏在 Module Implementation 内。 +- **Leverage**:新增玩法错误来源时只需补候选;调整弹窗纯规则时优先改 Module 与单测。 +- **Locality**:平台错误弹窗、任务完成弹窗和后台生成 still-running 识别集中在一个小 Module,避免继续散落在大型平台壳 Implementation 内。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformDialogStateModel.test.ts` +- `npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "background match3d draft failure notifies and reopens failed retry page|completed match3d draft notice first opens trial then reopens result|puzzle compile timeout shows failure dialog when reread session is still generating"` +- `npx eslint src/components/platform-entry/platformDialogStateModel.ts src/components/platform-entry/platformDialogStateModel.test.ts --max-warnings 0` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 7d15d87d..1540a5f8 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -421,6 +421,19 @@ import { hasPuzzleRuntimeUrlStateValue, normalizeCreationUrlValue, } from './platformCreationUrlStateModel'; +import { + buildPlatformErrorDialogDismissKey, + buildPlatformTaskCompletionDialogDismissKey, + formatPlatformDialogSource, + isBackgroundGenerationStillRunningMessage, + PLATFORM_TASK_COMPLETION_MESSAGE, + type PlatformDialogCandidate, + type PlatformErrorDialogState, + type PlatformTaskCompletionDialogState, + type PlatformTaskFailureDialogState, + resolveActivePlatformDialog, + resolvePlatformErrorDialog, +} from './platformDialogStateModel'; import { buildCreationWorkShelfRuntimeState, buildDraftCompletionDialogSource, @@ -480,10 +493,7 @@ import type { SelectionStage, } from './platformEntryTypes'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; -import { - PlatformErrorDialog, - type PlatformErrorDialogPayload, -} from './PlatformErrorDialog'; +import { PlatformErrorDialog } from './PlatformErrorDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; import { buildMatch3DProfileFromSession, @@ -509,10 +519,7 @@ import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, } from './platformPuzzleIdentityModel'; -import { - PlatformTaskCompletionDialog, - type PlatformTaskCompletionDialogPayload, -} from './PlatformTaskCompletionDialog'; +import { PlatformTaskCompletionDialog } from './PlatformTaskCompletionDialog'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; @@ -1507,39 +1514,6 @@ function buildWoodenFishPendingSession( }; } -function normalizePlatformErrorMessage(message: string | null | undefined) { - const normalized = message?.trim(); - return normalized ? normalized : null; -} - -function formatPlatformErrorSource(label: string, id?: string | null) { - const normalizedId = id?.trim(); - return normalizedId ? `${label} ${normalizedId}` : label; -} - -function isBackgroundGenerationStillRunningMessage(message: string) { - return /仍在后台处理|后台仍在处理|仍在生成|后台生成/u.test(message); -} - -function buildPlatformErrorDialogDismissKey( - error: (PlatformErrorDialogPayload & { key: string }) | null, -) { - return error ? `${error.key}:${error.source}:${error.message}` : null; -} - -function buildPlatformTaskCompletionDialogDismissKey( - completion: - | (PlatformTaskCompletionDialogPayload & { - key: string; - completedAtMs: number | null; - }) - | null, -) { - return completion - ? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}` - : null; -} - /** 为恢复的小游戏草稿重建生成态,保留后端开始时间作为进度事实源。 */ function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, @@ -2733,23 +2707,11 @@ export function PlatformEntryFlowShellImpl({ const [ pendingPlatformTaskCompletionDialog, setPendingPlatformTaskCompletionDialog, - ] = useState< - | (PlatformTaskCompletionDialogPayload & { - key: string; - completedAtMs: number | null; - }) - | null - >(null); + ] = useState(null); const [ pendingPlatformTaskFailureDialog, setPendingPlatformTaskFailureDialog, - ] = useState< - | (PlatformErrorDialogPayload & { - key: string; - failedAtMs: number; - }) - | null - >(null); + ] = useState(null); const [profileTaskRefreshKey, setProfileTaskRefreshKey] = useState(0); const [initialCreationUrlState] = useState(() => readCreationUrlState()); const handledInitialCreationUrlStateRef = useRef(false); @@ -2916,7 +2878,7 @@ export function PlatformEntryFlowShellImpl({ setPendingPlatformTaskCompletionDialog({ key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`, source: buildDraftCompletionDialogSource(kind, ids), - message: '生成任务已完成,可以继续查看草稿。', + message: PLATFORM_TASK_COMPLETION_MESSAGE, completedAtMs, }); }, @@ -5462,263 +5424,237 @@ export function PlatformEntryFlowShellImpl({ dismissedPlatformTaskCompletionDialogKey, setDismissedPlatformTaskCompletionDialogKey, ] = useState(null); - const currentPlatformErrorDialog = useMemo< - (PlatformErrorDialogPayload & { key: string }) | null - >(() => { - const candidates: Array<{ - key: string; - source: string; - message: string | null | undefined; - }> = [ - { - key: pendingPlatformTaskFailureDialog?.key ?? 'draft-failure', - source: pendingPlatformTaskFailureDialog?.source ?? '创作草稿', - message: pendingPlatformTaskFailureDialog?.message, - }, - { - key: 'creation-entry-config', - source: '创作入口配置', - message: creationEntryConfigError, - }, - { - key: 'platform-bootstrap', - source: '平台首页', - message: platformBootstrap.platformError, - }, - { - key: 'rpg-creation-type', - source: '创作入口', - message: sessionController.creationTypeError, - }, - { - key: 'rpg-restore', - source: '创作作品架', - message: sessionController.agentWorkspaceRestoreError, - }, - { - key: 'rpg-result', - source: formatPlatformErrorSource( - 'RPG 草稿', - sessionController.agentSession?.sessionId ?? - sessionController.generatedCustomWorldProfile?.id, - ), - message: resultViewError, - }, - { - key: 'public-work-detail', - source: formatPlatformErrorSource( - '作品详情', - selectedPublicWorkDetail - ? resolvePlatformPublicWorkCode(selectedPublicWorkDetail) - : selectedDetailEntry?.profileId, - ), - message: publicWorkDetailError ?? detailNavigation.detailError, - }, - { - key: 'big-fish', - source: formatPlatformErrorSource( - selectionStage === 'big-fish-runtime' ? '大鱼吃小鱼游玩' : '大鱼草稿', - bigFishRun?.runId ?? bigFishSession?.sessionId, - ), - message: bigFishError, - }, - { - key: 'match3d', - source: formatPlatformErrorSource( - selectionStage === 'match3d-runtime' ? '抓大鹅游玩' : '抓大鹅草稿', - match3dRun?.runId ?? - match3dGenerationViewSession?.sessionId ?? - match3dSession?.sessionId, - ), - message: match3dGenerationViewError ?? match3dError, - }, - { - key: 'square-hole', - source: formatPlatformErrorSource( - selectionStage === 'square-hole-runtime' - ? '方洞挑战游玩' - : '方洞挑战草稿', - squareHoleRun?.runId ?? squareHoleSession?.sessionId, - ), - message: squareHoleError, - }, - { - key: 'jump-hop', - source: formatPlatformErrorSource( - selectionStage === 'jump-hop-runtime' ? '跳一跳游玩' : '跳一跳草稿', - jumpHopRun?.runId ?? jumpHopSession?.sessionId, - ), - message: jumpHopError, - }, - { - key: 'wooden-fish', - source: formatPlatformErrorSource( - selectionStage === 'wooden-fish-runtime' - ? '敲木鱼游玩' - : '敲木鱼草稿', - woodenFishRun?.runId ?? woodenFishSession?.sessionId, - ), - message: woodenFishError, - }, - { - key: 'puzzle', - source: formatPlatformErrorSource( - selectionStage === 'puzzle-runtime' ? '拼图游玩' : '拼图草稿', - puzzleRun?.runId ?? - puzzleGenerationViewSession?.sessionId ?? - puzzleSession?.sessionId, - ), - message: - puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError, - }, - { - key: 'puzzle-onboarding', - source: '拼图首次创作', - message: puzzleOnboardingError, - }, - { - key: 'puzzle-shelf', - source: '拼图作品架', - message: puzzleShelfError, - }, - { - key: 'visual-novel', - source: formatPlatformErrorSource( - selectionStage === 'visual-novel-runtime' - ? '视觉小说游玩' - : '视觉小说草稿', - visualNovelRun?.runId ?? visualNovelSession?.sessionId, - ), - message: visualNovelError, - }, - { - key: 'baby-object-match', - source: formatPlatformErrorSource( - selectionStage === 'baby-object-match-runtime' - ? '宝贝识物游玩' - : '宝贝识物草稿', - babyObjectMatchDraft?.profileId, - ), - message: babyObjectMatchError, - }, - { - key: 'bark-battle', - source: formatPlatformErrorSource( - selectionStage === 'bark-battle-runtime' - ? '汪汪声浪游玩' - : '汪汪声浪草稿', - barkBattlePublishedConfig?.workId ?? barkBattleDraftConfig?.workId, - ), - message: barkBattleError, - }, - { - key: 'creative-agent', - source: formatPlatformErrorSource( - '智能创作 Agent', - creativeAgentSession?.sessionId, - ), - message: creativeAgentError, - }, - { - key: 'rpg-generation', - source: formatPlatformErrorSource( - 'RPG 草稿生成', - sessionController.agentSession?.sessionId, - ), - message: sessionController.activeGenerationError, - }, - ]; + const currentPlatformErrorDialog = + useMemo(() => { + const candidates: PlatformDialogCandidate[] = [ + { + key: pendingPlatformTaskFailureDialog?.key ?? 'draft-failure', + source: pendingPlatformTaskFailureDialog?.source ?? '创作草稿', + message: pendingPlatformTaskFailureDialog?.message, + }, + { + key: 'creation-entry-config', + source: '创作入口配置', + message: creationEntryConfigError, + }, + { + key: 'platform-bootstrap', + source: '平台首页', + message: platformBootstrap.platformError, + }, + { + key: 'rpg-creation-type', + source: '创作入口', + message: sessionController.creationTypeError, + }, + { + key: 'rpg-restore', + source: '创作作品架', + message: sessionController.agentWorkspaceRestoreError, + }, + { + key: 'rpg-result', + source: formatPlatformDialogSource( + 'RPG 草稿', + sessionController.agentSession?.sessionId ?? + sessionController.generatedCustomWorldProfile?.id, + ), + message: resultViewError, + }, + { + key: 'public-work-detail', + source: formatPlatformDialogSource( + '作品详情', + selectedPublicWorkDetail + ? resolvePlatformPublicWorkCode(selectedPublicWorkDetail) + : selectedDetailEntry?.profileId, + ), + message: publicWorkDetailError ?? detailNavigation.detailError, + }, + { + key: 'big-fish', + source: formatPlatformDialogSource( + selectionStage === 'big-fish-runtime' + ? '大鱼吃小鱼游玩' + : '大鱼草稿', + bigFishRun?.runId ?? bigFishSession?.sessionId, + ), + message: bigFishError, + }, + { + key: 'match3d', + source: formatPlatformDialogSource( + selectionStage === 'match3d-runtime' ? '抓大鹅游玩' : '抓大鹅草稿', + match3dRun?.runId ?? + match3dGenerationViewSession?.sessionId ?? + match3dSession?.sessionId, + ), + message: match3dGenerationViewError ?? match3dError, + }, + { + key: 'square-hole', + source: formatPlatformDialogSource( + selectionStage === 'square-hole-runtime' + ? '方洞挑战游玩' + : '方洞挑战草稿', + squareHoleRun?.runId ?? squareHoleSession?.sessionId, + ), + message: squareHoleError, + }, + { + key: 'jump-hop', + source: formatPlatformDialogSource( + selectionStage === 'jump-hop-runtime' ? '跳一跳游玩' : '跳一跳草稿', + jumpHopRun?.runId ?? jumpHopSession?.sessionId, + ), + message: jumpHopError, + }, + { + key: 'wooden-fish', + source: formatPlatformDialogSource( + selectionStage === 'wooden-fish-runtime' + ? '敲木鱼游玩' + : '敲木鱼草稿', + woodenFishRun?.runId ?? woodenFishSession?.sessionId, + ), + message: woodenFishError, + }, + { + key: 'puzzle', + source: formatPlatformDialogSource( + selectionStage === 'puzzle-runtime' ? '拼图游玩' : '拼图草稿', + puzzleRun?.runId ?? + puzzleGenerationViewSession?.sessionId ?? + puzzleSession?.sessionId, + ), + message: + puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError, + }, + { + key: 'puzzle-onboarding', + source: '拼图首次创作', + message: puzzleOnboardingError, + }, + { + key: 'puzzle-shelf', + source: '拼图作品架', + message: puzzleShelfError, + }, + { + key: 'visual-novel', + source: formatPlatformDialogSource( + selectionStage === 'visual-novel-runtime' + ? '视觉小说游玩' + : '视觉小说草稿', + visualNovelRun?.runId ?? visualNovelSession?.sessionId, + ), + message: visualNovelError, + }, + { + key: 'baby-object-match', + source: formatPlatformDialogSource( + selectionStage === 'baby-object-match-runtime' + ? '宝贝识物游玩' + : '宝贝识物草稿', + babyObjectMatchDraft?.profileId, + ), + message: babyObjectMatchError, + }, + { + key: 'bark-battle', + source: formatPlatformDialogSource( + selectionStage === 'bark-battle-runtime' + ? '汪汪声浪游玩' + : '汪汪声浪草稿', + barkBattlePublishedConfig?.workId ?? barkBattleDraftConfig?.workId, + ), + message: barkBattleError, + }, + { + key: 'creative-agent', + source: formatPlatformDialogSource( + '智能创作 Agent', + creativeAgentSession?.sessionId, + ), + message: creativeAgentError, + }, + { + key: 'rpg-generation', + source: formatPlatformDialogSource( + 'RPG 草稿生成', + sessionController.agentSession?.sessionId, + ), + message: sessionController.activeGenerationError, + }, + ]; - for (const candidate of candidates) { - const message = normalizePlatformErrorMessage(candidate.message); - if (message) { - return { - key: candidate.key, - source: candidate.source, - message, - }; - } - } - - return null; - }, [ - babyObjectMatchDraft?.profileId, - babyObjectMatchError, - barkBattleDraftConfig?.workId, - barkBattleError, - barkBattlePublishedConfig?.workId, - bigFishError, - bigFishRun?.runId, - bigFishSession?.sessionId, - creationEntryConfigError, - creativeAgentError, - creativeAgentSession?.sessionId, - detailNavigation.detailError, - jumpHopError, - jumpHopRun?.runId, - jumpHopSession?.sessionId, - match3dError, - match3dGenerationViewError, - match3dGenerationViewSession?.sessionId, - match3dRun?.runId, - match3dSession?.sessionId, - pendingPlatformTaskFailureDialog, - platformBootstrap.platformError, - publicWorkDetailError, - puzzleCreationError, - puzzleError, - puzzleGenerationViewError, - puzzleGenerationViewSession?.sessionId, - puzzleOnboardingError, - puzzleRun?.runId, - puzzleSession?.sessionId, - puzzleShelfError, - resultViewError, - selectedDetailEntry?.profileId, - selectedPublicWorkDetail, - selectionStage, - sessionController.activeGenerationError, - sessionController.agentSession?.sessionId, - sessionController.agentWorkspaceRestoreError, - sessionController.creationTypeError, - sessionController.generatedCustomWorldProfile?.id, - squareHoleError, - squareHoleRun?.runId, - squareHoleSession?.sessionId, - visualNovelError, - visualNovelRun?.runId, - visualNovelSession?.sessionId, - woodenFishError, - woodenFishRun?.runId, - woodenFishSession?.sessionId, - ]); - const currentPlatformTaskCompletionDialog = useMemo< - | (PlatformTaskCompletionDialogPayload & { - key: string; - completedAtMs: number | null; - }) - | null - >( - () => pendingPlatformTaskCompletionDialog, - [pendingPlatformTaskCompletionDialog], - ); - const activePlatformTaskCompletionDialogDismissKey = - buildPlatformTaskCompletionDialogDismissKey( - currentPlatformTaskCompletionDialog, + return resolvePlatformErrorDialog(candidates); + }, [ + babyObjectMatchDraft?.profileId, + babyObjectMatchError, + barkBattleDraftConfig?.workId, + barkBattleError, + barkBattlePublishedConfig?.workId, + bigFishError, + bigFishRun?.runId, + bigFishSession?.sessionId, + creationEntryConfigError, + creativeAgentError, + creativeAgentSession?.sessionId, + detailNavigation.detailError, + jumpHopError, + jumpHopRun?.runId, + jumpHopSession?.sessionId, + match3dError, + match3dGenerationViewError, + match3dGenerationViewSession?.sessionId, + match3dRun?.runId, + match3dSession?.sessionId, + pendingPlatformTaskFailureDialog, + platformBootstrap.platformError, + publicWorkDetailError, + puzzleCreationError, + puzzleError, + puzzleGenerationViewError, + puzzleGenerationViewSession?.sessionId, + puzzleOnboardingError, + puzzleRun?.runId, + puzzleSession?.sessionId, + puzzleShelfError, + resultViewError, + selectedDetailEntry?.profileId, + selectedPublicWorkDetail, + selectionStage, + sessionController.activeGenerationError, + sessionController.agentSession?.sessionId, + sessionController.agentWorkspaceRestoreError, + sessionController.creationTypeError, + sessionController.generatedCustomWorldProfile?.id, + squareHoleError, + squareHoleRun?.runId, + squareHoleSession?.sessionId, + visualNovelError, + visualNovelRun?.runId, + visualNovelSession?.sessionId, + woodenFishError, + woodenFishRun?.runId, + woodenFishSession?.sessionId, + ]); + const currentPlatformTaskCompletionDialog = + useMemo( + () => pendingPlatformTaskCompletionDialog, + [pendingPlatformTaskCompletionDialog], ); - const activePlatformTaskCompletionDialog = - activePlatformTaskCompletionDialogDismissKey && - activePlatformTaskCompletionDialogDismissKey === - dismissedPlatformTaskCompletionDialogKey - ? null - : currentPlatformTaskCompletionDialog; - const activePlatformErrorDialogDismissKey = - buildPlatformErrorDialogDismissKey(currentPlatformErrorDialog); - const activePlatformErrorDialog = - activePlatformErrorDialogDismissKey && - activePlatformErrorDialogDismissKey === dismissedPlatformErrorDialogKey - ? null - : currentPlatformErrorDialog; + const activePlatformTaskCompletionDialog = resolveActivePlatformDialog( + currentPlatformTaskCompletionDialog, + dismissedPlatformTaskCompletionDialogKey, + buildPlatformTaskCompletionDialogDismissKey, + ); + const activePlatformErrorDialog = resolveActivePlatformDialog( + currentPlatformErrorDialog, + dismissedPlatformErrorDialogKey, + buildPlatformErrorDialogDismissKey, + ); const closePlatformErrorDialog = useCallback(() => { if (!currentPlatformErrorDialog) { return; diff --git a/src/components/platform-entry/platformDialogStateModel.test.ts b/src/components/platform-entry/platformDialogStateModel.test.ts new file mode 100644 index 00000000..cbd15ff0 --- /dev/null +++ b/src/components/platform-entry/platformDialogStateModel.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from 'vitest'; + +import { + buildPlatformErrorDialogDismissKey, + buildPlatformTaskCompletionDialogDismissKey, + formatPlatformDialogSource, + isBackgroundGenerationStillRunningMessage, + normalizePlatformDialogMessage, + PLATFORM_TASK_COMPLETION_MESSAGE, + resolveActivePlatformDialog, + resolvePlatformErrorDialog, +} from './platformDialogStateModel'; + +describe('platformDialogStateModel', () => { + test('normalizes platform dialog messages', () => { + expect(normalizePlatformDialogMessage(' 图片失败 ')).toBe('图片失败'); + expect(normalizePlatformDialogMessage(' ')).toBeNull(); + expect(normalizePlatformDialogMessage(null)).toBeNull(); + }); + + test('formats dialog source with optional identity', () => { + expect(formatPlatformDialogSource('拼图草稿', ' puzzle-session-1 ')).toBe( + '拼图草稿 puzzle-session-1', + ); + expect(formatPlatformDialogSource('拼图草稿', ' ')).toBe('拼图草稿'); + }); + + test('detects background generation still running messages', () => { + expect( + isBackgroundGenerationStillRunningMessage('后台仍在处理,请稍后查看。'), + ).toBe(true); + expect(isBackgroundGenerationStillRunningMessage('素材生成失败。')).toBe( + false, + ); + }); + + test('resolves the first non-empty error candidate', () => { + expect( + resolvePlatformErrorDialog([ + { + key: 'empty', + source: '空来源', + message: ' ', + }, + { + key: 'puzzle', + source: '拼图草稿 puzzle-session-1', + message: ' 素材生成失败。 ', + }, + ]), + ).toEqual({ + key: 'puzzle', + source: '拼图草稿 puzzle-session-1', + message: '素材生成失败。', + }); + + expect( + resolvePlatformErrorDialog([ + { + key: 'empty', + source: '空来源', + message: null, + }, + ]), + ).toBeNull(); + }); + + test('builds stable dismiss keys for error and completion dialogs', () => { + expect( + buildPlatformErrorDialogDismissKey({ + key: 'puzzle', + source: '拼图草稿 puzzle-session-1', + message: '素材生成失败。', + }), + ).toBe('puzzle:拼图草稿 puzzle-session-1:素材生成失败。'); + expect(buildPlatformErrorDialogDismissKey(null)).toBeNull(); + + expect( + buildPlatformTaskCompletionDialogDismissKey({ + key: 'match3d', + source: '抓大鹅草稿 match3d-session-1', + message: PLATFORM_TASK_COMPLETION_MESSAGE, + completedAtMs: null, + }), + ).toBe( + `match3d:抓大鹅草稿 match3d-session-1:${PLATFORM_TASK_COMPLETION_MESSAGE}:0`, + ); + }); + + test('hides active dialog when the dismiss key has already been recorded', () => { + const dialog = { + key: 'puzzle', + source: '拼图草稿 puzzle-session-1', + message: '素材生成失败。', + }; + const dismissKey = buildPlatformErrorDialogDismissKey(dialog); + + expect( + resolveActivePlatformDialog( + dialog, + dismissKey, + buildPlatformErrorDialogDismissKey, + ), + ).toBeNull(); + expect( + resolveActivePlatformDialog( + dialog, + 'other-dismiss-key', + buildPlatformErrorDialogDismissKey, + ), + ).toBe(dialog); + }); +}); diff --git a/src/components/platform-entry/platformDialogStateModel.ts b/src/components/platform-entry/platformDialogStateModel.ts new file mode 100644 index 00000000..796cc1fd --- /dev/null +++ b/src/components/platform-entry/platformDialogStateModel.ts @@ -0,0 +1,85 @@ +import type { PlatformErrorDialogPayload } from './PlatformErrorDialog'; +import type { PlatformTaskCompletionDialogPayload } from './PlatformTaskCompletionDialog'; + +export type PlatformErrorDialogState = PlatformErrorDialogPayload & { + key: string; +}; + +export type PlatformTaskFailureDialogState = PlatformErrorDialogState & { + failedAtMs: number; +}; + +export type PlatformTaskCompletionDialogState = + PlatformTaskCompletionDialogPayload & { + key: string; + completedAtMs: number | null; + }; + +export type PlatformDialogCandidate = { + key: string; + source: string; + message: string | null | undefined; +}; + +export const PLATFORM_TASK_COMPLETION_MESSAGE = + '生成任务已完成,可以继续查看草稿。'; + +/** 收口平台弹窗候选的纯状态规则,壳层只负责副作用清理。 */ +export function normalizePlatformDialogMessage( + message: string | null | undefined, +) { + const normalized = message?.trim(); + return normalized ? normalized : null; +} + +export function formatPlatformDialogSource(label: string, id?: string | null) { + const normalizedId = id?.trim(); + return normalizedId ? `${label} ${normalizedId}` : label; +} + +export function isBackgroundGenerationStillRunningMessage(message: string) { + return /仍在后台处理|后台仍在处理|仍在生成|后台生成/u.test(message); +} + +export function resolvePlatformErrorDialog( + candidates: readonly PlatformDialogCandidate[], +): PlatformErrorDialogState | null { + for (const candidate of candidates) { + const message = normalizePlatformDialogMessage(candidate.message); + if (message) { + return { + key: candidate.key, + source: candidate.source, + message, + }; + } + } + + return null; +} + +export function buildPlatformErrorDialogDismissKey( + error: PlatformErrorDialogState | null, +) { + return error ? `${error.key}:${error.source}:${error.message}` : null; +} + +export function buildPlatformTaskCompletionDialogDismissKey( + completion: PlatformTaskCompletionDialogState | null, +) { + return completion + ? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}` + : null; +} + +export function resolveActivePlatformDialog( + currentDialog: TDialog | null, + dismissedDialogKey: string | null, + buildDismissKey: (dialog: TDialog | null) => string | null, +): TDialog | null { + const currentDialogDismissKey = buildDismissKey(currentDialog); + return currentDialogDismissKey && + currentDialogDismissKey === dismissedDialogKey + ? null + : currentDialog; +} From 00820e6571ceb6849ab84b7f00963eacfa56c2c7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 22:22:13 +0800 Subject: [PATCH 23/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E4=BD=9C=E5=93=81=E8=AF=A6=E6=83=85=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...atformPublicWorkDetailFlow收口计划-2026-06-03.md | 41 ++++ .../PlatformEntryFlowShellImpl.tsx | 89 +++---- .../platformPublicWorkDetailFlow.test.ts | 229 ++++++++++++++++++ .../platformPublicWorkDetailFlow.ts | 190 +++++++++++++++ 6 files changed, 506 insertions(+), 53 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md create mode 100644 src/components/platform-entry/platformPublicWorkDetailFlow.test.ts create mode 100644 src/components/platform-entry/platformPublicWorkDetailFlow.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index d1663fbf..d9f8192f 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-03 平台入口公开作品详情 Strategy 收口 + +- 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。 +- 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy` 和 `resolvePlatformPublicWorkActionMode` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter;启动、点赞、remix 和编辑副作用暂不抽走。 +- 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`。 + ## 2026-06-03 平台入口弹窗状态规则收口 - 背景:`PlatformEntryFlowShellImpl.tsx` 曾同时持有平台级错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key、后台生成 still-running 识别和任务完成文案,导致壳层 Interface 偏浅,测试面不稳定。 diff --git a/docs/README.md b/docs/README.md index adec95d3..cf10707b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +统一作品详情页的玩法 kind、详情打开策略和自有作品动作模式收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 + 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md new file mode 100644 index 00000000..effc3c3a --- /dev/null +++ b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md @@ -0,0 +1,41 @@ +# PlatformPublicWorkDetailFlow 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 已把公开作品身份、去重和推荐 runtime kind 收口到 `platformPublicGalleryFlow.ts`,但统一作品详情入口仍在壳层 Implementation 内直接判断 RPG、拼图、跳一跳、敲木鱼、视觉小说和其它玩法。壳层既要知道哪些公开详情可直接使用当前 entry,又要知道哪些玩法必须先补读完整详情,还要按当前用户判断详情按钮是“编辑”还是“改造”。这些是纯决策规则,继续留在巨型壳层会削弱 Locality。 + +## 决策 + +- 新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts` 作为 Platform Public Work Detail Flow Module。 +- Module Interface 收口: + - `getPlatformPublicWorkDetailKind(entry)` + - `resolvePlatformPublicWorkDetailOpenStrategy(entry)` + - `resolvePlatformPublicWorkActionMode(entry, viewerUserId)` +- `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter:根据 open strategy 调用 `openPublicWorkDetail`、`openPuzzlePublicWorkDetail`、`openJumpHopPublicWorkDetail`、`openWoodenFishPublicWorkDetail`、`openVisualNovelPublicWorkDetail` 或 `openRpgPublicWorkDetail`。 +- 本次不抽 `startSelectedPublicWork`、`likePublicWork`、`remixPublicWork`、`editOwnedPublicWork`。这些函数牵涉运行态启动、计数写入、草稿恢复、作品架缓存和多路错误 setter;若直接搬进一个 Hook,会形成浅 Interface。 + +## Interface 约束 + +- `getPlatformPublicWorkDetailKind` 只根据 `PlatformPublicGalleryCard` 的玩法判定 helper 归一 kind;没有 `sourceType` 的公开 RPG 作品回退为 `rpg`。 +- `resolvePlatformPublicWorkDetailOpenStrategy` 只表达“如何打开详情”,不执行网络请求或 state setter。 +- 拼图、跳一跳、敲木鱼、视觉小说需要按 `profileId` 补读完整详情;返回对应 `load-*` strategy。 +- 大鱼吃小鱼、抓大鹅、方洞挑战、汪汪声浪、宝贝识物和其它可直接展示的公开 entry 返回 `use-entry` strategy。 +- RPG 返回 `load-rpg-detail` strategy,由壳层 Adapter 继续调用 RPG 详情读取流程。 +- `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id;当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`。 + +## Depth / Leverage / Locality + +- **Depth**:壳层传入公开作品 entry 和当前用户 id,即可得到详情打开策略和动作模式;玩法判定细则藏在 Module Implementation 内。 +- **Leverage**:新增玩法公开详情时先补 Strategy 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断。 +- **Locality**:公开作品详情入口的纯策略集中到一个小 Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts` +- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts` +- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "public detail|owned public puzzle detail|direct missing public work detail"` +- `npx eslint src/components/platform-entry/platformPublicWorkDetailFlow.ts src/components/platform-entry/platformPublicWorkDetailFlow.test.ts --max-warnings 0` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 1540a5f8..98366607 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -515,6 +515,10 @@ import { mergePlatformPublicGalleryEntries, type RecommendRuntimeKind, } from './platformPublicGalleryFlow'; +import { + resolvePlatformPublicWorkActionMode, + resolvePlatformPublicWorkDetailOpenStrategy, +} from './platformPublicWorkDetailFlow'; import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, @@ -4025,13 +4029,13 @@ export function PlatformEntryFlowShellImpl({ const resultViewError = autosaveCoordinator.customWorldAutoSaveError ?? sessionController.customWorldError; - const isSelectedPublicWorkOwned = Boolean( - authUi?.user?.id && - selectedPublicWorkDetail?.ownerUserId === authUi.user.id, - ); - const selectedPublicWorkActionMode = isSelectedPublicWorkOwned - ? 'edit' + const selectedPublicWorkActionMode = selectedPublicWorkDetail + ? resolvePlatformPublicWorkActionMode( + selectedPublicWorkDetail, + authUi?.user?.id, + ) : 'remix'; + const isSelectedPublicWorkOwned = selectedPublicWorkActionMode === 'edit'; useEffect(() => { if ( @@ -11574,54 +11578,33 @@ export function PlatformEntryFlowShellImpl({ const openPublicGalleryDetail = useCallback( (entry: PlatformPublicGalleryCard) => { - if (isBigFishGalleryEntry(entry)) { - openPublicWorkDetail(entry); - return; + const strategy = resolvePlatformPublicWorkDetailOpenStrategy(entry); + switch (strategy.type) { + case 'use-entry': + openPublicWorkDetail(entry); + return; + case 'load-puzzle-detail': + void openPuzzlePublicWorkDetail(strategy.profileId, { + tab: platformBootstrap.platformTab, + }); + return; + case 'load-jump-hop-detail': + void openJumpHopPublicWorkDetail(strategy.profileId); + return; + case 'load-wooden-fish-detail': + void openWoodenFishPublicWorkDetail(strategy.profileId); + return; + case 'load-visual-novel-detail': + void openVisualNovelPublicWorkDetail(strategy.profileId); + return; + case 'load-rpg-detail': + void openRpgPublicWorkDetail(strategy.entry); + return; + default: { + const exhaustive: never = strategy; + return exhaustive; + } } - - if (isPuzzleGalleryEntry(entry)) { - void openPuzzlePublicWorkDetail(entry.profileId, { - tab: platformBootstrap.platformTab, - }); - return; - } - - if (isMatch3DGalleryEntry(entry)) { - openPublicWorkDetail(entry); - return; - } - - if (isSquareHoleGalleryEntry(entry)) { - openPublicWorkDetail(entry); - return; - } - - if (isJumpHopGalleryEntry(entry)) { - void openJumpHopPublicWorkDetail(entry.profileId); - return; - } - - if (isWoodenFishGalleryEntry(entry)) { - void openWoodenFishPublicWorkDetail(entry.profileId); - return; - } - - if (isVisualNovelGalleryEntry(entry)) { - void openVisualNovelPublicWorkDetail(entry.profileId); - return; - } - - if (isBarkBattleGalleryEntry(entry)) { - openPublicWorkDetail(entry); - return; - } - - if (isEdutainmentGalleryEntry(entry)) { - openPublicWorkDetail(entry); - return; - } - - void openRpgPublicWorkDetail(entry); }, [ openPuzzlePublicWorkDetail, diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts new file mode 100644 index 00000000..994fb7b0 --- /dev/null +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts @@ -0,0 +1,229 @@ +import { expect, test } from 'vitest'; + +import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import { + EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, + EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; +import { + getPlatformPublicWorkDetailKind, + type PlatformPublicWorkDetailKind, + type PlatformPublicWorkDetailOpenStrategy, + resolvePlatformPublicWorkActionMode, + resolvePlatformPublicWorkDetailOpenStrategy, +} from './platformPublicWorkDetailFlow'; + +type TypedPlatformPublicGalleryCard = Extract< + PlatformPublicGalleryCard, + { sourceType: string } +>; +type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType']; +type TypedPlatformPublicGalleryCardOverrides = Partial< + Omit +>; + +function buildRpgEntry( + overrides: Partial = {}, +): CustomWorldGalleryCard { + return { + ownerUserId: 'user-1', + profileId: 'rpg-profile', + publicWorkCode: 'CW-RPG', + authorPublicUserCode: null, + visibility: 'published', + publishedAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T01:00:00.000Z', + authorDisplayName: '玩家', + worldName: 'RPG 世界', + subtitle: '公开作品', + summaryText: '公开作品摘要', + coverImageSrc: null, + themeMode: 'martial', + playableNpcCount: 1, + landmarkCount: 1, + ...overrides, + }; +} + +function buildTypedEntry( + sourceType: PlatformGallerySourceType, + overrides: TypedPlatformPublicGalleryCardOverrides = {}, +): PlatformPublicGalleryCard { + const common = { + workId: `${sourceType}-work`, + profileId: `${sourceType}-profile`, + publicWorkCode: `${sourceType}-code`, + ownerUserId: 'user-1', + authorDisplayName: '玩家', + worldName: `${sourceType} 作品`, + subtitle: '公开作品', + summaryText: '公开作品摘要', + coverImageSrc: null, + themeTags: [sourceType], + visibility: 'published' as const, + publishedAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T01:00:00.000Z', + }; + + switch (sourceType) { + case 'puzzle': + return { ...common, ...overrides, sourceType }; + case 'big-fish': + return { ...common, ...overrides, sourceType }; + case 'match3d': + return { ...common, ...overrides, sourceType }; + case 'square-hole': + return { ...common, ...overrides, sourceType }; + case 'visual-novel': + return { ...common, ...overrides, sourceType }; + case 'jump-hop': + return { ...common, ...overrides, sourceType }; + case 'wooden-fish': + return { ...common, ...overrides, sourceType }; + case 'edutainment': + return { + ...common, + ...overrides, + sourceType, + templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + }; + case 'bark-battle': + return { + ...common, + ...overrides, + sourceType, + authorPublicUserCode: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + themeMode: 'martial', + playableNpcCount: 1, + landmarkCount: 1, + }; + default: { + const exhaustive: never = sourceType; + return exhaustive; + } + } +} + +test('platform public work detail flow resolves detail kind for every play kind', () => { + const cases: Array< + [sourceType: PlatformGallerySourceType, kind: PlatformPublicWorkDetailKind] + > = [ + ['big-fish', 'big-fish'], + ['puzzle', 'puzzle'], + ['jump-hop', 'jump-hop'], + ['wooden-fish', 'wooden-fish'], + ['match3d', 'match3d'], + ['square-hole', 'square-hole'], + ['visual-novel', 'visual-novel'], + ['bark-battle', 'bark-battle'], + ['edutainment', 'edutainment'], + ]; + + cases.forEach(([sourceType, kind]) => { + expect(getPlatformPublicWorkDetailKind(buildTypedEntry(sourceType))).toBe( + kind, + ); + }); + + expect(getPlatformPublicWorkDetailKind(buildRpgEntry())).toBe('rpg'); +}); + +test('platform public work detail flow resolves open strategy', () => { + const rpgEntry = buildRpgEntry(); + const cases: Array< + [ + entry: PlatformPublicGalleryCard, + strategy: PlatformPublicWorkDetailOpenStrategy, + ] + > = [ + [ + buildTypedEntry('big-fish'), + { + type: 'use-entry', + kind: 'big-fish', + }, + ], + [ + buildTypedEntry('match3d'), + { + type: 'use-entry', + kind: 'match3d', + }, + ], + [ + buildTypedEntry('square-hole'), + { + type: 'use-entry', + kind: 'square-hole', + }, + ], + [ + buildTypedEntry('bark-battle'), + { + type: 'use-entry', + kind: 'bark-battle', + }, + ], + [ + buildTypedEntry('edutainment'), + { + type: 'use-entry', + kind: 'edutainment', + }, + ], + [ + buildTypedEntry('puzzle'), + { + type: 'load-puzzle-detail', + profileId: 'puzzle-profile', + }, + ], + [ + buildTypedEntry('jump-hop'), + { + type: 'load-jump-hop-detail', + profileId: 'jump-hop-profile', + }, + ], + [ + buildTypedEntry('wooden-fish'), + { + type: 'load-wooden-fish-detail', + profileId: 'wooden-fish-profile', + }, + ], + [ + buildTypedEntry('visual-novel'), + { + type: 'load-visual-novel-detail', + profileId: 'visual-novel-profile', + }, + ], + [ + rpgEntry, + { + type: 'load-rpg-detail', + entry: rpgEntry, + }, + ], + ]; + + cases.forEach(([entry, strategy]) => { + expect(resolvePlatformPublicWorkDetailOpenStrategy(entry)).toEqual( + strategy, + ); + }); +}); + +test('platform public work detail flow resolves edit mode only for owned works', () => { + const entry = buildTypedEntry('puzzle'); + + expect(resolvePlatformPublicWorkActionMode(entry, 'user-1')).toBe('edit'); + expect(resolvePlatformPublicWorkActionMode(entry, ' user-1 ')).toBe('edit'); + expect(resolvePlatformPublicWorkActionMode(entry, 'user-2')).toBe('remix'); + expect(resolvePlatformPublicWorkActionMode(entry, null)).toBe('remix'); +}); diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.ts new file mode 100644 index 00000000..942f220e --- /dev/null +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.ts @@ -0,0 +1,190 @@ +import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import { + isBarkBattleGalleryEntry, + isBigFishGalleryEntry, + isEdutainmentGalleryEntry, + isJumpHopGalleryEntry, + isMatch3DGalleryEntry, + isPuzzleGalleryEntry, + isSquareHoleGalleryEntry, + isVisualNovelGalleryEntry, + isWoodenFishGalleryEntry, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; + +export type PlatformPublicWorkDetailKind = + | 'bark-battle' + | 'big-fish' + | 'edutainment' + | 'jump-hop' + | 'match3d' + | 'puzzle' + | 'rpg' + | 'square-hole' + | 'visual-novel' + | 'wooden-fish'; + +export type PlatformPublicWorkDetailOpenStrategy = + | { + type: 'use-entry'; + kind: Exclude< + PlatformPublicWorkDetailKind, + 'jump-hop' | 'puzzle' | 'rpg' | 'visual-novel' | 'wooden-fish' + >; + } + | { + type: 'load-puzzle-detail'; + profileId: string; + } + | { + type: 'load-jump-hop-detail'; + profileId: string; + } + | { + type: 'load-wooden-fish-detail'; + profileId: string; + } + | { + type: 'load-visual-novel-detail'; + profileId: string; + } + | { + type: 'load-rpg-detail'; + entry: CustomWorldGalleryCard; + }; + +export type PlatformPublicWorkActionMode = 'edit' | 'remix'; + +export function isRpgPublicWorkDetailEntry( + entry: PlatformPublicGalleryCard, +): entry is CustomWorldGalleryCard { + return !('sourceType' in entry); +} + +export function getPlatformPublicWorkDetailKind( + entry: PlatformPublicGalleryCard, +): PlatformPublicWorkDetailKind { + if (isBigFishGalleryEntry(entry)) { + return 'big-fish'; + } + + if (isPuzzleGalleryEntry(entry)) { + return 'puzzle'; + } + + if (isJumpHopGalleryEntry(entry)) { + return 'jump-hop'; + } + + if (isWoodenFishGalleryEntry(entry)) { + return 'wooden-fish'; + } + + if (isMatch3DGalleryEntry(entry)) { + return 'match3d'; + } + + if (isSquareHoleGalleryEntry(entry)) { + return 'square-hole'; + } + + if (isVisualNovelGalleryEntry(entry)) { + return 'visual-novel'; + } + + if (isBarkBattleGalleryEntry(entry)) { + return 'bark-battle'; + } + + if (isEdutainmentGalleryEntry(entry)) { + return 'edutainment'; + } + + return 'rpg'; +} + +export function resolvePlatformPublicWorkDetailOpenStrategy( + entry: PlatformPublicGalleryCard, +): PlatformPublicWorkDetailOpenStrategy { + if (isBigFishGalleryEntry(entry)) { + return { + type: 'use-entry', + kind: 'big-fish', + }; + } + + if (isPuzzleGalleryEntry(entry)) { + return { + type: 'load-puzzle-detail', + profileId: entry.profileId, + }; + } + + if (isJumpHopGalleryEntry(entry)) { + return { + type: 'load-jump-hop-detail', + profileId: entry.profileId, + }; + } + + if (isWoodenFishGalleryEntry(entry)) { + return { + type: 'load-wooden-fish-detail', + profileId: entry.profileId, + }; + } + + if (isMatch3DGalleryEntry(entry)) { + return { + type: 'use-entry', + kind: 'match3d', + }; + } + + if (isSquareHoleGalleryEntry(entry)) { + return { + type: 'use-entry', + kind: 'square-hole', + }; + } + + if (isVisualNovelGalleryEntry(entry)) { + return { + type: 'load-visual-novel-detail', + profileId: entry.profileId, + }; + } + + if (isBarkBattleGalleryEntry(entry)) { + return { + type: 'use-entry', + kind: 'bark-battle', + }; + } + + if (isEdutainmentGalleryEntry(entry)) { + return { + type: 'use-entry', + kind: 'edutainment', + }; + } + + if (isRpgPublicWorkDetailEntry(entry)) { + return { + type: 'load-rpg-detail', + entry, + }; + } + + const exhaustive: never = entry; + return exhaustive; +} + +export function resolvePlatformPublicWorkActionMode( + entry: PlatformPublicGalleryCard, + viewerUserId: string | null | undefined, +): PlatformPublicWorkActionMode { + return viewerUserId?.trim() && entry.ownerUserId === viewerUserId.trim() + ? 'edit' + : 'remix'; +} From dd52848e9cccf3dfafa647c234fd60008a6a21a8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 22:38:26 +0800 Subject: [PATCH 24/67] =?UTF-8?q?refactor:=20=E6=B7=B1=E5=8C=96=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E4=BD=9C=E5=93=81=E8=AF=A6=E6=83=85=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 2 +- ...atformPublicWorkDetailFlow收口计划-2026-06-03.md | 4 + .../PlatformEntryFlowShellImpl.tsx | 36 ++++---- .../platformPublicWorkDetailFlow.test.ts | 90 +++++++++++++++++++ .../platformPublicWorkDetailFlow.ts | 87 ++++++++++++++++++ 5 files changed, 199 insertions(+), 20 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index d9f8192f..decd8733 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -19,7 +19,7 @@ ## 2026-06-03 平台入口公开作品详情 Strategy 收口 - 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。 -- 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy` 和 `resolvePlatformPublicWorkActionMode` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter;启动、点赞、remix 和编辑副作用暂不抽走。 +- 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy`、`resolvePlatformPublicWorkActionMode`、`resolvePlatformPublicWorkDetailOpenDecision` 和 `resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter,并保留作者请求竞态控制;启动、点赞、remix 和编辑副作用暂不抽走。 - 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 - 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`。 diff --git a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md index effc3c3a..1d5bb942 100644 --- a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md @@ -11,6 +11,8 @@ - `getPlatformPublicWorkDetailKind(entry)` - `resolvePlatformPublicWorkDetailOpenStrategy(entry)` - `resolvePlatformPublicWorkActionMode(entry, viewerUserId)` + - `resolvePlatformPublicWorkDetailOpenDecision(entry, deps)` + - `resolveActivePlatformPublicWorkAuthorEntry(args)` - `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter:根据 open strategy 调用 `openPublicWorkDetail`、`openPuzzlePublicWorkDetail`、`openJumpHopPublicWorkDetail`、`openWoodenFishPublicWorkDetail`、`openVisualNovelPublicWorkDetail` 或 `openRpgPublicWorkDetail`。 - 本次不抽 `startSelectedPublicWork`、`likePublicWork`、`remixPublicWork`、`editOwnedPublicWork`。这些函数牵涉运行态启动、计数写入、草稿恢复、作品架缓存和多路错误 setter;若直接搬进一个 Hook,会形成浅 Interface。 @@ -22,6 +24,8 @@ - 大鱼吃小鱼、抓大鹅、方洞挑战、汪汪声浪、宝贝识物和其它可直接展示的公开 entry 返回 `use-entry` strategy。 - RPG 返回 `load-rpg-detail` strategy,由壳层 Adapter 继续调用 RPG 详情读取流程。 - `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id;当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`。 +- `resolvePlatformPublicWorkDetailOpenDecision` 只表达直接展示公开详情的打开 / 阻断结果、错误文案、目标 stage 与可写入历史的路径;真正执行 setter、push history 的副作用仍由壳层 Adapter 执行。 +- `resolveActivePlatformPublicWorkAuthorEntry` 只在 `work-detail` 阶段选择统一公开详情 entry,在 RPG `detail` 阶段只选择非 draft 的 RPG 详情 entry;作者请求、竞态 request key 和缓存仍留壳层。 ## Depth / Leverage / Locality diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 98366607..3efa1b72 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -516,7 +516,9 @@ import { type RecommendRuntimeKind, } from './platformPublicGalleryFlow'; import { + resolveActivePlatformPublicWorkAuthorEntry, resolvePlatformPublicWorkActionMode, + resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, } from './platformPublicWorkDetailFlow'; import { @@ -10908,20 +10910,19 @@ export function PlatformEntryFlowShellImpl({ const openPublicWorkDetail = useCallback( (entry: PlatformPublicGalleryCard) => { - if (!canExposePublicWork(entry)) { - setSelectedPublicWorkDetail(null); - setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE); - setSelectionStage('platform'); + const decision = resolvePlatformPublicWorkDetailOpenDecision(entry); + if (decision.type === 'blocked') { + setSelectedPublicWorkDetail(decision.selectedDetail); + setPublicWorkDetailError(decision.errorMessage); + setSelectionStage(decision.selectionStage); return; } - setSelectedPublicWorkDetail(entry); - setPublicWorkDetailError(null); - setSelectionStage('work-detail'); - if (entry.publicWorkCode?.trim()) { - pushAppHistoryPath( - buildPublicWorkStagePath('work-detail', entry.publicWorkCode), - ); + setSelectedPublicWorkDetail(decision.selectedDetail); + setPublicWorkDetailError(decision.errorMessage); + setSelectionStage(decision.selectionStage); + if (decision.historyPath) { + pushAppHistoryPath(decision.historyPath); } }, [setSelectionStage], @@ -11118,14 +11119,11 @@ export function PlatformEntryFlowShellImpl({ ); useEffect(() => { - const detailEntry = - selectionStage === 'work-detail' - ? selectedPublicWorkDetail - : selectionStage === 'detail' && - selectedDetailEntry && - selectedDetailEntry.visibility !== 'draft' - ? mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry) - : null; + const detailEntry = resolveActivePlatformPublicWorkAuthorEntry({ + selectionStage, + selectedPublicWorkDetail, + selectedRpgDetailEntry: selectedDetailEntry, + }); if (!detailEntry) { clearSelectedPublicWorkAuthor(); diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts index 994fb7b0..2b8df398 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts @@ -10,7 +10,9 @@ import { getPlatformPublicWorkDetailKind, type PlatformPublicWorkDetailKind, type PlatformPublicWorkDetailOpenStrategy, + resolveActivePlatformPublicWorkAuthorEntry, resolvePlatformPublicWorkActionMode, + resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, } from './platformPublicWorkDetailFlow'; @@ -227,3 +229,91 @@ test('platform public work detail flow resolves edit mode only for owned works', expect(resolvePlatformPublicWorkActionMode(entry, 'user-2')).toBe('remix'); expect(resolvePlatformPublicWorkActionMode(entry, null)).toBe('remix'); }); + +test('platform public work detail flow resolves direct open decision', () => { + const entry = buildTypedEntry('match3d', { + publicWorkCode: ' M3D-001 ', + }); + const buildWorkDetailPath = (publicWorkCode: string) => + `/works/detail?work=${publicWorkCode.trim()}`; + + expect( + resolvePlatformPublicWorkDetailOpenDecision(entry, { + buildWorkDetailPath, + }), + ).toEqual({ + type: 'open', + selectedDetail: entry, + errorMessage: null, + selectionStage: 'work-detail', + historyPath: '/works/detail?work=M3D-001', + }); + expect( + resolvePlatformPublicWorkDetailOpenDecision( + buildTypedEntry('match3d', { publicWorkCode: ' ' }), + { + buildWorkDetailPath, + }, + ), + ).toEqual({ + type: 'open', + selectedDetail: buildTypedEntry('match3d', { publicWorkCode: ' ' }), + errorMessage: null, + selectionStage: 'work-detail', + historyPath: null, + }); + expect( + resolvePlatformPublicWorkDetailOpenDecision(entry, { + canExposeEntry: () => false, + hiddenMessage: '隐藏', + buildWorkDetailPath, + }), + ).toEqual({ + type: 'blocked', + selectedDetail: null, + errorMessage: '隐藏', + selectionStage: 'platform', + historyPath: null, + }); +}); + +test('platform public work detail flow selects author lookup entry by stage', () => { + const selectedPublicWorkDetail = buildTypedEntry('puzzle'); + const publishedRpgEntry = buildRpgEntry({ + visibility: 'published', + profileId: 'published-rpg-profile', + }); + const draftRpgEntry = buildRpgEntry({ + visibility: 'draft', + profileId: 'draft-rpg-profile', + }); + + expect( + resolveActivePlatformPublicWorkAuthorEntry({ + selectionStage: 'work-detail', + selectedPublicWorkDetail, + selectedRpgDetailEntry: publishedRpgEntry, + }), + ).toBe(selectedPublicWorkDetail); + expect( + resolveActivePlatformPublicWorkAuthorEntry({ + selectionStage: 'detail', + selectedPublicWorkDetail: null, + selectedRpgDetailEntry: publishedRpgEntry, + }), + ).toBe(publishedRpgEntry); + expect( + resolveActivePlatformPublicWorkAuthorEntry({ + selectionStage: 'detail', + selectedPublicWorkDetail: null, + selectedRpgDetailEntry: draftRpgEntry, + }), + ).toBeNull(); + expect( + resolveActivePlatformPublicWorkAuthorEntry({ + selectionStage: 'platform', + selectedPublicWorkDetail, + selectedRpgDetailEntry: publishedRpgEntry, + }), + ).toBeNull(); +}); diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.ts index 942f220e..a99c3bda 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.ts @@ -1,4 +1,5 @@ import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { isBarkBattleGalleryEntry, isBigFishGalleryEntry, @@ -11,6 +12,10 @@ import { isWoodenFishGalleryEntry, type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; +import { + canExposePublicWork, + EDUTAINMENT_HIDDEN_MESSAGE, +} from './platformEdutainmentVisibility'; export type PlatformPublicWorkDetailKind = | 'bark-battle' @@ -55,6 +60,34 @@ export type PlatformPublicWorkDetailOpenStrategy = export type PlatformPublicWorkActionMode = 'edit' | 'remix'; +export type PlatformPublicWorkDetailOpenDecision = + | { + type: 'blocked'; + selectedDetail: null; + errorMessage: string; + selectionStage: 'platform'; + historyPath: null; + } + | { + type: 'open'; + selectedDetail: PlatformPublicGalleryCard; + errorMessage: null; + selectionStage: 'work-detail'; + historyPath: string | null; + }; + +export type PlatformPublicWorkDetailOpenDecisionDeps = { + canExposeEntry?: (entry: PlatformPublicGalleryCard) => boolean; + hiddenMessage?: string; + buildWorkDetailPath?: (publicWorkCode: string) => string; +}; + +export type ActivePlatformPublicWorkAuthorEntryInput = { + selectionStage: string; + selectedPublicWorkDetail: PlatformPublicGalleryCard | null; + selectedRpgDetailEntry: CustomWorldGalleryCard | null; +}; + export function isRpgPublicWorkDetailEntry( entry: PlatformPublicGalleryCard, ): entry is CustomWorldGalleryCard { @@ -188,3 +221,57 @@ export function resolvePlatformPublicWorkActionMode( ? 'edit' : 'remix'; } + +export function resolvePlatformPublicWorkDetailOpenDecision( + entry: PlatformPublicGalleryCard, + deps: PlatformPublicWorkDetailOpenDecisionDeps = {}, +): PlatformPublicWorkDetailOpenDecision { + const canExposeEntry = deps.canExposeEntry ?? canExposePublicWork; + const hiddenMessage = deps.hiddenMessage ?? EDUTAINMENT_HIDDEN_MESSAGE; + const buildWorkDetailPath = + deps.buildWorkDetailPath ?? + ((publicWorkCode: string) => + buildPublicWorkStagePath('work-detail', publicWorkCode)); + + if (!canExposeEntry(entry)) { + return { + type: 'blocked', + selectedDetail: null, + errorMessage: hiddenMessage, + selectionStage: 'platform', + historyPath: null, + }; + } + + const publicWorkCode = entry.publicWorkCode?.trim() + ? entry.publicWorkCode + : null; + + return { + type: 'open', + selectedDetail: entry, + errorMessage: null, + selectionStage: 'work-detail', + historyPath: publicWorkCode ? buildWorkDetailPath(publicWorkCode) : null, + }; +} + +export function resolveActivePlatformPublicWorkAuthorEntry({ + selectionStage, + selectedPublicWorkDetail, + selectedRpgDetailEntry, +}: ActivePlatformPublicWorkAuthorEntryInput): PlatformPublicGalleryCard | null { + if (selectionStage === 'work-detail') { + return selectedPublicWorkDetail; + } + + if ( + selectionStage === 'detail' && + selectedRpgDetailEntry && + selectedRpgDetailEntry.visibility !== 'draft' + ) { + return selectedRpgDetailEntry; + } + + return null; +} From 39522f3b96937b18b49978516f4e23a2b800ed59 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 00:17:31 +0800 Subject: [PATCH 25/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E4=BD=9C=E5=93=81=E8=AF=A6=E6=83=85=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 3 +- docs/README.md | 2 +- ...atformPublicWorkDetailFlow收口计划-2026-06-03.md | 17 +- .../PlatformEntryFlowShellImpl.tsx | 218 +-------- .../platformMatch3DRuntimeProfile.test.ts | 26 ++ .../platformMatch3DRuntimeProfile.ts | 9 + .../platformPublicWorkDetailFlow.test.ts | 416 +++++++++++++++++- .../platformPublicWorkDetailFlow.ts | 216 +++++++++ 8 files changed, 675 insertions(+), 232 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index decd8733..b0ab045b 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -20,8 +20,9 @@ - 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。 - 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy`、`resolvePlatformPublicWorkActionMode`、`resolvePlatformPublicWorkDetailOpenDecision` 和 `resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter,并保留作者请求竞态控制;启动、点赞、remix 和编辑副作用暂不抽走。 +- 追加决策:公开详情 entry 映射与公开详情反推玩法 work 摘要也归入 `platformPublicWorkDetailFlow.ts`,包括 RPG、拼图、大鱼吃小鱼、方洞挑战、视觉小说、跳一跳、敲木鱼和汪汪声浪的通用映射。抓大鹅 `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 做素材归一和背景资产提升,避免把 Match3D 运行态规则复制到公开详情 Flow Module。 - 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 -- 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`。 ## 2026-06-03 平台入口弹窗状态规则收口 diff --git a/docs/README.md b/docs/README.md index cf10707b..d7f50317 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -统一作品详情页的玩法 kind、详情打开策略和自有作品动作模式收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 +统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md index 1d5bb942..d0348d3a 100644 --- a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md @@ -13,7 +13,14 @@ - `resolvePlatformPublicWorkActionMode(entry, viewerUserId)` - `resolvePlatformPublicWorkDetailOpenDecision(entry, deps)` - `resolveActivePlatformPublicWorkAuthorEntry(args)` + - `map*WorkToPublicWorkDetail(...)` + - `mapPublicWorkDetailToPuzzleWork(entry)` + - `mapPublicWorkDetailToBigFishWork(entry)` + - `mapPublicWorkDetailToSquareHoleWork(entry)` + - `mapBarkBattlePublicDetailToWorkSummary(entry)` - `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter:根据 open strategy 调用 `openPublicWorkDetail`、`openPuzzlePublicWorkDetail`、`openJumpHopPublicWorkDetail`、`openWoodenFishPublicWorkDetail`、`openVisualNovelPublicWorkDetail` 或 `openRpgPublicWorkDetail`。 +- 公开详情 entry 映射与公开详情反推玩法 work 摘要也收口到 Module。壳层只在运行态启动、编辑、改造、推荐缓存和详情展示时调用映射 Interface,不再在壳层顶部持有每个玩法的 DTO 拼装 Implementation。 +- `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 处理素材归一和背景资产提升;`platformPublicWorkDetailFlow.ts` 不复制 Match3D 运行态素材规则。 - 本次不抽 `startSelectedPublicWork`、`likePublicWork`、`remixPublicWork`、`editOwnedPublicWork`。这些函数牵涉运行态启动、计数写入、草稿恢复、作品架缓存和多路错误 setter;若直接搬进一个 Hook,会形成浅 Interface。 ## Interface 约束 @@ -26,16 +33,20 @@ - `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id;当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`。 - `resolvePlatformPublicWorkDetailOpenDecision` 只表达直接展示公开详情的打开 / 阻断结果、错误文案、目标 stage 与可写入历史的路径;真正执行 setter、push history 的副作用仍由壳层 Adapter 执行。 - `resolveActivePlatformPublicWorkAuthorEntry` 只在 `work-detail` 阶段选择统一公开详情 entry,在 RPG `detail` 阶段只选择非 draft 的 RPG 详情 entry;作者请求、竞态 request key 和缓存仍留壳层。 +- `map*WorkToPublicWorkDetail` 只把各玩法已存在的 work / gallery summary 映射为统一详情 entry;公开码、封面、统计与标题字段继续复用 `rpgEntryWorldPresentation.ts` 的平台公开卡片映射。 +- `mapPublicWorkDetailToPuzzleWork`、`mapPublicWorkDetailToBigFishWork`、`mapPublicWorkDetailToSquareHoleWork` 和 `mapBarkBattlePublicDetailToWorkSummary` 只用于公开详情 CTA、推荐缓存或运行态启动前的兼容 work 摘要拼装;缺省值必须留在 Module 测试中固定,壳层不得重复推导。 +- Match3D 的公开详情与 work 摘要互转仍属于 Match3D Runtime Profile Module,因为它依赖 `generatedItemAssets` 归一化与背景资产提升。公开详情 Flow 只接统一详情策略,不复制该运行态规则。 ## Depth / Leverage / Locality -- **Depth**:壳层传入公开作品 entry 和当前用户 id,即可得到详情打开策略和动作模式;玩法判定细则藏在 Module Implementation 内。 -- **Leverage**:新增玩法公开详情时先补 Strategy 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断。 -- **Locality**:公开作品详情入口的纯策略集中到一个小 Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 +- **Depth**:壳层传入公开作品 entry、玩法 work summary 或当前用户 id,即可得到详情打开策略、动作模式和统一详情映射;玩法判定与 DTO 默认值藏在 Module Implementation 内。 +- **Leverage**:新增玩法公开详情时先补 Strategy / Mapping 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断或 DTO 回填。 +- **Locality**:公开作品详情入口的纯策略与通用映射集中到一个小 Module;Match3D 素材归一仍在 Match3D Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 ## 验收 - `npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts` +- `npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts` - `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts` - `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx` - `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "public detail|owned public puzzle detail|direct missing public work detail"` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 3efa1b72..023c4c21 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -374,7 +374,6 @@ import { mapBarkBattleWorkToPlatformGalleryCard, mapBigFishWorkToPlatformGalleryCard, mapJumpHopWorkToPlatformGalleryCard, - mapMatch3DWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard, mapSquareHoleWorkToPlatformGalleryCard, mapVisualNovelWorkToPlatformGalleryCard, @@ -500,6 +499,7 @@ import { hasMatch3DRuntimeAsset, hasMatch3DRuntimeBackgroundAsset, mapMatch3DWorksForRuntimeUi, + mapMatch3DWorkToPublicWorkDetail, mapPublicWorkDetailToMatch3DWork, normalizeMatch3DWorkForRuntimeUi, promoteMatch3DGeneratedBackgroundAsset, @@ -516,6 +516,18 @@ import { type RecommendRuntimeKind, } from './platformPublicGalleryFlow'; import { + mapBarkBattlePublicDetailToWorkSummary, + mapBarkBattleWorkToPublicWorkDetail, + mapBigFishWorkToPublicWorkDetail, + mapJumpHopWorkToPublicWorkDetail, + mapPublicWorkDetailToBigFishWork, + mapPublicWorkDetailToPuzzleWork, + mapPublicWorkDetailToSquareHoleWork, + mapPuzzleWorkToPublicWorkDetail, + mapRpgGalleryCardToPublicWorkDetail, + mapSquareHoleWorkToPublicWorkDetail, + mapVisualNovelWorkToPublicWorkDetail, + mapWoodenFishWorkToPublicWorkDetail, resolveActivePlatformPublicWorkAuthorEntry, resolvePlatformPublicWorkActionMode, resolvePlatformPublicWorkDetailOpenDecision, @@ -719,18 +731,6 @@ function isRecommendRuntimeReadyForEntry( return true; } -function mapRpgGalleryCardToPublicWorkDetail( - entry: CustomWorldGalleryCard, -): PlatformPublicGalleryCard { - return entry; -} - -function mapPuzzleWorkToPublicWorkDetail( - item: PuzzleWorkSummary, -): PlatformPublicGalleryCard { - return mapPuzzleWorkToPlatformGalleryCard(item); -} - function resolveVisiblePuzzleDetailCoverCount( entry: PlatformPublicGalleryCard | null, run: PuzzleRunSnapshot | null, @@ -747,44 +747,6 @@ function resolveVisiblePuzzleDetailCoverCount( return Math.max(1, run.clearedLevelCount + 1); } -function mapMatch3DWorkToPublicWorkDetail( - item: Match3DWorkSummary, -): PlatformPublicGalleryCard { - return mapMatch3DWorkToPlatformGalleryCard( - normalizeMatch3DWorkForRuntimeUi(item), - ); -} - -function mapSquareHoleWorkToPublicWorkDetail( - item: SquareHoleWorkSummary, -): PlatformPublicGalleryCard { - return mapSquareHoleWorkToPlatformGalleryCard(item); -} - -function mapBigFishWorkToPublicWorkDetail( - item: BigFishWorkSummary, -): PlatformPublicGalleryCard { - return mapBigFishWorkToPlatformGalleryCard(item); -} - -function mapVisualNovelWorkToPublicWorkDetail( - item: VisualNovelWorkSummary, -): PlatformPublicGalleryCard { - return mapVisualNovelWorkToPlatformGalleryCard(item); -} - -function mapJumpHopWorkToPublicWorkDetail( - item: JumpHopGalleryCardResponse | JumpHopWorkProfileResponse, -): PlatformPublicGalleryCard { - return mapJumpHopWorkToPlatformGalleryCard(item); -} - -function mapBarkBattleWorkToPublicWorkDetail( - item: BarkBattleWorkSummary, -): PlatformPublicGalleryCard { - return mapBarkBattleWorkToPlatformGalleryCard(item); -} - function mapBarkBattleWorkToPublishedConfig( work: BarkBattleWorkSummary, ): BarkBattlePublishedConfig { @@ -809,44 +771,6 @@ function mapBarkBattleWorkToPublishedConfig( }; } -function mapBarkBattlePublicDetailToWorkSummary( - entry: PlatformPublicGalleryCard, -): BarkBattleWorkSummary | null { - if (!isBarkBattleGalleryEntry(entry)) { - return null; - } - - return { - workId: entry.workId, - draftId: entry.sourceSessionId ?? null, - ownerUserId: entry.ownerUserId, - authorDisplayName: entry.authorDisplayName, - title: entry.worldName, - summary: entry.summaryText, - themeDescription: entry.themeTags[0] ?? entry.summaryText, - playerImageDescription: entry.themeTags[1] ?? entry.summaryText, - opponentImageDescription: entry.themeTags[2] ?? entry.summaryText, - onomatopoeia: undefined, - playerCharacterImageSrc: entry.coverCharacterImageSrcs[0] ?? null, - opponentCharacterImageSrc: entry.coverCharacterImageSrcs[1] ?? null, - uiBackgroundImageSrc: entry.coverImageSrc, - difficultyPreset: 'normal', - status: 'published', - generationStatus: 'ready', - publishReady: true, - playCount: entry.playCount ?? 0, - recentPlayCount7d: entry.recentPlayCount7d ?? 0, - updatedAt: entry.updatedAt, - publishedAt: entry.publishedAt, - }; -} - -function mapWoodenFishWorkToPublicWorkDetail( - item: WoodenFishGalleryCardResponse | WoodenFishWorkProfileResponse, -): PlatformPublicGalleryCard { - return mapWoodenFishWorkToPlatformGalleryCard(item); -} - function mapVisualNovelWorkDetailToSession( work: VisualNovelWorkDetail, ): VisualNovelAgentSessionSnapshot { @@ -892,122 +816,6 @@ function resolveMatch3DGenerationStateFromAssets( }; } -function mapPublicWorkDetailToPuzzleWork( - entry: PlatformPublicGalleryCard, -): PuzzleWorkSummary | null { - if (!isPuzzleGalleryEntry(entry)) { - return null; - } - - return { - workId: entry.workId, - profileId: entry.profileId, - ownerUserId: entry.ownerUserId, - sourceSessionId: - 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' - ? entry.sourceSessionId - : null, - authorDisplayName: entry.authorDisplayName, - levelName: entry.worldName, - summary: entry.summaryText, - themeTags: entry.themeTags, - coverImageSrc: entry.coverImageSrc, - publicationStatus: 'published', - updatedAt: entry.updatedAt, - publishedAt: entry.publishedAt, - playCount: entry.playCount ?? 0, - remixCount: entry.remixCount ?? 0, - likeCount: entry.likeCount ?? 0, - pointIncentiveTotalHalfPoints: 0, - pointIncentiveClaimedPoints: 0, - pointIncentiveTotalPoints: 0, - pointIncentiveClaimablePoints: 0, - publishReady: true, - levels: - entry.coverSlides?.map((slide, index) => ({ - levelId: slide.id || `puzzle-level-${index + 1}`, - levelName: slide.label, - pictureDescription: entry.summaryText, - candidates: [], - selectedCandidateId: null, - coverImageSrc: slide.imageSrc, - coverAssetId: null, - generationStatus: 'ready' as const, - })) ?? [], - }; -} - -function mapPublicWorkDetailToBigFishWork( - entry: PlatformPublicGalleryCard, -): BigFishWorkSummary | null { - if (!isBigFishGalleryEntry(entry)) { - return null; - } - - const levelCount = Number.parseInt( - entry.themeTags.find((tag) => /^\d+级$/u.test(tag))?.replace('级', '') ?? - '0', - 10, - ); - - return { - workId: entry.workId, - sourceSessionId: entry.profileId, - ownerUserId: entry.ownerUserId, - authorDisplayName: entry.authorDisplayName, - title: entry.worldName, - subtitle: entry.subtitle, - summary: entry.summaryText, - coverImageSrc: entry.coverImageSrc, - status: 'published', - updatedAt: entry.updatedAt, - publishedAt: entry.publishedAt, - publishReady: true, - levelCount: Number.isNaN(levelCount) ? 0 : levelCount, - levelMainImageReadyCount: 0, - levelMotionReadyCount: 0, - backgroundReady: Boolean(entry.coverImageSrc), - playCount: entry.playCount ?? 0, - remixCount: entry.remixCount ?? 0, - likeCount: entry.likeCount ?? 0, - }; -} - -function mapPublicWorkDetailToSquareHoleWork( - entry: PlatformPublicGalleryCard, -): SquareHoleWorkSummary | null { - if (!isSquareHoleGalleryEntry(entry)) { - return null; - } - - return { - workId: entry.workId, - profileId: entry.profileId, - ownerUserId: entry.ownerUserId, - sourceSessionId: - 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' - ? entry.sourceSessionId - : null, - gameName: entry.worldName, - themeText: entry.themeTags[0] ?? '方洞挑战', - twistRule: entry.subtitle, - summary: entry.summaryText, - tags: entry.themeTags, - coverImageSrc: entry.coverImageSrc, - backgroundPrompt: entry.backgroundPrompt ?? '方洞挑战运行背景', - backgroundImageSrc: entry.backgroundImageSrc ?? null, - shapeOptions: entry.shapeOptions ?? [], - holeOptions: entry.holeOptions ?? [], - shapeCount: entry.shapeCount ?? 8, - difficulty: entry.difficulty ?? 4, - publicationStatus: 'published', - playCount: entry.playCount ?? 0, - updatedAt: entry.updatedAt, - publishedAt: entry.publishedAt, - publishReady: true, - }; -} - function buildSquareHoleProfileFromSession( session: SquareHoleSessionSnapshot | null, ): SquareHoleWorkProfile | null { diff --git a/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts b/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts index 56e98a95..92fd3924 100644 --- a/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts +++ b/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts @@ -10,6 +10,7 @@ import type { import type { PlatformMatch3DGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; import { buildMatch3DProfileFromSession, + mapMatch3DWorkToPublicWorkDetail, mapPublicWorkDetailToMatch3DWork, resolveActiveMatch3DRuntimeProfile, resolveMatch3DRuntimeBackgroundImageSrc, @@ -142,6 +143,31 @@ test('Match3D runtime profile maps public detail and promotes item background as expect(work?.backgroundImageObjectKey).toBe('oss/background-from-item.png'); }); +test('Match3D runtime profile maps work summary to public detail with promoted background asset', () => { + const backgroundAsset = buildBackgroundAsset({ + imageSrc: '/generated/match3d/detail-background.png', + }); + const detail = mapMatch3DWorkToPublicWorkDetail( + buildProfile({ + generatedBackgroundAsset: null, + backgroundImageSrc: null, + generatedItemAssets: [ + buildItemAsset({ + backgroundAsset, + }), + ], + }), + ); + + expect(detail).toMatchObject({ + sourceType: 'match3d', + workId: 'match3d-work-1', + profileId: 'match3d-profile-1', + backgroundImageSrc: '/generated/match3d/detail-background.png', + generatedBackgroundAsset: backgroundAsset, + }); +}); + test('Match3D runtime profile builds draft profile from session snapshot', () => { const backgroundAsset = buildBackgroundAsset({ imageSrc: '/generated/match3d/draft-background.png', diff --git a/src/components/platform-entry/platformMatch3DRuntimeProfile.ts b/src/components/platform-entry/platformMatch3DRuntimeProfile.ts index 4f0325ce..2cfc5701 100644 --- a/src/components/platform-entry/platformMatch3DRuntimeProfile.ts +++ b/src/components/platform-entry/platformMatch3DRuntimeProfile.ts @@ -13,9 +13,18 @@ import { } from '../../services/match3dGeneratedModelCache'; import { isMatch3DGalleryEntry, + mapMatch3DWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; +export function mapMatch3DWorkToPublicWorkDetail( + item: Match3DWorkSummary, +): PlatformPublicGalleryCard { + return mapMatch3DWorkToPlatformGalleryCard( + normalizeMatch3DWorkForRuntimeUi(item), + ); +} + export function mapPublicWorkDetailToMatch3DWork( entry: PlatformPublicGalleryCard, ): Match3DWorkSummary | null { diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts index 2b8df398..973d5fc6 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts @@ -1,6 +1,13 @@ import { expect, test } from 'vitest'; +import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, @@ -8,6 +15,18 @@ import { } from '../rpg-entry/rpgEntryWorldPresentation'; import { getPlatformPublicWorkDetailKind, + mapBarkBattlePublicDetailToWorkSummary, + mapBarkBattleWorkToPublicWorkDetail, + mapBigFishWorkToPublicWorkDetail, + mapJumpHopWorkToPublicWorkDetail, + mapPublicWorkDetailToBigFishWork, + mapPublicWorkDetailToPuzzleWork, + mapPublicWorkDetailToSquareHoleWork, + mapPuzzleWorkToPublicWorkDetail, + mapRpgGalleryCardToPublicWorkDetail, + mapSquareHoleWorkToPublicWorkDetail, + mapVisualNovelWorkToPublicWorkDetail, + mapWoodenFishWorkToPublicWorkDetail, type PlatformPublicWorkDetailKind, type PlatformPublicWorkDetailOpenStrategy, resolveActivePlatformPublicWorkAuthorEntry, @@ -21,10 +40,21 @@ type TypedPlatformPublicGalleryCard = Extract< { sourceType: string } >; type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType']; -type TypedPlatformPublicGalleryCardOverrides = Partial< - Omit +type TypedPlatformPublicGalleryCardOverrides< + TSourceType extends PlatformGallerySourceType, +> = Partial< + Omit< + Extract, + 'sourceType' + > >; +function narrowTypedEntry( + entry: TypedPlatformPublicGalleryCard, +): Extract { + return entry as Extract; +} + function buildRpgEntry( overrides: Partial = {}, ): CustomWorldGalleryCard { @@ -48,10 +78,10 @@ function buildRpgEntry( }; } -function buildTypedEntry( - sourceType: PlatformGallerySourceType, - overrides: TypedPlatformPublicGalleryCardOverrides = {}, -): PlatformPublicGalleryCard { +function buildTypedEntry( + sourceType: TSourceType, + overrides: TypedPlatformPublicGalleryCardOverrides = {}, +): Extract { const common = { workId: `${sourceType}-work`, profileId: `${sourceType}-profile`, @@ -70,31 +100,30 @@ function buildTypedEntry( switch (sourceType) { case 'puzzle': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'big-fish': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'match3d': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'square-hole': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'visual-novel': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'jump-hop': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'wooden-fish': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'edutainment': - return { + return narrowTypedEntry({ ...common, - ...overrides, sourceType, templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, - }; - case 'bark-battle': - return { - ...common, ...overrides, + }); + case 'bark-battle': + return narrowTypedEntry({ + ...common, sourceType, authorPublicUserCode: null, coverRenderMode: 'image', @@ -102,14 +131,190 @@ function buildTypedEntry( themeMode: 'martial', playableNpcCount: 1, landmarkCount: 1, - }; + ...overrides, + }); default: { - const exhaustive: never = sourceType; - return exhaustive; + throw new Error(`Unsupported source type: ${sourceType}`); } } } +function buildPuzzleWork( + overrides: Partial = {}, +): PuzzleWorkSummary { + return { + workId: 'puzzle-work', + profileId: 'puzzle-profile', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session', + authorDisplayName: '玩家', + workTitle: '拼图作品', + workDescription: '拼图描述', + levelName: '第一关', + summary: '拼图摘要', + themeTags: ['拼图'], + coverImageSrc: '/puzzle-cover.png', + publicationStatus: 'published', + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + playCount: 3, + remixCount: 2, + likeCount: 1, + publishReady: true, + ...overrides, + }; +} + +function buildBigFishWork( + overrides: Partial = {}, +): BigFishWorkSummary { + return { + workId: 'big-fish-work', + sourceSessionId: 'big-fish-session', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + title: '大鱼作品', + subtitle: '大鱼吃小鱼', + summary: '大鱼摘要', + coverImageSrc: '/big-fish-cover.png', + status: 'published', + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + publishReady: true, + levelCount: 12, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: true, + playCount: 4, + remixCount: 3, + likeCount: 2, + ...overrides, + }; +} + +function buildSquareHoleWork( + overrides: Partial = {}, +): SquareHoleWorkSummary { + return { + workId: 'square-hole-work', + profileId: 'square-hole-profile', + ownerUserId: 'user-1', + sourceSessionId: 'square-hole-session', + gameName: '方洞作品', + themeText: '形状', + twistRule: '反直觉', + summary: '方洞摘要', + tags: ['方洞'], + coverImageSrc: '/square-hole-cover.png', + backgroundPrompt: '方洞背景', + backgroundImageSrc: '/square-hole-bg.png', + shapeOptions: [], + holeOptions: [], + shapeCount: 8, + difficulty: 4, + publicationStatus: 'published', + playCount: 5, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + publishReady: true, + ...overrides, + }; +} + +function buildVisualNovelWork( + overrides: Partial = {}, +): VisualNovelWorkSummary { + return { + runtimeKind: 'visual-novel', + profileId: 'visual-novel-profile', + ownerUserId: 'user-1', + title: '视觉小说作品', + description: '视觉小说摘要', + coverImageSrc: '/visual-novel-cover.png', + tags: ['视觉小说'], + publishStatus: 'published', + publishReady: true, + playCount: 6, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + ...overrides, + }; +} + +function buildJumpHopGalleryCard( + overrides: Partial = {}, +): JumpHopGalleryCardResponse { + return { + publicWorkCode: 'JH-0001', + workId: 'jump-hop-work', + profileId: 'jump-hop-profile', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + workTitle: '跳一跳作品', + workDescription: '跳一跳摘要', + coverImageSrc: '/jump-hop-cover.png', + themeTags: ['跳一跳'], + difficulty: 'standard', + stylePreset: 'paper-toy', + publicationStatus: 'published', + playCount: 7, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + generationStatus: 'ready', + ...overrides, + }; +} + +function buildWoodenFishGalleryCard( + overrides: Partial = {}, +): WoodenFishGalleryCardResponse { + return { + publicWorkCode: 'WF-0001', + workId: 'wooden-fish-work', + profileId: 'wooden-fish-profile', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + workTitle: '木鱼作品', + workDescription: '木鱼摘要', + coverImageSrc: '/wooden-fish-cover.png', + themeTags: ['敲木鱼'], + publicationStatus: 'published', + playCount: 8, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + generationStatus: 'ready', + ...overrides, + }; +} + +function buildBarkBattleWork( + overrides: Partial = {}, +): BarkBattleWorkSummary { + return { + workId: 'bark-battle-work', + draftId: 'bark-battle-draft', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + title: '汪汪声浪作品', + summary: '汪汪摘要', + themeDescription: '森林擂台', + playerImageDescription: '小狗', + opponentImageDescription: '对手', + playerCharacterImageSrc: '/player.png', + opponentCharacterImageSrc: '/opponent.png', + uiBackgroundImageSrc: '/bark-bg.png', + difficultyPreset: 'normal', + status: 'published', + generationStatus: 'ready', + publishReady: true, + playCount: 9, + recentPlayCount7d: 2, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + ...overrides, + }; +} + test('platform public work detail flow resolves detail kind for every play kind', () => { const cases: Array< [sourceType: PlatformGallerySourceType, kind: PlatformPublicWorkDetailKind] @@ -221,6 +426,173 @@ test('platform public work detail flow resolves open strategy', () => { }); }); +test('platform public work detail flow maps work summaries to detail entries', () => { + const rpgEntry = buildRpgEntry(); + + expect(mapRpgGalleryCardToPublicWorkDetail(rpgEntry)).toBe(rpgEntry); + expect(mapPuzzleWorkToPublicWorkDetail(buildPuzzleWork())).toMatchObject({ + sourceType: 'puzzle', + workId: 'puzzle-work', + profileId: 'puzzle-profile', + playCount: 3, + remixCount: 2, + likeCount: 1, + }); + expect(mapBigFishWorkToPublicWorkDetail(buildBigFishWork())).toMatchObject({ + sourceType: 'big-fish', + workId: 'big-fish-work', + profileId: 'big-fish-session', + playCount: 4, + }); + expect( + mapSquareHoleWorkToPublicWorkDetail(buildSquareHoleWork()), + ).toMatchObject({ + sourceType: 'square-hole', + workId: 'square-hole-work', + profileId: 'square-hole-profile', + backgroundPrompt: '方洞背景', + }); + expect( + mapVisualNovelWorkToPublicWorkDetail(buildVisualNovelWork()), + ).toMatchObject({ + sourceType: 'visual-novel', + workId: 'visual-novel-profile', + profileId: 'visual-novel-profile', + playCount: 6, + }); + expect( + mapJumpHopWorkToPublicWorkDetail(buildJumpHopGalleryCard()), + ).toMatchObject({ + sourceType: 'jump-hop', + workId: 'jump-hop-work', + profileId: 'jump-hop-profile', + publicWorkCode: 'JH-0001', + }); + expect( + mapWoodenFishWorkToPublicWorkDetail(buildWoodenFishGalleryCard()), + ).toMatchObject({ + sourceType: 'wooden-fish', + workId: 'wooden-fish-work', + profileId: 'wooden-fish-profile', + publicWorkCode: 'WF-0001', + }); + expect( + mapBarkBattleWorkToPublicWorkDetail(buildBarkBattleWork()), + ).toMatchObject({ + sourceType: 'bark-battle', + workId: 'bark-battle-work', + sourceSessionId: 'bark-battle-draft', + coverRenderMode: 'scene_with_roles', + coverCharacterImageSrcs: ['/player.png', '/opponent.png'], + }); +}); + +test('platform public work detail flow maps detail entries back to work summaries', () => { + expect( + mapPublicWorkDetailToPuzzleWork({ + ...buildTypedEntry('puzzle', { + coverSlides: [ + { + id: 'level-1', + imageSrc: '/level-1.png', + label: '第一关', + }, + ], + playCount: 10, + remixCount: 4, + likeCount: 3, + }), + sourceSessionId: 'puzzle-session', + }), + ).toMatchObject({ + workId: 'puzzle-work', + profileId: 'puzzle-profile', + sourceSessionId: 'puzzle-session', + playCount: 10, + remixCount: 4, + likeCount: 3, + pointIncentiveTotalPoints: 0, + levels: [ + { + levelId: 'level-1', + levelName: '第一关', + coverImageSrc: '/level-1.png', + generationStatus: 'ready', + }, + ], + }); + + expect( + mapPublicWorkDetailToBigFishWork( + buildTypedEntry('big-fish', { + themeTags: ['大鱼', '12级'], + coverImageSrc: '/big-fish-cover.png', + }), + ), + ).toMatchObject({ + workId: 'big-fish-work', + sourceSessionId: 'big-fish-profile', + levelCount: 12, + backgroundReady: true, + }); + expect( + mapPublicWorkDetailToBigFishWork( + buildTypedEntry('big-fish', { themeTags: ['大鱼'] }), + )?.levelCount, + ).toBe(0); + + expect( + mapPublicWorkDetailToSquareHoleWork( + buildTypedEntry('square-hole', { themeTags: [] }), + ), + ).toMatchObject({ + workId: 'square-hole-work', + profileId: 'square-hole-profile', + themeText: '方洞挑战', + backgroundPrompt: '方洞挑战运行背景', + shapeOptions: [], + holeOptions: [], + shapeCount: 8, + difficulty: 4, + }); + + expect( + mapBarkBattlePublicDetailToWorkSummary( + { + ...buildTypedEntry('bark-battle', { + themeTags: ['森林', '小狗', '对手'], + coverImageSrc: '/bark-bg.png', + coverCharacterImageSrcs: ['/player.png', '/opponent.png'], + playCount: 11, + recentPlayCount7d: 5, + }), + sourceSessionId: 'bark-draft', + }, + ), + ).toMatchObject({ + workId: 'bark-battle-work', + draftId: 'bark-draft', + themeDescription: '森林', + playerImageDescription: '小狗', + opponentImageDescription: '对手', + playerCharacterImageSrc: '/player.png', + opponentCharacterImageSrc: '/opponent.png', + uiBackgroundImageSrc: '/bark-bg.png', + difficultyPreset: 'normal', + playCount: 11, + recentPlayCount7d: 5, + }); + + expect(mapPublicWorkDetailToPuzzleWork(buildTypedEntry('big-fish'))).toBeNull(); + expect(mapPublicWorkDetailToBigFishWork(buildTypedEntry('puzzle'))).toBeNull(); + expect( + mapPublicWorkDetailToSquareHoleWork(buildTypedEntry('puzzle')), + ).toBeNull(); + expect( + mapBarkBattlePublicDetailToWorkSummary(buildTypedEntry('puzzle')), + ).toBeNull(); +}); + test('platform public work detail flow resolves edit mode only for owned works', () => { const entry = buildTypedEntry('puzzle'); diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.ts index a99c3bda..4864f01c 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.ts @@ -1,4 +1,17 @@ +import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { + JumpHopGalleryCardResponse, + JumpHopWorkProfileResponse, +} from '../../../packages/shared/src/contracts/jumpHop'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { + WoodenFishGalleryCardResponse, + WoodenFishWorkProfileResponse, +} from '../../../packages/shared/src/contracts/woodenFish'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { isBarkBattleGalleryEntry, @@ -10,6 +23,13 @@ import { isSquareHoleGalleryEntry, isVisualNovelGalleryEntry, isWoodenFishGalleryEntry, + mapBarkBattleWorkToPlatformGalleryCard, + mapBigFishWorkToPlatformGalleryCard, + mapJumpHopWorkToPlatformGalleryCard, + mapPuzzleWorkToPlatformGalleryCard, + mapSquareHoleWorkToPlatformGalleryCard, + mapVisualNovelWorkToPlatformGalleryCard, + mapWoodenFishWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; import { @@ -94,6 +114,202 @@ export function isRpgPublicWorkDetailEntry( return !('sourceType' in entry); } +export function mapRpgGalleryCardToPublicWorkDetail( + entry: CustomWorldGalleryCard, +): PlatformPublicGalleryCard { + return entry; +} + +export function mapPuzzleWorkToPublicWorkDetail( + item: PuzzleWorkSummary, +): PlatformPublicGalleryCard { + return mapPuzzleWorkToPlatformGalleryCard(item); +} + +export function mapSquareHoleWorkToPublicWorkDetail( + item: SquareHoleWorkSummary, +): PlatformPublicGalleryCard { + return mapSquareHoleWorkToPlatformGalleryCard(item); +} + +export function mapBigFishWorkToPublicWorkDetail( + item: BigFishWorkSummary, +): PlatformPublicGalleryCard { + return mapBigFishWorkToPlatformGalleryCard(item); +} + +export function mapVisualNovelWorkToPublicWorkDetail( + item: VisualNovelWorkSummary, +): PlatformPublicGalleryCard { + return mapVisualNovelWorkToPlatformGalleryCard(item); +} + +export function mapJumpHopWorkToPublicWorkDetail( + item: JumpHopGalleryCardResponse | JumpHopWorkProfileResponse, +): PlatformPublicGalleryCard { + return mapJumpHopWorkToPlatformGalleryCard(item); +} + +export function mapBarkBattleWorkToPublicWorkDetail( + item: BarkBattleWorkSummary, +): PlatformPublicGalleryCard { + return mapBarkBattleWorkToPlatformGalleryCard(item); +} + +export function mapWoodenFishWorkToPublicWorkDetail( + item: WoodenFishGalleryCardResponse | WoodenFishWorkProfileResponse, +): PlatformPublicGalleryCard { + return mapWoodenFishWorkToPlatformGalleryCard(item); +} + +export function mapBarkBattlePublicDetailToWorkSummary( + entry: PlatformPublicGalleryCard, +): BarkBattleWorkSummary | null { + if (!isBarkBattleGalleryEntry(entry)) { + return null; + } + + return { + workId: entry.workId, + draftId: entry.sourceSessionId ?? null, + ownerUserId: entry.ownerUserId, + authorDisplayName: entry.authorDisplayName, + title: entry.worldName, + summary: entry.summaryText, + themeDescription: entry.themeTags[0] ?? entry.summaryText, + playerImageDescription: entry.themeTags[1] ?? entry.summaryText, + opponentImageDescription: entry.themeTags[2] ?? entry.summaryText, + onomatopoeia: undefined, + playerCharacterImageSrc: entry.coverCharacterImageSrcs[0] ?? null, + opponentCharacterImageSrc: entry.coverCharacterImageSrcs[1] ?? null, + uiBackgroundImageSrc: entry.coverImageSrc, + difficultyPreset: 'normal', + status: 'published', + generationStatus: 'ready', + publishReady: true, + playCount: entry.playCount ?? 0, + recentPlayCount7d: entry.recentPlayCount7d ?? 0, + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + }; +} + +export function mapPublicWorkDetailToPuzzleWork( + entry: PlatformPublicGalleryCard, +): PuzzleWorkSummary | null { + if (!isPuzzleGalleryEntry(entry)) { + return null; + } + + return { + workId: entry.workId, + profileId: entry.profileId, + ownerUserId: entry.ownerUserId, + sourceSessionId: + 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' + ? entry.sourceSessionId + : null, + authorDisplayName: entry.authorDisplayName, + levelName: entry.worldName, + summary: entry.summaryText, + themeTags: entry.themeTags, + coverImageSrc: entry.coverImageSrc, + publicationStatus: 'published', + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + playCount: entry.playCount ?? 0, + remixCount: entry.remixCount ?? 0, + likeCount: entry.likeCount ?? 0, + pointIncentiveTotalHalfPoints: 0, + pointIncentiveClaimedPoints: 0, + pointIncentiveTotalPoints: 0, + pointIncentiveClaimablePoints: 0, + publishReady: true, + levels: + entry.coverSlides?.map((slide, index) => ({ + levelId: slide.id || `puzzle-level-${index + 1}`, + levelName: slide.label, + pictureDescription: entry.summaryText, + candidates: [], + selectedCandidateId: null, + coverImageSrc: slide.imageSrc, + coverAssetId: null, + generationStatus: 'ready' as const, + })) ?? [], + }; +} + +export function mapPublicWorkDetailToBigFishWork( + entry: PlatformPublicGalleryCard, +): BigFishWorkSummary | null { + if (!isBigFishGalleryEntry(entry)) { + return null; + } + + const levelCount = Number.parseInt( + entry.themeTags.find((tag) => /^\d+级$/u.test(tag))?.replace('级', '') ?? + '0', + 10, + ); + + return { + workId: entry.workId, + sourceSessionId: entry.profileId, + ownerUserId: entry.ownerUserId, + authorDisplayName: entry.authorDisplayName, + title: entry.worldName, + subtitle: entry.subtitle, + summary: entry.summaryText, + coverImageSrc: entry.coverImageSrc, + status: 'published', + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + publishReady: true, + levelCount: Number.isNaN(levelCount) ? 0 : levelCount, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: Boolean(entry.coverImageSrc), + playCount: entry.playCount ?? 0, + remixCount: entry.remixCount ?? 0, + likeCount: entry.likeCount ?? 0, + }; +} + +export function mapPublicWorkDetailToSquareHoleWork( + entry: PlatformPublicGalleryCard, +): SquareHoleWorkSummary | null { + if (!isSquareHoleGalleryEntry(entry)) { + return null; + } + + return { + workId: entry.workId, + profileId: entry.profileId, + ownerUserId: entry.ownerUserId, + sourceSessionId: + 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' + ? entry.sourceSessionId + : null, + gameName: entry.worldName, + themeText: entry.themeTags[0] ?? '方洞挑战', + twistRule: entry.subtitle, + summary: entry.summaryText, + tags: entry.themeTags, + coverImageSrc: entry.coverImageSrc, + backgroundPrompt: entry.backgroundPrompt ?? '方洞挑战运行背景', + backgroundImageSrc: entry.backgroundImageSrc ?? null, + shapeOptions: entry.shapeOptions ?? [], + holeOptions: entry.holeOptions ?? [], + shapeCount: entry.shapeCount ?? 8, + difficulty: entry.difficulty ?? 4, + publicationStatus: 'published', + playCount: entry.playCount ?? 0, + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + publishReady: true, + }; +} + export function getPlatformPublicWorkDetailKind( entry: PlatformPublicGalleryCard, ): PlatformPublicWorkDetailKind { From 8c54d40b9cfae19a88ea6e0084b9d28f8a92d112 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 00:21:57 +0800 Subject: [PATCH 26/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E8=AF=A6=E6=83=85=E5=B0=81=E9=9D=A2=E8=A7=A3=E9=94=81?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 1 + ...atformPublicWorkDetailFlow收口计划-2026-06-03.md | 4 +- .../PlatformEntryFlowShellImpl.tsx | 17 +------ .../platformPublicWorkDetailFlow.test.ts | 49 +++++++++++++++++++ .../platformPublicWorkDetailFlow.ts | 17 +++++++ 5 files changed, 71 insertions(+), 17 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b0ab045b..9a0e6933 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -21,6 +21,7 @@ - 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。 - 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy`、`resolvePlatformPublicWorkActionMode`、`resolvePlatformPublicWorkDetailOpenDecision` 和 `resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter,并保留作者请求竞态控制;启动、点赞、remix 和编辑副作用暂不抽走。 - 追加决策:公开详情 entry 映射与公开详情反推玩法 work 摘要也归入 `platformPublicWorkDetailFlow.ts`,包括 RPG、拼图、大鱼吃小鱼、方洞挑战、视觉小说、跳一跳、敲木鱼和汪汪声浪的通用映射。抓大鹅 `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 做素材归一和背景资产提升,避免把 Match3D 运行态规则复制到公开详情 Flow Module。 +- 追加决策:拼图公开详情封面解锁数由 `resolveVisiblePuzzleDetailCoverCount(entry, run)` 收口;非拼图、无当前 run 或 run 不匹配当前公开详情时只展示首图,匹配当前公开详情时按 `clearedLevelCount + 1` 解锁且至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。 - 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 - 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`。 diff --git a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md index d0348d3a..849dd712 100644 --- a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md @@ -18,6 +18,7 @@ - `mapPublicWorkDetailToBigFishWork(entry)` - `mapPublicWorkDetailToSquareHoleWork(entry)` - `mapBarkBattlePublicDetailToWorkSummary(entry)` + - `resolveVisiblePuzzleDetailCoverCount(entry, run)` - `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter:根据 open strategy 调用 `openPublicWorkDetail`、`openPuzzlePublicWorkDetail`、`openJumpHopPublicWorkDetail`、`openWoodenFishPublicWorkDetail`、`openVisualNovelPublicWorkDetail` 或 `openRpgPublicWorkDetail`。 - 公开详情 entry 映射与公开详情反推玩法 work 摘要也收口到 Module。壳层只在运行态启动、编辑、改造、推荐缓存和详情展示时调用映射 Interface,不再在壳层顶部持有每个玩法的 DTO 拼装 Implementation。 - `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 处理素材归一和背景资产提升;`platformPublicWorkDetailFlow.ts` 不复制 Match3D 运行态素材规则。 @@ -35,11 +36,12 @@ - `resolveActivePlatformPublicWorkAuthorEntry` 只在 `work-detail` 阶段选择统一公开详情 entry,在 RPG `detail` 阶段只选择非 draft 的 RPG 详情 entry;作者请求、竞态 request key 和缓存仍留壳层。 - `map*WorkToPublicWorkDetail` 只把各玩法已存在的 work / gallery summary 映射为统一详情 entry;公开码、封面、统计与标题字段继续复用 `rpgEntryWorldPresentation.ts` 的平台公开卡片映射。 - `mapPublicWorkDetailToPuzzleWork`、`mapPublicWorkDetailToBigFishWork`、`mapPublicWorkDetailToSquareHoleWork` 和 `mapBarkBattlePublicDetailToWorkSummary` 只用于公开详情 CTA、推荐缓存或运行态启动前的兼容 work 摘要拼装;缺省值必须留在 Module 测试中固定,壳层不得重复推导。 +- `resolveVisiblePuzzleDetailCoverCount` 只表达拼图公开详情封面解锁规则:非拼图、无当前 run 或 run 不属于当前公开详情时只展示首图;当前 run 属于该公开详情时按 `clearedLevelCount + 1` 解锁,但至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。 - Match3D 的公开详情与 work 摘要互转仍属于 Match3D Runtime Profile Module,因为它依赖 `generatedItemAssets` 归一化与背景资产提升。公开详情 Flow 只接统一详情策略,不复制该运行态规则。 ## Depth / Leverage / Locality -- **Depth**:壳层传入公开作品 entry、玩法 work summary 或当前用户 id,即可得到详情打开策略、动作模式和统一详情映射;玩法判定与 DTO 默认值藏在 Module Implementation 内。 +- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id 或当前拼图 run,即可得到详情打开策略、动作模式、统一详情映射和封面可见数;玩法判定与 DTO 默认值藏在 Module Implementation 内。 - **Leverage**:新增玩法公开详情时先补 Strategy / Mapping 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断或 DTO 回填。 - **Locality**:公开作品详情入口的纯策略与通用映射集中到一个小 Module;Match3D 素材归一仍在 Match3D Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 023c4c21..9bd2488d 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -532,6 +532,7 @@ import { resolvePlatformPublicWorkActionMode, resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, + resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; import { buildPuzzleResultProfileId, @@ -731,22 +732,6 @@ function isRecommendRuntimeReadyForEntry( return true; } -function resolveVisiblePuzzleDetailCoverCount( - entry: PlatformPublicGalleryCard | null, - run: PuzzleRunSnapshot | null, -) { - if (!entry || !isPuzzleGalleryEntry(entry)) { - return 1; - } - - if (run?.entryProfileId !== entry.profileId) { - return 1; - } - - // 中文注释:封面首图永远公开,后续封面跟随当前玩家本次 run 的通关进度即时解锁。 - return Math.max(1, run.clearedLevelCount + 1); -} - function mapBarkBattleWorkToPublishedConfig( work: BarkBattleWorkSummary, ): BarkBattlePublishedConfig { diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts index 973d5fc6..f0acefa7 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts @@ -3,6 +3,7 @@ import { expect, test } from 'vitest'; import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; @@ -33,6 +34,7 @@ import { resolvePlatformPublicWorkActionMode, resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, + resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; type TypedPlatformPublicGalleryCard = Extract< @@ -165,6 +167,24 @@ function buildPuzzleWork( }; } +function buildPuzzleRun( + overrides: Partial = {}, +): PuzzleRunSnapshot { + return { + runId: 'puzzle-run', + entryProfileId: 'puzzle-profile', + clearedLevelCount: 0, + currentLevelIndex: 0, + currentGridSize: 3, + playedProfileIds: ['puzzle-profile'], + previousLevelTags: [], + currentLevel: null, + recommendedNextProfileId: null, + leaderboardEntries: [], + ...overrides, + }; +} + function buildBigFishWork( overrides: Partial = {}, ): BigFishWorkSummary { @@ -593,6 +613,35 @@ test('platform public work detail flow maps detail entries back to work summarie ).toBeNull(); }); +test('platform public work detail flow resolves visible puzzle cover count', () => { + const puzzleEntry = buildTypedEntry('puzzle', { + profileId: 'puzzle-profile', + }); + + expect(resolveVisiblePuzzleDetailCoverCount(null, null)).toBe(1); + expect( + resolveVisiblePuzzleDetailCoverCount(buildTypedEntry('big-fish'), null), + ).toBe(1); + expect( + resolveVisiblePuzzleDetailCoverCount( + puzzleEntry, + buildPuzzleRun({ entryProfileId: 'other-profile', clearedLevelCount: 9 }), + ), + ).toBe(1); + expect( + resolveVisiblePuzzleDetailCoverCount( + puzzleEntry, + buildPuzzleRun({ clearedLevelCount: 2 }), + ), + ).toBe(3); + expect( + resolveVisiblePuzzleDetailCoverCount( + puzzleEntry, + buildPuzzleRun({ clearedLevelCount: -1 }), + ), + ).toBe(1); +}); + test('platform public work detail flow resolves edit mode only for owned works', () => { const entry = buildTypedEntry('puzzle'); diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.ts index 4864f01c..b27f9737 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.ts @@ -4,6 +4,7 @@ import type { JumpHopGalleryCardResponse, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; +import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; @@ -239,6 +240,22 @@ export function mapPublicWorkDetailToPuzzleWork( }; } +export function resolveVisiblePuzzleDetailCoverCount( + entry: PlatformPublicGalleryCard | null, + run: PuzzleRunSnapshot | null, +) { + if (!entry || !isPuzzleGalleryEntry(entry)) { + return 1; + } + + if (run?.entryProfileId !== entry.profileId) { + return 1; + } + + // 中文注释:封面首图永远公开,后续封面跟随当前玩家本次 run 的通关进度即时解锁。 + return Math.max(1, run.clearedLevelCount + 1); +} + export function mapPublicWorkDetailToBigFishWork( entry: PlatformPublicGalleryCard, ): BigFishWorkSummary | null { From 872d741fdcf1b3dec7630e18a5fd527d3a180c4c Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 00:27:34 +0800 Subject: [PATCH 27/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E8=AF=A6=E6=83=85=E7=82=B9=E8=B5=9E=E6=84=8F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 1 + docs/README.md | 2 +- ...atformPublicWorkDetailFlow收口计划-2026-06-03.md | 4 +- .../PlatformEntryFlowShellImpl.tsx | 37 +++------- .../platformPublicWorkDetailFlow.test.ts | 44 ++++++++++++ .../platformPublicWorkDetailFlow.ts | 71 +++++++++++++++++++ 6 files changed, 131 insertions(+), 28 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 9a0e6933..fb52fc62 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -22,6 +22,7 @@ - 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy`、`resolvePlatformPublicWorkActionMode`、`resolvePlatformPublicWorkDetailOpenDecision` 和 `resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter,并保留作者请求竞态控制;启动、点赞、remix 和编辑副作用暂不抽走。 - 追加决策:公开详情 entry 映射与公开详情反推玩法 work 摘要也归入 `platformPublicWorkDetailFlow.ts`,包括 RPG、拼图、大鱼吃小鱼、方洞挑战、视觉小说、跳一跳、敲木鱼和汪汪声浪的通用映射。抓大鹅 `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 做素材归一和背景资产提升,避免把 Match3D 运行态规则复制到公开详情 Flow Module。 - 追加决策:拼图公开详情封面解锁数由 `resolveVisiblePuzzleDetailCoverCount(entry, run)` 收口;非拼图、无当前 run 或 run 不匹配当前公开详情时只展示首图,匹配当前公开详情时按 `clearedLevelCount + 1` 解锁且至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。 +- 追加决策:公开详情点赞能力矩阵由 `resolvePlatformPublicWorkLikeIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、API 调用、缓存同步、错误展示和 busy 状态。 - 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 - 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index d7f50317..84d5ebee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 +统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、点赞意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md index 849dd712..1671ab52 100644 --- a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md @@ -11,6 +11,7 @@ - `getPlatformPublicWorkDetailKind(entry)` - `resolvePlatformPublicWorkDetailOpenStrategy(entry)` - `resolvePlatformPublicWorkActionMode(entry, viewerUserId)` + - `resolvePlatformPublicWorkLikeIntent(entry)` - `resolvePlatformPublicWorkDetailOpenDecision(entry, deps)` - `resolveActivePlatformPublicWorkAuthorEntry(args)` - `map*WorkToPublicWorkDetail(...)` @@ -32,6 +33,7 @@ - 大鱼吃小鱼、抓大鹅、方洞挑战、汪汪声浪、宝贝识物和其它可直接展示的公开 entry 返回 `use-entry` strategy。 - RPG 返回 `load-rpg-detail` strategy,由壳层 Adapter 继续调用 RPG 详情读取流程。 - `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id;当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`。 +- `resolvePlatformPublicWorkLikeIntent` 只表达公开作品点赞意图:大鱼吃小鱼、拼图和旧 RPG gallery fallback 返回可执行 intent;宝贝识物、汪汪声浪、方洞挑战和视觉小说返回不可用文案。壳层只按 intent 调用 API、写缓存和展示错误,不再持有这组能力矩阵。 - `resolvePlatformPublicWorkDetailOpenDecision` 只表达直接展示公开详情的打开 / 阻断结果、错误文案、目标 stage 与可写入历史的路径;真正执行 setter、push history 的副作用仍由壳层 Adapter 执行。 - `resolveActivePlatformPublicWorkAuthorEntry` 只在 `work-detail` 阶段选择统一公开详情 entry,在 RPG `detail` 阶段只选择非 draft 的 RPG 详情 entry;作者请求、竞态 request key 和缓存仍留壳层。 - `map*WorkToPublicWorkDetail` 只把各玩法已存在的 work / gallery summary 映射为统一详情 entry;公开码、封面、统计与标题字段继续复用 `rpgEntryWorldPresentation.ts` 的平台公开卡片映射。 @@ -41,7 +43,7 @@ ## Depth / Leverage / Locality -- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id 或当前拼图 run,即可得到详情打开策略、动作模式、统一详情映射和封面可见数;玩法判定与 DTO 默认值藏在 Module Implementation 内。 +- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id 或当前拼图 run,即可得到详情打开策略、动作模式、点赞意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。 - **Leverage**:新增玩法公开详情时先补 Strategy / Mapping 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断或 DTO 回填。 - **Locality**:公开作品详情入口的纯策略与通用映射集中到一个小 Module;Match3D 素材归一仍在 Match3D Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 9bd2488d..65a2737e 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -532,6 +532,7 @@ import { resolvePlatformPublicWorkActionMode, resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, + resolvePlatformPublicWorkLikeIntent, resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; import { @@ -10778,11 +10779,13 @@ export function PlatformEntryFlowShellImpl({ setIsPublicWorkDetailBusy(true); setPublicWorkDetailError(null); - if (isBigFishGalleryEntry(entry)) { - void likeBigFishGalleryWork(entry.profileId) + const intent = resolvePlatformPublicWorkLikeIntent(entry); + + if (intent.type === 'like-big-fish') { + void likeBigFishGalleryWork(intent.profileId) .then((response) => { const updatedWork = response.items.find( - (item) => item.sourceSessionId === entry.profileId, + (item) => item.sourceSessionId === intent.profileId, ); if (!updatedWork) { return; @@ -10817,8 +10820,8 @@ export function PlatformEntryFlowShellImpl({ return; } - if (isPuzzleGalleryEntry(entry)) { - void likePuzzleGalleryWork(entry.profileId) + if (intent.type === 'like-puzzle') { + void likePuzzleGalleryWork(intent.profileId) .then((response) => { const updatedWork = response.item; setPuzzleGalleryEntries((current) => @@ -10851,31 +10854,13 @@ export function PlatformEntryFlowShellImpl({ return; } - if (isEdutainmentGalleryEntry(entry)) { - setPublicWorkDetailError('宝贝识物点赞将在后续版本开放。'); + if (intent.type === 'unsupported') { + setPublicWorkDetailError(intent.errorMessage); setIsPublicWorkDetailBusy(false); return; } - if (isBarkBattleGalleryEntry(entry)) { - setPublicWorkDetailError('汪汪声浪点赞将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isSquareHoleGalleryEntry(entry)) { - setPublicWorkDetailError('方洞挑战点赞将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isVisualNovelGalleryEntry(entry)) { - setPublicWorkDetailError('视觉小说点赞将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - void likeRpgEntryWorldGallery(entry.ownerUserId, entry.profileId) + void likeRpgEntryWorldGallery(intent.ownerUserId, intent.profileId) .then((updatedEntry) => { setSelectedDetailEntry((current) => current?.profileId === updatedEntry.profileId diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts index f0acefa7..26de21b9 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts @@ -34,6 +34,7 @@ import { resolvePlatformPublicWorkActionMode, resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, + resolvePlatformPublicWorkLikeIntent, resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; @@ -651,6 +652,49 @@ test('platform public work detail flow resolves edit mode only for owned works', expect(resolvePlatformPublicWorkActionMode(entry, null)).toBe('remix'); }); +test('platform public work detail flow resolves like intent', () => { + expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('big-fish'))).toEqual( + { + type: 'like-big-fish', + profileId: 'big-fish-profile', + }, + ); + expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('puzzle'))).toEqual({ + type: 'like-puzzle', + profileId: 'puzzle-profile', + }); + expect(resolvePlatformPublicWorkLikeIntent(buildRpgEntry())).toEqual({ + type: 'like-rpg-gallery', + ownerUserId: 'user-1', + profileId: 'rpg-profile', + }); + expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('match3d'))).toEqual( + { + type: 'like-rpg-gallery', + ownerUserId: 'user-1', + profileId: 'match3d-profile', + }, + ); + expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('edutainment'))).toEqual({ + type: 'unsupported', + errorMessage: '宝贝识物点赞将在后续版本开放。', + }); + expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('bark-battle'))).toEqual( + { + type: 'unsupported', + errorMessage: '汪汪声浪点赞将在后续版本开放。', + }, + ); + expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('square-hole'))).toEqual({ + type: 'unsupported', + errorMessage: '方洞挑战点赞将在后续版本开放。', + }); + expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('visual-novel'))).toEqual({ + type: 'unsupported', + errorMessage: '视觉小说点赞将在后续版本开放。', + }); +}); + test('platform public work detail flow resolves direct open decision', () => { const entry = buildTypedEntry('match3d', { publicWorkCode: ' M3D-001 ', diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.ts index b27f9737..395fb124 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.ts @@ -81,6 +81,25 @@ export type PlatformPublicWorkDetailOpenStrategy = export type PlatformPublicWorkActionMode = 'edit' | 'remix'; +export type PlatformPublicWorkLikeIntent = + | { + type: 'like-big-fish'; + profileId: string; + } + | { + type: 'like-puzzle'; + profileId: string; + } + | { + type: 'like-rpg-gallery'; + ownerUserId: string; + profileId: string; + } + | { + type: 'unsupported'; + errorMessage: string; + }; + export type PlatformPublicWorkDetailOpenDecision = | { type: 'blocked'; @@ -455,6 +474,58 @@ export function resolvePlatformPublicWorkActionMode( : 'remix'; } +export function resolvePlatformPublicWorkLikeIntent( + entry: PlatformPublicGalleryCard, +): PlatformPublicWorkLikeIntent { + if (isBigFishGalleryEntry(entry)) { + return { + type: 'like-big-fish', + profileId: entry.profileId, + }; + } + + if (isPuzzleGalleryEntry(entry)) { + return { + type: 'like-puzzle', + profileId: entry.profileId, + }; + } + + if (isEdutainmentGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '宝贝识物点赞将在后续版本开放。', + }; + } + + if (isBarkBattleGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '汪汪声浪点赞将在后续版本开放。', + }; + } + + if (isSquareHoleGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '方洞挑战点赞将在后续版本开放。', + }; + } + + if (isVisualNovelGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '视觉小说点赞将在后续版本开放。', + }; + } + + return { + type: 'like-rpg-gallery', + ownerUserId: entry.ownerUserId, + profileId: entry.profileId, + }; +} + export function resolvePlatformPublicWorkDetailOpenDecision( entry: PlatformPublicGalleryCard, deps: PlatformPublicWorkDetailOpenDecisionDeps = {}, From 37a35daddb6fe7c59477838a9b8c9f42fe0685f4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 00:32:10 +0800 Subject: [PATCH 28/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E8=AF=A6=E6=83=85=E6=94=B9=E9=80=A0=E6=84=8F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 1 + docs/README.md | 2 +- ...atformPublicWorkDetailFlow收口计划-2026-06-03.md | 4 +- .../PlatformEntryFlowShellImpl.tsx | 57 +++-------- .../platformPublicWorkDetailFlow.test.ts | 53 ++++++++++ .../platformPublicWorkDetailFlow.ts | 96 +++++++++++++++++++ 6 files changed, 166 insertions(+), 47 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index fb52fc62..5ee2ac85 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -23,6 +23,7 @@ - 追加决策:公开详情 entry 映射与公开详情反推玩法 work 摘要也归入 `platformPublicWorkDetailFlow.ts`,包括 RPG、拼图、大鱼吃小鱼、方洞挑战、视觉小说、跳一跳、敲木鱼和汪汪声浪的通用映射。抓大鹅 `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 做素材归一和背景资产提升,避免把 Match3D 运行态规则复制到公开详情 Flow Module。 - 追加决策:拼图公开详情封面解锁数由 `resolveVisiblePuzzleDetailCoverCount(entry, run)` 收口;非拼图、无当前 run 或 run 不匹配当前公开详情时只展示首图,匹配当前公开详情时按 `clearedLevelCount + 1` 解锁且至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。 - 追加决策:公开详情点赞能力矩阵由 `resolvePlatformPublicWorkLikeIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、API 调用、缓存同步、错误展示和 busy 状态。 +- 追加决策:公开详情改造能力矩阵由 `resolvePlatformPublicWorkRemixIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、remix API、session / 缓存写入、stage 切换、错误展示和 busy 状态。 - 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 - 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index 84d5ebee..db0dd44e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、点赞意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 +统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、点赞 / 改造意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md index 1671ab52..3b273e1e 100644 --- a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md @@ -12,6 +12,7 @@ - `resolvePlatformPublicWorkDetailOpenStrategy(entry)` - `resolvePlatformPublicWorkActionMode(entry, viewerUserId)` - `resolvePlatformPublicWorkLikeIntent(entry)` + - `resolvePlatformPublicWorkRemixIntent(entry)` - `resolvePlatformPublicWorkDetailOpenDecision(entry, deps)` - `resolveActivePlatformPublicWorkAuthorEntry(args)` - `map*WorkToPublicWorkDetail(...)` @@ -34,6 +35,7 @@ - RPG 返回 `load-rpg-detail` strategy,由壳层 Adapter 继续调用 RPG 详情读取流程。 - `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id;当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`。 - `resolvePlatformPublicWorkLikeIntent` 只表达公开作品点赞意图:大鱼吃小鱼、拼图和旧 RPG gallery fallback 返回可执行 intent;宝贝识物、汪汪声浪、方洞挑战和视觉小说返回不可用文案。壳层只按 intent 调用 API、写缓存和展示错误,不再持有这组能力矩阵。 +- `resolvePlatformPublicWorkRemixIntent` 只表达公开作品改造意图:大鱼吃小鱼和拼图返回可执行 intent 与成功后目标 stage,旧 RPG gallery fallback 返回可执行 intent,其它玩法返回原未开放文案。壳层只按 intent 调用 remix API、写 session / 缓存、切 stage 和展示错误。 - `resolvePlatformPublicWorkDetailOpenDecision` 只表达直接展示公开详情的打开 / 阻断结果、错误文案、目标 stage 与可写入历史的路径;真正执行 setter、push history 的副作用仍由壳层 Adapter 执行。 - `resolveActivePlatformPublicWorkAuthorEntry` 只在 `work-detail` 阶段选择统一公开详情 entry,在 RPG `detail` 阶段只选择非 draft 的 RPG 详情 entry;作者请求、竞态 request key 和缓存仍留壳层。 - `map*WorkToPublicWorkDetail` 只把各玩法已存在的 work / gallery summary 映射为统一详情 entry;公开码、封面、统计与标题字段继续复用 `rpgEntryWorldPresentation.ts` 的平台公开卡片映射。 @@ -43,7 +45,7 @@ ## Depth / Leverage / Locality -- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id 或当前拼图 run,即可得到详情打开策略、动作模式、点赞意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。 +- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id 或当前拼图 run,即可得到详情打开策略、动作模式、点赞 / 改造意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。 - **Leverage**:新增玩法公开详情时先补 Strategy / Mapping 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断或 DTO 回填。 - **Locality**:公开作品详情入口的纯策略与通用映射集中到一个小 Module;Match3D 素材归一仍在 Match3D Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 65a2737e..9e563408 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -533,6 +533,7 @@ import { resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, resolvePlatformPublicWorkLikeIntent, + resolvePlatformPublicWorkRemixIntent, resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; import { @@ -13460,12 +13461,14 @@ export function PlatformEntryFlowShellImpl({ setIsPublicWorkDetailBusy(true); setPublicWorkDetailError(null); - if (isBigFishGalleryEntry(entry)) { - void remixBigFishGalleryWork(entry.profileId) + const intent = resolvePlatformPublicWorkRemixIntent(entry); + + if (intent.type === 'remix-big-fish') { + void remixBigFishGalleryWork(intent.profileId) .then((response) => { bigFishFlow.setSession(response.session); enterCreateTab(); - setSelectionStage('big-fish-result'); + setSelectionStage(intent.selectionStage); }) .catch((error) => { setPublicWorkDetailError( @@ -13478,14 +13481,14 @@ export function PlatformEntryFlowShellImpl({ return; } - if (isPuzzleGalleryEntry(entry)) { - void remixPuzzleGalleryWork(entry.profileId) + if (intent.type === 'remix-puzzle') { + void remixPuzzleGalleryWork(intent.profileId) .then((response) => { resetRecommendRuntimeSelection(); puzzleFlow.setSession(response.session); setPuzzleOperation(null); enterCreateTab(); - setSelectionStage('puzzle-result'); + setSelectionStage(intent.selectionStage); }) .catch((error) => { setPublicWorkDetailError( @@ -13498,49 +13501,13 @@ export function PlatformEntryFlowShellImpl({ return; } - if (isMatch3DGalleryEntry(entry)) { - setPublicWorkDetailError('抓大鹅作品改造将在后续版本开放。'); + if (intent.type === 'unsupported') { + setPublicWorkDetailError(intent.errorMessage); setIsPublicWorkDetailBusy(false); return; } - if (isSquareHoleGalleryEntry(entry)) { - setPublicWorkDetailError('方洞挑战作品改造将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isJumpHopGalleryEntry(entry)) { - setPublicWorkDetailError('跳一跳作品改造将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isWoodenFishGalleryEntry(entry)) { - setPublicWorkDetailError('敲木鱼作品改造将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isVisualNovelGalleryEntry(entry)) { - setPublicWorkDetailError('视觉小说作品改造将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isEdutainmentGalleryEntry(entry)) { - setPublicWorkDetailError('宝贝识物作品改造将在创作链路接入后开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - if (isBarkBattleGalleryEntry(entry)) { - setPublicWorkDetailError('汪汪声浪作品改造将在后续版本开放。'); - setIsPublicWorkDetailBusy(false); - return; - } - - void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId) + void remixRpgEntryWorldGallery(intent.ownerUserId, intent.profileId) .then((response) => { const nextEntry = response.entry; setSelectedDetailEntry(nextEntry); diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts index 26de21b9..c42cf770 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts @@ -35,6 +35,7 @@ import { resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, resolvePlatformPublicWorkLikeIntent, + resolvePlatformPublicWorkRemixIntent, resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; @@ -695,6 +696,58 @@ test('platform public work detail flow resolves like intent', () => { }); }); +test('platform public work detail flow resolves remix intent', () => { + expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('big-fish'))).toEqual( + { + type: 'remix-big-fish', + profileId: 'big-fish-profile', + selectionStage: 'big-fish-result', + }, + ); + expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('puzzle'))).toEqual({ + type: 'remix-puzzle', + profileId: 'puzzle-profile', + selectionStage: 'puzzle-result', + }); + expect(resolvePlatformPublicWorkRemixIntent(buildRpgEntry())).toEqual({ + type: 'remix-rpg-gallery', + ownerUserId: 'user-1', + profileId: 'rpg-profile', + }); + expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('match3d'))).toEqual( + { + type: 'unsupported', + errorMessage: '抓大鹅作品改造将在后续版本开放。', + }, + ); + expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('square-hole'))).toEqual({ + type: 'unsupported', + errorMessage: '方洞挑战作品改造将在后续版本开放。', + }); + expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('jump-hop'))).toEqual({ + type: 'unsupported', + errorMessage: '跳一跳作品改造将在后续版本开放。', + }); + expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('wooden-fish'))).toEqual({ + type: 'unsupported', + errorMessage: '敲木鱼作品改造将在后续版本开放。', + }); + expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('visual-novel'))).toEqual({ + type: 'unsupported', + errorMessage: '视觉小说作品改造将在后续版本开放。', + }); + expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('edutainment'))).toEqual({ + type: 'unsupported', + errorMessage: '宝贝识物作品改造将在创作链路接入后开放。', + }); + expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('bark-battle'))).toEqual( + { + type: 'unsupported', + errorMessage: '汪汪声浪作品改造将在后续版本开放。', + }, + ); +}); + test('platform public work detail flow resolves direct open decision', () => { const entry = buildTypedEntry('match3d', { publicWorkCode: ' M3D-001 ', diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.ts index 395fb124..7dd983c0 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.ts @@ -100,6 +100,27 @@ export type PlatformPublicWorkLikeIntent = errorMessage: string; }; +export type PlatformPublicWorkRemixIntent = + | { + type: 'remix-big-fish'; + profileId: string; + selectionStage: 'big-fish-result'; + } + | { + type: 'remix-puzzle'; + profileId: string; + selectionStage: 'puzzle-result'; + } + | { + type: 'remix-rpg-gallery'; + ownerUserId: string; + profileId: string; + } + | { + type: 'unsupported'; + errorMessage: string; + }; + export type PlatformPublicWorkDetailOpenDecision = | { type: 'blocked'; @@ -526,6 +547,81 @@ export function resolvePlatformPublicWorkLikeIntent( }; } +export function resolvePlatformPublicWorkRemixIntent( + entry: PlatformPublicGalleryCard, +): PlatformPublicWorkRemixIntent { + if (isBigFishGalleryEntry(entry)) { + return { + type: 'remix-big-fish', + profileId: entry.profileId, + selectionStage: 'big-fish-result', + }; + } + + if (isPuzzleGalleryEntry(entry)) { + return { + type: 'remix-puzzle', + profileId: entry.profileId, + selectionStage: 'puzzle-result', + }; + } + + if (isMatch3DGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '抓大鹅作品改造将在后续版本开放。', + }; + } + + if (isSquareHoleGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '方洞挑战作品改造将在后续版本开放。', + }; + } + + if (isJumpHopGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '跳一跳作品改造将在后续版本开放。', + }; + } + + if (isWoodenFishGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '敲木鱼作品改造将在后续版本开放。', + }; + } + + if (isVisualNovelGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '视觉小说作品改造将在后续版本开放。', + }; + } + + if (isEdutainmentGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '宝贝识物作品改造将在创作链路接入后开放。', + }; + } + + if (isBarkBattleGalleryEntry(entry)) { + return { + type: 'unsupported', + errorMessage: '汪汪声浪作品改造将在后续版本开放。', + }; + } + + return { + type: 'remix-rpg-gallery', + ownerUserId: entry.ownerUserId, + profileId: entry.profileId, + }; +} + export function resolvePlatformPublicWorkDetailOpenDecision( entry: PlatformPublicGalleryCard, deps: PlatformPublicWorkDetailOpenDecisionDeps = {}, From e1134cc9ec9969b0f5378f8157426937db438d44 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 00:46:58 +0800 Subject: [PATCH 29/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E8=AF=A6=E6=83=85=E5=90=AF=E5=8A=A8=E6=84=8F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 3 +- docs/README.md | 2 +- ...atformPublicWorkDetailFlow收口计划-2026-06-03.md | 7 +- .../PlatformEntryFlowShellImpl.tsx | 188 +++----- .../platformPublicWorkDetailFlow.test.ts | 423 +++++++++++++++--- .../platformPublicWorkDetailFlow.ts | 210 +++++++++ 6 files changed, 645 insertions(+), 188 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 5ee2ac85..243a3e96 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -19,11 +19,12 @@ ## 2026-06-03 平台入口公开作品详情 Strategy 收口 - 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。 -- 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy`、`resolvePlatformPublicWorkActionMode`、`resolvePlatformPublicWorkDetailOpenDecision` 和 `resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter,并保留作者请求竞态控制;启动、点赞、remix 和编辑副作用暂不抽走。 +- 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy`、`resolvePlatformPublicWorkActionMode`、`resolvePlatformPublicWorkDetailOpenDecision` 和 `resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter,并保留作者请求竞态控制;启动、点赞、remix 和编辑副作用不搬入 Module。 - 追加决策:公开详情 entry 映射与公开详情反推玩法 work 摘要也归入 `platformPublicWorkDetailFlow.ts`,包括 RPG、拼图、大鱼吃小鱼、方洞挑战、视觉小说、跳一跳、敲木鱼和汪汪声浪的通用映射。抓大鹅 `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 做素材归一和背景资产提升,避免把 Match3D 运行态规则复制到公开详情 Flow Module。 - 追加决策:拼图公开详情封面解锁数由 `resolveVisiblePuzzleDetailCoverCount(entry, run)` 收口;非拼图、无当前 run 或 run 不匹配当前公开详情时只展示首图,匹配当前公开详情时按 `clearedLevelCount + 1` 解锁且至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。 - 追加决策:公开详情点赞能力矩阵由 `resolvePlatformPublicWorkLikeIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、API 调用、缓存同步、错误展示和 busy 状态。 - 追加决策:公开详情改造能力矩阵由 `resolvePlatformPublicWorkRemixIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、remix API、session / 缓存写入、stage 切换、错误展示和 busy 状态。 +- 追加决策:公开详情启动分流由 `resolvePlatformPublicWorkStartIntent(entry, deps)` 收口;Module 只返回大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪、宝贝识物或旧 RPG gallery 记录游玩的 intent。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示;抓大鹅 public detail -> work mapper 作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护素材归一与背景资产提升。 - 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 - 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index db0dd44e..bf0f499e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、点赞 / 改造意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 +统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、点赞 / 改造 / 启动意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射与启动 Adapter 的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md index 3b273e1e..3160a28d 100644 --- a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md @@ -13,6 +13,7 @@ - `resolvePlatformPublicWorkActionMode(entry, viewerUserId)` - `resolvePlatformPublicWorkLikeIntent(entry)` - `resolvePlatformPublicWorkRemixIntent(entry)` + - `resolvePlatformPublicWorkStartIntent(entry, deps)` - `resolvePlatformPublicWorkDetailOpenDecision(entry, deps)` - `resolveActivePlatformPublicWorkAuthorEntry(args)` - `map*WorkToPublicWorkDetail(...)` @@ -24,7 +25,7 @@ - `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter:根据 open strategy 调用 `openPublicWorkDetail`、`openPuzzlePublicWorkDetail`、`openJumpHopPublicWorkDetail`、`openWoodenFishPublicWorkDetail`、`openVisualNovelPublicWorkDetail` 或 `openRpgPublicWorkDetail`。 - 公开详情 entry 映射与公开详情反推玩法 work 摘要也收口到 Module。壳层只在运行态启动、编辑、改造、推荐缓存和详情展示时调用映射 Interface,不再在壳层顶部持有每个玩法的 DTO 拼装 Implementation。 - `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 处理素材归一和背景资产提升;`platformPublicWorkDetailFlow.ts` 不复制 Match3D 运行态素材规则。 -- 本次不抽 `startSelectedPublicWork`、`likePublicWork`、`remixPublicWork`、`editOwnedPublicWork`。这些函数牵涉运行态启动、计数写入、草稿恢复、作品架缓存和多路错误 setter;若直接搬进一个 Hook,会形成浅 Interface。 +- 公开详情启动、点赞和改造只抽“意图” Interface,不把整个 callback 搬进 Module。壳层继续作为 Adapter 执行鉴权、API 调用、运行态启动、busy 状态、缓存同步、stage 切换和错误 setter,避免形成伪 Seam。 ## Interface 约束 @@ -36,6 +37,8 @@ - `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id;当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`。 - `resolvePlatformPublicWorkLikeIntent` 只表达公开作品点赞意图:大鱼吃小鱼、拼图和旧 RPG gallery fallback 返回可执行 intent;宝贝识物、汪汪声浪、方洞挑战和视觉小说返回不可用文案。壳层只按 intent 调用 API、写缓存和展示错误,不再持有这组能力矩阵。 - `resolvePlatformPublicWorkRemixIntent` 只表达公开作品改造意图:大鱼吃小鱼和拼图返回可执行 intent 与成功后目标 stage,旧 RPG gallery fallback 返回可执行 intent,其它玩法返回原未开放文案。壳层只按 intent 调用 remix API、写 session / 缓存、切 stage 和展示错误。 +- `resolvePlatformPublicWorkStartIntent` 只表达公开作品“开始游玩”意图:大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪和宝贝识物返回对应启动目标;旧 RPG gallery fallback 只在完整 RPG 详情已补读且 profile 匹配时返回记录游玩 intent,否则返回原阻断文案。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示。 +- `resolvePlatformPublicWorkStartIntent` 的 `deps` 只接启动决策所需的当前拼图详情、当前 RPG 详情、汪汪声浪作品缓存,以及抓大鹅 public detail -> work 的 Adapter。抓大鹅 Adapter 必须来自 Match3D Runtime Profile Module,以保留 `generatedItemAssets` 归一化与背景资产提升的 Locality。 - `resolvePlatformPublicWorkDetailOpenDecision` 只表达直接展示公开详情的打开 / 阻断结果、错误文案、目标 stage 与可写入历史的路径;真正执行 setter、push history 的副作用仍由壳层 Adapter 执行。 - `resolveActivePlatformPublicWorkAuthorEntry` 只在 `work-detail` 阶段选择统一公开详情 entry,在 RPG `detail` 阶段只选择非 draft 的 RPG 详情 entry;作者请求、竞态 request key 和缓存仍留壳层。 - `map*WorkToPublicWorkDetail` 只把各玩法已存在的 work / gallery summary 映射为统一详情 entry;公开码、封面、统计与标题字段继续复用 `rpgEntryWorldPresentation.ts` 的平台公开卡片映射。 @@ -45,7 +48,7 @@ ## Depth / Leverage / Locality -- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id 或当前拼图 run,即可得到详情打开策略、动作模式、点赞 / 改造意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。 +- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id、当前拼图 run 或少量启动 deps,即可得到详情打开策略、动作模式、点赞 / 改造 / 启动意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。 - **Leverage**:新增玩法公开详情时先补 Strategy / Mapping 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断或 DTO 回填。 - **Locality**:公开作品详情入口的纯策略与通用映射集中到一个小 Module;Match3D 素材归一仍在 Match3D Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 9e563408..b3474814 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -534,6 +534,7 @@ import { resolvePlatformPublicWorkDetailOpenStrategy, resolvePlatformPublicWorkLikeIntent, resolvePlatformPublicWorkRemixIntent, + resolvePlatformPublicWorkStartIntent, resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; import { @@ -12611,134 +12612,93 @@ export function PlatformEntryFlowShellImpl({ } runProtectedAction(() => { - if (isBigFishGalleryEntry(selectedPublicWorkDetail)) { - const work = mapPublicWorkDetailToBigFishWork(selectedPublicWorkDetail); - if (!work) { - setPublicWorkDetailError('当前作品缺少会话信息,暂时无法进入玩法。'); - return; - } - startBigFishRunFromWork(work); - return; - } + const intent = resolvePlatformPublicWorkStartIntent( + selectedPublicWorkDetail, + { + selectedPuzzleDetail, + selectedRpgDetailEntry: selectedDetailEntry, + barkBattleGalleryEntries, + barkBattleWorks, + mapMatch3DWork: mapPublicWorkDetailToMatch3DWork, + }, + ); - if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) { - const work = - selectedPuzzleDetail?.profileId === selectedPublicWorkDetail.profileId - ? selectedPuzzleDetail - : mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail); - if (!work) { - setPublicWorkDetailError( - '当前拼图作品信息不完整,暂时无法进入玩法。', + switch (intent.type) { + case 'blocked': + setPublicWorkDetailError(intent.errorMessage); + return; + case 'start-big-fish': + startBigFishRunFromWork(intent.work, intent.returnStage); + return; + case 'start-puzzle': + setPublicWorkDetailError(null); + void startPuzzleRunFromProfile( + intent.work.profileId, + intent.returnStage, + intent.work, + true, + null, + { authMode: intent.authMode }, ); return; - } - setPublicWorkDetailError(null); - void startPuzzleRunFromProfile( - work.profileId, - 'work-detail', - work, - true, - null, - { authMode: 'isolated' }, - ); - return; - } - - if (isJumpHopGalleryEntry(selectedPublicWorkDetail)) { - setPublicWorkDetailError(null); - void startJumpHopRunFromProfile(selectedPublicWorkDetail.profileId, { - returnStage: 'work-detail', - }); - return; - } - - if (isWoodenFishGalleryEntry(selectedPublicWorkDetail)) { - setPublicWorkDetailError(null); - void startWoodenFishRunFromProfile(selectedPublicWorkDetail.profileId, { - returnStage: 'work-detail', - }); - return; - } - - if (isMatch3DGalleryEntry(selectedPublicWorkDetail)) { - const work = mapPublicWorkDetailToMatch3DWork(selectedPublicWorkDetail); - if (!work) { - setPublicWorkDetailError( - '当前抓大鹅作品信息不完整,暂时无法进入玩法。', + case 'start-jump-hop': + setPublicWorkDetailError(null); + void startJumpHopRunFromProfile(intent.profileId, { + returnStage: intent.returnStage, + }); + return; + case 'start-wooden-fish': + setPublicWorkDetailError(null); + void startWoodenFishRunFromProfile(intent.profileId, { + returnStage: intent.returnStage, + }); + return; + case 'start-match3d': + setPublicWorkDetailError(null); + void startMatch3DRunFromProfile( + intent.work, + intent.returnStage, + true, ); return; - } - setPublicWorkDetailError(null); - void startMatch3DRunFromProfile(work, 'work-detail', true); - return; - } - - if (isSquareHoleGalleryEntry(selectedPublicWorkDetail)) { - const work = mapPublicWorkDetailToSquareHoleWork( - selectedPublicWorkDetail, - ); - if (!work) { - setPublicWorkDetailError( - '当前方洞挑战作品信息不完整,暂时无法进入玩法。', + case 'start-square-hole': + setPublicWorkDetailError(null); + void startSquareHoleRunFromProfile( + intent.work, + intent.returnStage, + true, ); return; - } - setPublicWorkDetailError(null); - void startSquareHoleRunFromProfile(work, 'work-detail', true); - return; - } - - if (isVisualNovelGalleryEntry(selectedPublicWorkDetail)) { - setPublicWorkDetailError(null); - void startVisualNovelRunFromProfile( - selectedPublicWorkDetail.profileId, - 'work-detail', - ); - return; - } - - if (isBarkBattleGalleryEntry(selectedPublicWorkDetail)) { - const work = - barkBattleGalleryEntries.find( - (item) => item.workId === selectedPublicWorkDetail.workId, - ) ?? - barkBattleWorks.find( - (item) => item.workId === selectedPublicWorkDetail.workId, - ) ?? - mapBarkBattlePublicDetailToWorkSummary(selectedPublicWorkDetail); - if (!work) { - setPublicWorkDetailError( - '当前汪汪声浪作品信息不完整,暂时无法进入玩法。', + case 'start-visual-novel': + setPublicWorkDetailError(null); + void startVisualNovelRunFromProfile( + intent.profileId, + intent.returnStage, ); return; + case 'start-bark-battle': + setPublicWorkDetailError(null); + startBarkBattleRunFromWork(intent.work, intent.returnStage); + return; + case 'start-edutainment': + setPublicWorkDetailError(null); + void startBabyObjectMatchRuntimeFromEntry( + intent.entry, + intent.returnStage, + ); + return; + case 'record-rpg-gallery-play': + break; + default: { + const exhaustive: never = intent; + return exhaustive; } - setPublicWorkDetailError(null); - startBarkBattleRunFromWork(work, 'work-detail'); - return; - } - - if (isEdutainmentGalleryEntry(selectedPublicWorkDetail)) { - setPublicWorkDetailError(null); - void startBabyObjectMatchRuntimeFromEntry( - selectedPublicWorkDetail, - 'work-detail', - ); - return; - } - - const launchEntry = - selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId - ? selectedDetailEntry - : null; - if (!launchEntry) { - setPublicWorkDetailError('作品详情尚未读取完成。'); - return; } setIsPublicWorkDetailBusy(true); void recordRpgEntryWorldGalleryPlay( - launchEntry.ownerUserId, - launchEntry.profileId, + intent.entry.ownerUserId, + intent.entry.profileId, ) .then((updatedEntry) => { setSelectedDetailEntry(updatedEntry); diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts index c42cf770..50886cd2 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts @@ -3,6 +3,7 @@ import { expect, test } from 'vitest'; import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; @@ -30,12 +31,14 @@ import { mapWoodenFishWorkToPublicWorkDetail, type PlatformPublicWorkDetailKind, type PlatformPublicWorkDetailOpenStrategy, + type PlatformPublicWorkStartIntentDeps, resolveActivePlatformPublicWorkAuthorEntry, resolvePlatformPublicWorkActionMode, resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, resolvePlatformPublicWorkLikeIntent, resolvePlatformPublicWorkRemixIntent, + resolvePlatformPublicWorkStartIntent, resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; @@ -56,7 +59,10 @@ type TypedPlatformPublicGalleryCardOverrides< function narrowTypedEntry( entry: TypedPlatformPublicGalleryCard, ): Extract { - return entry as Extract; + return entry as Extract< + TypedPlatformPublicGalleryCard, + { sourceType: TSourceType } + >; } function buildRpgEntry( @@ -104,19 +110,47 @@ function buildTypedEntry( switch (sourceType) { case 'puzzle': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'big-fish': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'match3d': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'square-hole': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'visual-novel': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'jump-hop': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'wooden-fish': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'edutainment': return narrowTypedEntry({ ...common, @@ -337,6 +371,45 @@ function buildBarkBattleWork( }; } +function buildMatch3DWork( + overrides: Partial = {}, +): Match3DWorkSummary { + return { + workId: 'match3d-work', + profileId: 'match3d-profile', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session', + gameName: '抓大鹅作品', + themeText: '经典消除', + summary: '抓大鹅摘要', + tags: ['抓大鹅'], + coverImageSrc: '/match3d-cover.png', + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'published', + playCount: 10, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + publishReady: true, + generatedItemAssets: [], + ...overrides, + }; +} + +function buildStartIntentDeps( + overrides: Partial = {}, +): PlatformPublicWorkStartIntentDeps { + return { + selectedPuzzleDetail: null, + selectedRpgDetailEntry: null, + barkBattleGalleryEntries: [], + barkBattleWorks: [], + mapMatch3DWork: () => buildMatch3DWork(), + ...overrides, + }; +} + test('platform public work detail flow resolves detail kind for every play kind', () => { const cases: Array< [sourceType: PlatformGallerySourceType, kind: PlatformPublicWorkDetailKind] @@ -579,18 +652,16 @@ test('platform public work detail flow maps detail entries back to work summarie }); expect( - mapBarkBattlePublicDetailToWorkSummary( - { - ...buildTypedEntry('bark-battle', { - themeTags: ['森林', '小狗', '对手'], - coverImageSrc: '/bark-bg.png', - coverCharacterImageSrcs: ['/player.png', '/opponent.png'], - playCount: 11, - recentPlayCount7d: 5, - }), - sourceSessionId: 'bark-draft', - }, - ), + mapBarkBattlePublicDetailToWorkSummary({ + ...buildTypedEntry('bark-battle', { + themeTags: ['森林', '小狗', '对手'], + coverImageSrc: '/bark-bg.png', + coverCharacterImageSrcs: ['/player.png', '/opponent.png'], + playCount: 11, + recentPlayCount7d: 5, + }), + sourceSessionId: 'bark-draft', + }), ).toMatchObject({ workId: 'bark-battle-work', draftId: 'bark-draft', @@ -605,8 +676,12 @@ test('platform public work detail flow maps detail entries back to work summarie recentPlayCount7d: 5, }); - expect(mapPublicWorkDetailToPuzzleWork(buildTypedEntry('big-fish'))).toBeNull(); - expect(mapPublicWorkDetailToBigFishWork(buildTypedEntry('puzzle'))).toBeNull(); + expect( + mapPublicWorkDetailToPuzzleWork(buildTypedEntry('big-fish')), + ).toBeNull(); + expect( + mapPublicWorkDetailToBigFishWork(buildTypedEntry('puzzle')), + ).toBeNull(); expect( mapPublicWorkDetailToSquareHoleWork(buildTypedEntry('puzzle')), ).toBeNull(); @@ -654,13 +729,15 @@ test('platform public work detail flow resolves edit mode only for owned works', }); test('platform public work detail flow resolves like intent', () => { - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('big-fish'))).toEqual( - { - type: 'like-big-fish', - profileId: 'big-fish-profile', - }, - ); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('puzzle'))).toEqual({ + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('big-fish')), + ).toEqual({ + type: 'like-big-fish', + profileId: 'big-fish-profile', + }); + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('puzzle')), + ).toEqual({ type: 'like-puzzle', profileId: 'puzzle-profile', }); @@ -669,42 +746,50 @@ test('platform public work detail flow resolves like intent', () => { ownerUserId: 'user-1', profileId: 'rpg-profile', }); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('match3d'))).toEqual( - { - type: 'like-rpg-gallery', - ownerUserId: 'user-1', - profileId: 'match3d-profile', - }, - ); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('edutainment'))).toEqual({ + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('match3d')), + ).toEqual({ + type: 'like-rpg-gallery', + ownerUserId: 'user-1', + profileId: 'match3d-profile', + }); + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('edutainment')), + ).toEqual({ type: 'unsupported', errorMessage: '宝贝识物点赞将在后续版本开放。', }); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('bark-battle'))).toEqual( - { - type: 'unsupported', - errorMessage: '汪汪声浪点赞将在后续版本开放。', - }, - ); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('square-hole'))).toEqual({ + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('bark-battle')), + ).toEqual({ + type: 'unsupported', + errorMessage: '汪汪声浪点赞将在后续版本开放。', + }); + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('square-hole')), + ).toEqual({ type: 'unsupported', errorMessage: '方洞挑战点赞将在后续版本开放。', }); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('visual-novel'))).toEqual({ + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('visual-novel')), + ).toEqual({ type: 'unsupported', errorMessage: '视觉小说点赞将在后续版本开放。', }); }); test('platform public work detail flow resolves remix intent', () => { - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('big-fish'))).toEqual( - { - type: 'remix-big-fish', - profileId: 'big-fish-profile', - selectionStage: 'big-fish-result', - }, - ); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('puzzle'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('big-fish')), + ).toEqual({ + type: 'remix-big-fish', + profileId: 'big-fish-profile', + selectionStage: 'big-fish-result', + }); + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('puzzle')), + ).toEqual({ type: 'remix-puzzle', profileId: 'puzzle-profile', selectionStage: 'puzzle-result', @@ -714,38 +799,236 @@ test('platform public work detail flow resolves remix intent', () => { ownerUserId: 'user-1', profileId: 'rpg-profile', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('match3d'))).toEqual( - { - type: 'unsupported', - errorMessage: '抓大鹅作品改造将在后续版本开放。', - }, - ); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('square-hole'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('match3d')), + ).toEqual({ + type: 'unsupported', + errorMessage: '抓大鹅作品改造将在后续版本开放。', + }); + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('square-hole')), + ).toEqual({ type: 'unsupported', errorMessage: '方洞挑战作品改造将在后续版本开放。', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('jump-hop'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('jump-hop')), + ).toEqual({ type: 'unsupported', errorMessage: '跳一跳作品改造将在后续版本开放。', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('wooden-fish'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('wooden-fish')), + ).toEqual({ type: 'unsupported', errorMessage: '敲木鱼作品改造将在后续版本开放。', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('visual-novel'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('visual-novel')), + ).toEqual({ type: 'unsupported', errorMessage: '视觉小说作品改造将在后续版本开放。', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('edutainment'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('edutainment')), + ).toEqual({ type: 'unsupported', errorMessage: '宝贝识物作品改造将在创作链路接入后开放。', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('bark-battle'))).toEqual( - { - type: 'unsupported', - errorMessage: '汪汪声浪作品改造将在后续版本开放。', - }, - ); + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('bark-battle')), + ).toEqual({ + type: 'unsupported', + errorMessage: '汪汪声浪作品改造将在后续版本开放。', + }); +}); + +test('platform public work detail flow resolves start intent for direct launches', () => { + const bigFishEntry = buildTypedEntry('big-fish'); + expect( + resolvePlatformPublicWorkStartIntent(bigFishEntry, buildStartIntentDeps()), + ).toEqual({ + type: 'start-big-fish', + work: mapPublicWorkDetailToBigFishWork(bigFishEntry), + returnStage: 'work-detail', + }); + + const selectedPuzzleDetail = buildPuzzleWork({ + profileId: 'puzzle-profile', + }); + expect( + resolvePlatformPublicWorkStartIntent( + buildTypedEntry('puzzle'), + buildStartIntentDeps({ selectedPuzzleDetail }), + ), + ).toEqual({ + type: 'start-puzzle', + work: selectedPuzzleDetail, + returnStage: 'work-detail', + authMode: 'isolated', + }); + + const puzzleEntry = buildTypedEntry('puzzle', { + profileId: 'fallback-puzzle-profile', + }); + expect( + resolvePlatformPublicWorkStartIntent( + puzzleEntry, + buildStartIntentDeps({ + selectedPuzzleDetail: buildPuzzleWork({ profileId: 'stale-profile' }), + }), + ), + ).toEqual({ + type: 'start-puzzle', + work: mapPublicWorkDetailToPuzzleWork(puzzleEntry), + returnStage: 'work-detail', + authMode: 'isolated', + }); + + expect( + resolvePlatformPublicWorkStartIntent( + buildTypedEntry('jump-hop'), + buildStartIntentDeps(), + ), + ).toEqual({ + type: 'start-jump-hop', + profileId: 'jump-hop-profile', + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent( + buildTypedEntry('wooden-fish'), + buildStartIntentDeps(), + ), + ).toEqual({ + type: 'start-wooden-fish', + profileId: 'wooden-fish-profile', + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent( + buildTypedEntry('visual-novel'), + buildStartIntentDeps(), + ), + ).toEqual({ + type: 'start-visual-novel', + profileId: 'visual-novel-profile', + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent( + buildTypedEntry('edutainment'), + buildStartIntentDeps(), + ), + ).toEqual({ + type: 'start-edutainment', + entry: buildTypedEntry('edutainment'), + returnStage: 'work-detail', + }); +}); + +test('platform public work detail flow resolves start intent for mapper-backed launches', () => { + const match3DEntry = buildTypedEntry('match3d'); + const match3DWork = buildMatch3DWork({ workId: 'mapped-match3d-work' }); + expect( + resolvePlatformPublicWorkStartIntent( + match3DEntry, + buildStartIntentDeps({ + mapMatch3DWork: (entry) => + entry === match3DEntry ? match3DWork : null, + }), + ), + ).toEqual({ + type: 'start-match3d', + work: match3DWork, + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent( + match3DEntry, + buildStartIntentDeps({ mapMatch3DWork: () => null }), + ), + ).toEqual({ + type: 'blocked', + errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。', + }); + + const squareHoleEntry = buildTypedEntry('square-hole'); + expect( + resolvePlatformPublicWorkStartIntent( + squareHoleEntry, + buildStartIntentDeps(), + ), + ).toEqual({ + type: 'start-square-hole', + work: mapPublicWorkDetailToSquareHoleWork(squareHoleEntry), + returnStage: 'work-detail', + }); +}); + +test('platform public work detail flow resolves bark battle start work priority', () => { + const entry = buildTypedEntry('bark-battle'); + const galleryWork = buildBarkBattleWork({ + workId: 'bark-battle-work', + title: '作品架缓存', + }); + const loadedWork = buildBarkBattleWork({ + workId: 'bark-battle-work', + title: '完整作品列表', + }); + + expect( + resolvePlatformPublicWorkStartIntent( + entry, + buildStartIntentDeps({ + barkBattleGalleryEntries: [galleryWork], + barkBattleWorks: [loadedWork], + }), + ), + ).toEqual({ + type: 'start-bark-battle', + work: galleryWork, + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent( + entry, + buildStartIntentDeps({ + barkBattleWorks: [loadedWork], + }), + ), + ).toEqual({ + type: 'start-bark-battle', + work: loadedWork, + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent(entry, buildStartIntentDeps()), + ).toEqual({ + type: 'start-bark-battle', + work: mapBarkBattlePublicDetailToWorkSummary(entry), + returnStage: 'work-detail', + }); +}); + +test('platform public work detail flow resolves rpg start intent from loaded detail', () => { + const rpgEntry = buildRpgEntry(); + + expect( + resolvePlatformPublicWorkStartIntent( + rpgEntry, + buildStartIntentDeps({ selectedRpgDetailEntry: rpgEntry }), + ), + ).toEqual({ + type: 'record-rpg-gallery-play', + entry: rpgEntry, + }); + expect( + resolvePlatformPublicWorkStartIntent(rpgEntry, buildStartIntentDeps()), + ).toEqual({ + type: 'blocked', + errorMessage: '作品详情尚未读取完成。', + }); }); test('platform public work detail flow resolves direct open decision', () => { diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.ts index 7dd983c0..2dc79d32 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.ts @@ -4,6 +4,7 @@ import type { JumpHopGalleryCardResponse, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; @@ -121,6 +122,72 @@ export type PlatformPublicWorkRemixIntent = errorMessage: string; }; +export type PlatformPublicWorkStartIntent = + | { + type: 'blocked'; + errorMessage: string; + } + | { + type: 'start-big-fish'; + work: BigFishWorkSummary; + returnStage: 'work-detail'; + } + | { + type: 'start-puzzle'; + work: PuzzleWorkSummary; + returnStage: 'work-detail'; + authMode: 'isolated'; + } + | { + type: 'start-jump-hop'; + profileId: string; + returnStage: 'work-detail'; + } + | { + type: 'start-wooden-fish'; + profileId: string; + returnStage: 'work-detail'; + } + | { + type: 'start-match3d'; + work: Match3DWorkSummary; + returnStage: 'work-detail'; + } + | { + type: 'start-square-hole'; + work: SquareHoleWorkSummary; + returnStage: 'work-detail'; + } + | { + type: 'start-visual-novel'; + profileId: string; + returnStage: 'work-detail'; + } + | { + type: 'start-bark-battle'; + work: BarkBattleWorkSummary; + returnStage: 'work-detail'; + } + | { + type: 'start-edutainment'; + entry: PlatformPublicGalleryCard; + returnStage: 'work-detail'; + } + | { + type: 'record-rpg-gallery-play'; + entry: CustomWorldGalleryCard; + }; + +export type PlatformPublicWorkStartIntentDeps = { + selectedPuzzleDetail?: PuzzleWorkSummary | null; + selectedRpgDetailEntry?: CustomWorldGalleryCard | null; + barkBattleGalleryEntries?: readonly BarkBattleWorkSummary[]; + barkBattleWorks?: readonly BarkBattleWorkSummary[]; + mapMatch3DWork: ( + entry: PlatformPublicGalleryCard, + ) => Match3DWorkSummary | null; +}; + export type PlatformPublicWorkDetailOpenDecision = | { type: 'blocked'; @@ -622,6 +689,149 @@ export function resolvePlatformPublicWorkRemixIntent( }; } +export function resolvePlatformPublicWorkStartIntent( + entry: PlatformPublicGalleryCard, + deps: PlatformPublicWorkStartIntentDeps, +): PlatformPublicWorkStartIntent { + if (isBigFishGalleryEntry(entry)) { + const work = mapPublicWorkDetailToBigFishWork(entry); + if (!work) { + return { + type: 'blocked', + errorMessage: '当前作品缺少会话信息,暂时无法进入玩法。', + }; + } + + return { + type: 'start-big-fish', + work, + returnStage: 'work-detail', + }; + } + + if (isPuzzleGalleryEntry(entry)) { + const work = + deps.selectedPuzzleDetail?.profileId === entry.profileId + ? deps.selectedPuzzleDetail + : mapPublicWorkDetailToPuzzleWork(entry); + if (!work) { + return { + type: 'blocked', + errorMessage: '当前拼图作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-puzzle', + work, + returnStage: 'work-detail', + authMode: 'isolated', + }; + } + + if (isJumpHopGalleryEntry(entry)) { + return { + type: 'start-jump-hop', + profileId: entry.profileId, + returnStage: 'work-detail', + }; + } + + if (isWoodenFishGalleryEntry(entry)) { + return { + type: 'start-wooden-fish', + profileId: entry.profileId, + returnStage: 'work-detail', + }; + } + + if (isMatch3DGalleryEntry(entry)) { + // 中文注释:抓大鹅运行态素材归一仍归 Match3D Module,公开详情 Flow 只接其 Adapter。 + const work = deps.mapMatch3DWork(entry); + if (!work) { + return { + type: 'blocked', + errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-match3d', + work, + returnStage: 'work-detail', + }; + } + + if (isSquareHoleGalleryEntry(entry)) { + const work = mapPublicWorkDetailToSquareHoleWork(entry); + if (!work) { + return { + type: 'blocked', + errorMessage: '当前方洞挑战作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-square-hole', + work, + returnStage: 'work-detail', + }; + } + + if (isVisualNovelGalleryEntry(entry)) { + return { + type: 'start-visual-novel', + profileId: entry.profileId, + returnStage: 'work-detail', + }; + } + + if (isBarkBattleGalleryEntry(entry)) { + const work = + deps.barkBattleGalleryEntries?.find( + (item) => item.workId === entry.workId, + ) ?? + deps.barkBattleWorks?.find((item) => item.workId === entry.workId) ?? + mapBarkBattlePublicDetailToWorkSummary(entry); + if (!work) { + return { + type: 'blocked', + errorMessage: '当前汪汪声浪作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-bark-battle', + work, + returnStage: 'work-detail', + }; + } + + if (isEdutainmentGalleryEntry(entry)) { + return { + type: 'start-edutainment', + entry, + returnStage: 'work-detail', + }; + } + + const launchEntry = + deps.selectedRpgDetailEntry?.profileId === entry.profileId + ? deps.selectedRpgDetailEntry + : null; + if (!launchEntry) { + return { + type: 'blocked', + errorMessage: '作品详情尚未读取完成。', + }; + } + + return { + type: 'record-rpg-gallery-play', + entry: launchEntry, + }; +} + export function resolvePlatformPublicWorkDetailOpenDecision( entry: PlatformPublicGalleryCard, deps: PlatformPublicWorkDetailOpenDecisionDeps = {}, From 7349c6df4fea6be801a4b98f044c106399105c56 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 01:00:09 +0800 Subject: [PATCH 30/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E8=AF=A6=E6=83=85=E7=BC=96=E8=BE=91=E6=84=8F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 1 + docs/README.md | 2 +- ...atformPublicWorkDetailFlow收口计划-2026-06-03.md | 7 +- .../PlatformEntryFlowShellImpl.tsx | 157 ++++-------- .../platformPublicWorkDetailFlow.test.ts | 237 +++++++++++++++++- .../platformPublicWorkDetailFlow.ts | 229 ++++++++++++++++- 6 files changed, 516 insertions(+), 117 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 243a3e96..4f7e0899 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -25,6 +25,7 @@ - 追加决策:公开详情点赞能力矩阵由 `resolvePlatformPublicWorkLikeIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、API 调用、缓存同步、错误展示和 busy 状态。 - 追加决策:公开详情改造能力矩阵由 `resolvePlatformPublicWorkRemixIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、remix API、session / 缓存写入、stage 切换、错误展示和 busy 状态。 - 追加决策:公开详情启动分流由 `resolvePlatformPublicWorkStartIntent(entry, deps)` 收口;Module 只返回大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪、宝贝识物或旧 RPG gallery 记录游玩的 intent。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示;抓大鹅 public detail -> work mapper 作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护素材归一与背景资产提升。 +- 追加决策:自有公开作品编辑分流由 `resolvePlatformPublicWorkEditIntent(entry, deps)` 收口;Module 只返回可编辑草稿目标、需解析宝贝识物本地草稿 intent、旧 RPG gallery 编辑 intent 或原阻断文案。壳层仍执行登录保护、草稿恢复、宝贝识物异步草稿解析、RPG 编辑导航和错误展示;抓大鹅 public detail -> work mapper 仍作为 Adapter 注入,不复制 Match3D 素材归一规则。 - 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 - 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index bf0f499e..abdad1fa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、点赞 / 改造 / 启动意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射与启动 Adapter 的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 +统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、编辑 / 点赞 / 改造 / 启动意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射与启动 / 编辑 Adapter 的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md index 3160a28d..37571dab 100644 --- a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md @@ -11,6 +11,7 @@ - `getPlatformPublicWorkDetailKind(entry)` - `resolvePlatformPublicWorkDetailOpenStrategy(entry)` - `resolvePlatformPublicWorkActionMode(entry, viewerUserId)` + - `resolvePlatformPublicWorkEditIntent(entry, deps)` - `resolvePlatformPublicWorkLikeIntent(entry)` - `resolvePlatformPublicWorkRemixIntent(entry)` - `resolvePlatformPublicWorkStartIntent(entry, deps)` @@ -25,7 +26,7 @@ - `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter:根据 open strategy 调用 `openPublicWorkDetail`、`openPuzzlePublicWorkDetail`、`openJumpHopPublicWorkDetail`、`openWoodenFishPublicWorkDetail`、`openVisualNovelPublicWorkDetail` 或 `openRpgPublicWorkDetail`。 - 公开详情 entry 映射与公开详情反推玩法 work 摘要也收口到 Module。壳层只在运行态启动、编辑、改造、推荐缓存和详情展示时调用映射 Interface,不再在壳层顶部持有每个玩法的 DTO 拼装 Implementation。 - `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 处理素材归一和背景资产提升;`platformPublicWorkDetailFlow.ts` 不复制 Match3D 运行态素材规则。 -- 公开详情启动、点赞和改造只抽“意图” Interface,不把整个 callback 搬进 Module。壳层继续作为 Adapter 执行鉴权、API 调用、运行态启动、busy 状态、缓存同步、stage 切换和错误 setter,避免形成伪 Seam。 +- 公开详情启动、编辑、点赞和改造只抽“意图” Interface,不把整个 callback 搬进 Module。壳层继续作为 Adapter 执行鉴权、API 调用、运行态启动、草稿恢复、busy 状态、缓存同步、stage 切换和错误 setter,避免形成伪 Seam。 ## Interface 约束 @@ -35,6 +36,8 @@ - 大鱼吃小鱼、抓大鹅、方洞挑战、汪汪声浪、宝贝识物和其它可直接展示的公开 entry 返回 `use-entry` strategy。 - RPG 返回 `load-rpg-detail` strategy,由壳层 Adapter 继续调用 RPG 详情读取流程。 - `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id;当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`。 +- `resolvePlatformPublicWorkEditIntent` 只表达自有公开作品编辑意图:大鱼吃小鱼、拼图、抓大鹅、方洞挑战、视觉小说和汪汪声浪在能定位原草稿时返回对应 draft open 目标;跳一跳、敲木鱼和缺草稿作品返回原阻断文案;宝贝识物只返回需解析本地草稿的 intent;旧 RPG gallery fallback 只在完整 RPG 详情已补读且 profile 匹配时返回编辑 intent。壳层仍执行草稿恢复、宝贝识物异步草稿解析、RPG 编辑导航和错误展示。 +- `resolvePlatformPublicWorkEditIntent` 的 `deps` 只接编辑决策所需的当前拼图详情、当前 RPG 详情、视觉小说作品缓存、汪汪声浪作品缓存,以及抓大鹅 public detail -> work 的 Adapter。抓大鹅 Adapter 必须来自 Match3D Runtime Profile Module,以保留 `generatedItemAssets` 归一化与背景资产提升的 Locality。 - `resolvePlatformPublicWorkLikeIntent` 只表达公开作品点赞意图:大鱼吃小鱼、拼图和旧 RPG gallery fallback 返回可执行 intent;宝贝识物、汪汪声浪、方洞挑战和视觉小说返回不可用文案。壳层只按 intent 调用 API、写缓存和展示错误,不再持有这组能力矩阵。 - `resolvePlatformPublicWorkRemixIntent` 只表达公开作品改造意图:大鱼吃小鱼和拼图返回可执行 intent 与成功后目标 stage,旧 RPG gallery fallback 返回可执行 intent,其它玩法返回原未开放文案。壳层只按 intent 调用 remix API、写 session / 缓存、切 stage 和展示错误。 - `resolvePlatformPublicWorkStartIntent` 只表达公开作品“开始游玩”意图:大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪和宝贝识物返回对应启动目标;旧 RPG gallery fallback 只在完整 RPG 详情已补读且 profile 匹配时返回记录游玩 intent,否则返回原阻断文案。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示。 @@ -48,7 +51,7 @@ ## Depth / Leverage / Locality -- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id、当前拼图 run 或少量启动 deps,即可得到详情打开策略、动作模式、点赞 / 改造 / 启动意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。 +- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id、当前拼图 run 或少量启动 / 编辑 deps,即可得到详情打开策略、动作模式、编辑 / 点赞 / 改造 / 启动意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。 - **Leverage**:新增玩法公开详情时先补 Strategy / Mapping 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断或 DTO 回填。 - **Locality**:公开作品详情入口的纯策略与通用映射集中到一个小 Module;Match3D 素材归一仍在 Match3D Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index b3474814..a828c87d 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -532,6 +532,7 @@ import { resolvePlatformPublicWorkActionMode, resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, + resolvePlatformPublicWorkEditIntent, resolvePlatformPublicWorkLikeIntent, resolvePlatformPublicWorkRemixIntent, resolvePlatformPublicWorkStartIntent, @@ -13513,117 +13514,65 @@ export function PlatformEntryFlowShellImpl({ runProtectedAction(async () => { setPublicWorkDetailError(null); - // 中文注释:自有公开作品必须恢复原草稿,不能复用 remix 复制链路。 - if (isBigFishGalleryEntry(entry)) { - const work = mapPublicWorkDetailToBigFishWork(entry); - if (!work?.sourceSessionId?.trim()) { - setPublicWorkDetailError( - '这份大鱼吃小鱼作品缺少原草稿会话,暂时无法编辑。', + const intent = resolvePlatformPublicWorkEditIntent(entry, { + selectedPuzzleDetail, + selectedRpgDetailEntry: selectedDetailEntry, + visualNovelWorks, + barkBattleGalleryEntries, + barkBattleWorks, + mapMatch3DWork: mapPublicWorkDetailToMatch3DWork, + }); + + switch (intent.type) { + case 'blocked': + setPublicWorkDetailError(intent.errorMessage); + return; + case 'edit-big-fish': + void openBigFishDraft(intent.work); + return; + case 'edit-puzzle': + void openPuzzleDraft(intent.work); + return; + case 'edit-match3d': + void openMatch3DDraft(intent.work, { + forceDraft: intent.forceDraft, + }); + return; + case 'edit-square-hole': + void openSquareHoleDraft(intent.work, { + forceDraft: intent.forceDraft, + }); + return; + case 'edit-visual-novel': + void openVisualNovelDraft(intent.work, { + forceDraft: intent.forceDraft, + }); + return; + case 'resolve-edutainment-draft': { + const matchedDraft = await resolveBabyObjectMatchRuntimeDraft( + intent.entry, ); + if (!matchedDraft) { + setPublicWorkDetailError('这份宝贝识物缺少可编辑草稿。'); + return; + } + + openBabyObjectMatchDraft(matchedDraft); return; } - void openBigFishDraft(work); - return; - } - - if (isPuzzleGalleryEntry(entry)) { - const work = - selectedPuzzleDetail?.profileId === entry.profileId - ? selectedPuzzleDetail - : mapPublicWorkDetailToPuzzleWork(entry); - if (!work?.sourceSessionId?.trim()) { - setPublicWorkDetailError( - '这份拼图作品缺少原草稿会话,暂时无法编辑。', - ); + case 'edit-bark-battle': + openBarkBattleDraft(intent.work, { + forceDraft: intent.forceDraft, + }); return; - } - void openPuzzleDraft(work); - return; - } - - if (isMatch3DGalleryEntry(entry)) { - const work = mapPublicWorkDetailToMatch3DWork(entry); - if (!work?.sourceSessionId?.trim()) { - setPublicWorkDetailError( - '这份抓大鹅作品缺少原草稿会话,暂时无法编辑。', - ); + case 'edit-rpg-gallery': + void detailNavigation.openSavedCustomWorldEditor(intent.entry); return; + default: { + const exhaustive: never = intent; + return exhaustive; } - void openMatch3DDraft(work, { forceDraft: true }); - return; } - - if (isSquareHoleGalleryEntry(entry)) { - const work = mapPublicWorkDetailToSquareHoleWork(entry); - if (!work?.sourceSessionId?.trim()) { - setPublicWorkDetailError( - '这份方洞挑战作品缺少原草稿会话,暂时无法编辑。', - ); - return; - } - void openSquareHoleDraft(work, { forceDraft: true }); - return; - } - - if (isJumpHopGalleryEntry(entry)) { - setPublicWorkDetailError('这份跳一跳作品暂时请从作品架编辑。'); - return; - } - - if (isWoodenFishGalleryEntry(entry)) { - setPublicWorkDetailError('这份敲木鱼作品暂时请从作品架编辑。'); - return; - } - - if (isVisualNovelGalleryEntry(entry)) { - const matchedWork = visualNovelWorks.find( - (work) => work.profileId === entry.profileId, - ); - if (!matchedWork) { - setPublicWorkDetailError('这份视觉小说缺少可编辑草稿。'); - return; - } - void openVisualNovelDraft(matchedWork, { forceDraft: true }); - return; - } - - if (isEdutainmentGalleryEntry(entry)) { - const matchedDraft = await resolveBabyObjectMatchRuntimeDraft(entry); - if (!matchedDraft) { - setPublicWorkDetailError('这份宝贝识物缺少可编辑草稿。'); - return; - } - - openBabyObjectMatchDraft(matchedDraft); - return; - } - - if (isBarkBattleGalleryEntry(entry)) { - const matchedWork = - barkBattleWorks.find((work) => work.workId === entry.workId) ?? - barkBattleGalleryEntries.find( - (work) => work.workId === entry.workId, - ) ?? - mapBarkBattlePublicDetailToWorkSummary(entry); - if (!matchedWork?.draftId?.trim()) { - setPublicWorkDetailError('这份汪汪声浪缺少可编辑草稿。'); - return; - } - - openBarkBattleDraft(matchedWork, { forceDraft: true }); - return; - } - - const editEntry = - selectedDetailEntry?.profileId === entry.profileId - ? selectedDetailEntry - : null; - if (!editEntry) { - setPublicWorkDetailError('作品详情尚未读取完成。'); - return; - } - - void detailNavigation.openSavedCustomWorldEditor(editEntry); }); }, [ diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts index 50886cd2..d8d40e0c 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts @@ -6,10 +6,14 @@ import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/co import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; -import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, +} from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish'; +import type { CustomWorldProfile } from '../../types'; import { EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, @@ -31,11 +35,13 @@ import { mapWoodenFishWorkToPublicWorkDetail, type PlatformPublicWorkDetailKind, type PlatformPublicWorkDetailOpenStrategy, + type PlatformPublicWorkEditIntentDeps, type PlatformPublicWorkStartIntentDeps, resolveActivePlatformPublicWorkAuthorEntry, resolvePlatformPublicWorkActionMode, resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, + resolvePlatformPublicWorkEditIntent, resolvePlatformPublicWorkLikeIntent, resolvePlatformPublicWorkRemixIntent, resolvePlatformPublicWorkStartIntent, @@ -88,6 +94,18 @@ function buildRpgEntry( }; } +function buildRpgLibraryEntry( + overrides: Partial> = {}, +): CustomWorldLibraryEntry { + return { + ...buildRpgEntry(overrides), + profile: { + id: overrides.profileId ?? 'rpg-profile', + } as unknown as CustomWorldProfile, + ...overrides, + }; +} + function buildTypedEntry( sourceType: TSourceType, overrides: TypedPlatformPublicGalleryCardOverrides = {}, @@ -410,6 +428,20 @@ function buildStartIntentDeps( }; } +function buildEditIntentDeps( + overrides: Partial = {}, +): PlatformPublicWorkEditIntentDeps { + return { + selectedPuzzleDetail: null, + selectedRpgDetailEntry: null, + visualNovelWorks: [], + barkBattleGalleryEntries: [], + barkBattleWorks: [], + mapMatch3DWork: () => buildMatch3DWork(), + ...overrides, + }; +} + test('platform public work detail flow resolves detail kind for every play kind', () => { const cases: Array< [sourceType: PlatformGallerySourceType, kind: PlatformPublicWorkDetailKind] @@ -435,7 +467,7 @@ test('platform public work detail flow resolves detail kind for every play kind' }); test('platform public work detail flow resolves open strategy', () => { - const rpgEntry = buildRpgEntry(); + const rpgEntry = buildRpgLibraryEntry(); const cases: Array< [ entry: PlatformPublicGalleryCard, @@ -522,7 +554,7 @@ test('platform public work detail flow resolves open strategy', () => { }); test('platform public work detail flow maps work summaries to detail entries', () => { - const rpgEntry = buildRpgEntry(); + const rpgEntry = buildRpgLibraryEntry(); expect(mapRpgGalleryCardToPublicWorkDetail(rpgEntry)).toBe(rpgEntry); expect(mapPuzzleWorkToPublicWorkDetail(buildPuzzleWork())).toMatchObject({ @@ -843,6 +875,205 @@ test('platform public work detail flow resolves remix intent', () => { }); }); +test('platform public work detail flow resolves edit intent for draft-backed works', () => { + const bigFishEntry = buildTypedEntry('big-fish'); + expect(resolvePlatformPublicWorkEditIntent(bigFishEntry, buildEditIntentDeps())) + .toEqual({ + type: 'edit-big-fish', + work: mapPublicWorkDetailToBigFishWork(bigFishEntry), + }); + + const selectedPuzzleDetail = buildPuzzleWork({ + profileId: 'puzzle-profile', + sourceSessionId: 'selected-puzzle-session', + }); + expect( + resolvePlatformPublicWorkEditIntent( + buildTypedEntry('puzzle'), + buildEditIntentDeps({ selectedPuzzleDetail }), + ), + ).toEqual({ + type: 'edit-puzzle', + work: selectedPuzzleDetail, + }); + + const puzzleEntry = buildTypedEntry('puzzle', { + profileId: 'fallback-puzzle-profile', + sourceSessionId: 'fallback-puzzle-session', + }); + expect( + resolvePlatformPublicWorkEditIntent( + puzzleEntry, + buildEditIntentDeps({ + selectedPuzzleDetail: buildPuzzleWork({ profileId: 'stale-profile' }), + }), + ), + ).toEqual({ + type: 'edit-puzzle', + work: mapPublicWorkDetailToPuzzleWork(puzzleEntry), + }); + + expect( + resolvePlatformPublicWorkEditIntent( + buildTypedEntry('puzzle', { sourceSessionId: null }), + buildEditIntentDeps(), + ), + ).toEqual({ + type: 'blocked', + errorMessage: '这份拼图作品缺少原草稿会话,暂时无法编辑。', + }); +}); + +test('platform public work detail flow resolves edit intent for mapper-backed works', () => { + const match3DEntry = buildTypedEntry('match3d'); + const match3DWork = buildMatch3DWork({ workId: 'editable-match3d-work' }); + expect( + resolvePlatformPublicWorkEditIntent( + match3DEntry, + buildEditIntentDeps({ + mapMatch3DWork: (entry) => + entry === match3DEntry ? match3DWork : null, + }), + ), + ).toEqual({ + type: 'edit-match3d', + work: match3DWork, + forceDraft: true, + }); + expect( + resolvePlatformPublicWorkEditIntent( + match3DEntry, + buildEditIntentDeps({ + mapMatch3DWork: () => buildMatch3DWork({ sourceSessionId: ' ' }), + }), + ), + ).toEqual({ + type: 'blocked', + errorMessage: '这份抓大鹅作品缺少原草稿会话,暂时无法编辑。', + }); + + const squareHoleEntry = buildTypedEntry('square-hole', { + sourceSessionId: 'square-hole-session', + }); + expect( + resolvePlatformPublicWorkEditIntent(squareHoleEntry, buildEditIntentDeps()), + ).toEqual({ + type: 'edit-square-hole', + work: mapPublicWorkDetailToSquareHoleWork(squareHoleEntry), + forceDraft: true, + }); + expect( + resolvePlatformPublicWorkEditIntent( + buildTypedEntry('square-hole', { sourceSessionId: null }), + buildEditIntentDeps(), + ), + ).toEqual({ + type: 'blocked', + errorMessage: '这份方洞挑战作品缺少原草稿会话,暂时无法编辑。', + }); +}); + +test('platform public work detail flow resolves edit intent for cached work lookups', () => { + const visualNovelWork = buildVisualNovelWork(); + expect( + resolvePlatformPublicWorkEditIntent( + buildTypedEntry('visual-novel'), + buildEditIntentDeps({ visualNovelWorks: [visualNovelWork] }), + ), + ).toEqual({ + type: 'edit-visual-novel', + work: visualNovelWork, + forceDraft: true, + }); + expect( + resolvePlatformPublicWorkEditIntent( + buildTypedEntry('visual-novel'), + buildEditIntentDeps(), + ), + ).toEqual({ + type: 'blocked', + errorMessage: '这份视觉小说缺少可编辑草稿。', + }); + + const entry = buildTypedEntry('bark-battle'); + const galleryWork = buildBarkBattleWork({ + workId: 'bark-battle-work', + draftId: 'gallery-draft', + }); + const loadedWork = buildBarkBattleWork({ + workId: 'bark-battle-work', + draftId: 'loaded-draft', + }); + expect( + resolvePlatformPublicWorkEditIntent( + entry, + buildEditIntentDeps({ + barkBattleGalleryEntries: [galleryWork], + barkBattleWorks: [loadedWork], + }), + ), + ).toEqual({ + type: 'edit-bark-battle', + work: loadedWork, + forceDraft: true, + }); + expect( + resolvePlatformPublicWorkEditIntent( + buildTypedEntry('bark-battle', { sourceSessionId: null }), + buildEditIntentDeps(), + ), + ).toEqual({ + type: 'blocked', + errorMessage: '这份汪汪声浪缺少可编辑草稿。', + }); +}); + +test('platform public work detail flow resolves edit intent for unsupported and deferred works', () => { + expect( + resolvePlatformPublicWorkEditIntent( + buildTypedEntry('jump-hop'), + buildEditIntentDeps(), + ), + ).toEqual({ + type: 'blocked', + errorMessage: '这份跳一跳作品暂时请从作品架编辑。', + }); + expect( + resolvePlatformPublicWorkEditIntent( + buildTypedEntry('wooden-fish'), + buildEditIntentDeps(), + ), + ).toEqual({ + type: 'blocked', + errorMessage: '这份敲木鱼作品暂时请从作品架编辑。', + }); + + const edutainmentEntry = buildTypedEntry('edutainment'); + expect( + resolvePlatformPublicWorkEditIntent(edutainmentEntry, buildEditIntentDeps()), + ).toEqual({ + type: 'resolve-edutainment-draft', + entry: edutainmentEntry, + }); + + const rpgEntry = buildRpgLibraryEntry(); + expect( + resolvePlatformPublicWorkEditIntent( + rpgEntry, + buildEditIntentDeps({ selectedRpgDetailEntry: rpgEntry }), + ), + ).toEqual({ + type: 'edit-rpg-gallery', + entry: rpgEntry, + }); + expect( + resolvePlatformPublicWorkEditIntent(rpgEntry, buildEditIntentDeps()), + ).toEqual({ + type: 'blocked', + errorMessage: '作品详情尚未读取完成。', + }); +}); + test('platform public work detail flow resolves start intent for direct launches', () => { const bigFishEntry = buildTypedEntry('big-fish'); expect( diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.ts index 2dc79d32..9bcb4c0f 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.ts @@ -7,7 +7,10 @@ import type { import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; -import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, +} from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; import type { @@ -15,6 +18,7 @@ import type { WoodenFishWorkProfileResponse, } from '../../../packages/shared/src/contracts/woodenFish'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; +import type { CustomWorldProfile } from '../../types'; import { isBarkBattleGalleryEntry, isBigFishGalleryEntry, @@ -51,6 +55,10 @@ export type PlatformPublicWorkDetailKind = | 'visual-novel' | 'wooden-fish'; +export type PlatformRpgPublicWorkDetailEntry = + | CustomWorldGalleryCard + | CustomWorldLibraryEntry; + export type PlatformPublicWorkDetailOpenStrategy = | { type: 'use-entry'; @@ -77,7 +85,7 @@ export type PlatformPublicWorkDetailOpenStrategy = } | { type: 'load-rpg-detail'; - entry: CustomWorldGalleryCard; + entry: PlatformRpgPublicWorkDetailEntry; }; export type PlatformPublicWorkActionMode = 'edit' | 'remix'; @@ -122,6 +130,59 @@ export type PlatformPublicWorkRemixIntent = errorMessage: string; }; +export type PlatformPublicWorkEditIntent = + | { + type: 'blocked'; + errorMessage: string; + } + | { + type: 'edit-big-fish'; + work: BigFishWorkSummary; + } + | { + type: 'edit-puzzle'; + work: PuzzleWorkSummary; + } + | { + type: 'edit-match3d'; + work: Match3DWorkSummary; + forceDraft: true; + } + | { + type: 'edit-square-hole'; + work: SquareHoleWorkSummary; + forceDraft: true; + } + | { + type: 'edit-visual-novel'; + work: VisualNovelWorkSummary; + forceDraft: true; + } + | { + type: 'resolve-edutainment-draft'; + entry: PlatformPublicGalleryCard; + } + | { + type: 'edit-bark-battle'; + work: BarkBattleWorkSummary; + forceDraft: true; + } + | { + type: 'edit-rpg-gallery'; + entry: CustomWorldLibraryEntry; + }; + +export type PlatformPublicWorkEditIntentDeps = { + selectedPuzzleDetail?: PuzzleWorkSummary | null; + selectedRpgDetailEntry?: PlatformRpgPublicWorkDetailEntry | null; + visualNovelWorks?: readonly VisualNovelWorkSummary[]; + barkBattleGalleryEntries?: readonly BarkBattleWorkSummary[]; + barkBattleWorks?: readonly BarkBattleWorkSummary[]; + mapMatch3DWork: ( + entry: PlatformPublicGalleryCard, + ) => Match3DWorkSummary | null; +}; + export type PlatformPublicWorkStartIntent = | { type: 'blocked'; @@ -175,12 +236,12 @@ export type PlatformPublicWorkStartIntent = } | { type: 'record-rpg-gallery-play'; - entry: CustomWorldGalleryCard; + entry: PlatformRpgPublicWorkDetailEntry; }; export type PlatformPublicWorkStartIntentDeps = { selectedPuzzleDetail?: PuzzleWorkSummary | null; - selectedRpgDetailEntry?: CustomWorldGalleryCard | null; + selectedRpgDetailEntry?: PlatformRpgPublicWorkDetailEntry | null; barkBattleGalleryEntries?: readonly BarkBattleWorkSummary[]; barkBattleWorks?: readonly BarkBattleWorkSummary[]; mapMatch3DWork: ( @@ -213,21 +274,27 @@ export type PlatformPublicWorkDetailOpenDecisionDeps = { export type ActivePlatformPublicWorkAuthorEntryInput = { selectionStage: string; selectedPublicWorkDetail: PlatformPublicGalleryCard | null; - selectedRpgDetailEntry: CustomWorldGalleryCard | null; + selectedRpgDetailEntry: PlatformRpgPublicWorkDetailEntry | null; }; export function isRpgPublicWorkDetailEntry( entry: PlatformPublicGalleryCard, -): entry is CustomWorldGalleryCard { +): entry is PlatformRpgPublicWorkDetailEntry { return !('sourceType' in entry); } export function mapRpgGalleryCardToPublicWorkDetail( - entry: CustomWorldGalleryCard, + entry: PlatformRpgPublicWorkDetailEntry, ): PlatformPublicGalleryCard { return entry; } +function isRpgPublicWorkLibraryEntry( + entry: PlatformRpgPublicWorkDetailEntry | null | undefined, +): entry is CustomWorldLibraryEntry { + return Boolean(entry && 'profile' in entry); +} + export function mapPuzzleWorkToPublicWorkDetail( item: PuzzleWorkSummary, ): PlatformPublicGalleryCard { @@ -689,6 +756,154 @@ export function resolvePlatformPublicWorkRemixIntent( }; } +export function resolvePlatformPublicWorkEditIntent( + entry: PlatformPublicGalleryCard, + deps: PlatformPublicWorkEditIntentDeps, +): PlatformPublicWorkEditIntent { + if (isBigFishGalleryEntry(entry)) { + const work = mapPublicWorkDetailToBigFishWork(entry); + if (!work?.sourceSessionId?.trim()) { + return { + type: 'blocked', + errorMessage: '这份大鱼吃小鱼作品缺少原草稿会话,暂时无法编辑。', + }; + } + + return { + type: 'edit-big-fish', + work, + }; + } + + if (isPuzzleGalleryEntry(entry)) { + const work = + deps.selectedPuzzleDetail?.profileId === entry.profileId + ? deps.selectedPuzzleDetail + : mapPublicWorkDetailToPuzzleWork(entry); + if (!work?.sourceSessionId?.trim()) { + return { + type: 'blocked', + errorMessage: '这份拼图作品缺少原草稿会话,暂时无法编辑。', + }; + } + + return { + type: 'edit-puzzle', + work, + }; + } + + if (isMatch3DGalleryEntry(entry)) { + // 中文注释:抓大鹅草稿恢复仍复用 Match3D Module 的 public detail -> work Adapter。 + const work = deps.mapMatch3DWork(entry); + if (!work?.sourceSessionId?.trim()) { + return { + type: 'blocked', + errorMessage: '这份抓大鹅作品缺少原草稿会话,暂时无法编辑。', + }; + } + + return { + type: 'edit-match3d', + work, + forceDraft: true, + }; + } + + if (isSquareHoleGalleryEntry(entry)) { + const work = mapPublicWorkDetailToSquareHoleWork(entry); + if (!work?.sourceSessionId?.trim()) { + return { + type: 'blocked', + errorMessage: '这份方洞挑战作品缺少原草稿会话,暂时无法编辑。', + }; + } + + return { + type: 'edit-square-hole', + work, + forceDraft: true, + }; + } + + if (isJumpHopGalleryEntry(entry)) { + return { + type: 'blocked', + errorMessage: '这份跳一跳作品暂时请从作品架编辑。', + }; + } + + if (isWoodenFishGalleryEntry(entry)) { + return { + type: 'blocked', + errorMessage: '这份敲木鱼作品暂时请从作品架编辑。', + }; + } + + if (isVisualNovelGalleryEntry(entry)) { + const work = + deps.visualNovelWorks?.find((item) => item.profileId === entry.profileId) ?? + null; + if (!work) { + return { + type: 'blocked', + errorMessage: '这份视觉小说缺少可编辑草稿。', + }; + } + + return { + type: 'edit-visual-novel', + work, + forceDraft: true, + }; + } + + if (isEdutainmentGalleryEntry(entry)) { + return { + type: 'resolve-edutainment-draft', + entry, + }; + } + + if (isBarkBattleGalleryEntry(entry)) { + const work = + deps.barkBattleWorks?.find((item) => item.workId === entry.workId) ?? + deps.barkBattleGalleryEntries?.find( + (item) => item.workId === entry.workId, + ) ?? + mapBarkBattlePublicDetailToWorkSummary(entry); + if (!work?.draftId?.trim()) { + return { + type: 'blocked', + errorMessage: '这份汪汪声浪缺少可编辑草稿。', + }; + } + + return { + type: 'edit-bark-battle', + work, + forceDraft: true, + }; + } + + const editEntry = + deps.selectedRpgDetailEntry?.profileId === entry.profileId && + isRpgPublicWorkLibraryEntry(deps.selectedRpgDetailEntry) + ? deps.selectedRpgDetailEntry + : null; + if (!editEntry) { + return { + type: 'blocked', + errorMessage: '作品详情尚未读取完成。', + }; + } + + return { + type: 'edit-rpg-gallery', + entry: editEntry, + }; +} + export function resolvePlatformPublicWorkStartIntent( entry: PlatformPublicGalleryCard, deps: PlatformPublicWorkStartIntentDeps, From 8d3e14020f51fd3ab0370f8c461b61986265c270 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 01:11:27 +0800 Subject: [PATCH 31/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E8=BF=90=E8=A1=8C=E6=80=81=E5=90=AF=E5=8A=A8=E6=84=8F?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 2 +- docs/README.md | 2 +- ...入口PublicGalleryFlowModule收口计划-2026-06-03.md | 14 +- .../PlatformEntryFlowShellImpl.tsx | 184 ++++++------ .../platformPublicGalleryFlow.test.ts | 279 ++++++++++++++++++ .../platformPublicGalleryFlow.ts | 232 +++++++++++++++ 6 files changed, 612 insertions(+), 101 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 4f7e0899..bab8893e 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -49,7 +49,7 @@ ## 2026-06-03 平台入口公开作品流身份规则收口 - 背景:平台入口公开作品推荐流需要同时处理 RPG、拼图、抓大鹅、跳一跳、敲木鱼、视觉小说、Bark Battle、宝贝识物等卡片,公开作品身份、跨玩法去重、排序和推荐运行态 kind 判定曾放在 `PlatformEntryFlowShellImpl.tsx` 巨型实现里。 -- 决策:公开作品身份和排序规则统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey`、`getPlatformRecommendRuntimeKind`、`isSamePlatformPublicGalleryEntry` 和 `mergePlatformPublicGalleryEntries`。`edutainment` key 必须带 `templateId`,RPG 卡片回退为 `rpg`。 +- 决策:公开作品身份、排序规则和推荐 runtime 启动意图统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey`、`getPlatformRecommendRuntimeKind`、`resolvePlatformRecommendRuntimeStartIntent`、`isSamePlatformPublicGalleryEntry` 和 `mergePlatformPublicGalleryEntries`。`edutainment` key 必须带 `templateId`,RPG 卡片回退为 `rpg`。推荐 runtime 启动 intent 只返回启动目标、`embedded` / `returnStage` 参数、阻断文案和错误落点;壳层仍执行 request key、运行态 API、错误 setter 与 UI 状态。 - 影响范围:平台入口推荐流、公开作品详情、推荐 runtime 启动、跨玩法公开作品合并,以及后续新增玩法的入口接入。 - 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index abdad1fa..4dd4b85b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,7 +39,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`,事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。 -平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定、推荐 runtime 启动意图和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、编辑 / 点赞 / 改造 / 启动意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射与启动 / 编辑 Adapter 的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md index 5cb2ca6c..6b09dd89 100644 --- a/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md @@ -10,10 +10,11 @@ - `getPlatformPublicGalleryEntryKey(entry)`:按玩法类型、作者和 `profileId` 生成公开作品身份。 - `getPlatformRecommendRuntimeKind(entry)`:把公开作品卡映射为推荐运行态 kind。 +- `resolvePlatformRecommendRuntimeStartIntent(entry, deps)`:把公开作品卡映射为推荐 runtime 启动意图、错误落点和 embedded / returnStage 参数。 - `isSamePlatformPublicGalleryEntry(left, right)`:按公开作品身份比较。 - `mergePlatformPublicGalleryEntries(rpgEntries, puzzleEntries)`:统一完成 RPG 与各玩法公开作品去重、覆盖和倒序排序。 -入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份和排序规则。`isRecommendRuntimeReadyForEntry` 暂留入口壳层,因为它依赖各运行态 run 状态,直接抽出会把更多 runtime 状态类型拖入这个 Module,降低本次改造的 locality。 +入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份、排序规则和推荐 runtime 启动能力矩阵。`isRecommendRuntimeReadyForEntry` 暂留入口壳层,因为它依赖各运行态 run 状态,直接抽出会把更多 runtime 状态类型拖入这个 Module,降低本次改造的 locality。 ## 玩法身份规则 @@ -23,9 +24,18 @@ - 最终 key 格式为 `${kind}:${ownerUserId}:${profileId}`。 - 合并时后进入的相同 key 会覆盖先进入的卡片,然后按 `publishedAt ?? updatedAt` 新到旧排序;非法时间按 `0` 处理。 +## 推荐 runtime 启动意图 + +- `resolvePlatformRecommendRuntimeStartIntent` 只表达推荐 runtime 的启动目标,不执行鉴权、运行态 API、错误 setter、缓存、request key 或 UI 状态更新。 +- 大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪和宝贝识物返回对应启动 intent;RPG 维持当前无嵌入 runtime 的 `mark-ready` 行为。 +- 大鱼吃小鱼、拼图、抓大鹅、方洞挑战和汪汪声浪在公开卡无法拼出启动 work 时返回 `blocked`,同时给出 `errorTarget`,由壳层 Adapter 分发到对应玩法错误 setter。 +- 拼图优先使用同 `profileId` 的 `selectedPuzzleDetail`,否则从公开卡映射兼容 work 摘要。 +- 抓大鹅 public detail -> work mapper 必须作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护 `generatedItemAssets` 归一化与背景资产提升。推荐 runtime 固定沿用旧参数 `returnStage = 'work-detail'` 与 `embedded = true`。 +- 汪汪声浪优先使用推荐流已持有的 `barkBattleGalleryEntries`,再回退公开卡映射;不额外读取作品架列表。 + ## 后续深化 -下一步可继续把平台入口的作品架刷新、删除确认和直达恢复逻辑收口成更深的 Work Shelf **Module**。当前 `platformPublicGalleryFlow` 先提供一个稳定 seam,使公开作品 identity 与 runtime kind 的修改集中在一处。 +下一步可继续把平台入口的作品架刷新、删除确认和直达恢复逻辑收口成更深的 Work Shelf **Module**。当前 `platformPublicGalleryFlow` 先提供一个稳定 seam,使公开作品 identity、runtime kind 与推荐 runtime 启动意图的修改集中在一处。 ## 验证 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index a828c87d..3b9c34f6 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -361,15 +361,7 @@ import { selectAdjacentPlatformRecommendEntry, } from '../rpg-entry/rpgEntryPublicGalleryViewModel'; import { - isBarkBattleGalleryEntry, - isBigFishGalleryEntry, isEdutainmentGalleryEntry, - isJumpHopGalleryEntry, - isMatch3DGalleryEntry, - isPuzzleGalleryEntry, - isSquareHoleGalleryEntry, - isVisualNovelGalleryEntry, - isWoodenFishGalleryEntry, mapBabyObjectMatchDraftToPlatformGalleryCard, mapBarkBattleWorkToPlatformGalleryCard, mapBigFishWorkToPlatformGalleryCard, @@ -514,15 +506,12 @@ import { isSamePlatformPublicGalleryEntry, mergePlatformPublicGalleryEntries, type RecommendRuntimeKind, + resolvePlatformRecommendRuntimeStartIntent, } from './platformPublicGalleryFlow'; import { - mapBarkBattlePublicDetailToWorkSummary, mapBarkBattleWorkToPublicWorkDetail, mapBigFishWorkToPublicWorkDetail, mapJumpHopWorkToPublicWorkDetail, - mapPublicWorkDetailToBigFishWork, - mapPublicWorkDetailToPuzzleWork, - mapPublicWorkDetailToSquareHoleWork, mapPuzzleWorkToPublicWorkDetail, mapRpgGalleryCardToPublicWorkDetail, mapSquareHoleWorkToPublicWorkDetail, @@ -12762,98 +12751,99 @@ export function PlatformEntryFlowShellImpl({ try { let started = false; - if (isBigFishGalleryEntry(entry)) { - const work = mapPublicWorkDetailToBigFishWork(entry); - if (!work) { - setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); - } else { - started = await startBigFishRunFromWork(work, 'platform', { - embedded: true, + const intent = resolvePlatformRecommendRuntimeStartIntent(entry, { + selectedPuzzleDetail, + barkBattleGalleryEntries, + mapMatch3DWork: mapPublicWorkDetailToMatch3DWork, + }); + + switch (intent.type) { + case 'blocked': + if (intent.errorTarget === 'big-fish') { + setBigFishError(intent.errorMessage); + } else if (intent.errorTarget === 'puzzle') { + setPuzzleError(intent.errorMessage); + } else if (intent.errorTarget === 'match3d') { + setMatch3DError(intent.errorMessage); + } else if (intent.errorTarget === 'square-hole') { + setSquareHoleError(intent.errorMessage); + } else { + setBarkBattleError(intent.errorMessage); + } + break; + case 'start-big-fish': + started = await startBigFishRunFromWork(intent.work, 'platform', { + embedded: intent.embedded, }); - } - } else if (isPuzzleGalleryEntry(entry)) { - const work = - selectedPuzzleDetail?.profileId === entry.profileId - ? selectedPuzzleDetail - : mapPublicWorkDetailToPuzzleWork(entry); - if (!work) { - setPuzzleError('当前拼图作品信息不完整,暂时无法进入玩法。'); - } else { + break; + case 'start-puzzle': started = await startPuzzleRunFromProfile( - work.profileId, - 'platform', - work, + intent.work.profileId, + intent.returnStage, + intent.work, false, null, - { embedded: true }, + { embedded: intent.embedded }, ); - } - } else if (isJumpHopGalleryEntry(entry)) { - started = await startJumpHopRunFromProfile(entry.profileId, { - embedded: true, - returnStage: 'platform', - }); - } else if (isWoodenFishGalleryEntry(entry)) { - started = await startWoodenFishRunFromProfile(entry.profileId, { - embedded: true, - returnStage: 'platform', - }); - } else if (isMatch3DGalleryEntry(entry)) { - const work = mapPublicWorkDetailToMatch3DWork(entry); - if (!work) { - setMatch3DError('当前抓大鹅作品信息不完整,暂时无法进入玩法。'); - } else { - started = await startMatch3DRunFromProfile( - work, - 'work-detail', - false, - { embedded: true }, - ); - } - } else if (isSquareHoleGalleryEntry(entry)) { - const work = mapPublicWorkDetailToSquareHoleWork(entry); - if (!work) { - setSquareHoleError( - '当前方洞挑战作品信息不完整,暂时无法进入玩法。', - ); - } else { - started = await startSquareHoleRunFromProfile( - work, - 'platform', - false, - { embedded: true }, - ); - } - } else if (isVisualNovelGalleryEntry(entry)) { - started = await startVisualNovelRunFromProfile( - entry.profileId, - 'platform', - { embedded: true }, - ); - } else if (isBarkBattleGalleryEntry(entry)) { - const work = - barkBattleGalleryEntries.find( - (item) => item.workId === entry.workId, - ) ?? mapBarkBattlePublicDetailToWorkSummary(entry); - if (!work) { - setBarkBattleError( - '当前汪汪声浪作品信息不完整,暂时无法进入玩法。', - ); - } else { - started = await startBarkBattleRunFromWork(work, 'platform', { - embedded: true, + break; + case 'start-jump-hop': + started = await startJumpHopRunFromProfile(intent.profileId, { + embedded: intent.embedded, + returnStage: intent.returnStage, }); + break; + case 'start-wooden-fish': + started = await startWoodenFishRunFromProfile(intent.profileId, { + embedded: intent.embedded, + returnStage: intent.returnStage, + }); + break; + case 'start-match3d': + started = await startMatch3DRunFromProfile( + intent.work, + intent.returnStage, + false, + { embedded: intent.embedded }, + ); + break; + case 'start-square-hole': + started = await startSquareHoleRunFromProfile( + intent.work, + intent.returnStage, + false, + { embedded: intent.embedded }, + ); + break; + case 'start-visual-novel': + started = await startVisualNovelRunFromProfile( + intent.profileId, + intent.returnStage, + { embedded: intent.embedded }, + ); + break; + case 'start-bark-battle': + started = await startBarkBattleRunFromWork( + intent.work, + intent.returnStage, + { embedded: intent.embedded }, + ); + break; + case 'start-edutainment': + started = await startBabyObjectMatchRuntimeFromEntry( + intent.entry, + intent.returnStage, + { + embedded: intent.embedded, + }, + ); + break; + case 'mark-ready': + started = true; + break; + default: { + const exhaustive: never = intent; + return exhaustive; } - } else if (isEdutainmentGalleryEntry(entry)) { - started = await startBabyObjectMatchRuntimeFromEntry( - entry, - 'platform', - { - embedded: true, - }, - ); - } else { - started = true; } if (!isCurrentStartRequest()) { diff --git a/src/components/platform-entry/platformPublicGalleryFlow.test.ts b/src/components/platform-entry/platformPublicGalleryFlow.test.ts index 7003243c..410c601c 100644 --- a/src/components/platform-entry/platformPublicGalleryFlow.test.ts +++ b/src/components/platform-entry/platformPublicGalleryFlow.test.ts @@ -1,5 +1,8 @@ import { expect, test } from 'vitest'; +import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; import { EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, @@ -12,8 +15,16 @@ import { getPlatformRecommendRuntimeKind, isSamePlatformPublicGalleryEntry, mergePlatformPublicGalleryEntries, + type PlatformRecommendRuntimeStartIntentDeps, type RecommendRuntimeKind, + resolvePlatformRecommendRuntimeStartIntent, } from './platformPublicGalleryFlow'; +import { + mapBarkBattlePublicDetailToWorkSummary, + mapPublicWorkDetailToBigFishWork, + mapPublicWorkDetailToPuzzleWork, + mapPublicWorkDetailToSquareHoleWork, +} from './platformPublicWorkDetailFlow'; type TypedPlatformPublicGalleryCard = Extract< PlatformPublicGalleryCard, @@ -109,6 +120,99 @@ function buildTypedEntry( } } +function buildPuzzleWork( + overrides: Partial = {}, +): PuzzleWorkSummary { + return { + workId: 'puzzle-work', + profileId: 'puzzle-profile', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session', + authorDisplayName: '玩家', + levelName: '拼图作品', + summary: '拼图摘要', + themeTags: ['拼图'], + coverImageSrc: '/puzzle-cover.png', + publicationStatus: 'published', + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + playCount: 3, + remixCount: 2, + likeCount: 1, + pointIncentiveTotalHalfPoints: 0, + pointIncentiveClaimedPoints: 0, + pointIncentiveTotalPoints: 0, + pointIncentiveClaimablePoints: 0, + publishReady: true, + ...overrides, + }; +} + +function buildMatch3DWork( + overrides: Partial = {}, +): Match3DWorkSummary { + return { + workId: 'match3d-work', + profileId: 'match3d-profile', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session', + gameName: '抓大鹅作品', + themeText: '经典消除', + summary: '抓大鹅摘要', + tags: ['抓大鹅'], + coverImageSrc: '/match3d-cover.png', + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'published', + playCount: 10, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + publishReady: true, + generatedItemAssets: [], + ...overrides, + }; +} + +function buildBarkBattleWork( + overrides: Partial = {}, +): BarkBattleWorkSummary { + return { + workId: 'bark-battle-work', + draftId: 'bark-battle-draft', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + title: '汪汪声浪作品', + summary: '汪汪摘要', + themeDescription: '森林擂台', + playerImageDescription: '小狗', + opponentImageDescription: '对手', + playerCharacterImageSrc: '/player.png', + opponentCharacterImageSrc: '/opponent.png', + uiBackgroundImageSrc: '/bark-bg.png', + difficultyPreset: 'normal', + status: 'published', + generationStatus: 'ready', + publishReady: true, + playCount: 9, + recentPlayCount7d: 2, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + ...overrides, + }; +} + +function buildRecommendRuntimeStartDeps( + overrides: Partial = {}, +): PlatformRecommendRuntimeStartIntentDeps { + return { + selectedPuzzleDetail: null, + barkBattleGalleryEntries: [], + mapMatch3DWork: () => buildMatch3DWork(), + ...overrides, + }; +} + test('platform public gallery flow resolves stable key and runtime kind for every play kind', () => { const cases: Array< [sourceType: PlatformGallerySourceType, keyKind: string, kind: RecommendRuntimeKind] @@ -160,6 +264,181 @@ test('platform public gallery flow compares entries by resolved identity', () => expect(isSamePlatformPublicGalleryEntry(left, otherKind)).toBe(false); }); +test('platform public gallery flow resolves recommend runtime start intent', () => { + const bigFishEntry = buildTypedEntry('big-fish'); + expect( + resolvePlatformRecommendRuntimeStartIntent( + bigFishEntry, + buildRecommendRuntimeStartDeps(), + ), + ).toEqual({ + type: 'start-big-fish', + work: mapPublicWorkDetailToBigFishWork(bigFishEntry), + returnStage: 'platform', + embedded: true, + }); + + const selectedPuzzleDetail = buildPuzzleWork({ + profileId: 'puzzle-profile', + }); + expect( + resolvePlatformRecommendRuntimeStartIntent( + buildTypedEntry('puzzle'), + buildRecommendRuntimeStartDeps({ selectedPuzzleDetail }), + ), + ).toEqual({ + type: 'start-puzzle', + work: selectedPuzzleDetail, + returnStage: 'platform', + embedded: true, + }); + + const puzzleEntry = buildTypedEntry('puzzle', { + profileId: 'fallback-puzzle-profile', + }); + expect( + resolvePlatformRecommendRuntimeStartIntent( + puzzleEntry, + buildRecommendRuntimeStartDeps({ + selectedPuzzleDetail: buildPuzzleWork({ profileId: 'stale-profile' }), + }), + ), + ).toEqual({ + type: 'start-puzzle', + work: mapPublicWorkDetailToPuzzleWork(puzzleEntry), + returnStage: 'platform', + embedded: true, + }); + + expect( + resolvePlatformRecommendRuntimeStartIntent( + buildTypedEntry('jump-hop'), + buildRecommendRuntimeStartDeps(), + ), + ).toEqual({ + type: 'start-jump-hop', + profileId: 'jump-hop-profile', + returnStage: 'platform', + embedded: true, + }); + expect( + resolvePlatformRecommendRuntimeStartIntent( + buildTypedEntry('wooden-fish'), + buildRecommendRuntimeStartDeps(), + ), + ).toEqual({ + type: 'start-wooden-fish', + profileId: 'wooden-fish-profile', + returnStage: 'platform', + embedded: true, + }); + expect( + resolvePlatformRecommendRuntimeStartIntent( + buildTypedEntry('visual-novel'), + buildRecommendRuntimeStartDeps(), + ), + ).toEqual({ + type: 'start-visual-novel', + profileId: 'visual-novel-profile', + returnStage: 'platform', + embedded: true, + }); + expect( + resolvePlatformRecommendRuntimeStartIntent( + buildTypedEntry('edutainment'), + buildRecommendRuntimeStartDeps(), + ), + ).toEqual({ + type: 'start-edutainment', + entry: buildTypedEntry('edutainment'), + returnStage: 'platform', + embedded: true, + }); + expect( + resolvePlatformRecommendRuntimeStartIntent( + buildRpgEntry(), + buildRecommendRuntimeStartDeps(), + ), + ).toEqual({ + type: 'mark-ready', + }); +}); + +test('platform public gallery flow resolves recommend runtime mapper-backed start intent', () => { + const match3DEntry = buildTypedEntry('match3d'); + const match3DWork = buildMatch3DWork({ workId: 'mapped-match3d-work' }); + expect( + resolvePlatformRecommendRuntimeStartIntent( + match3DEntry, + buildRecommendRuntimeStartDeps({ + mapMatch3DWork: (entry) => + entry === match3DEntry ? match3DWork : null, + }), + ), + ).toEqual({ + type: 'start-match3d', + work: match3DWork, + returnStage: 'work-detail', + embedded: true, + }); + expect( + resolvePlatformRecommendRuntimeStartIntent( + match3DEntry, + buildRecommendRuntimeStartDeps({ mapMatch3DWork: () => null }), + ), + ).toEqual({ + type: 'blocked', + errorTarget: 'match3d', + errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。', + }); + + const squareHoleEntry = buildTypedEntry('square-hole'); + expect( + resolvePlatformRecommendRuntimeStartIntent( + squareHoleEntry, + buildRecommendRuntimeStartDeps(), + ), + ).toEqual({ + type: 'start-square-hole', + work: mapPublicWorkDetailToSquareHoleWork(squareHoleEntry), + returnStage: 'platform', + embedded: true, + }); +}); + +test('platform public gallery flow resolves recommend runtime bark battle priority', () => { + const entry = buildTypedEntry('bark-battle'); + const galleryWork = buildBarkBattleWork({ + workId: 'bark-battle-work', + title: '推荐缓存', + }); + + expect( + resolvePlatformRecommendRuntimeStartIntent( + entry, + buildRecommendRuntimeStartDeps({ + barkBattleGalleryEntries: [galleryWork], + }), + ), + ).toEqual({ + type: 'start-bark-battle', + work: galleryWork, + returnStage: 'platform', + embedded: true, + }); + expect( + resolvePlatformRecommendRuntimeStartIntent( + entry, + buildRecommendRuntimeStartDeps(), + ), + ).toEqual({ + type: 'start-bark-battle', + work: mapBarkBattlePublicDetailToWorkSummary(entry), + returnStage: 'platform', + embedded: true, + }); +}); + test('platform public gallery flow merges duplicate identities and sorts newest first', () => { const staleRpgEntry = buildRpgEntry({ profileId: 'shared-rpg', diff --git a/src/components/platform-entry/platformPublicGalleryFlow.ts b/src/components/platform-entry/platformPublicGalleryFlow.ts index 6a28e0c9..c8dd70e7 100644 --- a/src/components/platform-entry/platformPublicGalleryFlow.ts +++ b/src/components/platform-entry/platformPublicGalleryFlow.ts @@ -1,4 +1,9 @@ +import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import { isBarkBattleGalleryEntry, isBigFishGalleryEntry, @@ -11,6 +16,12 @@ import { isWoodenFishGalleryEntry, type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; +import { + mapBarkBattlePublicDetailToWorkSummary, + mapPublicWorkDetailToBigFishWork, + mapPublicWorkDetailToPuzzleWork, + mapPublicWorkDetailToSquareHoleWork, +} from './platformPublicWorkDetailFlow'; export type RecommendRuntimeKind = | 'bark-battle' @@ -24,6 +35,85 @@ export type RecommendRuntimeKind = | 'visual-novel' | 'rpg'; +export type PlatformRecommendRuntimeStartErrorTarget = + | 'bark-battle' + | 'big-fish' + | 'match3d' + | 'puzzle' + | 'square-hole'; + +export type PlatformRecommendRuntimeStartIntent = + | { + type: 'blocked'; + errorTarget: PlatformRecommendRuntimeStartErrorTarget; + errorMessage: string; + } + | { + type: 'start-big-fish'; + work: BigFishWorkSummary; + returnStage: 'platform'; + embedded: true; + } + | { + type: 'start-puzzle'; + work: PuzzleWorkSummary; + returnStage: 'platform'; + embedded: true; + } + | { + type: 'start-jump-hop'; + profileId: string; + returnStage: 'platform'; + embedded: true; + } + | { + type: 'start-wooden-fish'; + profileId: string; + returnStage: 'platform'; + embedded: true; + } + | { + type: 'start-match3d'; + work: Match3DWorkSummary; + returnStage: 'work-detail'; + embedded: true; + } + | { + type: 'start-square-hole'; + work: SquareHoleWorkSummary; + returnStage: 'platform'; + embedded: true; + } + | { + type: 'start-visual-novel'; + profileId: string; + returnStage: 'platform'; + embedded: true; + } + | { + type: 'start-bark-battle'; + work: BarkBattleWorkSummary; + returnStage: 'platform'; + embedded: true; + } + | { + type: 'start-edutainment'; + entry: PlatformPublicGalleryCard; + returnStage: 'platform'; + embedded: true; + } + | { + type: 'mark-ready'; + }; + +export type PlatformRecommendRuntimeStartIntentDeps = { + selectedPuzzleDetail?: PuzzleWorkSummary | null; + barkBattleGalleryEntries?: readonly BarkBattleWorkSummary[]; + mapMatch3DWork: ( + entry: PlatformPublicGalleryCard, + ) => Match3DWorkSummary | null; +}; + export function getPlatformPublicGalleryEntryTime( entry: PlatformPublicGalleryCard, ) { @@ -100,6 +190,148 @@ export function getPlatformRecommendRuntimeKind( return 'rpg'; } +export function resolvePlatformRecommendRuntimeStartIntent( + entry: PlatformPublicGalleryCard, + deps: PlatformRecommendRuntimeStartIntentDeps, +): PlatformRecommendRuntimeStartIntent { + if (isBigFishGalleryEntry(entry)) { + const work = mapPublicWorkDetailToBigFishWork(entry); + if (!work) { + return { + type: 'blocked', + errorTarget: 'big-fish', + errorMessage: '当前作品缺少会话信息,暂时无法进入玩法。', + }; + } + + return { + type: 'start-big-fish', + work, + returnStage: 'platform', + embedded: true, + }; + } + + if (isPuzzleGalleryEntry(entry)) { + const work = + deps.selectedPuzzleDetail?.profileId === entry.profileId + ? deps.selectedPuzzleDetail + : mapPublicWorkDetailToPuzzleWork(entry); + if (!work) { + return { + type: 'blocked', + errorTarget: 'puzzle', + errorMessage: '当前拼图作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-puzzle', + work, + returnStage: 'platform', + embedded: true, + }; + } + + if (isJumpHopGalleryEntry(entry)) { + return { + type: 'start-jump-hop', + profileId: entry.profileId, + returnStage: 'platform', + embedded: true, + }; + } + + if (isWoodenFishGalleryEntry(entry)) { + return { + type: 'start-wooden-fish', + profileId: entry.profileId, + returnStage: 'platform', + embedded: true, + }; + } + + if (isMatch3DGalleryEntry(entry)) { + // 中文注释:抓大鹅推荐 runtime 仍接 Match3D Module 的 Adapter,避免复制素材归一规则。 + const work = deps.mapMatch3DWork(entry); + if (!work) { + return { + type: 'blocked', + errorTarget: 'match3d', + errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-match3d', + work, + returnStage: 'work-detail', + embedded: true, + }; + } + + if (isSquareHoleGalleryEntry(entry)) { + const work = mapPublicWorkDetailToSquareHoleWork(entry); + if (!work) { + return { + type: 'blocked', + errorTarget: 'square-hole', + errorMessage: '当前方洞挑战作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-square-hole', + work, + returnStage: 'platform', + embedded: true, + }; + } + + if (isVisualNovelGalleryEntry(entry)) { + return { + type: 'start-visual-novel', + profileId: entry.profileId, + returnStage: 'platform', + embedded: true, + }; + } + + if (isBarkBattleGalleryEntry(entry)) { + const work = + deps.barkBattleGalleryEntries?.find( + (item) => item.workId === entry.workId, + ) ?? mapBarkBattlePublicDetailToWorkSummary(entry); + if (!work) { + return { + type: 'blocked', + errorTarget: 'bark-battle', + errorMessage: '当前汪汪声浪作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-bark-battle', + work, + returnStage: 'platform', + embedded: true, + }; + } + + if (isEdutainmentGalleryEntry(entry)) { + return { + type: 'start-edutainment', + entry, + returnStage: 'platform', + embedded: true, + }; + } + + return { + type: 'mark-ready', + }; +} + export function isSamePlatformPublicGalleryEntry( left: PlatformPublicGalleryCard, right: PlatformPublicGalleryCard, From 7301043afbf850a76894352e828b1ff90848693e Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 01:19:23 +0800 Subject: [PATCH 32/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E8=BF=90=E8=A1=8C=E6=80=81=E5=B0=B1=E7=BB=AA=E5=88=A4?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 2 +- docs/README.md | 2 +- ...入口PublicGalleryFlowModule收口计划-2026-06-03.md | 13 ++- .../PlatformEntryFlowShellImpl.tsx | 76 +++------------- .../platformPublicGalleryFlow.test.ts | 89 +++++++++++++++++++ .../platformPublicGalleryFlow.ts | 56 ++++++++++++ 6 files changed, 170 insertions(+), 68 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index bab8893e..78854d31 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -49,7 +49,7 @@ ## 2026-06-03 平台入口公开作品流身份规则收口 - 背景:平台入口公开作品推荐流需要同时处理 RPG、拼图、抓大鹅、跳一跳、敲木鱼、视觉小说、Bark Battle、宝贝识物等卡片,公开作品身份、跨玩法去重、排序和推荐运行态 kind 判定曾放在 `PlatformEntryFlowShellImpl.tsx` 巨型实现里。 -- 决策:公开作品身份、排序规则和推荐 runtime 启动意图统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey`、`getPlatformRecommendRuntimeKind`、`resolvePlatformRecommendRuntimeStartIntent`、`isSamePlatformPublicGalleryEntry` 和 `mergePlatformPublicGalleryEntries`。`edutainment` key 必须带 `templateId`,RPG 卡片回退为 `rpg`。推荐 runtime 启动 intent 只返回启动目标、`embedded` / `returnStage` 参数、阻断文案和错误落点;壳层仍执行 request key、运行态 API、错误 setter 与 UI 状态。 +- 决策:公开作品身份、排序规则、推荐 runtime 启动意图和 ready 判定统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey`、`getPlatformRecommendRuntimeKind`、`resolvePlatformRecommendRuntimeStartIntent`、`isPlatformRecommendRuntimeReadyForEntry`、`isSamePlatformPublicGalleryEntry` 和 `mergePlatformPublicGalleryEntries`。`edutainment` key 必须带 `templateId`,RPG 卡片回退为 `rpg`。推荐 runtime 启动 intent 只返回启动目标、`embedded` / `returnStage` 参数、阻断文案和错误落点;ready 判定只接布尔值与拼图 profile id,避免把各玩法 run snapshot 类型拖入 Module。壳层仍执行 request key、运行态 API、错误 setter 与 UI 状态。 - 影响范围:平台入口推荐流、公开作品详情、推荐 runtime 启动、跨玩法公开作品合并,以及后续新增玩法的入口接入。 - 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index 4dd4b85b..466e891a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,7 +39,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`,事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。 -平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定、推荐 runtime 启动意图和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定、推荐 runtime 启动意图、ready 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、编辑 / 点赞 / 改造 / 启动意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射与启动 / 编辑 Adapter 的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md index 6b09dd89..4fdebc80 100644 --- a/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md @@ -11,10 +11,11 @@ - `getPlatformPublicGalleryEntryKey(entry)`:按玩法类型、作者和 `profileId` 生成公开作品身份。 - `getPlatformRecommendRuntimeKind(entry)`:把公开作品卡映射为推荐运行态 kind。 - `resolvePlatformRecommendRuntimeStartIntent(entry, deps)`:把公开作品卡映射为推荐 runtime 启动意图、错误落点和 embedded / returnStage 参数。 +- `isPlatformRecommendRuntimeReadyForEntry(entry, state)`:用标量 ready state 判定当前推荐 runtime 是否已能承接该公开作品。 - `isSamePlatformPublicGalleryEntry(left, right)`:按公开作品身份比较。 - `mergePlatformPublicGalleryEntries(rpgEntries, puzzleEntries)`:统一完成 RPG 与各玩法公开作品去重、覆盖和倒序排序。 -入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份、排序规则和推荐 runtime 启动能力矩阵。`isRecommendRuntimeReadyForEntry` 暂留入口壳层,因为它依赖各运行态 run 状态,直接抽出会把更多 runtime 状态类型拖入这个 Module,降低本次改造的 locality。 +入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份、排序规则、推荐 runtime 启动能力矩阵和 ready 判定。ready 判定只接布尔值与拼图 profile id,不把各玩法 run snapshot 类型拖入 Module。 ## 玩法身份规则 @@ -33,9 +34,17 @@ - 抓大鹅 public detail -> work mapper 必须作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护 `generatedItemAssets` 归一化与背景资产提升。推荐 runtime 固定沿用旧参数 `returnStage = 'work-detail'` 与 `embedded = true`。 - 汪汪声浪优先使用推荐流已持有的 `barkBattleGalleryEntries`,再回退公开卡映射;不额外读取作品架列表。 +## 推荐 runtime ready 判定 + +- `isPlatformRecommendRuntimeReadyForEntry` 先要求 `state.activeKind` 与当前公开作品的 `getPlatformRecommendRuntimeKind(entry)` 相同,否则返回 `false`。 +- 大鱼吃小鱼、跳一跳、敲木鱼、抓大鹅、方洞挑战和视觉小说只看对应 `has*Run` 布尔值,保持旧行为,不在本 Module 内解析 run snapshot。 +- 拼图只看 `puzzleRunEntryProfileId` 或 `puzzleRunCurrentLevelProfileId` 是否等于当前公开作品 `profileId`。 +- 汪汪声浪和 RPG 在 kind 匹配时沿用旧 `ready = true` 行为;宝贝识物只看 `hasBabyObjectMatchDraft`。 +- 若未来要修正同玩法旧 run 误判或 RPG 无嵌入 runtime 的旧行为,应另立行为变更任务;本 Module 先只收口现有规则。 + ## 后续深化 -下一步可继续把平台入口的作品架刷新、删除确认和直达恢复逻辑收口成更深的 Work Shelf **Module**。当前 `platformPublicGalleryFlow` 先提供一个稳定 seam,使公开作品 identity、runtime kind 与推荐 runtime 启动意图的修改集中在一处。 +下一步可继续把平台入口的作品架刷新、删除确认和直达恢复逻辑收口成更深的 Work Shelf **Module**。当前 `platformPublicGalleryFlow` 先提供一个稳定 seam,使公开作品 identity、runtime kind、推荐 runtime 启动意图与 ready 判定的修改集中在一处。 ## 验证 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 3b9c34f6..c43ea772 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -503,6 +503,7 @@ import { import { getPlatformPublicGalleryEntryKey, getPlatformRecommendRuntimeKind, + isPlatformRecommendRuntimeReadyForEntry, isSamePlatformPublicGalleryEntry, mergePlatformPublicGalleryEntries, type RecommendRuntimeKind, @@ -600,18 +601,6 @@ type WoodenFishRuntimeReturnStage = type VisualNovelEntryGenerationPhase = 'generating' | 'ready' | 'failed'; type BabyObjectMatchGenerationPhase = 'generating' | 'ready' | 'failed'; -type RecommendRuntimeState = { - activeKind: RecommendRuntimeKind | null; - babyObjectMatchDraft: BabyObjectMatchDraft | null; - bigFishRun: BigFishRuntimeSnapshotResponse | null; - jumpHopRun: JumpHopRunResponse['run'] | null; - match3dRun: Match3DRunSnapshot | null; - puzzleRun: PuzzleRunSnapshot | null; - squareHoleRun: SquareHoleRunSnapshot | null; - visualNovelRun: VisualNovelRunSnapshot | null; - woodenFishRun: WoodenFishRunResponse['run'] | null; -}; - type PuzzleSaveArchiveState = { runtimeKind?: unknown; entryProfileId?: unknown; @@ -682,49 +671,6 @@ const PUZZLE_DRAFT_GENERATION_POINT_COST = 2; const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3; -function isRecommendRuntimeReadyForEntry( - entry: PlatformPublicGalleryCard, - state: RecommendRuntimeState, -) { - const expectedKind = getPlatformRecommendRuntimeKind(entry); - if (state.activeKind !== expectedKind) { - return false; - } - - if (expectedKind === 'big-fish') { - return Boolean(state.bigFishRun); - } - if (expectedKind === 'jump-hop') { - return Boolean(state.jumpHopRun); - } - if (expectedKind === 'wooden-fish') { - return Boolean(state.woodenFishRun); - } - if (expectedKind === 'match3d') { - return Boolean(state.match3dRun); - } - if (expectedKind === 'puzzle') { - return ( - state.puzzleRun?.entryProfileId === entry.profileId || - state.puzzleRun?.currentLevel?.profileId === entry.profileId - ); - } - if (expectedKind === 'square-hole') { - return Boolean(state.squareHoleRun); - } - if (expectedKind === 'visual-novel') { - return Boolean(state.visualNovelRun); - } - if (expectedKind === 'bark-battle') { - return true; - } - if (expectedKind === 'edutainment') { - return Boolean(state.babyObjectMatchDraft); - } - - return true; -} - function mapBarkBattleWorkToPublishedConfig( work: BarkBattleWorkSummary, ): BarkBattlePublishedConfig { @@ -13359,16 +13305,18 @@ export function PlatformEntryFlowShellImpl({ : null; const isActiveRecommendRuntimeReady = activeRecommendEntry !== null && - isRecommendRuntimeReadyForEntry(activeRecommendEntry, { + isPlatformRecommendRuntimeReadyForEntry(activeRecommendEntry, { activeKind: activeRecommendRuntimeKind, - babyObjectMatchDraft, - bigFishRun, - jumpHopRun, - match3dRun, - puzzleRun, - squareHoleRun, - visualNovelRun, - woodenFishRun, + hasBabyObjectMatchDraft: Boolean(babyObjectMatchDraft), + hasBigFishRun: Boolean(bigFishRun), + hasJumpHopRun: Boolean(jumpHopRun), + hasMatch3DRun: Boolean(match3dRun), + hasSquareHoleRun: Boolean(squareHoleRun), + hasVisualNovelRun: Boolean(visualNovelRun), + hasWoodenFishRun: Boolean(woodenFishRun), + puzzleRunEntryProfileId: puzzleRun?.entryProfileId ?? null, + puzzleRunCurrentLevelProfileId: + puzzleRun?.currentLevel?.profileId ?? null, }); if ( (activeRecommendEntry !== null && isActiveRecommendRuntimeReady) || diff --git a/src/components/platform-entry/platformPublicGalleryFlow.test.ts b/src/components/platform-entry/platformPublicGalleryFlow.test.ts index 410c601c..5c53d3e6 100644 --- a/src/components/platform-entry/platformPublicGalleryFlow.test.ts +++ b/src/components/platform-entry/platformPublicGalleryFlow.test.ts @@ -13,6 +13,7 @@ import { getPlatformPublicGalleryEntryKey, getPlatformPublicGalleryEntryTime, getPlatformRecommendRuntimeKind, + isPlatformRecommendRuntimeReadyForEntry, isSamePlatformPublicGalleryEntry, mergePlatformPublicGalleryEntries, type PlatformRecommendRuntimeStartIntentDeps, @@ -439,6 +440,94 @@ test('platform public gallery flow resolves recommend runtime bark battle priori }); }); +test('platform public gallery flow resolves recommend runtime readiness', () => { + expect( + isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), { + activeKind: 'puzzle', + hasBigFishRun: true, + }), + ).toBe(false); + expect( + isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), { + activeKind: 'big-fish', + hasBigFishRun: true, + }), + ).toBe(true); + expect( + isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('jump-hop'), { + activeKind: 'jump-hop', + hasJumpHopRun: true, + }), + ).toBe(true); + expect( + isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('wooden-fish'), { + activeKind: 'wooden-fish', + hasWoodenFishRun: true, + }), + ).toBe(true); + expect( + isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('match3d'), { + activeKind: 'match3d', + hasMatch3DRun: true, + }), + ).toBe(true); + expect( + isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('square-hole'), { + activeKind: 'square-hole', + hasSquareHoleRun: true, + }), + ).toBe(true); + expect( + isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('visual-novel'), { + activeKind: 'visual-novel', + hasVisualNovelRun: true, + }), + ).toBe(true); + expect( + isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('bark-battle'), { + activeKind: 'bark-battle', + }), + ).toBe(true); + expect( + isPlatformRecommendRuntimeReadyForEntry(buildRpgEntry(), { + activeKind: 'rpg', + }), + ).toBe(true); +}); + +test('platform public gallery flow resolves puzzle and edutainment readiness details', () => { + const puzzleEntry = buildTypedEntry('puzzle', { + profileId: 'puzzle-profile', + }); + + expect( + isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, { + activeKind: 'puzzle', + puzzleRunEntryProfileId: 'other-profile', + puzzleRunCurrentLevelProfileId: 'puzzle-profile', + }), + ).toBe(true); + expect( + isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, { + activeKind: 'puzzle', + puzzleRunEntryProfileId: 'other-profile', + puzzleRunCurrentLevelProfileId: 'another-profile', + }), + ).toBe(false); + expect( + isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), { + activeKind: 'edutainment', + hasBabyObjectMatchDraft: true, + }), + ).toBe(true); + expect( + isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), { + activeKind: 'edutainment', + hasBabyObjectMatchDraft: false, + }), + ).toBe(false); +}); + test('platform public gallery flow merges duplicate identities and sorts newest first', () => { const staleRpgEntry = buildRpgEntry({ profileId: 'shared-rpg', diff --git a/src/components/platform-entry/platformPublicGalleryFlow.ts b/src/components/platform-entry/platformPublicGalleryFlow.ts index c8dd70e7..914d64bb 100644 --- a/src/components/platform-entry/platformPublicGalleryFlow.ts +++ b/src/components/platform-entry/platformPublicGalleryFlow.ts @@ -114,6 +114,19 @@ export type PlatformRecommendRuntimeStartIntentDeps = { ) => Match3DWorkSummary | null; }; +export type PlatformRecommendRuntimeReadyState = { + activeKind: RecommendRuntimeKind | null; + hasBabyObjectMatchDraft?: boolean; + hasBigFishRun?: boolean; + hasJumpHopRun?: boolean; + hasMatch3DRun?: boolean; + hasSquareHoleRun?: boolean; + hasVisualNovelRun?: boolean; + hasWoodenFishRun?: boolean; + puzzleRunEntryProfileId?: string | null; + puzzleRunCurrentLevelProfileId?: string | null; +}; + export function getPlatformPublicGalleryEntryTime( entry: PlatformPublicGalleryCard, ) { @@ -332,6 +345,49 @@ export function resolvePlatformRecommendRuntimeStartIntent( }; } +export function isPlatformRecommendRuntimeReadyForEntry( + entry: PlatformPublicGalleryCard, + state: PlatformRecommendRuntimeReadyState, +) { + const expectedKind = getPlatformRecommendRuntimeKind(entry); + if (state.activeKind !== expectedKind) { + return false; + } + + if (expectedKind === 'big-fish') { + return Boolean(state.hasBigFishRun); + } + if (expectedKind === 'jump-hop') { + return Boolean(state.hasJumpHopRun); + } + if (expectedKind === 'wooden-fish') { + return Boolean(state.hasWoodenFishRun); + } + if (expectedKind === 'match3d') { + return Boolean(state.hasMatch3DRun); + } + if (expectedKind === 'puzzle') { + return ( + state.puzzleRunEntryProfileId === entry.profileId || + state.puzzleRunCurrentLevelProfileId === entry.profileId + ); + } + if (expectedKind === 'square-hole') { + return Boolean(state.hasSquareHoleRun); + } + if (expectedKind === 'visual-novel') { + return Boolean(state.hasVisualNovelRun); + } + if (expectedKind === 'bark-battle') { + return true; + } + if (expectedKind === 'edutainment') { + return Boolean(state.hasBabyObjectMatchDraft); + } + + return true; +} + export function isSamePlatformPublicGalleryEntry( left: PlatformPublicGalleryCard, right: PlatformPublicGalleryCard, From 75593b8860adeef5eb999320bb185780ce09d671 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 01:35:11 +0800 Subject: [PATCH 33/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E6=9E=B6=E5=88=A0=E9=99=A4=E7=A1=AE=E8=AE=A4=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...】CreationWorkDeleteFlow收口计划-2026-06-04.md | 33 ++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 1 + .../PlatformEntryFlowShellImpl.tsx | 169 ++++----- .../platformCreationWorkDeleteFlow.test.ts | 334 ++++++++++++++++++ .../platformCreationWorkDeleteFlow.ts | 288 +++++++++++++++ 7 files changed, 738 insertions(+), 97 deletions(-) create mode 100644 docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts create mode 100644 src/components/platform-entry/platformCreationWorkDeleteFlow.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 78854d31..43701f76 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-04 Creation Work Delete Flow 收口 + +- 背景:平台入口作品架删除入口在 RPG、拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说和宝贝识物 handler 内重复计算确认标题、删除说明、草稿 notice key 与拼图派生稳定 ID,导致删除确认规则散在巨型壳层。 +- 决策:新增 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts`,以 `resolvePlatformCreationWorkDeleteConfirmationModel(input)` 收口作品架删除确认纯模型;输出 `id/title/detail/noticeKeys`。`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 Adapter,保留删除 API、刷新作品架 / 公开广场、错误状态、`markDraftNoticeSeen` 和页面跳转。 +- 影响范围:创作中心作品架删除确认弹窗、删除后生成 notice 清理、拼图稳定 result ID 清理、宝贝识物已发布删除说明,以及后续新增玩法作品架删除接入。 +- 验证方式:`npm run test -- src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts`、`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md`。 + ## 2026-06-03 平台入口公开作品详情 Strategy 收口 - 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 466e891a..a436abe6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +作品架删除确认的标题、删除说明、草稿 notice key 和拼图派生稳定 ID 收口到 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts`,平台壳只保留删除 API、刷新、错误和页面跳转副作用,规则见 [【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md](./technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md)。 + 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md b/docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md new file mode 100644 index 00000000..ea4cd66d --- /dev/null +++ b/docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md @@ -0,0 +1,33 @@ +# 【前端架构】Creation Work Delete Flow 收口计划 + +## 背景 + +平台入口作品架的删除入口覆盖 RPG、拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说和宝贝识物。此前 `PlatformEntryFlowShellImpl.tsx` 在每个删除 handler 内重复计算确认框标题、删除说明、草稿 notice key 和拼图派生稳定 ID。壳层既要理解每种玩法的作品身份,又要承接异步删除、刷新列表、错误状态和页面跳转,导致删除确认规则缺少稳定测试面。 + +该 **Interface** 过浅:页面只想展示“删除哪个作品、会从哪里移除、删除成功后清哪些生成 notice”,却必须知道 `workId` / `profileId` / `sourceSessionId` / `draftId`、`status` / `publicationStatus` / `publishStatus` 和宝贝识物特殊公开去向。 + +## 决策 + +新增 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts` 作为 Creation Work Delete Flow **Module**。其唯一公开 **Interface** 是 `resolvePlatformCreationWorkDeleteConfirmationModel(input)`,输入为带 `kind` 的 union,输出: + +- `id`:确认框和删除 busy 使用的稳定作品 ID。 +- `title`:确认框标题,含拼图、视觉小说和宝贝识物标题兜底。 +- `detail`:草稿 / 已发布删除说明,宝贝识物已发布使用“寓教于乐板块”文案。 +- `noticeKeys`:删除成功后应标记已读的草稿生成 notice keys,拼图包含 `buildPuzzleResultWorkId` / `buildPuzzleResultProfileId` 派生 key。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责鉴权保护、确认框 state、调用各玩法删除 API、清错误、刷新作品架 / 公开广场、`markDraftNoticeSeen` 和必要的页面跳转。`run` 不进入纯 **Module**,避免把网络副作用和 React state 写入藏入模型层。 + +## 约定 + +- 新玩法接入作品架删除时,先补齐后端删除链路、作品架 action 和本 **Module** 的确认模型,再开放删除按钮。 +- Jump Hop、Wooden Fish 和 Bark Battle 当前仅有作品架 action 预留,平台壳不传删除 handler;不得因本 Module 存在而默认开放删除。 +- 删除确认文案不得散回平台壳;若公开去向不是公开广场,应在本 **Module** 明确分支。 +- 草稿 notice key 的身份扩展必须复用 `collectDraftNoticeKeys`,保持 trim、去空和去重语义一致。 + +## 验证 + +- `npm run test -- src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts` +- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts` +- `npx eslint src/components/platform-entry/platformCreationWorkDeleteFlow.ts src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 479c91c2..323d3d2e 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -53,6 +53,7 @@ 9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。 11. 移动端草稿页整体禁止长按选择文字,避免误触系统选区;输入框、文本域和可编辑区域仍必须保留文本选择能力。 +12. 作品架删除确认的纯规则统一由 `platformCreationWorkDeleteFlow.ts` 解析,输出确认框 `id/title/detail` 与删除成功后清理的草稿 notice keys;平台壳只接回该模型执行删除 API、刷新列表、清错误和跳转。Jump Hop、Wooden Fish、Bark Battle 虽在作品架 action 层有预留删除入口,但未补齐删除 API 前不得传入删除 handler 或开放按钮。 发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index c43ea772..c9b26af8 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -412,6 +412,7 @@ import { hasPuzzleRuntimeUrlStateValue, normalizeCreationUrlValue, } from './platformCreationUrlStateModel'; +import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow'; import { buildPlatformErrorDialogDismissKey, buildPlatformTaskCompletionDialogDismissKey, @@ -10212,12 +10213,16 @@ export function PlatformEntryFlowShellImpl({ return; } + const deleteModel = resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'rpg-library', + entry, + }); requestDeleteCreationWork({ - id: entry.profileId, - title: entry.worldName, - detail: '删除后会从你的作品列表和公开广场中移除。', + id: deleteModel.id, + title: deleteModel.title, + detail: deleteModel.detail, run: () => { - setDeletingCreationWorkId(entry.profileId); + setDeletingCreationWorkId(deleteModel.id); platformBootstrap.setPlatformError(null); void deleteRpgEntryWorldProfile(entry.profileId) @@ -10245,21 +10250,17 @@ export function PlatformEntryFlowShellImpl({ if (deletingCreationWorkId) { return; } - const noticeKeys = collectDraftNoticeKeys('rpg', [ - work.workId, - work.sessionId, - work.profileId, - ]); + const deleteModel = resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'rpg', + work, + }); requestDeleteCreationWork({ - id: work.workId, - title: work.title, - detail: - work.status === 'published' - ? '删除后会从你的作品列表和公开广场中移除。' - : '删除后会从你的作品列表中移除。', + id: deleteModel.id, + title: deleteModel.title, + detail: deleteModel.detail, run: () => { - setDeletingCreationWorkId(work.workId); + setDeletingCreationWorkId(deleteModel.id); platformBootstrap.setPlatformError(null); const deleteTask = @@ -10282,7 +10283,7 @@ export function PlatformEntryFlowShellImpl({ void deleteTask .then(async () => { - markDraftNoticeSeen(noticeKeys); + markDraftNoticeSeen(deleteModel.noticeKeys); await platformBootstrap.refreshPublishedGallery().catch(() => []); }) .catch((error) => { @@ -10309,25 +10310,22 @@ export function PlatformEntryFlowShellImpl({ if (deletingCreationWorkId) { return; } - const noticeKeys = collectDraftNoticeKeys('big-fish', [ - work.workId, - work.sourceSessionId, - ]); + const deleteModel = resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'big-fish', + work, + }); requestDeleteCreationWork({ - id: work.workId, - title: work.title, - detail: - work.status === 'published' - ? '删除后会从你的作品列表和公开广场中移除。' - : '删除后会从你的作品列表中移除。', + id: deleteModel.id, + title: deleteModel.title, + detail: deleteModel.detail, run: () => { - setDeletingCreationWorkId(work.workId); + setDeletingCreationWorkId(deleteModel.id); setBigFishError(null); void deleteBigFishWork(work.sourceSessionId) .then(async (response) => { - markDraftNoticeSeen(noticeKeys); + markDraftNoticeSeen(deleteModel.noticeKeys); setBigFishWorks(response.items); await refreshBigFishGallery().catch(() => []); }) @@ -10357,31 +10355,22 @@ export function PlatformEntryFlowShellImpl({ if (deletingCreationWorkId) { return; } - const noticeKeys = collectDraftNoticeKeys('puzzle', [ - work.workId, - work.profileId, - work.sourceSessionId, - buildPuzzleResultWorkId(work.sourceSessionId), - buildPuzzleResultProfileId(work.sourceSessionId), - ]); - - const displayName = - work.workTitle?.trim() || work.levelName.trim() || '未命名拼图'; + const deleteModel = resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'puzzle', + work, + }); requestDeleteCreationWork({ - id: work.workId, - title: displayName, - detail: - work.publicationStatus === 'published' - ? '删除后会从你的作品列表和公开广场中移除。' - : '删除后会从你的作品列表中移除。', + id: deleteModel.id, + title: deleteModel.title, + detail: deleteModel.detail, run: () => { - setDeletingCreationWorkId(work.workId); + setDeletingCreationWorkId(deleteModel.id); setPuzzleFormDraftPayload(null); setPuzzleError(null); void deletePuzzleWork(work.profileId) .then((response) => { - markDraftNoticeSeen(noticeKeys); + markDraftNoticeSeen(deleteModel.noticeKeys); setPuzzleWorks(response.items); void refreshPuzzleGallery(); }) @@ -10411,27 +10400,23 @@ export function PlatformEntryFlowShellImpl({ if (deletingCreationWorkId) { return; } - const noticeKeys = collectDraftNoticeKeys('match3d', [ - work.workId, - work.profileId, - work.sourceSessionId, - ]); + const deleteModel = resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'match3d', + work, + }); requestDeleteCreationWork({ - id: work.workId, - title: work.gameName, - detail: - work.publicationStatus === 'published' - ? '删除后会从你的作品列表和公开广场中移除。' - : '删除后会从你的作品列表中移除。', + id: deleteModel.id, + title: deleteModel.title, + detail: deleteModel.detail, run: () => { - setDeletingCreationWorkId(work.workId); + setDeletingCreationWorkId(deleteModel.id); setMatch3DFormDraftPayload(null); setMatch3DError(null); void deleteMatch3DWork(work.profileId) .then((response) => { - markDraftNoticeSeen(noticeKeys); + markDraftNoticeSeen(deleteModel.noticeKeys); setMatch3DWorks(mapMatch3DWorksForRuntimeUi(response.items)); void refreshMatch3DGallery(); }) @@ -10462,26 +10447,22 @@ export function PlatformEntryFlowShellImpl({ if (deletingCreationWorkId) { return; } - const noticeKeys = collectDraftNoticeKeys('square-hole', [ - work.workId, - work.profileId, - work.sourceSessionId, - ]); + const deleteModel = resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'square-hole', + work, + }); requestDeleteCreationWork({ - id: work.workId, - title: work.gameName, - detail: - work.publicationStatus === 'published' - ? '删除后会从你的作品列表和公开广场中移除。' - : '删除后会从你的作品列表中移除。', + id: deleteModel.id, + title: deleteModel.title, + detail: deleteModel.detail, run: () => { - setDeletingCreationWorkId(work.workId); + setDeletingCreationWorkId(deleteModel.id); setSquareHoleError(null); void deleteSquareHoleWork(work.profileId) .then((response) => { - markDraftNoticeSeen(noticeKeys); + markDraftNoticeSeen(deleteModel.noticeKeys); setSquareHoleWorks(response.items); void refreshSquareHoleGallery(); }) @@ -10511,24 +10492,22 @@ export function PlatformEntryFlowShellImpl({ if (deletingCreationWorkId) { return; } - const noticeKeys = collectDraftNoticeKeys('visual-novel', [ - work.profileId, - ]); + const deleteModel = resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'visual-novel', + work, + }); requestDeleteCreationWork({ - id: work.profileId, - title: work.title || '未命名视觉小说', - detail: - work.publishStatus === 'published' - ? '删除后会从你的作品列表和公开广场中移除。' - : '删除后会从你的作品列表中移除。', + id: deleteModel.id, + title: deleteModel.title, + detail: deleteModel.detail, run: () => { - setDeletingCreationWorkId(work.profileId); + setDeletingCreationWorkId(deleteModel.id); setVisualNovelError(null); void deleteVisualNovelWork(work.profileId) .then(async (response) => { - markDraftNoticeSeen(noticeKeys); + markDraftNoticeSeen(deleteModel.noticeKeys); setVisualNovelWorks(response.works); await refreshVisualNovelGallery(); }) @@ -10558,26 +10537,22 @@ export function PlatformEntryFlowShellImpl({ if (deletingCreationWorkId) { return; } - const noticeKeys = collectDraftNoticeKeys('baby-object-match', [ - work.profileId, - work.draftId, - ]); - const displayName = work.workTitle.trim() || work.templateName; + const deleteModel = resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'baby-object-match', + work, + }); requestDeleteCreationWork({ - id: work.profileId, - title: displayName, - detail: - work.publicationStatus === 'published' - ? '删除后会从你的作品列表和寓教于乐板块中移除。' - : '删除后会从你的作品列表中移除。', + id: deleteModel.id, + title: deleteModel.title, + detail: deleteModel.detail, run: () => { - setDeletingCreationWorkId(work.profileId); + setDeletingCreationWorkId(deleteModel.id); setBabyObjectMatchError(null); void deleteLocalBabyObjectMatchDraft(work.profileId) .then((nextDrafts) => { - markDraftNoticeSeen(noticeKeys); + markDraftNoticeSeen(deleteModel.noticeKeys); setBabyObjectMatchDrafts(nextDrafts); setBabyObjectMatchDraft((current) => current?.profileId === work.profileId ? null : current, diff --git a/src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts b/src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts new file mode 100644 index 00000000..e8cf726e --- /dev/null +++ b/src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, test } from 'vitest'; + +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow'; + +describe('platformCreationWorkDeleteFlow', () => { + test('resolves RPG library delete confirmation without draft notice keys', () => { + expect( + resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'rpg-library', + entry: { + profileId: 'rpg-profile', + worldName: '潮雾列岛', + }, + }), + ).toEqual({ + id: 'rpg-profile', + title: '潮雾列岛', + detail: '删除后会从你的作品列表和公开广场中移除。', + noticeKeys: [], + }); + }); + + test('resolves RPG work delete detail and notice keys by work status', () => { + expect( + resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'rpg', + work: buildRpgWork(), + }), + ).toEqual({ + id: 'rpg-work', + title: 'RPG 草稿', + detail: '删除后会从你的作品列表中移除。', + noticeKeys: ['rpg:rpg-work', 'rpg:rpg-session', 'rpg:rpg-profile'], + }); + + expect( + resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'rpg', + work: buildRpgWork({ status: 'published' }), + }).detail, + ).toBe('删除后会从你的作品列表和公开广场中移除。'); + }); + + test('resolves mini game delete models with shared public and private detail copy', () => { + expect( + resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'big-fish', + work: buildBigFishWork({ status: 'published' }), + }), + ).toMatchObject({ + id: 'big-fish-work', + title: '大鱼作品', + detail: '删除后会从你的作品列表和公开广场中移除。', + noticeKeys: ['big-fish:big-fish-work', 'big-fish:big-fish-session'], + }); + + expect( + resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'match3d', + work: buildMatch3DWork(), + }).detail, + ).toBe('删除后会从你的作品列表中移除。'); + expect( + resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'square-hole', + work: buildSquareHoleWork({ publicationStatus: 'published' }), + }).noticeKeys, + ).toEqual([ + 'square-hole:square-hole-work', + 'square-hole:square-hole-profile', + 'square-hole:square-hole-session', + ]); + }); + + test('resolves puzzle title fallback and stable result notice keys', () => { + expect( + resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'puzzle', + work: buildPuzzleWork({ + workTitle: ' ', + levelName: ' 雾港第一关 ', + sourceSessionId: 'puzzle-session-ocean', + }), + }), + ).toEqual({ + id: 'puzzle-work', + title: '雾港第一关', + detail: '删除后会从你的作品列表中移除。', + noticeKeys: [ + 'puzzle:puzzle-work', + 'puzzle:puzzle-profile', + 'puzzle:puzzle-session-ocean', + 'puzzle:puzzle-work-ocean', + 'puzzle:puzzle-profile-ocean', + ], + }); + + expect( + resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'puzzle', + work: buildPuzzleWork({ workTitle: '', levelName: ' ' }), + }).title, + ).toBe('未命名拼图'); + }); + + test('resolves visual novel and baby object match special delete copy', () => { + expect( + resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'visual-novel', + work: buildVisualNovelWork({ title: '', publishStatus: 'published' }), + }), + ).toEqual({ + id: 'visual-novel-profile', + title: '未命名视觉小说', + detail: '删除后会从你的作品列表和公开广场中移除。', + noticeKeys: ['visual-novel:visual-novel-profile'], + }); + + expect( + resolvePlatformCreationWorkDeleteConfirmationModel({ + kind: 'baby-object-match', + work: buildBabyObjectMatchDraft({ + workTitle: ' ', + publicationStatus: 'published', + }), + }), + ).toEqual({ + id: 'baby-profile', + title: '宝贝识物', + detail: '删除后会从你的作品列表和寓教于乐板块中移除。', + noticeKeys: [ + 'baby-object-match:baby-profile', + 'baby-object-match:baby-draft', + ], + }); + }); +}); + +function buildRpgWork( + overrides: Partial = {}, +): CustomWorldWorkSummary { + return { + workId: 'rpg-work', + sourceType: 'agent_session', + status: 'draft', + title: 'RPG 草稿', + subtitle: '待完善', + summary: 'RPG 摘要。', + coverImageSrc: null, + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: null, + stage: 'draft', + stageLabel: '草稿', + playableNpcCount: 1, + landmarkCount: 1, + sessionId: 'rpg-session', + profileId: 'rpg-profile', + canResume: true, + canEnterWorld: false, + ...overrides, + }; +} + +function buildBigFishWork( + overrides: Partial = {}, +): BigFishWorkSummary { + return { + workId: 'big-fish-work', + sourceSessionId: 'big-fish-session', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + title: '大鱼作品', + subtitle: '大鱼吃小鱼', + summary: '大鱼摘要。', + coverImageSrc: null, + status: 'draft', + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: null, + publishReady: false, + levelCount: 1, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: true, + ...overrides, + }; +} + +function buildPuzzleWork( + overrides: Partial = {}, +): PuzzleWorkSummary { + return { + workId: 'puzzle-work', + profileId: 'puzzle-profile', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session', + authorDisplayName: '玩家', + workTitle: '拼图作品', + workDescription: '拼图摘要。', + levelName: '拼图第一关', + summary: '拼图摘要。', + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + levels: [], + ...overrides, + }; +} + +function buildMatch3DWork( + overrides: Partial = {}, +): Match3DWorkSummary { + return { + workId: 'match3d-work', + profileId: 'match3d-profile', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session', + gameName: '抓大鹅作品', + themeText: '糖果厨房', + summary: '抓大鹅摘要。', + tags: [], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: null, + publishReady: false, + generatedItemAssets: [], + ...overrides, + }; +} + +function buildSquareHoleWork( + overrides: Partial = {}, +): SquareHoleWorkSummary { + return { + workId: 'square-hole-work', + profileId: 'square-hole-profile', + ownerUserId: 'user-1', + sourceSessionId: 'square-hole-session', + gameName: '方洞作品', + themeText: '图形', + twistRule: '反直觉', + summary: '方洞摘要。', + tags: [], + coverImageSrc: null, + backgroundPrompt: '背景', + backgroundImageSrc: null, + shapeOptions: [], + holeOptions: [], + shapeCount: 8, + difficulty: 4, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: null, + publishReady: false, + ...overrides, + }; +} + +function buildVisualNovelWork( + overrides: Partial = {}, +): VisualNovelWorkSummary { + return { + runtimeKind: 'visual-novel', + profileId: 'visual-novel-profile', + ownerUserId: 'user-1', + title: '视觉小说作品', + description: '视觉小说摘要。', + coverImageSrc: null, + tags: [], + publishStatus: 'draft', + publishReady: false, + playCount: 0, + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: null, + ...overrides, + }; +} + +function buildBabyObjectMatchDraft( + overrides: Partial = {}, +): BabyObjectMatchDraft { + return { + draftId: 'baby-draft', + profileId: 'baby-profile', + templateId: 'baby-object-match', + templateName: '宝贝识物', + workTitle: '宝贝识物作品', + workDescription: '宝贝识物摘要。', + itemNames: ['苹果', '香蕉'], + itemAssets: [ + { + itemId: 'apple', + itemName: '苹果', + imageSrc: '/apple.png', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '苹果', + }, + { + itemId: 'banana', + itemName: '香蕉', + imageSrc: '/banana.png', + assetObjectId: null, + generationProvider: 'placeholder', + prompt: '香蕉', + }, + ], + themeTags: [], + publicationStatus: 'draft', + createdAt: '2026-06-04T00:00:00.000Z', + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: null, + ...overrides, + }; +} diff --git a/src/components/platform-entry/platformCreationWorkDeleteFlow.ts b/src/components/platform-entry/platformCreationWorkDeleteFlow.ts new file mode 100644 index 00000000..fe75a735 --- /dev/null +++ b/src/components/platform-entry/platformCreationWorkDeleteFlow.ts @@ -0,0 +1,288 @@ +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, + collectDraftNoticeKeys, +} from './platformDraftGenerationShelfModel'; + +const PRIVATE_WORK_DELETE_DETAIL = '删除后会从你的作品列表中移除。'; +const PUBLIC_GALLERY_DELETE_DETAIL = '删除后会从你的作品列表和公开广场中移除。'; +const EDUTAINMENT_PUBLIC_DELETE_DETAIL = + '删除后会从你的作品列表和寓教于乐板块中移除。'; + +export type PlatformCreationWorkDeleteConfirmationModel = { + id: string; + title: string; + detail: string; + noticeKeys: string[]; +}; + +export type PlatformCreationWorkDeleteInput = + | { + kind: 'rpg-library'; + entry: Pick, 'profileId' | 'worldName'>; + } + | { + kind: 'rpg'; + work: Pick< + CustomWorldWorkSummary, + 'workId' | 'title' | 'status' | 'sessionId' | 'profileId' + >; + } + | { + kind: 'big-fish'; + work: Pick< + BigFishWorkSummary, + 'workId' | 'title' | 'status' | 'sourceSessionId' + >; + } + | { + kind: 'puzzle'; + work: Pick< + PuzzleWorkSummary, + | 'workId' + | 'profileId' + | 'sourceSessionId' + | 'workTitle' + | 'levelName' + | 'publicationStatus' + >; + } + | { + kind: 'match3d'; + work: Pick< + Match3DWorkSummary, + | 'workId' + | 'profileId' + | 'sourceSessionId' + | 'gameName' + | 'publicationStatus' + >; + } + | { + kind: 'square-hole'; + work: Pick< + SquareHoleWorkSummary, + | 'workId' + | 'profileId' + | 'sourceSessionId' + | 'gameName' + | 'publicationStatus' + >; + } + | { + kind: 'visual-novel'; + work: Pick< + VisualNovelWorkSummary, + 'profileId' | 'title' | 'publishStatus' + >; + } + | { + kind: 'baby-object-match'; + work: Pick< + BabyObjectMatchDraft, + | 'profileId' + | 'draftId' + | 'workTitle' + | 'templateName' + | 'publicationStatus' + >; + }; + +export function resolvePlatformCreationWorkDeleteConfirmationModel( + input: PlatformCreationWorkDeleteInput, +): PlatformCreationWorkDeleteConfirmationModel { + switch (input.kind) { + case 'rpg-library': + return resolveRpgLibraryDeleteConfirmationModel(input.entry); + case 'rpg': + return resolveRpgWorkDeleteConfirmationModel(input.work); + case 'big-fish': + return resolveBigFishWorkDeleteConfirmationModel(input.work); + case 'puzzle': + return resolvePuzzleWorkDeleteConfirmationModel(input.work); + case 'match3d': + return resolveMatch3DWorkDeleteConfirmationModel(input.work); + case 'square-hole': + return resolveSquareHoleWorkDeleteConfirmationModel(input.work); + case 'visual-novel': + return resolveVisualNovelWorkDeleteConfirmationModel(input.work); + case 'baby-object-match': + return resolveBabyObjectMatchDeleteConfirmationModel(input.work); + default: { + const exhaustive: never = input; + return exhaustive; + } + } +} + +function resolveStatusDeleteDetail( + status: string, + publishedDetail = PUBLIC_GALLERY_DELETE_DETAIL, +) { + return status === 'published' ? publishedDetail : PRIVATE_WORK_DELETE_DETAIL; +} + +function resolveTrimmedTitle( + value: string | null | undefined, + fallback: string, +) { + const trimmedValue = value?.trim(); + return trimmedValue || fallback; +} + +function resolveRpgLibraryDeleteConfirmationModel( + entry: Pick, 'profileId' | 'worldName'>, +): PlatformCreationWorkDeleteConfirmationModel { + return { + id: entry.profileId, + title: entry.worldName, + detail: PUBLIC_GALLERY_DELETE_DETAIL, + noticeKeys: [], + }; +} + +function resolveRpgWorkDeleteConfirmationModel( + work: Pick< + CustomWorldWorkSummary, + 'workId' | 'title' | 'status' | 'sessionId' | 'profileId' + >, +): PlatformCreationWorkDeleteConfirmationModel { + return { + id: work.workId, + title: work.title, + detail: resolveStatusDeleteDetail(work.status), + noticeKeys: collectDraftNoticeKeys('rpg', [ + work.workId, + work.sessionId, + work.profileId, + ]), + }; +} + +function resolveBigFishWorkDeleteConfirmationModel( + work: Pick< + BigFishWorkSummary, + 'workId' | 'title' | 'status' | 'sourceSessionId' + >, +): PlatformCreationWorkDeleteConfirmationModel { + return { + id: work.workId, + title: work.title, + detail: resolveStatusDeleteDetail(work.status), + noticeKeys: collectDraftNoticeKeys('big-fish', [ + work.workId, + work.sourceSessionId, + ]), + }; +} + +function resolvePuzzleWorkDeleteConfirmationModel( + work: Pick< + PuzzleWorkSummary, + | 'workId' + | 'profileId' + | 'sourceSessionId' + | 'workTitle' + | 'levelName' + | 'publicationStatus' + >, +): PlatformCreationWorkDeleteConfirmationModel { + return { + id: work.workId, + title: resolveTrimmedTitle( + work.workTitle, + resolveTrimmedTitle(work.levelName, '未命名拼图'), + ), + detail: resolveStatusDeleteDetail(work.publicationStatus), + noticeKeys: collectDraftNoticeKeys('puzzle', [ + work.workId, + work.profileId, + work.sourceSessionId, + buildPuzzleResultWorkId(work.sourceSessionId), + buildPuzzleResultProfileId(work.sourceSessionId), + ]), + }; +} + +function resolveMatch3DWorkDeleteConfirmationModel( + work: Pick< + Match3DWorkSummary, + | 'workId' + | 'profileId' + | 'sourceSessionId' + | 'gameName' + | 'publicationStatus' + >, +): PlatformCreationWorkDeleteConfirmationModel { + return { + id: work.workId, + title: work.gameName, + detail: resolveStatusDeleteDetail(work.publicationStatus), + noticeKeys: collectDraftNoticeKeys('match3d', [ + work.workId, + work.profileId, + work.sourceSessionId, + ]), + }; +} + +function resolveSquareHoleWorkDeleteConfirmationModel( + work: Pick< + SquareHoleWorkSummary, + | 'workId' + | 'profileId' + | 'sourceSessionId' + | 'gameName' + | 'publicationStatus' + >, +): PlatformCreationWorkDeleteConfirmationModel { + return { + id: work.workId, + title: work.gameName, + detail: resolveStatusDeleteDetail(work.publicationStatus), + noticeKeys: collectDraftNoticeKeys('square-hole', [ + work.workId, + work.profileId, + work.sourceSessionId, + ]), + }; +} + +function resolveVisualNovelWorkDeleteConfirmationModel( + work: Pick, +): PlatformCreationWorkDeleteConfirmationModel { + return { + id: work.profileId, + title: work.title || '未命名视觉小说', + detail: resolveStatusDeleteDetail(work.publishStatus), + noticeKeys: collectDraftNoticeKeys('visual-novel', [work.profileId]), + }; +} + +function resolveBabyObjectMatchDeleteConfirmationModel( + work: Pick< + BabyObjectMatchDraft, + 'profileId' | 'draftId' | 'workTitle' | 'templateName' | 'publicationStatus' + >, +): PlatformCreationWorkDeleteConfirmationModel { + return { + id: work.profileId, + title: resolveTrimmedTitle(work.workTitle, work.templateName), + detail: resolveStatusDeleteDetail( + work.publicationStatus, + EDUTAINMENT_PUBLIC_DELETE_DETAIL, + ), + noticeKeys: collectDraftNoticeKeys('baby-object-match', [ + work.profileId, + work.draftId, + ]), + }; +} From 0d2d391cb277a895f6c4cabcfc98115d537e992c Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 01:43:31 +0800 Subject: [PATCH 34/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E7=9B=B4=E8=BE=BE=E6=81=A2=E5=A4=8D=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 1 + docs/README.md | 2 +- ...】CreationUrlStateModel收口计划-2026-06-03.md | 7 +-- .../PlatformEntryFlowShellImpl.tsx | 26 +++++------ .../platformCreationUrlStateModel.test.ts | 45 +++++++++++++++++++ .../platformCreationUrlStateModel.ts | 36 ++++++++++++++- 6 files changed, 98 insertions(+), 19 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 43701f76..919b3850 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1331,6 +1331,7 @@ - 背景:平台壳内散落各玩法创作恢复 URL 的 `sessionId` / `profileId` / `draftId` / `workId` 组装、空值归一化、拼图 runtime query key 与拼图稳定身份互推,导致刷新恢复规则缺少稳定测试面。 - 决策:新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module,Interface 收口各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、URL state 非空判断和 runtime state key;新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,`platformDraftGenerationShelfModel.ts` 仅 re-export 旧入口以保持兼容。`PlatformEntryFlowShellImpl.tsx` 只保留路由、URL 写入和网络副作用 Adapter。 +- 追加决策:初始创作 URL 恢复的已处理、非创作路径、无私有 query、平台配置加载中、受保护数据暂不可读与可恢复判定也收口到 `resolveInitialCreationUrlRestoreDecision`;壳层只按 `skip`、`mark-handled`、`wait`、`restore` 执行 ref 标记或进入原恢复副作用。 - 影响范围:创作流程刷新恢复、拼图草稿 / 发布 runtime 深链、作品架打开试玩、跳一跳 / 敲木鱼 work-backed 恢复、Bark Battle / 宝贝识物本地草稿恢复。 - 验证方式:`npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts`、`npm run test -- src/services/creationUrlState.test.ts`、`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index a436abe6..b52ab02d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,7 +49,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -平台入口创作恢复 URL 私有 query、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 +平台入口创作恢复 URL 私有 query、初始恢复判定、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md index 7c9c9d3f..d50cd8e5 100644 --- a/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md @@ -7,9 +7,9 @@ ## 决策 - 新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module。 -- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、`normalizeCreationUrlValue`、`hasCreationUrlStateValue`、`hasPuzzleRuntimeUrlStateValue` 与 `buildPuzzleRuntimeUrlStateKey`。 +- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、`normalizeCreationUrlValue`、`hasCreationUrlStateValue`、`hasPuzzleRuntimeUrlStateValue`、`buildPuzzleRuntimeUrlStateKey` 与初始创作 URL 恢复判定 `resolveInitialCreationUrlRestoreDecision`。 - 新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,统一 `puzzle-session-*`、`puzzle-profile-*`、`puzzle-work-*` 的互推规则。 -- `PlatformEntryFlowShellImpl.tsx` 保留 React state、路由、登录门禁、网络请求和 URL 写入副作用 Adapter;不再在壳层内定义各玩法 URL 状态构造函数。 +- `PlatformEntryFlowShellImpl.tsx` 保留 React state、路由、登录门禁、网络请求和 URL 写入副作用 Adapter;不再在壳层内定义各玩法 URL 状态构造函数,也不直接内联初始恢复的已处理 / 等待 / 可恢复判定。 ## Interface 约束 @@ -18,11 +18,12 @@ - work-backed 玩法优先使用后端 work summary 的公开 `workId` / `profileId`;仅缺失时才回退 session draft。 - 拼图 runtime query 独立使用 `mode`、`runtimeSessionId`、`runtimeProfileId`、`runtimeLevelId`、`publicWorkCode`,不与创作恢复 query 混写。 - 拼图 draft runtime 若没有 `sourceSessionId`,只允许从 `puzzle-profile-*` 反推出 `puzzle-session-*`。 +- 初始创作 URL 恢复只在未处理、当前路径属于创作恢复路径、私有 query 有值、平台配置加载完成且受保护数据可读时执行;非创作路径或无私有 query 时标记已处理,加载中或暂不可读时等待。 ## Depth / Leverage / Locality - **Depth**:调用方只传玩法快照或作品摘要,即可得到规范化 URL state;各玩法字段优先级藏在 Module Implementation 内。 -- **Leverage**:新增或调整玩法恢复规则时,优先补 Module Interface 测试,再接壳层 Adapter。 +- **Leverage**:新增或调整玩法恢复规则、恢复等待条件时,优先补 Module Interface 测试,再接壳层 Adapter。 - **Locality**:恢复 query、拼图 runtime query 和拼图稳定身份规则集中在两个小 Module,避免散落在页面壳、作品架和 runtime 打开逻辑中。 ## 验收 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index c9b26af8..665af49c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -158,7 +158,6 @@ import { } from '../../services/creationEntryConfigService'; import { clearCreationUrlState, - isCreationRestorePath, readCreationUrlState, writeCreationUrlState, } from '../../services/creationUrlState'; @@ -408,9 +407,9 @@ import { buildSquareHoleCreationUrlState, buildVisualNovelCreationUrlState, buildWoodenFishCreationUrlState, - hasCreationUrlStateValue, hasPuzzleRuntimeUrlStateValue, normalizeCreationUrlValue, + resolveInitialCreationUrlRestoreDecision, } from './platformCreationUrlStateModel'; import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow'; import { @@ -12136,23 +12135,22 @@ export function PlatformEntryFlowShellImpl({ ); useEffect(() => { - if (handledInitialCreationUrlStateRef.current) { + const restoreDecision = resolveInitialCreationUrlRestoreDecision({ + handled: handledInitialCreationUrlStateRef.current, + pathname: window.location.pathname, + state: initialCreationUrlState, + isLoadingPlatform: platformBootstrap.isLoadingPlatform, + canReadProtectedData: platformBootstrap.canReadProtectedData, + }); + + if (restoreDecision.type === 'skip' || restoreDecision.type === 'wait') { return; } - if (!isCreationRestorePath(window.location.pathname)) { + + if (restoreDecision.type === 'mark-handled') { handledInitialCreationUrlStateRef.current = true; return; } - if (!hasCreationUrlStateValue(initialCreationUrlState)) { - handledInitialCreationUrlStateRef.current = true; - return; - } - if (platformBootstrap.isLoadingPlatform) { - return; - } - if (!platformBootstrap.canReadProtectedData) { - return; - } handledInitialCreationUrlStateRef.current = true; diff --git a/src/components/platform-entry/platformCreationUrlStateModel.test.ts b/src/components/platform-entry/platformCreationUrlStateModel.test.ts index 54cf53ef..90ca06cc 100644 --- a/src/components/platform-entry/platformCreationUrlStateModel.test.ts +++ b/src/components/platform-entry/platformCreationUrlStateModel.test.ts @@ -32,6 +32,7 @@ import { hasCreationUrlStateValue, hasPuzzleRuntimeUrlStateValue, normalizeCreationUrlValue, + resolveInitialCreationUrlRestoreDecision, } from './platformCreationUrlStateModel'; describe('platformCreationUrlStateModel', () => { @@ -49,6 +50,50 @@ describe('platformCreationUrlStateModel', () => { expect(hasCreationUrlStateValue({})).toBe(false); }); + test('resolves initial creation url restore readiness', () => { + const readyParams = { + handled: false, + pathname: '/creation/puzzle/result', + state: { sessionId: 'puzzle-session-1' }, + isLoadingPlatform: false, + canReadProtectedData: true, + }; + + expect( + resolveInitialCreationUrlRestoreDecision({ + ...readyParams, + handled: true, + }), + ).toEqual({ type: 'skip' }); + expect( + resolveInitialCreationUrlRestoreDecision({ + ...readyParams, + pathname: '/works/detail', + }), + ).toEqual({ type: 'mark-handled' }); + expect( + resolveInitialCreationUrlRestoreDecision({ + ...readyParams, + state: {}, + }), + ).toEqual({ type: 'mark-handled' }); + expect( + resolveInitialCreationUrlRestoreDecision({ + ...readyParams, + isLoadingPlatform: true, + }), + ).toEqual({ type: 'wait' }); + expect( + resolveInitialCreationUrlRestoreDecision({ + ...readyParams, + canReadProtectedData: false, + }), + ).toEqual({ type: 'wait' }); + expect(resolveInitialCreationUrlRestoreDecision(readyParams)).toEqual({ + type: 'restore', + }); + }); + test('builds creation restore state for core session based plays', () => { expect( buildBigFishCreationUrlState({ diff --git a/src/components/platform-entry/platformCreationUrlStateModel.ts b/src/components/platform-entry/platformCreationUrlStateModel.ts index 6ce46fa8..0961b1fb 100644 --- a/src/components/platform-entry/platformCreationUrlStateModel.ts +++ b/src/components/platform-entry/platformCreationUrlStateModel.ts @@ -6,7 +6,10 @@ import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/co import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent'; import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel'; -import type { CreationUrlState } from '../../services/creationUrlState'; +import { + type CreationUrlState, + isCreationRestorePath, +} from '../../services/creationUrlState'; import type { JumpHopSessionSnapshotResponse, JumpHopWorkProfileResponse, @@ -57,6 +60,37 @@ export function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) { ].join('|'); } +export type InitialCreationUrlRestoreDecision = + | { type: 'skip' } + | { type: 'mark-handled' } + | { type: 'wait' } + | { type: 'restore' }; + +export function resolveInitialCreationUrlRestoreDecision(params: { + handled: boolean; + pathname: string | undefined; + state: CreationUrlState; + isLoadingPlatform: boolean; + canReadProtectedData: boolean; +}): InitialCreationUrlRestoreDecision { + if (params.handled) { + return { type: 'skip' }; + } + + if ( + !isCreationRestorePath(params.pathname) || + !hasCreationUrlStateValue(params.state) + ) { + return { type: 'mark-handled' }; + } + + if (params.isLoadingPlatform || !params.canReadProtectedData) { + return { type: 'wait' }; + } + + return { type: 'restore' }; +} + export function buildBigFishCreationUrlState( session: BigFishSessionSnapshotResponse | null, ): CreationUrlState { From dbc00be2cc5936210859733ac3efd3d53958103a Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 01:49:12 +0800 Subject: [PATCH 35/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E9=98=B6=E6=AE=B5=E5=A4=B1=E6=9D=83=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...latformSelectionStageModel收口计划-2026-06-04.md | 28 +++++++ .../PlatformEntryFlowShellImpl.tsx | 23 ++---- .../platformSelectionStageModel.test.ts | 75 +++++++++++++++++++ .../platformSelectionStageModel.ts | 59 +++++++++++++++ 6 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformSelectionStageModel.test.ts create mode 100644 src/components/platform-entry/platformSelectionStageModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 919b3850..028c7a07 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-04 Platform Selection Stage Model 收口 + +- 背景:平台入口在受保护数据失效后会清空当前用户私有作品、草稿、运行态和生成状态,但哪些 `SelectionStage` 可保留、哪些必须回首页曾以内联长否定串散在 `PlatformEntryFlowShellImpl.tsx`。 +- 决策:新增 `src/components/platform-entry/platformSelectionStageModel.ts`,以 `resolveSelectionStageAfterProtectedDataLoss(stage)` 收口受保护数据失效后的 stage 去留判定。模型内部使用 `satisfies Record` 全量分类,新增 stage 时必须明确保留或回首页。壳层仍负责检测权限变化、清 state 和调用 `setSelectionStage`。 +- 影响范围:退出登录、鉴权上下文收回、平台入口公开页 / 工作台 / 结果页 / 生成页 / 运行态的阶段恢复规则,以及后续新增 `SelectionStage`。 +- 验证方式:`npm run test -- src/components/platform-entry/platformSelectionStageModel.test.ts`、针对新 Module 与壳层执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md`。 + ## 2026-06-04 Creation Work Delete Flow 收口 - 背景:平台入口作品架删除入口在 RPG、拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说和宝贝识物 handler 内重复计算确认标题、删除说明、草稿 notice key 与拼图派生稳定 ID,导致删除确认规则散在巨型壳层。 diff --git a/docs/README.md b/docs/README.md index b52ab02d..275a97b6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。 +平台入口受保护数据失效后的 stage 去留判定收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。 + 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 抓大鹅 runtime profile 的公开详情转 work、session draft 转 profile、生成背景资产提升和 run/profile/public detail 素材优先级收口到 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91Match3DRuntimeProfile%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md new file mode 100644 index 00000000..4662012a --- /dev/null +++ b/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md @@ -0,0 +1,28 @@ +# 【前端架构】Platform Selection Stage Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 在受保护数据失效后会清空当前用户的私有作品、运行态、草稿 notice 和生成状态。清理完成后,壳层还要判断当前 `SelectionStage` 是否还能继续展示:公开首页、公开详情、工作台入口等阶段可保留;结果页、生成页、运行态、个人反馈等依赖私有数据或运行态快照的阶段必须回到首页。 + +此前该规则以内联长否定串维护在壳层 **Implementation** 内。新增玩法 stage 或调整登录态行为时,维护者必须在巨型壳层中查找白名单,缺少独立测试面。 + +## 决策 + +新增 `src/components/platform-entry/platformSelectionStageModel.ts` 作为 Platform Selection Stage **Module**。其公开 **Interface** 为: + +- `resolveSelectionStageAfterProtectedDataLoss(stage)`:输入当前 `SelectionStage`,输出受保护数据失效后应停留的 stage;可保留则原样返回,否则返回 `platform`。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责检测受保护数据从可读变为不可读、清空各玩法缓存、重置生成和错误状态,并只在模型输出与当前 stage 不一致时调用 `setSelectionStage(nextStage)`。 + +## 约定 + +- 新增 `SelectionStage` 时,必须判断它在退出登录或鉴权上下文收回后是否仍可展示,并在本 **Module** 的全量 `Record` 与测试中列明。 +- 公开列表、公开详情和创作工作台入口可保留;依赖当前用户私有数据、生成 session、运行态 run 或个人资料的 stage 默认回 `platform`。 +- 此 **Module** 不清理 state、不调用路由、不触发登录弹窗,只表达纯 stage 决策。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformSelectionStageModel.test.ts` +- `npx eslint src/components/platform-entry/platformSelectionStageModel.ts src/components/platform-entry/platformSelectionStageModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 665af49c..8872eea5 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -532,6 +532,7 @@ import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, } from './platformPuzzleIdentityModel'; +import { resolveSelectionStageAfterProtectedDataLoss } from './platformSelectionStageModel'; import { PlatformTaskCompletionDialog } from './PlatformTaskCompletionDialog'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; @@ -6657,24 +6658,10 @@ export function PlatformEntryFlowShellImpl({ persistRpgAgentUiState(null, null); resetAutoSaveTrackingToIdle(); - if ( - selectionStage !== 'platform' && - selectionStage !== 'work-detail' && - selectionStage !== 'detail' && - selectionStage !== 'agent-workspace' && - selectionStage !== 'big-fish-agent-workspace' && - selectionStage !== 'match3d-agent-workspace' && - selectionStage !== 'square-hole-agent-workspace' && - selectionStage !== 'jump-hop-workspace' && - selectionStage !== 'wooden-fish-workspace' && - selectionStage !== 'puzzle-agent-workspace' && - selectionStage !== 'bark-battle-workspace' && - selectionStage !== 'visual-novel-agent-workspace' && - selectionStage !== 'baby-object-match-workspace' && - selectionStage !== 'creative-agent-workspace' && - selectionStage !== 'puzzle-gallery-detail' - ) { - setSelectionStage('platform'); + const nextSelectionStage = + resolveSelectionStageAfterProtectedDataLoss(selectionStage); + if (nextSelectionStage !== selectionStage) { + setSelectionStage(nextSelectionStage); } }, [ authUi?.user, diff --git a/src/components/platform-entry/platformSelectionStageModel.test.ts b/src/components/platform-entry/platformSelectionStageModel.test.ts new file mode 100644 index 00000000..56a7acde --- /dev/null +++ b/src/components/platform-entry/platformSelectionStageModel.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'vitest'; + +import type { SelectionStage } from './platformEntryTypes'; +import { resolveSelectionStageAfterProtectedDataLoss } from './platformSelectionStageModel'; + +describe('platformSelectionStageModel', () => { + test('keeps public and workspace stages after protected data loss', () => { + const stableStages: SelectionStage[] = [ + 'platform', + 'work-detail', + 'detail', + 'agent-workspace', + 'big-fish-agent-workspace', + 'match3d-agent-workspace', + 'square-hole-agent-workspace', + 'jump-hop-workspace', + 'wooden-fish-workspace', + 'puzzle-agent-workspace', + 'bark-battle-workspace', + 'visual-novel-agent-workspace', + 'baby-object-match-workspace', + 'creative-agent-workspace', + 'puzzle-gallery-detail', + ]; + + stableStages.forEach((stage) => { + expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe(stage); + }); + }); + + test('resets private result, generating, runtime and profile stages to platform', () => { + const resetStages: SelectionStage[] = [ + 'profile-feedback', + 'big-fish-generating', + 'big-fish-result', + 'big-fish-runtime', + 'match3d-generating', + 'match3d-result', + 'match3d-runtime', + 'square-hole-generating', + 'square-hole-result', + 'square-hole-runtime', + 'jump-hop-generating', + 'jump-hop-result', + 'jump-hop-runtime', + 'jump-hop-gallery-detail', + 'wooden-fish-generating', + 'wooden-fish-result', + 'wooden-fish-runtime', + 'visual-novel-generating', + 'visual-novel-result', + 'visual-novel-gallery-detail', + 'visual-novel-runtime', + 'baby-object-match-generating', + 'baby-object-match-result', + 'baby-object-match-runtime', + 'baby-love-drawing-runtime', + 'puzzle-generating', + 'puzzle-onboarding', + 'puzzle-result', + 'puzzle-runtime', + 'custom-world-generating', + 'custom-world-result', + 'bark-battle-generating', + 'bark-battle-result', + 'bark-battle-runtime', + ]; + + resetStages.forEach((stage) => { + expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe( + 'platform', + ); + }); + }); +}); diff --git a/src/components/platform-entry/platformSelectionStageModel.ts b/src/components/platform-entry/platformSelectionStageModel.ts new file mode 100644 index 00000000..33dca39c --- /dev/null +++ b/src/components/platform-entry/platformSelectionStageModel.ts @@ -0,0 +1,59 @@ +import type { SelectionStage } from './platformEntryTypes'; + +const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = { + platform: true, + 'profile-feedback': false, + 'work-detail': true, + detail: true, + 'agent-workspace': true, + 'big-fish-agent-workspace': true, + 'big-fish-generating': false, + 'big-fish-result': false, + 'big-fish-runtime': false, + 'match3d-agent-workspace': true, + 'match3d-generating': false, + 'match3d-result': false, + 'match3d-runtime': false, + 'square-hole-agent-workspace': true, + 'square-hole-generating': false, + 'square-hole-result': false, + 'square-hole-runtime': false, + 'jump-hop-workspace': true, + 'jump-hop-generating': false, + 'jump-hop-result': false, + 'jump-hop-runtime': false, + 'jump-hop-gallery-detail': false, + 'bark-battle-workspace': true, + 'bark-battle-generating': false, + 'bark-battle-result': false, + 'bark-battle-runtime': false, + 'wooden-fish-workspace': true, + 'wooden-fish-generating': false, + 'wooden-fish-result': false, + 'wooden-fish-runtime': false, + 'creative-agent-workspace': true, + 'visual-novel-agent-workspace': true, + 'visual-novel-generating': false, + 'visual-novel-result': false, + 'visual-novel-gallery-detail': false, + 'visual-novel-runtime': false, + 'baby-object-match-workspace': true, + 'baby-object-match-generating': false, + 'baby-object-match-result': false, + 'baby-object-match-runtime': false, + 'baby-love-drawing-runtime': false, + 'puzzle-agent-workspace': true, + 'puzzle-generating': false, + 'puzzle-onboarding': false, + 'puzzle-result': false, + 'puzzle-gallery-detail': true, + 'puzzle-runtime': false, + 'custom-world-generating': false, + 'custom-world-result': false, +} as const satisfies Record; + +export function resolveSelectionStageAfterProtectedDataLoss( + stage: SelectionStage, +): SelectionStage { + return PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE[stage] ? stage : 'platform'; +} From a504da1e329fda8df0f39b07b2ae3ec514b923a8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 01:57:13 +0800 Subject: [PATCH 36/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E7=9B=B4=E8=BE=BE=E6=81=A2=E5=A4=8D=E7=9B=AE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 1 + docs/README.md | 2 +- ...】CreationUrlStateModel收口计划-2026-06-03.md | 6 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- .../PlatformEntryFlowShellImpl.tsx | 43 ++++---- .../platformCreationUrlStateModel.test.ts | 99 +++++++++++++++++++ .../platformCreationUrlStateModel.ts | 87 ++++++++++++++++ 7 files changed, 213 insertions(+), 27 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 028c7a07..24fa12c2 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1340,6 +1340,7 @@ - 背景:平台壳内散落各玩法创作恢复 URL 的 `sessionId` / `profileId` / `draftId` / `workId` 组装、空值归一化、拼图 runtime query key 与拼图稳定身份互推,导致刷新恢复规则缺少稳定测试面。 - 决策:新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module,Interface 收口各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、URL state 非空判断和 runtime state key;新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,`platformDraftGenerationShelfModel.ts` 仅 re-export 旧入口以保持兼容。`PlatformEntryFlowShellImpl.tsx` 只保留路由、URL 写入和网络副作用 Adapter。 - 追加决策:初始创作 URL 恢复的已处理、非创作路径、无私有 query、平台配置加载中、受保护数据暂不可读与可恢复判定也收口到 `resolveInitialCreationUrlRestoreDecision`;壳层只按 `skip`、`mark-handled`、`wait`、`restore` 执行 ref 标记或进入原恢复副作用。 +- 追加决策:创作直达恢复目标解析收口到 `resolveCreationUrlRestoreTarget(pathname, state)`;Module 统一识别 big-fish、match3d、square-hole、puzzle、visual-novel、bark-battle、baby-object-match、jump-hop、wooden-fish 的 path、私有 query 归一化、生成路径标记和 big-fish workId 到 sessionId 兜底。壳层仍执行作品列表读取、草稿恢复、错误处理、stage 切换和 URL 写回;`/creation/rpg` 继续保持无具体恢复目标,后续要接入需先补规则与测试。 - 影响范围:创作流程刷新恢复、拼图草稿 / 发布 runtime 深链、作品架打开试玩、跳一跳 / 敲木鱼 work-backed 恢复、Bark Battle / 宝贝识物本地草稿恢复。 - 验证方式:`npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts`、`npm run test -- src/services/creationUrlState.test.ts`、`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index 275a97b6..53de69ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,7 +49,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -平台入口创作恢复 URL 私有 query、初始恢复判定、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 +平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md index d50cd8e5..340f4220 100644 --- a/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md @@ -7,7 +7,7 @@ ## 决策 - 新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module。 -- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、`normalizeCreationUrlValue`、`hasCreationUrlStateValue`、`hasPuzzleRuntimeUrlStateValue`、`buildPuzzleRuntimeUrlStateKey` 与初始创作 URL 恢复判定 `resolveInitialCreationUrlRestoreDecision`。 +- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、`normalizeCreationUrlValue`、`hasCreationUrlStateValue`、`hasPuzzleRuntimeUrlStateValue`、`buildPuzzleRuntimeUrlStateKey`、初始创作 URL 恢复判定 `resolveInitialCreationUrlRestoreDecision`,以及创作直达恢复目标解析 `resolveCreationUrlRestoreTarget`。 - 新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,统一 `puzzle-session-*`、`puzzle-profile-*`、`puzzle-work-*` 的互推规则。 - `PlatformEntryFlowShellImpl.tsx` 保留 React state、路由、登录门禁、网络请求和 URL 写入副作用 Adapter;不再在壳层内定义各玩法 URL 状态构造函数,也不直接内联初始恢复的已处理 / 等待 / 可恢复判定。 @@ -19,11 +19,13 @@ - 拼图 runtime query 独立使用 `mode`、`runtimeSessionId`、`runtimeProfileId`、`runtimeLevelId`、`publicWorkCode`,不与创作恢复 query 混写。 - 拼图 draft runtime 若没有 `sourceSessionId`,只允许从 `puzzle-profile-*` 反推出 `puzzle-session-*`。 - 初始创作 URL 恢复只在未处理、当前路径属于创作恢复路径、私有 query 有值、平台配置加载完成且受保护数据可读时执行;非创作路径或无私有 query 时标记已处理,加载中或暂不可读时等待。 +- 创作直达恢复目标由 `resolveCreationUrlRestoreTarget(pathname, state)` 统一识别;它只返回玩法 kind、归一化后的四个私有 query、生成路径标记和大鱼吃小鱼 session 兜底,不执行网络请求、草稿打开、stage 切换或 URL 写回。 +- `/creation/rpg` 当前仍不归入具体恢复目标;若后续要恢复 RPG 直达,需要先补明确恢复规则和测试,不得让壳层重新内联路径判定。 ## Depth / Leverage / Locality - **Depth**:调用方只传玩法快照或作品摘要,即可得到规范化 URL state;各玩法字段优先级藏在 Module Implementation 内。 -- **Leverage**:新增或调整玩法恢复规则、恢复等待条件时,优先补 Module Interface 测试,再接壳层 Adapter。 +- **Leverage**:新增或调整玩法恢复规则、恢复目标或恢复等待条件时,优先补 Module Interface 测试,再接壳层 Adapter。 - **Locality**:恢复 query、拼图 runtime query 和拼图稳定身份规则集中在两个小 Module,避免散落在页面壳、作品架和 runtime 打开逻辑中。 ## 验收 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 323d3d2e..97d37305 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -8,7 +8,7 @@ 当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。 -创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 +创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记和大鱼吃小鱼 workId 兜底统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 8872eea5..0d0f5b4c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -409,6 +409,7 @@ import { buildWoodenFishCreationUrlState, hasPuzzleRuntimeUrlStateValue, normalizeCreationUrlValue, + resolveCreationUrlRestoreTarget, resolveInitialCreationUrlRestoreDecision, } from './platformCreationUrlStateModel'; import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow'; @@ -12142,21 +12143,17 @@ export function PlatformEntryFlowShellImpl({ handledInitialCreationUrlStateRef.current = true; const restoreCreationUrlState = async () => { - const path = window.location.pathname; - const sessionId = normalizeCreationUrlValue( - initialCreationUrlState.sessionId, + const target = resolveCreationUrlRestoreTarget( + window.location.pathname, + initialCreationUrlState, ); - const profileId = normalizeCreationUrlValue( - initialCreationUrlState.profileId, - ); - const draftId = normalizeCreationUrlValue( - initialCreationUrlState.draftId, - ); - const workId = normalizeCreationUrlValue(initialCreationUrlState.workId); + if (!target) { + return; + } + const { sessionId, profileId, draftId, workId } = target; - if (path.startsWith('/creation/big-fish')) { - const targetSessionId = - sessionId ?? workId?.replace(/^big-fish-work-/u, ''); + if (target.kind === 'big-fish') { + const targetSessionId = target.bigFishSessionId; if (targetSessionId) { const matchedWork = (bigFishWorks.length > 0 @@ -12176,7 +12173,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if (path.startsWith('/creation/match3d')) { + if (target.kind === 'match3d') { const matchedWork = (match3dWorks.length > 0 ? match3dWorks @@ -12199,7 +12196,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if (path.startsWith('/creation/square-hole')) { + if (target.kind === 'square-hole') { const matchedWork = (squareHoleWorks.length > 0 ? squareHoleWorks @@ -12220,7 +12217,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if (path.startsWith('/creation/puzzle')) { + if (target.kind === 'puzzle') { const matchedWork = (puzzleWorks.length > 0 ? puzzleWorks @@ -12241,7 +12238,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if (path.startsWith('/creation/visual-novel')) { + if (target.kind === 'visual-novel') { const matchedWork = (visualNovelWorks.length > 0 ? visualNovelWorks @@ -12257,7 +12254,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if (path.startsWith('/creation/bark-battle')) { + if (target.kind === 'bark-battle') { const matchedWork = (barkBattleWorks.length > 0 ? barkBattleWorks @@ -12271,7 +12268,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if (path.startsWith('/creation/baby-object-match')) { + if (target.kind === 'baby-object-match') { const matchedDraft = (babyObjectMatchDrafts.length > 0 ? babyObjectMatchDrafts @@ -12288,7 +12285,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if (path.startsWith('/creation/jump-hop')) { + if (target.kind === 'jump-hop') { let session: JumpHopSessionSnapshotResponse | null = null; let work: JumpHopWorkProfileResponse | null = null; try { @@ -12316,7 +12313,7 @@ export function PlatformEntryFlowShellImpl({ ); enterCreateTab(); setSelectionStage( - path.includes('/generating') + target.isGeneratingPath ? 'jump-hop-generating' : session?.draft || work ? 'jump-hop-result' @@ -12330,7 +12327,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if (path.startsWith('/creation/wooden-fish')) { + if (target.kind === 'wooden-fish') { if (!sessionId) { return; } @@ -12347,7 +12344,7 @@ export function PlatformEntryFlowShellImpl({ ); enterCreateTab(); setSelectionStage( - path.includes('/generating') + target.isGeneratingPath ? 'wooden-fish-generating' : session.draft ? 'wooden-fish-result' diff --git a/src/components/platform-entry/platformCreationUrlStateModel.test.ts b/src/components/platform-entry/platformCreationUrlStateModel.test.ts index 90ca06cc..b7f7cbf4 100644 --- a/src/components/platform-entry/platformCreationUrlStateModel.test.ts +++ b/src/components/platform-entry/platformCreationUrlStateModel.test.ts @@ -32,6 +32,7 @@ import { hasCreationUrlStateValue, hasPuzzleRuntimeUrlStateValue, normalizeCreationUrlValue, + resolveCreationUrlRestoreTarget, resolveInitialCreationUrlRestoreDecision, } from './platformCreationUrlStateModel'; @@ -94,6 +95,104 @@ describe('platformCreationUrlStateModel', () => { }); }); + test('resolves supported creation url restore targets from paths', () => { + const state = { + sessionId: ' session-1 ', + profileId: ' profile-1 ', + draftId: ' draft-1 ', + workId: ' work-1 ', + }; + const cases = [ + ['/creation/big-fish/result', 'big-fish'], + ['/creation/match3d/result', 'match3d'], + ['/creation/square-hole/result', 'square-hole'], + ['/creation/puzzle/result', 'puzzle'], + ['/creation/visual-novel/result', 'visual-novel'], + ['/creation/bark-battle/result', 'bark-battle'], + ['/creation/baby-object-match/result', 'baby-object-match'], + ['/creation/jump-hop/result', 'jump-hop'], + ['/creation/wooden-fish/result', 'wooden-fish'], + ] as const; + + cases.forEach(([pathname, kind]) => { + expect(resolveCreationUrlRestoreTarget(pathname, state)).toMatchObject({ + kind, + sessionId: 'session-1', + profileId: 'profile-1', + draftId: 'draft-1', + workId: 'work-1', + isGeneratingPath: false, + }); + }); + }); + + test('normalizes creation url restore target values and generating paths', () => { + expect( + resolveCreationUrlRestoreTarget('/creation/jump-hop/generating', { + sessionId: ' ', + profileId: ' jump-profile-1 ', + draftId: undefined, + workId: null, + }), + ).toEqual({ + kind: 'jump-hop', + sessionId: null, + profileId: 'jump-profile-1', + draftId: null, + workId: null, + isGeneratingPath: true, + }); + }); + + test('derives big fish restore session from work id when needed', () => { + expect( + resolveCreationUrlRestoreTarget('/creation/big-fish/result', { + workId: 'big-fish-work-river', + }), + ).toEqual({ + kind: 'big-fish', + sessionId: null, + profileId: null, + draftId: null, + workId: 'big-fish-work-river', + isGeneratingPath: false, + bigFishSessionId: 'river', + }); + + expect( + resolveCreationUrlRestoreTarget('/creation/big-fish/result', { + sessionId: 'big-fish-session-carp', + workId: 'big-fish-work-river', + }), + ).toMatchObject({ + kind: 'big-fish', + bigFishSessionId: 'big-fish-session-carp', + }); + }); + + test('keeps unsupported creation paths without a concrete restore target', () => { + expect( + resolveCreationUrlRestoreTarget('/creation/rpg/result', { + sessionId: 'rpg-session-1', + }), + ).toBeNull(); + expect( + resolveCreationUrlRestoreTarget('/creation/unknown/result', { + sessionId: 'unknown-session-1', + }), + ).toBeNull(); + expect( + resolveCreationUrlRestoreTarget('/creation/big-fishery/result', { + sessionId: 'big-fish-session-1', + }), + ).toBeNull(); + expect( + resolveCreationUrlRestoreTarget('/works/detail', { + workId: 'work-1', + }), + ).toBeNull(); + }); + test('builds creation restore state for core session based plays', () => { expect( buildBigFishCreationUrlState({ diff --git a/src/components/platform-entry/platformCreationUrlStateModel.ts b/src/components/platform-entry/platformCreationUrlStateModel.ts index 0961b1fb..fbf366fb 100644 --- a/src/components/platform-entry/platformCreationUrlStateModel.ts +++ b/src/components/platform-entry/platformCreationUrlStateModel.ts @@ -60,6 +60,93 @@ export function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) { ].join('|'); } +export type CreationUrlRestoreTargetKind = + | 'big-fish' + | 'match3d' + | 'square-hole' + | 'puzzle' + | 'visual-novel' + | 'bark-battle' + | 'baby-object-match' + | 'jump-hop' + | 'wooden-fish'; + +type CreationUrlRestoreTargetBase = { + kind: CreationUrlRestoreTargetKind; + sessionId: string | null; + profileId: string | null; + draftId: string | null; + workId: string | null; + isGeneratingPath: boolean; +}; + +export type CreationUrlRestoreTarget = + | (CreationUrlRestoreTargetBase & { + kind: 'big-fish'; + bigFishSessionId: string | null; + }) + | (CreationUrlRestoreTargetBase & { + kind: Exclude; + }); + +type NonBigFishCreationUrlRestoreTarget = Extract< + CreationUrlRestoreTarget, + { kind: Exclude } +>; + +const CREATION_URL_RESTORE_TARGET_ROUTES = [ + ['/creation/big-fish', 'big-fish'], + ['/creation/match3d', 'match3d'], + ['/creation/square-hole', 'square-hole'], + ['/creation/puzzle', 'puzzle'], + ['/creation/visual-novel', 'visual-novel'], + ['/creation/bark-battle', 'bark-battle'], + ['/creation/baby-object-match', 'baby-object-match'], + ['/creation/jump-hop', 'jump-hop'], + ['/creation/wooden-fish', 'wooden-fish'], +] as const satisfies readonly (readonly [ + string, + CreationUrlRestoreTargetKind, +])[]; + +export function resolveCreationUrlRestoreTarget( + pathname: string | undefined, + state: CreationUrlState, +): CreationUrlRestoreTarget | null { + const path = pathname?.trim() ?? ''; + const route = CREATION_URL_RESTORE_TARGET_ROUTES.find(([prefix]) => + path === prefix || path.startsWith(`${prefix}/`), + ); + if (!route) { + return null; + } + + const kind = route[1]; + const sessionId = normalizeCreationUrlValue(state.sessionId); + const profileId = normalizeCreationUrlValue(state.profileId); + const draftId = normalizeCreationUrlValue(state.draftId); + const workId = normalizeCreationUrlValue(state.workId); + const base = { + kind, + sessionId, + profileId, + draftId, + workId, + isGeneratingPath: path.includes('/generating'), + }; + + if (kind === 'big-fish') { + return { + ...base, + kind, + bigFishSessionId: + sessionId ?? workId?.replace(/^big-fish-work-/u, '') ?? null, + }; + } + + return base as NonBigFishCreationUrlRestoreTarget; +} + export type InitialCreationUrlRestoreDecision = | { type: 'skip' } | { type: 'mark-handled' } From e6e0f931023f86e06e3b544a4fa6a61d0eb52f7f Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 02:04:53 +0800 Subject: [PATCH 37/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E6=81=A2=E5=A4=8D=E8=BA=AB=E4=BB=BD=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 1 + docs/README.md | 2 +- ...】CreationUrlStateModel收口计划-2026-06-03.md | 4 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- .../PlatformEntryFlowShellImpl.tsx | 70 +++++----- .../platformCreationUrlStateModel.test.ts | 130 +++++++++++++++++ .../platformCreationUrlStateModel.ts | 132 ++++++++++++++++-- 7 files changed, 288 insertions(+), 53 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 24fa12c2..4e03c97c 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1341,6 +1341,7 @@ - 决策:新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module,Interface 收口各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、URL state 非空判断和 runtime state key;新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,`platformDraftGenerationShelfModel.ts` 仅 re-export 旧入口以保持兼容。`PlatformEntryFlowShellImpl.tsx` 只保留路由、URL 写入和网络副作用 Adapter。 - 追加决策:初始创作 URL 恢复的已处理、非创作路径、无私有 query、平台配置加载中、受保护数据暂不可读与可恢复判定也收口到 `resolveInitialCreationUrlRestoreDecision`;壳层只按 `skip`、`mark-handled`、`wait`、`restore` 执行 ref 标记或进入原恢复副作用。 - 追加决策:创作直达恢复目标解析收口到 `resolveCreationUrlRestoreTarget(pathname, state)`;Module 统一识别 big-fish、match3d、square-hole、puzzle、visual-novel、bark-battle、baby-object-match、jump-hop、wooden-fish 的 path、私有 query 归一化、生成路径标记和 big-fish workId 到 sessionId 兜底。壳层仍执行作品列表读取、草稿恢复、错误处理、stage 切换和 URL 写回;`/creation/rpg` 继续保持无具体恢复目标,后续要接入需先补规则与测试。 +- 追加决策:创作 URL 恢复的作品 / 草稿身份匹配谓词、以及跳一跳 / 敲木鱼恢复后的阶段落点也归入 `platformCreationUrlStateModel.ts`。身份匹配只允许非空目标值命中,避免 query 缺失时用空值误开草稿;壳层只把已读取的列表项、session 或 work 交给 Module 判定,然后执行对应打开 / restore 副作用。 - 影响范围:创作流程刷新恢复、拼图草稿 / 发布 runtime 深链、作品架打开试玩、跳一跳 / 敲木鱼 work-backed 恢复、Bark Battle / 宝贝识物本地草稿恢复。 - 验证方式:`npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts`、`npm run test -- src/services/creationUrlState.test.ts`、`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index 53de69ab..036b6d89 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,7 +49,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 +平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md index 340f4220..a6612bb7 100644 --- a/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md @@ -7,7 +7,7 @@ ## 决策 - 新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module。 -- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、`normalizeCreationUrlValue`、`hasCreationUrlStateValue`、`hasPuzzleRuntimeUrlStateValue`、`buildPuzzleRuntimeUrlStateKey`、初始创作 URL 恢复判定 `resolveInitialCreationUrlRestoreDecision`,以及创作直达恢复目标解析 `resolveCreationUrlRestoreTarget`。 +- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、`normalizeCreationUrlValue`、`hasCreationUrlStateValue`、`hasPuzzleRuntimeUrlStateValue`、`buildPuzzleRuntimeUrlStateKey`、初始创作 URL 恢复判定 `resolveInitialCreationUrlRestoreDecision`、创作直达恢复目标解析 `resolveCreationUrlRestoreTarget`、恢复目标身份匹配谓词,以及跳一跳 / 敲木鱼恢复后的阶段落点判定。 - 新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,统一 `puzzle-session-*`、`puzzle-profile-*`、`puzzle-work-*` 的互推规则。 - `PlatformEntryFlowShellImpl.tsx` 保留 React state、路由、登录门禁、网络请求和 URL 写入副作用 Adapter;不再在壳层内定义各玩法 URL 状态构造函数,也不直接内联初始恢复的已处理 / 等待 / 可恢复判定。 @@ -20,6 +20,8 @@ - 拼图 draft runtime 若没有 `sourceSessionId`,只允许从 `puzzle-profile-*` 反推出 `puzzle-session-*`。 - 初始创作 URL 恢复只在未处理、当前路径属于创作恢复路径、私有 query 有值、平台配置加载完成且受保护数据可读时执行;非创作路径或无私有 query 时标记已处理,加载中或暂不可读时等待。 - 创作直达恢复目标由 `resolveCreationUrlRestoreTarget(pathname, state)` 统一识别;它只返回玩法 kind、归一化后的四个私有 query、生成路径标记和大鱼吃小鱼 session 兜底,不执行网络请求、草稿打开、stage 切换或 URL 写回。 +- 作品 / 草稿身份匹配只允许非空目标值命中,避免 query 缺失时用 `null` / 空值误匹配到无效草稿。匹配谓词仍只判断身份,不触发列表读取或打开动作。 +- 跳一跳和敲木鱼的恢复阶段落点由 `resolveJumpHopCreationUrlRestoreStage` 与 `resolveWoodenFishCreationUrlRestoreStage` 决定;生成路径优先进入生成页,否则按是否恢复到 draft / work 落到结果页或工作台。 - `/creation/rpg` 当前仍不归入具体恢复目标;若后续要恢复 RPG 直达,需要先补明确恢复规则和测试,不得让壳层重新内联路径判定。 ## Depth / Leverage / Locality diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 97d37305..b40e8bde 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -8,7 +8,7 @@ 当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。 -创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记和大鱼吃小鱼 workId 兜底统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 +创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记、大鱼吃小鱼 workId 兜底、作品 / 草稿身份匹配和跳一跳 / 敲木鱼恢复阶段落点统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 0d0f5b4c..04e18324 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -408,9 +408,16 @@ import { buildVisualNovelCreationUrlState, buildWoodenFishCreationUrlState, hasPuzzleRuntimeUrlStateValue, + matchesBabyObjectMatchCreationUrlRestoreTarget, + matchesBarkBattleCreationUrlRestoreTarget, + matchesBigFishCreationUrlRestoreTarget, + matchesSessionProfileWorkCreationUrlRestoreTarget, + matchesVisualNovelCreationUrlRestoreTarget, normalizeCreationUrlValue, resolveCreationUrlRestoreTarget, resolveInitialCreationUrlRestoreDecision, + resolveJumpHopCreationUrlRestoreStage, + resolveWoodenFishCreationUrlRestoreStage, } from './platformCreationUrlStateModel'; import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow'; import { @@ -12150,7 +12157,7 @@ export function PlatformEntryFlowShellImpl({ if (!target) { return; } - const { sessionId, profileId, draftId, workId } = target; + const { sessionId, profileId } = target; if (target.kind === 'big-fish') { const targetSessionId = target.bigFishSessionId; @@ -12159,10 +12166,8 @@ export function PlatformEntryFlowShellImpl({ (bigFishWorks.length > 0 ? bigFishWorks : (await listBigFishWorks().catch(() => ({ items: [] }))).items - ).find( - (item) => - item.sourceSessionId === targetSessionId || - item.workId === workId, + ).find((item) => + matchesBigFishCreationUrlRestoreTarget(item, target), ) ?? null; if (matchedWork) { await openBigFishDraft(matchedWork); @@ -12180,11 +12185,8 @@ export function PlatformEntryFlowShellImpl({ : mapMatch3DWorksForRuntimeUi( (await listMatch3DWorks().catch(() => ({ items: [] }))).items, ) - ).find( - (item) => - item.sourceSessionId === sessionId || - item.profileId === profileId || - item.workId === workId, + ).find((item) => + matchesSessionProfileWorkCreationUrlRestoreTarget(item, target), ) ?? null; if (matchedWork) { await openMatch3DDraft(matchedWork, { forceDraft: true }); @@ -12201,11 +12203,8 @@ export function PlatformEntryFlowShellImpl({ (squareHoleWorks.length > 0 ? squareHoleWorks : (await listSquareHoleWorks().catch(() => ({ items: [] }))).items - ).find( - (item) => - item.sourceSessionId === sessionId || - item.profileId === profileId || - item.workId === workId, + ).find((item) => + matchesSessionProfileWorkCreationUrlRestoreTarget(item, target), ) ?? null; if (matchedWork) { await openSquareHoleDraft(matchedWork, { forceDraft: true }); @@ -12222,11 +12221,8 @@ export function PlatformEntryFlowShellImpl({ (puzzleWorks.length > 0 ? puzzleWorks : (await listPuzzleWorks().catch(() => ({ items: [] }))).items - ).find( - (item) => - item.sourceSessionId === sessionId || - item.profileId === profileId || - item.workId === workId, + ).find((item) => + matchesSessionProfileWorkCreationUrlRestoreTarget(item, target), ) ?? null; if (matchedWork) { await openPuzzleDraft(matchedWork); @@ -12243,7 +12239,9 @@ export function PlatformEntryFlowShellImpl({ (visualNovelWorks.length > 0 ? visualNovelWorks : (await listVisualNovelWorks().catch(() => ({ works: [] }))).works - ).find((item) => item.profileId === profileId) ?? null; + ).find((item) => + matchesVisualNovelCreationUrlRestoreTarget(item, target), + ) ?? null; if (matchedWork) { await openVisualNovelDraft(matchedWork, { forceDraft: true }); return; @@ -12259,8 +12257,8 @@ export function PlatformEntryFlowShellImpl({ (barkBattleWorks.length > 0 ? barkBattleWorks : (await listBarkBattleWorks().catch(() => ({ items: [] }))).items - ).find( - (item) => item.workId === workId || item.draftId === draftId, + ).find((item) => + matchesBarkBattleCreationUrlRestoreTarget(item, target), ) ?? null; if (matchedWork) { openBarkBattleDraft(matchedWork, { forceDraft: true }); @@ -12273,11 +12271,8 @@ export function PlatformEntryFlowShellImpl({ (babyObjectMatchDrafts.length > 0 ? babyObjectMatchDrafts : await listLocalBabyObjectMatchDrafts().catch(() => []) - ).find( - (item) => - item.profileId === profileId || - item.draftId === draftId || - item.profileId === workId, + ).find((item) => + matchesBabyObjectMatchCreationUrlRestoreTarget(item, target), ) ?? null; if (matchedDraft) { openBabyObjectMatchDraft(matchedDraft); @@ -12313,11 +12308,11 @@ export function PlatformEntryFlowShellImpl({ ); enterCreateTab(); setSelectionStage( - target.isGeneratingPath - ? 'jump-hop-generating' - : session?.draft || work - ? 'jump-hop-result' - : 'jump-hop-workspace', + resolveJumpHopCreationUrlRestoreStage({ + isGeneratingPath: target.isGeneratingPath, + hasRestoredDraft: Boolean(session?.draft), + hasRestoredWork: Boolean(work), + }), ); } catch (error) { setJumpHopError( @@ -12344,11 +12339,10 @@ export function PlatformEntryFlowShellImpl({ ); enterCreateTab(); setSelectionStage( - target.isGeneratingPath - ? 'wooden-fish-generating' - : session.draft - ? 'wooden-fish-result' - : 'wooden-fish-workspace', + resolveWoodenFishCreationUrlRestoreStage({ + isGeneratingPath: target.isGeneratingPath, + hasRestoredDraft: Boolean(session.draft), + }), ); } catch (error) { setWoodenFishError( diff --git a/src/components/platform-entry/platformCreationUrlStateModel.test.ts b/src/components/platform-entry/platformCreationUrlStateModel.test.ts index b7f7cbf4..6e8a5bc9 100644 --- a/src/components/platform-entry/platformCreationUrlStateModel.test.ts +++ b/src/components/platform-entry/platformCreationUrlStateModel.test.ts @@ -31,9 +31,16 @@ import { buildWoodenFishCreationUrlState, hasCreationUrlStateValue, hasPuzzleRuntimeUrlStateValue, + matchesBabyObjectMatchCreationUrlRestoreTarget, + matchesBarkBattleCreationUrlRestoreTarget, + matchesBigFishCreationUrlRestoreTarget, + matchesSessionProfileWorkCreationUrlRestoreTarget, + matchesVisualNovelCreationUrlRestoreTarget, normalizeCreationUrlValue, resolveCreationUrlRestoreTarget, resolveInitialCreationUrlRestoreDecision, + resolveJumpHopCreationUrlRestoreStage, + resolveWoodenFishCreationUrlRestoreStage, } from './platformCreationUrlStateModel'; describe('platformCreationUrlStateModel', () => { @@ -193,6 +200,129 @@ describe('platformCreationUrlStateModel', () => { ).toBeNull(); }); + test('matches restore targets against work and draft identities', () => { + const bigFishTarget = resolveCreationUrlRestoreTarget( + '/creation/big-fish/result', + { + workId: 'big-fish-work-river', + }, + ); + expect(bigFishTarget?.kind).toBe('big-fish'); + if (bigFishTarget?.kind !== 'big-fish') { + throw new Error('big fish target expected'); + } + expect( + matchesBigFishCreationUrlRestoreTarget( + { sourceSessionId: 'river' }, + bigFishTarget, + ), + ).toBe(true); + expect( + matchesBigFishCreationUrlRestoreTarget( + { workId: 'big-fish-work-river' }, + bigFishTarget, + ), + ).toBe(true); + + const target = { + sessionId: 'session-1', + profileId: 'profile-1', + draftId: 'draft-1', + workId: 'work-1', + }; + expect( + matchesSessionProfileWorkCreationUrlRestoreTarget( + { sourceSessionId: 'session-1' }, + target, + ), + ).toBe(true); + expect( + matchesSessionProfileWorkCreationUrlRestoreTarget( + { profileId: 'profile-1' }, + target, + ), + ).toBe(true); + expect( + matchesSessionProfileWorkCreationUrlRestoreTarget( + { workId: 'work-1' }, + target, + ), + ).toBe(true); + expect( + matchesVisualNovelCreationUrlRestoreTarget( + { profileId: 'profile-1' }, + target, + ), + ).toBe(true); + expect( + matchesBarkBattleCreationUrlRestoreTarget( + { draftId: 'draft-1' }, + target, + ), + ).toBe(true); + expect( + matchesBabyObjectMatchCreationUrlRestoreTarget( + { profileId: 'work-1' }, + target, + ), + ).toBe(true); + expect( + matchesSessionProfileWorkCreationUrlRestoreTarget( + { sourceSessionId: null, profileId: null, workId: null }, + { sessionId: null, profileId: null, workId: null }, + ), + ).toBe(false); + expect( + matchesBarkBattleCreationUrlRestoreTarget( + { workId: null, draftId: null }, + { workId: null, draftId: null }, + ), + ).toBe(false); + }); + + test('resolves work backed restore stages', () => { + expect( + resolveJumpHopCreationUrlRestoreStage({ + isGeneratingPath: true, + hasRestoredDraft: false, + hasRestoredWork: true, + }), + ).toBe('jump-hop-generating'); + expect( + resolveJumpHopCreationUrlRestoreStage({ + isGeneratingPath: false, + hasRestoredDraft: false, + hasRestoredWork: true, + }), + ).toBe('jump-hop-result'); + expect( + resolveJumpHopCreationUrlRestoreStage({ + isGeneratingPath: false, + hasRestoredDraft: false, + hasRestoredWork: false, + }), + ).toBe('jump-hop-workspace'); + + expect( + resolveWoodenFishCreationUrlRestoreStage({ + isGeneratingPath: true, + hasRestoredDraft: true, + }), + ).toBe('wooden-fish-generating'); + expect( + resolveWoodenFishCreationUrlRestoreStage({ + isGeneratingPath: false, + hasRestoredDraft: true, + }), + ).toBe('wooden-fish-result'); + expect( + resolveWoodenFishCreationUrlRestoreStage({ + isGeneratingPath: false, + hasRestoredDraft: false, + }), + ).toBe('wooden-fish-workspace'); + }); + test('builds creation restore state for core session based plays', () => { expect( buildBigFishCreationUrlState({ diff --git a/src/components/platform-entry/platformCreationUrlStateModel.ts b/src/components/platform-entry/platformCreationUrlStateModel.ts index fbf366fb..45a153dc 100644 --- a/src/components/platform-entry/platformCreationUrlStateModel.ts +++ b/src/components/platform-entry/platformCreationUrlStateModel.ts @@ -20,6 +20,7 @@ import type { WoodenFishSessionSnapshotResponse, WoodenFishWorkProfileResponse, } from '../../services/wooden-fish/woodenFishClient'; +import type { SelectionStage } from './platformEntryTypes'; import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, @@ -80,19 +81,43 @@ type CreationUrlRestoreTargetBase = { isGeneratingPath: boolean; }; -export type CreationUrlRestoreTarget = - | (CreationUrlRestoreTargetBase & { - kind: 'big-fish'; - bigFishSessionId: string | null; - }) - | (CreationUrlRestoreTargetBase & { - kind: Exclude; - }); +export type BigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & { + kind: 'big-fish'; + bigFishSessionId: string | null; +}; -type NonBigFishCreationUrlRestoreTarget = Extract< - CreationUrlRestoreTarget, - { kind: Exclude } ->; +type NonBigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & { + kind: Exclude; +}; + +export type CreationUrlRestoreTarget = + | BigFishCreationUrlRestoreTarget + | NonBigFishCreationUrlRestoreTarget; + +export type BigFishRestoreWorkIdentity = { + sourceSessionId?: string | null; + workId?: string | null; +}; + +export type SessionProfileWorkRestoreIdentity = { + sourceSessionId?: string | null; + profileId?: string | null; + workId?: string | null; +}; + +export type ProfileRestoreWorkIdentity = { + profileId?: string | null; +}; + +export type BarkBattleRestoreWorkIdentity = { + workId?: string | null; + draftId?: string | null; +}; + +export type BabyObjectMatchRestoreDraftIdentity = { + profileId?: string | null; + draftId?: string | null; +}; const CREATION_URL_RESTORE_TARGET_ROUTES = [ ['/creation/big-fish', 'big-fish'], @@ -147,6 +172,89 @@ export function resolveCreationUrlRestoreTarget( return base as NonBigFishCreationUrlRestoreTarget; } +function matchesRestoreValue( + itemValue: string | null | undefined, + targetValue: string | null, +) { + return Boolean(targetValue && itemValue === targetValue); +} + +export function matchesBigFishCreationUrlRestoreTarget( + item: BigFishRestoreWorkIdentity, + target: BigFishCreationUrlRestoreTarget, +) { + return ( + matchesRestoreValue(item.sourceSessionId, target.bigFishSessionId) || + matchesRestoreValue(item.workId, target.workId) + ); +} + +export function matchesSessionProfileWorkCreationUrlRestoreTarget( + item: SessionProfileWorkRestoreIdentity, + target: Pick, +) { + return ( + matchesRestoreValue(item.sourceSessionId, target.sessionId) || + matchesRestoreValue(item.profileId, target.profileId) || + matchesRestoreValue(item.workId, target.workId) + ); +} + +export function matchesVisualNovelCreationUrlRestoreTarget( + item: ProfileRestoreWorkIdentity, + target: Pick, +) { + return matchesRestoreValue(item.profileId, target.profileId); +} + +export function matchesBarkBattleCreationUrlRestoreTarget( + item: BarkBattleRestoreWorkIdentity, + target: Pick, +) { + return ( + matchesRestoreValue(item.workId, target.workId) || + matchesRestoreValue(item.draftId, target.draftId) + ); +} + +export function matchesBabyObjectMatchCreationUrlRestoreTarget( + item: BabyObjectMatchRestoreDraftIdentity, + target: Pick, +) { + return ( + matchesRestoreValue(item.profileId, target.profileId) || + matchesRestoreValue(item.draftId, target.draftId) || + matchesRestoreValue(item.profileId, target.workId) + ); +} + +export function resolveJumpHopCreationUrlRestoreStage(params: { + isGeneratingPath: boolean; + hasRestoredDraft: boolean; + hasRestoredWork: boolean; +}): SelectionStage { + if (params.isGeneratingPath) { + return 'jump-hop-generating'; + } + + return params.hasRestoredDraft || params.hasRestoredWork + ? 'jump-hop-result' + : 'jump-hop-workspace'; +} + +export function resolveWoodenFishCreationUrlRestoreStage(params: { + isGeneratingPath: boolean; + hasRestoredDraft: boolean; +}): SelectionStage { + if (params.isGeneratingPath) { + return 'wooden-fish-generating'; + } + + return params.hasRestoredDraft + ? 'wooden-fish-result' + : 'wooden-fish-workspace'; +} + export type InitialCreationUrlRestoreDecision = | { type: 'skip' } | { type: 'mark-handled' } From 5ba5ca6bf8cfc62a57dd86da342fa98e69059026 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 02:12:52 +0800 Subject: [PATCH 38/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E5=88=9B=E4=BD=9C=E7=8A=B6=E6=80=81=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 1 + docs/README.md | 2 +- ...latformSelectionStageModel收口计划-2026-06-04.md | 8 +- .../PlatformEntryFlowShellImpl.tsx | 120 ++++------ .../platformSelectionStageModel.test.ts | 205 +++++++++++++++++- .../platformSelectionStageModel.ts | 94 ++++++++ 6 files changed, 350 insertions(+), 80 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 4e03c97c..b3a0f51b 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -20,6 +20,7 @@ - 背景:平台入口在受保护数据失效后会清空当前用户私有作品、草稿、运行态和生成状态,但哪些 `SelectionStage` 可保留、哪些必须回首页曾以内联长否定串散在 `PlatformEntryFlowShellImpl.tsx`。 - 决策:新增 `src/components/platform-entry/platformSelectionStageModel.ts`,以 `resolveSelectionStageAfterProtectedDataLoss(stage)` 收口受保护数据失效后的 stage 去留判定。模型内部使用 `satisfies Record` 全量分类,新增 stage 时必须明确保留或回首页。壳层仍负责检测权限变化、清 state 和调用 `setSelectionStage`。 +- 追加决策:缺失草稿 / 作品 / run 时的阶段回退也归入 `platformSelectionStageModel.ts`,由 `resolveSelectionStageAfterMissingCreationState(params)` 统一判断 big-fish、match3d、square-hole、visual-novel 和 baby-object-match 的 result / runtime / gallery-detail 是否还能被当前状态支撑。壳层只汇总布尔事实并按输出 stage 跳转;big-fish、match3d、square-hole 的草稿事实固定来自 `Boolean(session?.draft)`,visual-novel 的 session draft 与 work draft 可独立支撑结果页,baby-object-match runtime 缺 draft 时直接回首页。 - 影响范围:退出登录、鉴权上下文收回、平台入口公开页 / 工作台 / 结果页 / 生成页 / 运行态的阶段恢复规则,以及后续新增 `SelectionStage`。 - 验证方式:`npm run test -- src/components/platform-entry/platformSelectionStageModel.test.ts`、针对新 Module 与壳层执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md`。 diff --git a/docs/README.md b/docs/README.md index 036b6d89..b9287db1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,7 +53,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。 -平台入口受保护数据失效后的 stage 去留判定收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。 +平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md index 4662012a..bb235147 100644 --- a/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md @@ -4,20 +4,24 @@ `PlatformEntryFlowShellImpl.tsx` 在受保护数据失效后会清空当前用户的私有作品、运行态、草稿 notice 和生成状态。清理完成后,壳层还要判断当前 `SelectionStage` 是否还能继续展示:公开首页、公开详情、工作台入口等阶段可保留;结果页、生成页、运行态、个人反馈等依赖私有数据或运行态快照的阶段必须回到首页。 -此前该规则以内联长否定串维护在壳层 **Implementation** 内。新增玩法 stage 或调整登录态行为时,维护者必须在巨型壳层中查找白名单,缺少独立测试面。 +此外,平台壳还曾在多个 `useEffect` 中分别判断 big-fish、match3d、square-hole、visual-novel、baby-object-match 缺少草稿、作品或 run 时应回工作台、结果页还是首页。这类“当前 stage 已不能被现有状态支撑”的规则同样属于 stage 纯判定,不应散在壳层。 + +此前这些规则以内联长否定串或多段相似 effect 维护在壳层 **Implementation** 内。新增玩法 stage 或调整登录态行为时,维护者必须在巨型壳层中查找白名单和状态缺失回退,缺少独立测试面。 ## 决策 新增 `src/components/platform-entry/platformSelectionStageModel.ts` 作为 Platform Selection Stage **Module**。其公开 **Interface** 为: - `resolveSelectionStageAfterProtectedDataLoss(stage)`:输入当前 `SelectionStage`,输出受保护数据失效后应停留的 stage;可保留则原样返回,否则返回 `platform`。 +- `resolveSelectionStageAfterMissingCreationState(params)`:输入当前 `SelectionStage` 与各玩法“是否有 session / draft / run / work / formPayload”等可渲染事实,输出状态缺失后应停留的 stage;仍可展示则原样返回。 -`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责检测受保护数据从可读变为不可读、清空各玩法缓存、重置生成和错误状态,并只在模型输出与当前 stage 不一致时调用 `setSelectionStage(nextStage)`。 +`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责检测受保护数据从可读变为不可读、清空各玩法缓存、重置生成和错误状态,或把当前 React state 汇总为布尔事实,并只在模型输出与当前 stage 不一致时调用 `setSelectionStage(nextStage)`。 ## 约定 - 新增 `SelectionStage` 时,必须判断它在退出登录或鉴权上下文收回后是否仍可展示,并在本 **Module** 的全量 `Record` 与测试中列明。 - 公开列表、公开详情和创作工作台入口可保留;依赖当前用户私有数据、生成 session、运行态 run 或个人资料的 stage 默认回 `platform`。 +- 缺失状态回退只读取壳层传入的布尔事实,不直接读取玩法 session / work / run 对象。big-fish、match3d、square-hole 的草稿事实必须来自 `Boolean(session?.draft)`;visual-novel 的 session draft 与 work draft 可独立支撑结果页;baby-object-match runtime 缺 draft 时不看 formPayload,直接回 `platform`。 - 此 **Module** 不清理 state、不调用路由、不触发登录弹窗,只表达纯 stage 决策。 ## 验收 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 04e18324..f4a7ee9c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -540,7 +540,10 @@ import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, } from './platformPuzzleIdentityModel'; -import { resolveSelectionStageAfterProtectedDataLoss } from './platformSelectionStageModel'; +import { + resolveSelectionStageAfterMissingCreationState, + resolveSelectionStageAfterProtectedDataLoss, +} from './platformSelectionStageModel'; import { PlatformTaskCompletionDialog } from './PlatformTaskCompletionDialog'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; @@ -8861,88 +8864,53 @@ export function PlatformEntryFlowShellImpl({ ); useEffect(() => { - if (selectionStage === 'big-fish-result' && !bigFishSession?.draft) { - setSelectionStage( - bigFishSession ? 'big-fish-agent-workspace' : 'platform', - ); - } - if (selectionStage === 'big-fish-runtime' && !bigFishRun) { - setSelectionStage(bigFishSession?.draft ? 'big-fish-result' : 'platform'); - } - }, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]); + const nextSelectionStage = resolveSelectionStageAfterMissingCreationState({ + stage: selectionStage, + bigFish: { + hasSession: Boolean(bigFishSession), + hasSessionDraft: Boolean(bigFishSession?.draft), + hasRun: Boolean(bigFishRun), + }, + match3d: { + hasSession: Boolean(match3dSession), + hasSessionDraft: Boolean(match3dSession?.draft), + hasRun: Boolean(match3dRun), + }, + squareHole: { + hasSession: Boolean(squareHoleSession), + hasSessionDraft: Boolean(squareHoleSession?.draft), + hasRun: Boolean(squareHoleRun), + }, + visualNovel: { + hasSession: Boolean(visualNovelSession), + hasSessionDraft: Boolean(visualNovelSession?.draft), + hasWork: Boolean(visualNovelWork), + hasWorkDraft: Boolean(visualNovelWork?.draft), + hasRun: Boolean(visualNovelRun), + }, + babyObjectMatch: { + hasDraft: Boolean(babyObjectMatchDraft), + hasFormPayload: Boolean(babyObjectMatchFormPayload), + }, + }); - useEffect(() => { - if (selectionStage === 'match3d-result' && !match3dSession?.draft) { - setSelectionStage( - match3dSession ? 'match3d-agent-workspace' : 'platform', - ); - } - if (selectionStage === 'match3d-runtime' && !match3dRun) { - setSelectionStage(match3dSession?.draft ? 'match3d-result' : 'platform'); - } - }, [match3dRun, match3dSession, selectionStage, setSelectionStage]); - - useEffect(() => { - if (selectionStage === 'square-hole-result' && !squareHoleSession?.draft) { - setSelectionStage( - squareHoleSession ? 'square-hole-agent-workspace' : 'platform', - ); - } - if (selectionStage === 'square-hole-runtime' && !squareHoleRun) { - setSelectionStage( - squareHoleSession?.draft ? 'square-hole-result' : 'platform', - ); - } - }, [selectionStage, setSelectionStage, squareHoleRun, squareHoleSession]); - - useEffect(() => { - if ( - selectionStage === 'visual-novel-result' && - !visualNovelSession?.draft && - !visualNovelWork?.draft - ) { - setSelectionStage( - visualNovelSession ? 'visual-novel-agent-workspace' : 'platform', - ); - } - if (selectionStage === 'visual-novel-runtime' && !visualNovelRun) { - setSelectionStage( - visualNovelSession?.draft || visualNovelWork?.draft - ? 'visual-novel-result' - : 'platform', - ); - } - if (selectionStage === 'visual-novel-gallery-detail' && !visualNovelWork) { - setSelectionStage('platform'); - } - }, [ - selectionStage, - setSelectionStage, - visualNovelRun, - visualNovelSession, - visualNovelWork, - ]); - - useEffect(() => { - if ( - selectionStage === 'baby-object-match-result' && - !babyObjectMatchDraft - ) { - setSelectionStage( - babyObjectMatchFormPayload ? 'baby-object-match-workspace' : 'platform', - ); - } - if ( - selectionStage === 'baby-object-match-runtime' && - !babyObjectMatchDraft - ) { - setSelectionStage('platform'); + if (nextSelectionStage !== selectionStage) { + setSelectionStage(nextSelectionStage); } }, [ babyObjectMatchDraft, babyObjectMatchFormPayload, + bigFishRun, + bigFishSession, + match3dRun, + match3dSession, selectionStage, setSelectionStage, + squareHoleRun, + squareHoleSession, + visualNovelRun, + visualNovelSession, + visualNovelWork, ]); const startBigFishRun = useCallback(async () => { diff --git a/src/components/platform-entry/platformSelectionStageModel.test.ts b/src/components/platform-entry/platformSelectionStageModel.test.ts index 56a7acde..6b53e87b 100644 --- a/src/components/platform-entry/platformSelectionStageModel.test.ts +++ b/src/components/platform-entry/platformSelectionStageModel.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from 'vitest'; import type { SelectionStage } from './platformEntryTypes'; -import { resolveSelectionStageAfterProtectedDataLoss } from './platformSelectionStageModel'; +import { + type MissingCreationStateParams, + resolveSelectionStageAfterMissingCreationState, + resolveSelectionStageAfterProtectedDataLoss, +} from './platformSelectionStageModel'; describe('platformSelectionStageModel', () => { test('keeps public and workspace stages after protected data loss', () => { @@ -72,4 +76,203 @@ describe('platformSelectionStageModel', () => { ); }); }); + + test('resolves missing session draft result stages', () => { + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-result', + bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false }, + }), + ), + ).toBe('big-fish-agent-workspace'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-result', + bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false }, + }), + ), + ).toBe('platform'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'match3d-result', + match3d: { hasSession: true, hasSessionDraft: false, hasRun: false }, + }), + ), + ).toBe('match3d-agent-workspace'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'square-hole-result', + squareHole: { + hasSession: true, + hasSessionDraft: false, + hasRun: false, + }, + }), + ), + ).toBe('square-hole-agent-workspace'); + }); + + test('resolves missing session run stages', () => { + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-runtime', + bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false }, + }), + ), + ).toBe('big-fish-result'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-runtime', + bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false }, + }), + ), + ).toBe('platform'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'match3d-runtime', + match3d: { hasSession: true, hasSessionDraft: true, hasRun: false }, + }), + ), + ).toBe('match3d-result'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'square-hole-runtime', + squareHole: { + hasSession: true, + hasSessionDraft: true, + hasRun: false, + }, + }), + ), + ).toBe('square-hole-result'); + }); + + test('resolves visual novel and baby object missing state stages', () => { + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'visual-novel-result', + visualNovel: { + hasSession: true, + hasSessionDraft: false, + hasWork: false, + hasWorkDraft: false, + hasRun: false, + }, + }), + ), + ).toBe('visual-novel-agent-workspace'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'visual-novel-runtime', + visualNovel: { + hasSession: true, + hasSessionDraft: false, + hasWork: true, + hasWorkDraft: true, + hasRun: false, + }, + }), + ), + ).toBe('visual-novel-result'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'visual-novel-gallery-detail', + visualNovel: { + hasSession: false, + hasSessionDraft: false, + hasWork: false, + hasWorkDraft: false, + hasRun: false, + }, + }), + ), + ).toBe('platform'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'baby-object-match-result', + babyObjectMatch: { hasDraft: false, hasFormPayload: true }, + }), + ), + ).toBe('baby-object-match-workspace'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'baby-object-match-runtime', + babyObjectMatch: { hasDraft: false, hasFormPayload: true }, + }), + ), + ).toBe('platform'); + }); + + test('keeps stages when required creation state exists', () => { + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-result', + bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false }, + }), + ), + ).toBe('big-fish-result'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-runtime', + bigFish: { hasSession: true, hasSessionDraft: true, hasRun: true }, + }), + ), + ).toBe('big-fish-runtime'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'visual-novel-gallery-detail', + visualNovel: { + hasSession: false, + hasSessionDraft: false, + hasWork: true, + hasWorkDraft: false, + hasRun: false, + }, + }), + ), + ).toBe('visual-novel-gallery-detail'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'platform', + }), + ), + ).toBe('platform'); + }); }); + +function buildMissingCreationStateParams( + overrides: Partial = {}, +): MissingCreationStateParams { + return { + stage: 'platform', + bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false }, + match3d: { hasSession: false, hasSessionDraft: false, hasRun: false }, + squareHole: { hasSession: false, hasSessionDraft: false, hasRun: false }, + visualNovel: { + hasSession: false, + hasSessionDraft: false, + hasWork: false, + hasWorkDraft: false, + hasRun: false, + }, + babyObjectMatch: { hasDraft: false, hasFormPayload: false }, + ...overrides, + }; +} diff --git a/src/components/platform-entry/platformSelectionStageModel.ts b/src/components/platform-entry/platformSelectionStageModel.ts index 33dca39c..86bd0bea 100644 --- a/src/components/platform-entry/platformSelectionStageModel.ts +++ b/src/components/platform-entry/platformSelectionStageModel.ts @@ -57,3 +57,97 @@ export function resolveSelectionStageAfterProtectedDataLoss( ): SelectionStage { return PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE[stage] ? stage : 'platform'; } + +type SessionDraftRunState = { + hasSession: boolean; + hasSessionDraft: boolean; + hasRun: boolean; +}; + +type VisualNovelCreationState = { + hasSession: boolean; + hasSessionDraft: boolean; + hasWork: boolean; + hasWorkDraft: boolean; + hasRun: boolean; +}; + +type BabyObjectMatchCreationState = { + hasDraft: boolean; + hasFormPayload: boolean; +}; + +export type MissingCreationStateParams = { + stage: SelectionStage; + bigFish: SessionDraftRunState; + match3d: SessionDraftRunState; + squareHole: SessionDraftRunState; + visualNovel: VisualNovelCreationState; + babyObjectMatch: BabyObjectMatchCreationState; +}; + +export function resolveSelectionStageAfterMissingCreationState( + params: MissingCreationStateParams, +): SelectionStage { + const { stage } = params; + + if (stage === 'big-fish-result' && !params.bigFish.hasSessionDraft) { + return params.bigFish.hasSession ? 'big-fish-agent-workspace' : 'platform'; + } + if (stage === 'big-fish-runtime' && !params.bigFish.hasRun) { + return params.bigFish.hasSessionDraft ? 'big-fish-result' : 'platform'; + } + + if (stage === 'match3d-result' && !params.match3d.hasSessionDraft) { + return params.match3d.hasSession ? 'match3d-agent-workspace' : 'platform'; + } + if (stage === 'match3d-runtime' && !params.match3d.hasRun) { + return params.match3d.hasSessionDraft ? 'match3d-result' : 'platform'; + } + + if (stage === 'square-hole-result' && !params.squareHole.hasSessionDraft) { + return params.squareHole.hasSession + ? 'square-hole-agent-workspace' + : 'platform'; + } + if (stage === 'square-hole-runtime' && !params.squareHole.hasRun) { + return params.squareHole.hasSessionDraft + ? 'square-hole-result' + : 'platform'; + } + + if ( + stage === 'visual-novel-result' && + !params.visualNovel.hasSessionDraft && + !params.visualNovel.hasWorkDraft + ) { + return params.visualNovel.hasSession + ? 'visual-novel-agent-workspace' + : 'platform'; + } + if (stage === 'visual-novel-runtime' && !params.visualNovel.hasRun) { + return params.visualNovel.hasSessionDraft || params.visualNovel.hasWorkDraft + ? 'visual-novel-result' + : 'platform'; + } + if (stage === 'visual-novel-gallery-detail' && !params.visualNovel.hasWork) { + return 'platform'; + } + + if ( + stage === 'baby-object-match-result' && + !params.babyObjectMatch.hasDraft + ) { + return params.babyObjectMatch.hasFormPayload + ? 'baby-object-match-workspace' + : 'platform'; + } + if ( + stage === 'baby-object-match-runtime' && + !params.babyObjectMatch.hasDraft + ) { + return 'platform'; + } + + return stage; +} From 83ae363670b7332271eac7d41dec71d2a075dc34 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 02:20:48 +0800 Subject: [PATCH 39/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E5=85=A5=E5=8F=A3=E5=90=AF=E5=8A=A8=E6=84=8F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...latformCreationLaunchModel收口计划-2026-06-04.md | 29 +++++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- .../PlatformEntryFlowShellImpl.tsx | 101 +++++++----------- .../platformCreationLaunchModel.test.ts | 76 +++++++++++++ .../platformCreationLaunchModel.ts | 87 +++++++++++++++ 7 files changed, 243 insertions(+), 62 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformCreationLaunchModel.test.ts create mode 100644 src/components/platform-entry/platformCreationLaunchModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b3a0f51b..5d3d571c 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-04 Platform Creation Launch Model 收口 + +- 背景:平台创作入口点击回调曾在 `PlatformEntryFlowShellImpl.tsx` 内联判断 `airp` 占位、隐藏的 `baby-object-match`、未知入口和各玩法工作台启动目标,壳层同时承接入口 ID 规则、启动前准备顺序和副作用。 +- 决策:新增 `src/components/platform-entry/platformCreationLaunchModel.ts`,以 `resolvePlatformCreationLaunchIntent({ type, isBabyObjectMatchVisible })` 收口创作入口启动意图。`airp` 返回 `noop` 且不触发 `prepareCreationLaunch()`;隐藏 `baby-object-match` 返回 blocked intent 且仍在 prepare 后显示 `EDUTAINMENT_HIDDEN_MESSAGE`;未知入口保持旧语义,先 prepare 后 no-op;已知入口返回稳定 launch target。壳层只执行 prepare、错误提示和 `runProtectedAction(...)`。 +- 影响范围:底部加号创作入口模板卡点击、入口可见性拦截、后续新增可启动模板的 launch target 接入。 +- 验证方式:`npm run test -- src/components/platform-entry/platformCreationLaunchModel.test.ts`、针对新 Module 与壳层执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md`。 + ## 2026-06-04 Platform Selection Stage Model 收口 - 背景:平台入口在受保护数据失效后会清空当前用户私有作品、草稿、运行态和生成状态,但哪些 `SelectionStage` 可保留、哪些必须回首页曾以内联长否定串散在 `PlatformEntryFlowShellImpl.tsx`。 diff --git a/docs/README.md b/docs/README.md index b9287db1..4d22a415 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 作品架删除确认的标题、删除说明、草稿 notice key 和拼图派生稳定 ID 收口到 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts`,平台壳只保留删除 API、刷新、错误和页面跳转副作用,规则见 [【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md](./technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md)。 +创作入口点击的占位、隐藏模板拦截、未知入口 no-op 与工作台启动目标收口到 `src/components/platform-entry/platformCreationLaunchModel.ts`,壳层只执行启动前准备、错误提示和受保护动作,规则见 [【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md)。 + 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md new file mode 100644 index 00000000..bbcd2525 --- /dev/null +++ b/docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md @@ -0,0 +1,29 @@ +# 【前端架构】Platform Creation Launch Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 的创作入口点击回调曾直接以内联 `if` 链判断 `airp` 占位、隐藏的 `baby-object-match`、RPG 与各小游戏工作台启动目标。壳层因此同时理解入口 ID、是否需要执行启动前准备、隐藏模板错误文案和具体工作台分流。 + +这类规则属于创作入口启动意图。壳层应只执行准备、错误提示和受保护动作,不应持有入口 ID 到工作台目标的长链判定。 + +## 决策 + +新增 `src/components/platform-entry/platformCreationLaunchModel.ts` 作为 Platform Creation Launch **Module**。其公开 **Interface** 为: + +- `resolvePlatformCreationLaunchIntent({ type, isBabyObjectMatchVisible })`:输入后端入口配置下发的模板 ID 与幼教入口可见性,输出 `noop`、`blocked` 或 `launch` 意图。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:根据 intent 决定是否调用 `prepareCreationLaunch()`,对 blocked intent 写入 `sessionController.setCreationTypeError(...)`,对 launch intent 进入 `runProtectedAction(...)` 并调用具体工作台打开函数。 + +## 约定 + +- `airp` 是占位入口,必须在 `prepareCreationLaunch()` 之前返回 `noop`,避免触发新游戏初始化、返回目标复位或错误清理。 +- 隐藏的 `baby-object-match` 必须在 `prepareCreationLaunch()` 之后返回 blocked intent,错误文案仍使用 `EDUTAINMENT_HIDDEN_MESSAGE`。 +- 未知入口 ID 保持旧语义:先允许壳层执行启动前准备,再作为 `noop` 结束,避免改变未来后端配置异常时的准备流程。 +- 新增可启动模板时,先在本 **Module** 的 launch target union、目标集合和测试中列明,再在壳层 Adapter 中补具体启动函数。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformCreationLaunchModel.test.ts` +- `npx eslint src/components/platform-entry/platformCreationLaunchModel.ts src/components/platform-entry/platformCreationLaunchModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index b40e8bde..985aa6ae 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -6,7 +6,7 @@ 创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。 -当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。 +当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页;模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。 创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记、大鱼吃小鱼 workId 兜底、作品 / 草稿身份匹配和跳一跳 / 敲木鱼恢复阶段落点统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index f4a7ee9c..79642a0a 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -394,6 +394,10 @@ import { mergeBarkBattleWorkSummary, shouldPreserveLocalBarkBattleWorkOnRefresh, } from './barkBattleWorkCache'; +import { + type PlatformCreationLaunchTarget, + resolvePlatformCreationLaunchIntent, +} from './platformCreationLaunchModel'; import { buildBabyObjectMatchCreationUrlState, buildBarkBattleCreationUrlState, @@ -6701,7 +6705,12 @@ export function PlatformEntryFlowShellImpl({ const handleCreationHubCreateType = useCallback( (type: PlatformCreationTypeId) => { - if (type === 'airp') { + const intent = resolvePlatformCreationLaunchIntent({ + type, + isBabyObjectMatchVisible, + }); + + if (!intent.shouldPrepare) { return; } @@ -6709,79 +6718,49 @@ export function PlatformEntryFlowShellImpl({ return; } - if (type === 'baby-object-match' && !isBabyObjectMatchVisible) { - sessionController.setCreationTypeError(EDUTAINMENT_HIDDEN_MESSAGE); + if (intent.type === 'blocked') { + sessionController.setCreationTypeError(intent.message); return; } - if (type === 'rpg') { - runProtectedAction(() => { + if (intent.type !== 'launch') { + return; + } + + const launchers = { + rpg: () => { void sessionController.openRpgAgentWorkspace(); - }); - return; - } - - if (type === 'big-fish') { - runProtectedAction(() => { + }, + 'big-fish': () => { void openBigFishAgentWorkspace(); - }); - return; - } - - if (type === 'match3d') { - runProtectedAction(() => { + }, + match3d: () => { void openMatch3DWorkspace(); - }); - return; - } - - if (type === 'square-hole') { - runProtectedAction(() => { + }, + 'square-hole': () => { void openSquareHoleAgentWorkspace(); - }); - return; - } - - if (type === 'jump-hop') { - runProtectedAction(() => { + }, + 'jump-hop': () => { void openJumpHopWorkspace(); - }); - return; - } - - if (type === 'wooden-fish') { - runProtectedAction(() => { + }, + 'wooden-fish': () => { void openWoodenFishWorkspace(); - }); - return; - } - - if (type === 'puzzle') { - runProtectedAction(() => { + }, + puzzle: () => { void openPuzzleWorkspace(); - }); - return; - } - - if (type === 'bark-battle') { - runProtectedAction(() => { + }, + 'bark-battle': () => { void openBarkBattleWorkspace(); - }); - return; - } - - if (type === 'visual-novel') { - runProtectedAction(() => { + }, + 'visual-novel': () => { void openVisualNovelWorkspace(); - }); - return; - } - - if (type === 'baby-object-match') { - runProtectedAction(() => { + }, + 'baby-object-match': () => { void openBabyObjectMatchWorkspace(); - }); - } + }, + } satisfies Record void>; + + runProtectedAction(launchers[intent.target]); }, [ isBabyObjectMatchVisible, diff --git a/src/components/platform-entry/platformCreationLaunchModel.test.ts b/src/components/platform-entry/platformCreationLaunchModel.test.ts new file mode 100644 index 00000000..ccaa9977 --- /dev/null +++ b/src/components/platform-entry/platformCreationLaunchModel.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'vitest'; + +import { + type PlatformCreationLaunchTarget, + resolvePlatformCreationLaunchIntent, +} from './platformCreationLaunchModel'; +import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility'; + +describe('platformCreationLaunchModel', () => { + test('keeps airp as a placeholder noop before prepare', () => { + expect( + resolvePlatformCreationLaunchIntent({ + type: 'airp', + isBabyObjectMatchVisible: true, + }), + ).toEqual({ + type: 'noop', + shouldPrepare: false, + reason: 'placeholder', + }); + }); + + test('blocks hidden baby object match after prepare', () => { + expect( + resolvePlatformCreationLaunchIntent({ + type: 'baby-object-match', + isBabyObjectMatchVisible: false, + }), + ).toEqual({ + type: 'blocked', + shouldPrepare: true, + message: EDUTAINMENT_HIDDEN_MESSAGE, + }); + }); + + test('resolves known creation launch targets', () => { + const targets: PlatformCreationLaunchTarget[] = [ + 'rpg', + 'big-fish', + 'match3d', + 'square-hole', + 'jump-hop', + 'wooden-fish', + 'puzzle', + 'bark-battle', + 'visual-novel', + 'baby-object-match', + ]; + + targets.forEach((target) => { + expect( + resolvePlatformCreationLaunchIntent({ + type: target, + isBabyObjectMatchVisible: true, + }), + ).toEqual({ + type: 'launch', + shouldPrepare: true, + target, + }); + }); + }); + + test('keeps unknown creation type as a prepared noop', () => { + expect( + resolvePlatformCreationLaunchIntent({ + type: 'unknown-template', + isBabyObjectMatchVisible: true, + }), + ).toEqual({ + type: 'noop', + shouldPrepare: true, + reason: 'unknown', + }); + }); +}); diff --git a/src/components/platform-entry/platformCreationLaunchModel.ts b/src/components/platform-entry/platformCreationLaunchModel.ts new file mode 100644 index 00000000..0b2b210c --- /dev/null +++ b/src/components/platform-entry/platformCreationLaunchModel.ts @@ -0,0 +1,87 @@ +import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility'; +import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; + +export type PlatformCreationLaunchTarget = + | 'rpg' + | 'big-fish' + | 'match3d' + | 'square-hole' + | 'jump-hop' + | 'wooden-fish' + | 'puzzle' + | 'bark-battle' + | 'visual-novel' + | 'baby-object-match'; + +export type PlatformCreationLaunchIntent = + | { + type: 'noop'; + shouldPrepare: false; + reason: 'placeholder'; + } + | { + type: 'noop'; + shouldPrepare: true; + reason: 'unknown'; + } + | { + type: 'blocked'; + shouldPrepare: true; + message: string; + } + | { + type: 'launch'; + shouldPrepare: true; + target: PlatformCreationLaunchTarget; + }; + +const PLATFORM_CREATION_LAUNCH_TARGETS = new Set([ + 'rpg', + 'big-fish', + 'match3d', + 'square-hole', + 'jump-hop', + 'wooden-fish', + 'puzzle', + 'bark-battle', + 'visual-novel', + 'baby-object-match', +]); + +export function resolvePlatformCreationLaunchIntent(params: { + type: PlatformCreationTypeId; + isBabyObjectMatchVisible: boolean; +}): PlatformCreationLaunchIntent { + if (params.type === 'airp') { + return { + type: 'noop', + shouldPrepare: false, + reason: 'placeholder', + }; + } + + if ( + params.type === 'baby-object-match' && + !params.isBabyObjectMatchVisible + ) { + return { + type: 'blocked', + shouldPrepare: true, + message: EDUTAINMENT_HIDDEN_MESSAGE, + }; + } + + if (!PLATFORM_CREATION_LAUNCH_TARGETS.has(params.type)) { + return { + type: 'noop', + shouldPrepare: true, + reason: 'unknown', + }; + } + + return { + type: 'launch', + shouldPrepare: true, + target: params.type as PlatformCreationLaunchTarget, + }; +} From 4e8cac3856984a2949068314117fbec5228a55ac Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 02:32:08 +0800 Subject: [PATCH 40/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E7=A0=81=E6=90=9C=E7=B4=A2=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...tformPublicCodeSearchModel收口计划-2026-06-04.md | 37 ++++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 4 +- .../PlatformEntryFlowShellImpl.tsx | 171 ++++++------------ .../platformPublicCodeSearchModel.test.ts | 69 +++++++ .../platformPublicCodeSearchModel.ts | 83 +++++++++ 7 files changed, 257 insertions(+), 117 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformPublicCodeSearchModel.test.ts create mode 100644 src/components/platform-entry/platformPublicCodeSearchModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 5d3d571c..7a478fd8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1355,6 +1355,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts`、`npm run test -- src/services/creationUrlState.test.ts`、`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md`。 +## 2026-06-04 Platform Public Code Search Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 的公开搜索回调内联判断内部用户 ID、陶泥号、RPG 作品号、各玩法公开作品号前缀和 fallback 顺序,壳层同时承担纯搜索计划与网络 / 打开副作用。 +- 决策:新增 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,以 `resolvePlatformPublicCodeSearchPlan(keyword)` 返回 `normalizedKeyword` 与 `steps`。`user_` / `user-` 只查用户 ID;玩法前缀直达对应作品;`CW` / 纯数字先查 RPG 作品再查陶泥号;普通关键词和 `SY` 保持既有用户号、RPG 作品、汪汪声浪、用户号兜底顺序。壳层只按 step 执行既有查找、详情打开、Bark Battle runtime 特例和 missing work 归航。 +- 影响范围:发现页 / 推荐页公开搜索、作品详情深链初始搜索、陶泥号命中面板、各玩法公开作品号直达。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 4d22a415..87a3878f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 创作入口点击的占位、隐藏模板拦截、未知入口 no-op 与工作台启动目标收口到 `src/components/platform-entry/platformCreationLaunchModel.ts`,壳层只执行启动前准备、错误提示和受保护动作,规则见 [【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md)。 +平台入口公开码搜索的用户 ID、陶泥号、RPG 作品号、各玩法作品号前缀和失败回退顺序收口到 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,壳层只按计划执行网络读取、详情打开和错误归航副作用,规则见 [【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md)。 + 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md new file mode 100644 index 00000000..07c9ed2d --- /dev/null +++ b/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md @@ -0,0 +1,37 @@ +# 【前端架构】Platform Public Code Search Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 的公开搜索回调曾直接在壳层内判断 `user_` / `user-`、`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB`、`CW`、纯数字和普通关键词的优先级。壳层因此既要持有搜索输入到查找顺序的纯规则,又要执行各玩法公开详情读取、用户读取、运行态启动和错误归航副作用。 + +公开搜索的“先查什么、失败后回退什么”是稳定的分流规则,应有独立测试面;壳层只应作为副作用 Adapter,按计划执行网络读取与打开动作。 + +## 决策 + +新增 `src/components/platform-entry/platformPublicCodeSearchModel.ts` 作为 Platform Public Code Search **Module**。其公开 **Interface** 为: + +- `resolvePlatformPublicCodeSearchPlan(keyword)`:输入用户搜索词,输出 `{ normalizedKeyword, steps }`;空输入返回 `null`。 +- `PlatformPublicCodeSearchStep`:枚举壳层可执行的查找步骤,包括 `user-id`、`public-user-code`、`rpg-work`、各玩法公开作品步骤与 `bark-battle-work`。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `getPublicAuthUserByCode`、各玩法 gallery 刷新 / 详情打开、Bark Battle runtime 特例和 missing work 归航副作用,只按 `steps` 顺序执行,前一步失败才尝试下一步。 + +## Interface 约束 + +- 空白搜索词返回 `null`,壳层不得进入搜索 loading。 +- `user_` / `user-` 开头的内部用户 ID 只执行 `user-id`,不回退作品号。 +- `PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀只进入对应玩法公开作品查找;`M3D-*` 继续归入 `M3` / 抓大鹅。 +- `CW` 与 `1-8` 位纯数字先查 RPG 公开作品,再回退陶泥号。 +- 普通关键词与 `SY` 陶泥号保持既有顺序:先查陶泥号,再查 RPG 公开作品,再查汪汪声浪作品,最后再以陶泥号兜底。 + +## Depth / Leverage / Locality + +- **Depth**:壳层只消费短小的 `steps` Interface,搜索前缀、优先级和回退顺序藏入 Module Implementation。 +- **Leverage**:新增公开作品前缀时,先扩展 Module 的 step union、前缀表和单测,再在壳层 Adapter 绑定对应执行函数。 +- **Locality**:搜索计划规则集中在一个纯 Module;UI、网络、详情打开与 runtime 启动副作用继续留在壳层,避免把副作用 setter 变成浅 Interface。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts` +- `npx eslint src/components/platform-entry/platformPublicCodeSearchModel.ts src/components/platform-entry/platformPublicCodeSearchModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 985aa6ae..4d134bb8 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -1,6 +1,6 @@ # 平台入口与玩法链路 -更新时间:`2026-06-03` +更新时间:`2026-06-04` ## 平台创作入口 @@ -57,6 +57,8 @@ 发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。 +平台公开搜索的分流顺序统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID;`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀分别直达对应玩法公开作品;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链。 + 发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 ## RPG / 自定义世界 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 79642a0a..1ec3bdc3 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -512,6 +512,10 @@ import { resolveMatch3DRuntimeGeneratedBackgroundAsset, resolveMatch3DRuntimeGeneratedItemAssets, } from './platformMatch3DRuntimeProfile'; +import { + type PlatformPublicCodeSearchStep, + resolvePlatformPublicCodeSearchPlan, +} from './platformPublicCodeSearchModel'; import { getPlatformPublicGalleryEntryKey, getPlatformRecommendRuntimeKind, @@ -13449,53 +13453,17 @@ export function PlatformEntryFlowShellImpl({ const handlePublicCodeSearch = useCallback( async (keyword: string) => { - const normalizedKeyword = keyword.trim(); - if (!normalizedKeyword) { + const searchPlan = resolvePlatformPublicCodeSearchPlan(keyword); + if (!searchPlan) { return; } + const { normalizedKeyword } = searchPlan; + setIsSearchingPublicCode(true); setPublicSearchError(null); setSearchedPublicUser(null); - const upperKeyword = normalizedKeyword.toUpperCase(); - const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test( - normalizedKeyword, - ); - const shouldSearchBigFishFirst = upperKeyword.startsWith('BF'); - const shouldSearchBabyObjectFirst = upperKeyword.startsWith('BO'); - const shouldSearchJumpHopFirst = upperKeyword.startsWith('JH'); - const shouldSearchWoodenFishFirst = upperKeyword.startsWith('WF'); - const shouldSearchMatch3DFirst = upperKeyword.startsWith('M3'); - const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ'); - const shouldSearchSquareHoleFirst = upperKeyword.startsWith('SH'); - const shouldSearchVisualNovelFirst = upperKeyword.startsWith('VN'); - const shouldSearchBarkBattleFirst = upperKeyword.startsWith('BB'); - const shouldSearchWorkFirst = - !shouldSearchUserIdFirst && - !shouldSearchBabyObjectFirst && - !shouldSearchBigFishFirst && - !shouldSearchJumpHopFirst && - !shouldSearchWoodenFishFirst && - !shouldSearchMatch3DFirst && - !shouldSearchPuzzleFirst && - !shouldSearchSquareHoleFirst && - !shouldSearchVisualNovelFirst && - !shouldSearchBarkBattleFirst && - (upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword)); - const shouldSearchUserFirst = - shouldSearchUserIdFirst || - upperKeyword.startsWith('SY') || - (!shouldSearchWorkFirst && - !shouldSearchBigFishFirst && - !shouldSearchBabyObjectFirst && - !shouldSearchJumpHopFirst && - !shouldSearchWoodenFishFirst && - !shouldSearchMatch3DFirst && - !shouldSearchPuzzleFirst && - !shouldSearchSquareHoleFirst && - !shouldSearchVisualNovelFirst); - const tryOpenGalleryEntry = async () => { const entry = await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword); @@ -13717,95 +13685,66 @@ export function PlatformEntryFlowShellImpl({ ); }; - try { - if (shouldSearchUserIdFirst) { - const user = await getPublicAuthUserById(normalizedKeyword); - setSearchedPublicUser(user); - return; - } - - if (shouldSearchPuzzleFirst) { - await tryOpenPuzzleGalleryEntry(); - return; - } - - if (shouldSearchBigFishFirst) { - await tryOpenBigFishGalleryEntry(); - return; - } - - if (shouldSearchJumpHopFirst) { - await tryOpenJumpHopGalleryEntry(); - return; - } - - if (shouldSearchWoodenFishFirst) { - await tryOpenWoodenFishGalleryEntry(); - return; - } - - if (shouldSearchBabyObjectFirst) { - await tryOpenBabyObjectMatchGalleryEntry(); - return; - } - - if (shouldSearchMatch3DFirst) { - await tryOpenMatch3DGalleryEntry(); - return; - } - - if (shouldSearchSquareHoleFirst) { - await tryOpenSquareHoleGalleryEntry(); - return; - } - - if (shouldSearchVisualNovelFirst) { - await tryOpenVisualNovelGalleryEntry(); - return; - } - - if (shouldSearchBarkBattleFirst) { - await tryOpenBarkBattleGalleryEntry(); - return; - } - - if (shouldSearchWorkFirst) { - try { - await tryOpenGalleryEntry(); + const runSearchStep = async (step: PlatformPublicCodeSearchStep) => { + switch (step) { + case 'user-id': { + const user = await getPublicAuthUserById(normalizedKeyword); + setSearchedPublicUser(user); return; - } catch { - // 作品号优先时允许继续回退到用户号搜索。 } - } - - if (shouldSearchUserFirst) { - try { + case 'public-user-code': { const user = await getPublicAuthUserByCode(normalizedKeyword); setSearchedPublicUser(user); return; - } catch { - // 用户号优先时允许继续回退到作品号搜索。 } - } - - if (!shouldSearchWorkFirst) { - try { + case 'rpg-work': await tryOpenGalleryEntry(); return; - } catch { - // 常规作品未命中时继续尝试汪汪声浪作品号。 - } - - try { + case 'puzzle-work': + await tryOpenPuzzleGalleryEntry(); + return; + case 'big-fish-work': + await tryOpenBigFishGalleryEntry(); + return; + case 'jump-hop-work': + await tryOpenJumpHopGalleryEntry(); + return; + case 'wooden-fish-work': + await tryOpenWoodenFishGalleryEntry(); + return; + case 'baby-object-match-work': + await tryOpenBabyObjectMatchGalleryEntry(); + return; + case 'match3d-work': + await tryOpenMatch3DGalleryEntry(); + return; + case 'square-hole-work': + await tryOpenSquareHoleGalleryEntry(); + return; + case 'visual-novel-work': + await tryOpenVisualNovelGalleryEntry(); + return; + case 'bark-battle-work': await tryOpenBarkBattleGalleryEntry(); return; - } catch { - // 汪汪声浪作品未命中时继续回退到陶泥号搜索。 + default: { + const exhaustive: never = step; + return exhaustive; } } + }; - const user = await getPublicAuthUserByCode(normalizedKeyword); - setSearchedPublicUser(user); + try { + for (const [index, step] of searchPlan.steps.entries()) { + try { + await runSearchStep(step); + return; + } catch (error) { + if (index === searchPlan.steps.length - 1) { + throw error; + } + } + } } catch (error) { if (selectionStage === 'work-detail') { setSelectedPublicWorkDetail(null); diff --git a/src/components/platform-entry/platformPublicCodeSearchModel.test.ts b/src/components/platform-entry/platformPublicCodeSearchModel.test.ts new file mode 100644 index 00000000..c4bc04b1 --- /dev/null +++ b/src/components/platform-entry/platformPublicCodeSearchModel.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest'; + +import { + type PlatformPublicCodeSearchStep, + resolvePlatformPublicCodeSearchPlan, +} from './platformPublicCodeSearchModel'; + +function expectSearchSteps( + keyword: string, + steps: readonly PlatformPublicCodeSearchStep[], +) { + expect(resolvePlatformPublicCodeSearchPlan(keyword)?.steps).toEqual(steps); +} + +describe('platformPublicCodeSearchModel', () => { + test('ignores empty public code search input', () => { + expect(resolvePlatformPublicCodeSearchPlan(' ')).toBeNull(); + }); + + test('normalizes public code search keyword before planning', () => { + expect(resolvePlatformPublicCodeSearchPlan(' PZ-00000001 ')).toEqual({ + normalizedKeyword: 'PZ-00000001', + steps: ['puzzle-work'], + }); + }); + + test('searches internal user ids directly without work fallback', () => { + expectSearchSteps('user_00000001', ['user-id']); + expectSearchSteps('USER-profile-1', ['user-id']); + }); + + test('routes known public work prefixes to their play-specific lookup', () => { + const cases: Array< + [keyword: string, step: PlatformPublicCodeSearchStep] + > = [ + ['PZ-EPUBLIC1', 'puzzle-work'], + ['BF-NPUBLIC1', 'big-fish-work'], + ['JH-EPUBLIC1', 'jump-hop-work'], + ['WF-EPUBLIC1', 'wooden-fish-work'], + ['BO-EPUBLIC1', 'baby-object-match-work'], + ['M3-EPUBLIC1', 'match3d-work'], + ['M3D-LEGACY1', 'match3d-work'], + ['SH-EPUBLIC1', 'square-hole-work'], + ['VN-EPUBLIC1', 'visual-novel-work'], + ['BB-EPUBLIC1', 'bark-battle-work'], + ]; + + for (const [keyword, step] of cases) { + expectSearchSteps(keyword, [step]); + } + }); + + test('searches RPG public works before public user codes for CW and numeric codes', () => { + expectSearchSteps('CW-00000001', ['rpg-work', 'public-user-code']); + expectSearchSteps('12345678', ['rpg-work', 'public-user-code']); + }); + + test('keeps legacy user-code-first fallback for SY and ordinary keywords', () => { + const legacyFallbackSteps = [ + 'public-user-code', + 'rpg-work', + 'bark-battle-work', + 'public-user-code', + ] as const; + + expectSearchSteps('SY-00000001', legacyFallbackSteps); + expectSearchSteps('月井守望', legacyFallbackSteps); + }); +}); diff --git a/src/components/platform-entry/platformPublicCodeSearchModel.ts b/src/components/platform-entry/platformPublicCodeSearchModel.ts new file mode 100644 index 00000000..616d69aa --- /dev/null +++ b/src/components/platform-entry/platformPublicCodeSearchModel.ts @@ -0,0 +1,83 @@ +export type PlatformPublicCodeSearchStep = + | 'user-id' + | 'public-user-code' + | 'rpg-work' + | 'puzzle-work' + | 'big-fish-work' + | 'jump-hop-work' + | 'wooden-fish-work' + | 'baby-object-match-work' + | 'match3d-work' + | 'square-hole-work' + | 'visual-novel-work' + | 'bark-battle-work'; + +export type PlatformPublicCodeSearchPlan = { + normalizedKeyword: string; + steps: readonly PlatformPublicCodeSearchStep[]; +}; + +const PLATFORM_PUBLIC_USER_ID_PATTERN = /^user[_-][a-z0-9_-]+$/iu; +const PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN = /^\d{1,8}$/u; + +const DIRECT_WORK_PREFIX_STEPS: ReadonlyArray< + readonly [prefix: string, step: PlatformPublicCodeSearchStep] +> = [ + ['PZ', 'puzzle-work'], + ['BF', 'big-fish-work'], + ['JH', 'jump-hop-work'], + ['WF', 'wooden-fish-work'], + ['BO', 'baby-object-match-work'], + ['M3', 'match3d-work'], + ['SH', 'square-hole-work'], + ['VN', 'visual-novel-work'], + ['BB', 'bark-battle-work'], +]; + +/** 收口公开码搜索顺序,壳层只按步骤执行网络读取与打开副作用。 */ +export function resolvePlatformPublicCodeSearchPlan( + keyword: string, +): PlatformPublicCodeSearchPlan | null { + const normalizedKeyword = keyword.trim(); + if (!normalizedKeyword) { + return null; + } + + if (PLATFORM_PUBLIC_USER_ID_PATTERN.test(normalizedKeyword)) { + return { + normalizedKeyword, + steps: ['user-id'], + }; + } + + const upperKeyword = normalizedKeyword.toUpperCase(); + const directWorkStep = DIRECT_WORK_PREFIX_STEPS.find(([prefix]) => + upperKeyword.startsWith(prefix), + )?.[1]; + if (directWorkStep) { + return { + normalizedKeyword, + steps: [directWorkStep], + }; + } + + if ( + upperKeyword.startsWith('CW') || + PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN.test(normalizedKeyword) + ) { + return { + normalizedKeyword, + steps: ['rpg-work', 'public-user-code'], + }; + } + + return { + normalizedKeyword, + steps: [ + 'public-user-code', + 'rpg-work', + 'bark-battle-work', + 'public-user-code', + ], + }; +} From 80dab356462c1c491f93c04f596fad464cf5c47b Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 02:42:43 +0800 Subject: [PATCH 41/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E7=8E=A9?= =?UTF-8?q?=E8=BF=87=E4=BD=9C=E5=93=81=E6=89=93=E5=BC=80=E6=84=8F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...latformPlayedWorkOpenModel收口计划-2026-06-04.md | 39 ++++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 183 ++++----------- .../platformPlayedWorkOpenModel.test.ts | 205 +++++++++++++++++ .../platformPlayedWorkOpenModel.ts | 212 ++++++++++++++++++ 7 files changed, 517 insertions(+), 134 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformPlayedWorkOpenModel.test.ts create mode 100644 src/components/platform-entry/platformPlayedWorkOpenModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7a478fd8..0f10d5d3 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1363,6 +1363,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform Played Work Open Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 的个人“玩过作品”点击回调内联判断 `worldType`、`worldKey` 前缀、玩法别名、目标 ID、RPG fallback 详情和大鱼吃小鱼 fallback work,壳层同时承担打开意图与异步副作用。 +- 决策:新增 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,以 `resolvePlatformPlayedWorkOpenIntent(work)` 返回 `noop`、各玩法公开详情打开意图、`open-big-fish` 或 `open-rpg`。Module 负责玩法别名、`worldKey` 前缀兜底、big-fish gallery miss `fallbackWork` 和 RPG `CustomWorldGalleryCard` payload;壳层继续负责关闭面板、刷新 gallery、命中真实作品、打开详情和错误提示。 +- 影响范围:个人“玩过作品”面板点击打开、拼图 / 抓大鹅 / 方洞 / 跳一跳 / 敲木鱼 / 大鱼吃小鱼 / RPG 公开详情入口。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPlayedWorkOpenModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、相关 profile 面板交互片段、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 87a3878f..23f617d5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,6 +51,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口公开码搜索的用户 ID、陶泥号、RPG 作品号、各玩法作品号前缀和失败回退顺序收口到 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,壳层只按计划执行网络读取、详情打开和错误归航副作用,规则见 [【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md)。 +个人“玩过作品”面板的玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback 收口到 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,壳层只执行面板关闭、gallery 读取、详情打开和错误提示副作用,规则见 [【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md)。 + 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md new file mode 100644 index 00000000..554ec31a --- /dev/null +++ b/docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md @@ -0,0 +1,39 @@ +# 【前端架构】Platform Played Work Open Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 的个人“玩过作品”点击回调曾在壳层内直接判断 `worldType`、`worldKey` 前缀、玩法别名、目标 ID 兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback。壳层因此同时承载纯打开意图与异步副作用,后续新增玩法或修正玩过作品身份时缺少稳定测试面。 + +个人“玩过作品”的点击规则属于打开意图。壳层应只关闭面板、读取 gallery、打开详情和写错误;玩法别名、目标 ID、fallback payload 应收口到纯 **Module**。 + +## 决策 + +新增 `src/components/platform-entry/platformPlayedWorkOpenModel.ts` 作为 Platform Played Work Open **Module**。其公开 **Interface** 为: + +- `resolvePlatformPlayedWorkOpenIntent(work)`:输入 `ProfilePlayedWorkSummary`,输出 `noop`、各玩法公开详情打开意图、`open-big-fish` 或 `open-rpg`。 +- `PlatformPlayedWorkOpenIntent`:描述壳层可执行的打开动作;大鱼吃小鱼意图包含 `sessionId` 和 gallery miss 时使用的 `fallbackWork`,RPG 意图包含 `CustomWorldGalleryCard` 详情 payload。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `setIsProfilePlayStatsOpen(false)`、各玩法 `open*PublicWorkDetail`、`refreshBigFishGallery()`、大鱼 gallery 命中优先逻辑、`mapBigFishWorkToPublicWorkDetail(...)` 与错误 setter。 + +## Interface 约束 + +- `worldType` 只做小写归一,不 trim;`worldKey` 前缀匹配保持大小写敏感,延续旧行为。 +- `profileId` 使用 nullish 优先级:只在 `profileId` 为 `null` / `undefined` 时从 `worldKey` 前缀兜底;空字符串仍视为缺目标并返回 `noop`。 +- `puzzle` 打开时固定携带 `{ tab: 'profile' }`。 +- `match3d` / `match_3d`、`square-hole` / `square_hole`、`jump-hop` / `jump_hop`、`wooden-fish` / `wooden_fish`、`big-fish` / `big_fish` 均保持既有别名。 +- `big-fish` 缺 gallery 命中时使用 Module 生成的 `fallbackWork`,默认 `ownerUserId` 为空串、`authorDisplayName` 为 `worldSubtitle || '玩家'`、关卡和素材 ready 计数为 `0` / `false`。 +- 未识别的 `worldType` 仍按 RPG 公开详情打开;缺 `ownerUserId` 或缺 profile 目标时返回 `noop`。 + +## Depth / Leverage / Locality + +- **Depth**:调用方只消费一个打开 intent;玩法别名、目标 ID 兜底和 fallback payload 藏入 Module Implementation。 +- **Leverage**:新增“玩过作品”玩法时,先在 intent union、resolver 与单测中定义,再让壳层 Adapter 绑定对应打开副作用。 +- **Locality**:RPG fallback payload 与大鱼 fallback work 不再散落在大型壳层里,维护者可在纯测试中锁定字段契约。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPlayedWorkOpenModel.test.ts` +- `npx eslint src/components/platform-entry/platformPlayedWorkOpenModel.ts src/components/platform-entry/platformPlayedWorkOpenModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "authenticated users can open save archives from the profile played panel"` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 4d134bb8..f73a589c 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -59,6 +59,8 @@ 平台公开搜索的分流顺序统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID;`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀分别直达对应玩法公开作品;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链。 +个人“玩过作品”面板点击作品时,玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼缺 gallery 命中时的 fallback work 统一由 `platformPlayedWorkOpenModel.ts` 判定。平台壳只负责关闭面板、调用对应公开详情打开函数、刷新大鱼 gallery、优先使用真实 gallery 命中项和写入错误提示;不要在壳层重新维护 `worldType` / `worldKey` 分支链。 + 发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 ## RPG / 自定义世界 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 1ec3bdc3..bdc8ea92 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -512,6 +512,7 @@ import { resolveMatch3DRuntimeGeneratedBackgroundAsset, resolveMatch3DRuntimeGeneratedItemAssets, } from './platformMatch3DRuntimeProfile'; +import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel'; import { type PlatformPublicCodeSearchStep, resolvePlatformPublicCodeSearchPlan, @@ -13815,145 +13816,59 @@ export function PlatformEntryFlowShellImpl({ const openPlayedWork = useCallback( (work: ProfilePlayedWorkSummary) => { - const worldType = (work.worldType ?? '').toLowerCase(); + const intent = resolvePlatformPlayedWorkOpenIntent(work); setIsProfilePlayStatsOpen(false); - if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) { - const profileId = - work.profileId ?? work.worldKey.replace(/^puzzle:/u, ''); - if (profileId) { - void openPuzzlePublicWorkDetail(profileId, { tab: 'profile' }); - } - return; - } - - if ( - worldType === 'match3d' || - worldType === 'match_3d' || - work.worldKey.startsWith('match3d:') - ) { - const profileId = - work.profileId ?? work.worldKey.replace(/^match3d:/u, ''); - if (profileId) { - void openMatch3DPublicWorkDetail(profileId); - } - return; - } - - if ( - worldType === 'square-hole' || - worldType === 'square_hole' || - work.worldKey.startsWith('square-hole:') - ) { - const profileId = - work.profileId ?? work.worldKey.replace(/^square-hole:/u, ''); - if (profileId) { - void openSquareHolePublicWorkDetail(profileId); - } - return; - } - - if ( - worldType === 'jump-hop' || - worldType === 'jump_hop' || - work.worldKey.startsWith('jump-hop:') - ) { - const profileId = - work.profileId ?? work.worldKey.replace(/^jump-hop:/u, ''); - if (profileId) { - void openJumpHopPublicWorkDetail(profileId); - } - return; - } - - if ( - worldType === 'wooden-fish' || - worldType === 'wooden_fish' || - work.worldKey.startsWith('wooden-fish:') - ) { - const profileId = - work.profileId ?? work.worldKey.replace(/^wooden-fish:/u, ''); - if (profileId) { - void openWoodenFishPublicWorkDetail(profileId); - } - return; - } - - if ( - worldType === 'big_fish' || - worldType === 'big-fish' || - work.worldKey.startsWith('big-fish:') - ) { - const sessionId = - work.profileId ?? work.worldKey.replace(/^big-fish:/u, ''); - if (!sessionId) { + switch (intent.type) { + case 'noop': return; - } - void refreshBigFishGallery() - .then((entries) => { - const matchedEntry = entries.find( - (entry) => entry.sourceSessionId === sessionId, - ); - if (matchedEntry) { - openPublicWorkDetail( - mapBigFishWorkToPublicWorkDetail(matchedEntry), - ); - return; - } - openPublicWorkDetail( - mapBigFishWorkToPublicWorkDetail({ - workId: `big-fish:${sessionId}`, - sourceSessionId: sessionId, - ownerUserId: work.ownerUserId ?? '', - authorDisplayName: work.worldSubtitle || '玩家', - title: work.worldTitle, - subtitle: work.worldSubtitle, - summary: work.worldSubtitle, - coverImageSrc: null, - status: 'published', - updatedAt: work.lastPlayedAt, - publishReady: true, - levelCount: 0, - levelMainImageReadyCount: 0, - levelMotionReadyCount: 0, - backgroundReady: false, - }), - ); - }) - .catch((error) => { - setBigFishError( - resolveBigFishErrorMessage(error, '进入大鱼吃小鱼作品失败。'), - ); + case 'open-puzzle': + void openPuzzlePublicWorkDetail(intent.profileId, { + tab: intent.tab, }); - return; + return; + case 'open-match3d': + void openMatch3DPublicWorkDetail(intent.profileId); + return; + case 'open-square-hole': + void openSquareHolePublicWorkDetail(intent.profileId); + return; + case 'open-jump-hop': + void openJumpHopPublicWorkDetail(intent.profileId); + return; + case 'open-wooden-fish': + void openWoodenFishPublicWorkDetail(intent.profileId); + return; + case 'open-big-fish': + void refreshBigFishGallery() + .then((entries) => { + const matchedEntry = entries.find( + (entry) => entry.sourceSessionId === intent.sessionId, + ); + if (matchedEntry) { + openPublicWorkDetail( + mapBigFishWorkToPublicWorkDetail(matchedEntry), + ); + return; + } + openPublicWorkDetail( + mapBigFishWorkToPublicWorkDetail(intent.fallbackWork), + ); + }) + .catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '进入大鱼吃小鱼作品失败。'), + ); + }); + return; + case 'open-rpg': + void openRpgPublicWorkDetail(intent.detail); + return; + default: { + const exhaustive: never = intent; + return exhaustive; + } } - - const profileId = work.profileId ?? work.worldKey; - const ownerUserId = work.ownerUserId; - if (!ownerUserId || !profileId) { - return; - } - - void openRpgPublicWorkDetail({ - ownerUserId, - profileId, - publicWorkCode: null, - authorPublicUserCode: null, - visibility: 'published', - publishedAt: work.firstPlayedAt, - updatedAt: work.lastPlayedAt, - authorDisplayName: work.worldSubtitle, - worldName: work.worldTitle, - subtitle: work.worldSubtitle, - summaryText: '', - coverImageSrc: null, - themeMode: 'martial', - playableNpcCount: 0, - landmarkCount: 0, - playCount: 0, - remixCount: 0, - likeCount: 0, - }); }, [ openMatch3DPublicWorkDetail, diff --git a/src/components/platform-entry/platformPlayedWorkOpenModel.test.ts b/src/components/platform-entry/platformPlayedWorkOpenModel.test.ts new file mode 100644 index 00000000..5d0b5083 --- /dev/null +++ b/src/components/platform-entry/platformPlayedWorkOpenModel.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from 'vitest'; + +import type { ProfilePlayedWorkSummary } from '../../../packages/shared/src/contracts/runtime'; +import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel'; + +function buildPlayedWork( + overrides: Partial = {}, +): ProfilePlayedWorkSummary { + return { + worldKey: 'custom:world-1', + ownerUserId: 'user-1', + profileId: 'world-1', + worldType: 'CUSTOM', + worldTitle: '潮雾列岛', + worldSubtitle: '旧灯塔与失控航路', + firstPlayedAt: '2026-04-18T12:00:00.000Z', + lastPlayedAt: '2026-04-19T12:00:00.000Z', + lastObservedPlayTimeMs: 12_000, + ...overrides, + }; +} + +describe('platformPlayedWorkOpenModel', () => { + test('opens puzzle played works with profile tab context', () => { + expect( + resolvePlatformPlayedWorkOpenIntent( + buildPlayedWork({ + worldType: 'PUZZLE', + profileId: 'puzzle-profile-1', + }), + ), + ).toEqual({ + type: 'open-puzzle', + profileId: 'puzzle-profile-1', + tab: 'profile', + }); + }); + + test('falls back to worldKey prefixes when profile id is absent', () => { + const cases = [ + ['puzzle:profile-1', 'open-puzzle', 'profile-1'], + ['match3d:profile-2', 'open-match3d', 'profile-2'], + ['square-hole:profile-3', 'open-square-hole', 'profile-3'], + ['jump-hop:profile-4', 'open-jump-hop', 'profile-4'], + ['wooden-fish:profile-5', 'open-wooden-fish', 'profile-5'], + ] as const; + + for (const [worldKey, type, profileId] of cases) { + expect( + resolvePlatformPlayedWorkOpenIntent( + buildPlayedWork({ + worldKey, + profileId: null, + worldType: null, + }), + ), + ).toMatchObject({ type, profileId }); + } + }); + + test('keeps explicit profile id ahead of worldKey fallback', () => { + expect( + resolvePlatformPlayedWorkOpenIntent( + buildPlayedWork({ + worldKey: 'jump-hop:key-profile', + profileId: 'explicit-profile', + worldType: null, + }), + ), + ).toMatchObject({ + type: 'open-jump-hop', + profileId: 'explicit-profile', + }); + }); + + test('supports played work type aliases for mini-games', () => { + const cases = [ + ['match_3d', 'open-match3d'], + ['square_hole', 'open-square-hole'], + ['jump_hop', 'open-jump-hop'], + ['wooden_fish', 'open-wooden-fish'], + ] as const; + + for (const [worldType, type] of cases) { + expect( + resolvePlatformPlayedWorkOpenIntent( + buildPlayedWork({ + worldType, + profileId: `${worldType}-profile`, + }), + ), + ).toMatchObject({ + type, + profileId: `${worldType}-profile`, + }); + } + }); + + test('returns noop when a mini-game target is empty', () => { + expect( + resolvePlatformPlayedWorkOpenIntent( + buildPlayedWork({ + worldKey: 'puzzle:key-profile', + profileId: '', + worldType: 'puzzle', + }), + ), + ).toEqual({ + type: 'noop', + reason: 'missing-target', + }); + }); + + test('builds big fish intent and fallback work for gallery misses', () => { + expect( + resolvePlatformPlayedWorkOpenIntent( + buildPlayedWork({ + worldKey: 'big-fish:big-fish-session-1', + ownerUserId: null, + profileId: null, + worldType: 'big_fish', + worldTitle: '机械深海', + worldSubtitle: '', + }), + ), + ).toEqual({ + type: 'open-big-fish', + sessionId: 'big-fish-session-1', + fallbackWork: { + workId: 'big-fish:big-fish-session-1', + sourceSessionId: 'big-fish-session-1', + ownerUserId: '', + authorDisplayName: '玩家', + title: '机械深海', + subtitle: '', + summary: '', + coverImageSrc: null, + status: 'published', + updatedAt: '2026-04-19T12:00:00.000Z', + publishReady: true, + levelCount: 0, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + }, + }); + }); + + test('opens unknown played work types as RPG detail when identity is complete', () => { + expect( + resolvePlatformPlayedWorkOpenIntent( + buildPlayedWork({ + worldType: 'CUSTOM', + profileId: null, + }), + ), + ).toEqual({ + type: 'open-rpg', + detail: { + ownerUserId: 'user-1', + profileId: 'custom:world-1', + publicWorkCode: null, + authorPublicUserCode: null, + visibility: 'published', + publishedAt: '2026-04-18T12:00:00.000Z', + updatedAt: '2026-04-19T12:00:00.000Z', + authorDisplayName: '旧灯塔与失控航路', + worldName: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summaryText: '', + coverImageSrc: null, + themeMode: 'martial', + playableNpcCount: 0, + landmarkCount: 0, + playCount: 0, + remixCount: 0, + likeCount: 0, + }, + }); + }); + + test('returns noop for RPG fallback when owner or profile is missing', () => { + expect( + resolvePlatformPlayedWorkOpenIntent( + buildPlayedWork({ + ownerUserId: null, + }), + ), + ).toEqual({ + type: 'noop', + reason: 'missing-target', + }); + expect( + resolvePlatformPlayedWorkOpenIntent( + buildPlayedWork({ + worldKey: '', + profileId: null, + }), + ), + ).toEqual({ + type: 'noop', + reason: 'missing-target', + }); + }); +}); diff --git a/src/components/platform-entry/platformPlayedWorkOpenModel.ts b/src/components/platform-entry/platformPlayedWorkOpenModel.ts new file mode 100644 index 00000000..8e3ed856 --- /dev/null +++ b/src/components/platform-entry/platformPlayedWorkOpenModel.ts @@ -0,0 +1,212 @@ +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { + CustomWorldGalleryCard, + ProfilePlayedWorkSummary, +} from '../../../packages/shared/src/contracts/runtime'; + +export type PlatformPlayedWorkOpenIntent = + | { + type: 'noop'; + reason: 'missing-target'; + } + | { + type: 'open-puzzle'; + profileId: string; + tab: 'profile'; + } + | { + type: 'open-match3d'; + profileId: string; + } + | { + type: 'open-square-hole'; + profileId: string; + } + | { + type: 'open-jump-hop'; + profileId: string; + } + | { + type: 'open-wooden-fish'; + profileId: string; + } + | { + type: 'open-big-fish'; + sessionId: string; + fallbackWork: BigFishWorkSummary; + } + | { + type: 'open-rpg'; + detail: CustomWorldGalleryCard; + }; + +function normalizePlayedWorkWorldType(worldType: string | null) { + return (worldType ?? '').toLowerCase(); +} + +function resolvePlayedWorkTargetId( + work: ProfilePlayedWorkSummary, + worldKeyPrefix: string, +) { + const prefixedWorldKey = `${worldKeyPrefix}:`; + return ( + work.profileId ?? + (work.worldKey.startsWith(prefixedWorldKey) + ? work.worldKey.slice(prefixedWorldKey.length) + : work.worldKey) + ); +} + +function resolvePlayedWorkProfileIntent( + profileId: string, + intent: (profileId: string) => TIntent, +) { + return profileId ? intent(profileId) : buildMissingPlayedWorkTargetIntent(); +} + +function buildMissingPlayedWorkTargetIntent(): PlatformPlayedWorkOpenIntent { + return { + type: 'noop', + reason: 'missing-target', + }; +} + +function buildPlayedBigFishFallbackWork( + work: ProfilePlayedWorkSummary, + sessionId: string, +): BigFishWorkSummary { + return { + workId: `big-fish:${sessionId}`, + sourceSessionId: sessionId, + ownerUserId: work.ownerUserId ?? '', + authorDisplayName: work.worldSubtitle || '玩家', + title: work.worldTitle, + subtitle: work.worldSubtitle, + summary: work.worldSubtitle, + coverImageSrc: null, + status: 'published', + updatedAt: work.lastPlayedAt, + publishReady: true, + levelCount: 0, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + }; +} + +function buildPlayedRpgDetail( + work: ProfilePlayedWorkSummary, + profileId: string, + ownerUserId: string, +): CustomWorldGalleryCard { + return { + ownerUserId, + profileId, + publicWorkCode: null, + authorPublicUserCode: null, + visibility: 'published', + publishedAt: work.firstPlayedAt, + updatedAt: work.lastPlayedAt, + authorDisplayName: work.worldSubtitle, + worldName: work.worldTitle, + subtitle: work.worldSubtitle, + summaryText: '', + coverImageSrc: null, + themeMode: 'martial', + playableNpcCount: 0, + landmarkCount: 0, + playCount: 0, + remixCount: 0, + likeCount: 0, + }; +} + +/** 收口个人“玩过作品”点击后的玩法打开意图,壳层只执行副作用。 */ +export function resolvePlatformPlayedWorkOpenIntent( + work: ProfilePlayedWorkSummary, +): PlatformPlayedWorkOpenIntent { + const worldType = normalizePlayedWorkWorldType(work.worldType); + + if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) { + const profileId = resolvePlayedWorkTargetId(work, 'puzzle'); + return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({ + type: 'open-puzzle', + profileId: resolvedProfileId, + tab: 'profile', + })); + } + + if ( + worldType === 'match3d' || + worldType === 'match_3d' || + work.worldKey.startsWith('match3d:') + ) { + const profileId = resolvePlayedWorkTargetId(work, 'match3d'); + return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({ + type: 'open-match3d', + profileId: resolvedProfileId, + })); + } + + if ( + worldType === 'square-hole' || + worldType === 'square_hole' || + work.worldKey.startsWith('square-hole:') + ) { + const profileId = resolvePlayedWorkTargetId(work, 'square-hole'); + return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({ + type: 'open-square-hole', + profileId: resolvedProfileId, + })); + } + + if ( + worldType === 'jump-hop' || + worldType === 'jump_hop' || + work.worldKey.startsWith('jump-hop:') + ) { + const profileId = resolvePlayedWorkTargetId(work, 'jump-hop'); + return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({ + type: 'open-jump-hop', + profileId: resolvedProfileId, + })); + } + + if ( + worldType === 'wooden-fish' || + worldType === 'wooden_fish' || + work.worldKey.startsWith('wooden-fish:') + ) { + const profileId = resolvePlayedWorkTargetId(work, 'wooden-fish'); + return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({ + type: 'open-wooden-fish', + profileId: resolvedProfileId, + })); + } + + if ( + worldType === 'big_fish' || + worldType === 'big-fish' || + work.worldKey.startsWith('big-fish:') + ) { + const sessionId = resolvePlayedWorkTargetId(work, 'big-fish'); + return sessionId + ? { + type: 'open-big-fish', + sessionId, + fallbackWork: buildPlayedBigFishFallbackWork(work, sessionId), + } + : buildMissingPlayedWorkTargetIntent(); + } + + const profileId = work.profileId ?? work.worldKey; + const ownerUserId = work.ownerUserId; + if (!ownerUserId || !profileId) { + return buildMissingPlayedWorkTargetIntent(); + } + + return { + type: 'open-rpg', + detail: buildPlayedRpgDetail(work, profileId, ownerUserId), + }; +} From 5dd73186b07a59e0e15eb7223d8830cdb941d16b Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 02:49:23 +0800 Subject: [PATCH 42/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E7=94=9F?= =?UTF-8?q?=E6=88=90=E8=BF=9B=E5=BA=A6=20tick=20=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...enerationProgressTickModel收口计划-2026-06-04.md | 37 ++++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 44 ++--- ...latformGenerationProgressTickModel.test.ts | 173 ++++++++++++++++++ .../platformGenerationProgressTickModel.ts | 79 ++++++++ 7 files changed, 320 insertions(+), 25 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformGenerationProgressTickModel.test.ts create mode 100644 src/components/platform-entry/platformGenerationProgressTickModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 0f10d5d3..87e5fb67 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1371,6 +1371,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformPlayedWorkOpenModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、相关 profile 面板交互片段、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform Generation Progress Tick Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 的生成页进度 tick effect 内联维护 stage 到小游戏生成状态的三元链,并额外手写视觉小说 `startedAtMs` / `phase` 特例,壳层同时承担纯判定与 interval 副作用。 +- 决策:新增 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,以 `resolvePlatformGenerationProgressTickDecision(input)` 返回 `{ activeKind, shouldTick }`。Module 负责 stage 到 kind 映射、小游戏状态缺失 / 终态判定、视觉小说轻量生成判定;壳层继续负责 `Date.now()`、`window.setInterval`、progress now state 写入和 cleanup。 +- 影响范围:拼图、抓大鹅、大鱼吃小鱼、方洞挑战、跳一跳、敲木鱼、宝贝识物和视觉小说生成页进度 tick。 +- 验证方式:`npm run test -- src/components/platform-entry/platformGenerationProgressTickModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 23f617d5..22d1010e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 个人“玩过作品”面板的玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback 收口到 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,壳层只执行面板关闭、gallery 读取、详情打开和错误提示副作用,规则见 [【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md)。 +平台入口生成页进度 tick 的 stage 到生成状态映射、终态判定和视觉小说轻量生成特例收口到 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,壳层只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,规则见 [【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md)。 + 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md new file mode 100644 index 00000000..f9c5abe3 --- /dev/null +++ b/docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md @@ -0,0 +1,37 @@ +# 【前端架构】Platform Generation Progress Tick Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 的生成页进度 tick effect 曾以内联三元链按 `selectionStage` 选择拼图、抓大鹅、大鱼吃小鱼、方洞挑战、跳一跳、敲木鱼和宝贝识物的生成状态,并额外手写视觉小说的 `startedAtMs` / `phase` 判定。壳层因此既要维护 `setInterval` 副作用,又要记住每个生成页 stage 对应哪份进度状态。 + +生成进度是否需要 tick 是纯判定;`Date.now()`、`window.setInterval` 和进度时间 state 写入仍属于 React 壳层副作用。 + +## 决策 + +新增 `src/components/platform-entry/platformGenerationProgressTickModel.ts` 作为 Platform Generation Progress Tick **Module**。其公开 **Interface** 为: + +- `resolvePlatformGenerationProgressTickDecision(input)`:输入当前 `selectionStage`、各小游戏 `MiniGameDraftGenerationState` 和视觉小说轻量生成状态,输出 `{ activeKind, shouldTick }`。 +- `PlatformGenerationProgressTickKind`:枚举可 tick 的生成类型,包含已有小游戏生成 kind 与 `visual-novel`。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它把当前 state 组装给 Module,若 `shouldTick=false` 则不启动 interval;若为真,仍按旧逻辑立即写一次 `Date.now()`,再每 `500ms` 更新并在 effect cleanup 中清理 timer。 + +## Interface 约束 + +- 小游戏生成 stage 只读取匹配 kind 的 `MiniGameDraftGenerationState`;stage 与 state 不匹配时不 tick。 +- 小游戏状态缺失、`phase='ready'` 或 `phase='failed'` 时不 tick;其它 phase 按进行中处理。 +- `visual-novel-generating` 不强行转成 `MiniGameDraftGenerationState`,只在 `startedAtMs != null` 且 phase 非 `ready` / `failed` 时 tick。 +- 非生成 stage 即使传入可运行 state 也不 tick。 +- 本 Module 不计算进度、不重建 view state、不处理拼图 / 抓大鹅 background task 覆盖;这些仍按既有生成页和作品架模型处理。 + +## Depth / Leverage / Locality + +- **Depth**:壳层只消费 `shouldTick`,stage 到 state 的映射和终态判定藏入 Module Implementation。 +- **Leverage**:新增生成页玩法时,先扩展 stage-to-kind 映射和单测,再让壳层 Adapter 传入对应 state。 +- **Locality**:生成进度 tick 规则集中到一个纯测试面,interval 副作用继续局部留在 React effect,避免把 timer 控制做成浅 Interface。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformGenerationProgressTickModel.test.ts` +- `npx eslint src/components/platform-entry/platformGenerationProgressTickModel.ts src/components/platform-entry/platformGenerationProgressTickModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index f73a589c..7e618026 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -10,6 +10,8 @@ 创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记、大鱼吃小鱼 workId 兜底、作品 / 草稿身份匹配和跳一跳 / 敲木鱼恢复阶段落点统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 +生成页进度 tick 是否启动统一由 `platformGenerationProgressTickModel.ts` 判定:各小游戏生成页只在当前 stage 与对应生成状态匹配、状态存在且 phase 非 `ready` / `failed` 时 tick;视觉小说继续使用 `startedAtMs` 与轻量 phase 判定,不强行转成小游戏生成状态。平台壳只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,不在壳层重复维护 stage 到 state 的三元链。 + 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index bdc8ea92..a84d8fa8 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -498,6 +498,7 @@ import type { import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; import { PlatformErrorDialog } from './PlatformErrorDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; +import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel'; import { buildMatch3DProfileFromSession, hasMatch3DRuntimeAsset, @@ -3807,32 +3808,25 @@ export function PlatformEntryFlowShellImpl({ ]); useEffect(() => { - const activeGenerationState = - selectionStage === 'puzzle-generating' - ? puzzleGenerationState - : selectionStage === 'match3d-generating' - ? match3dGenerationState - : selectionStage === 'big-fish-generating' - ? bigFishGenerationState - : selectionStage === 'square-hole-generating' - ? squareHoleGenerationState - : selectionStage === 'jump-hop-generating' - ? jumpHopGenerationState - : selectionStage === 'wooden-fish-generating' - ? woodenFishGenerationState - : selectionStage === 'baby-object-match-generating' - ? babyObjectMatchGenerationState - : null; - const shouldTickProgress = - selectionStage === 'visual-novel-generating' - ? visualNovelGenerationStartedAtMs != null && - visualNovelGenerationPhase !== 'ready' && - visualNovelGenerationPhase !== 'failed' - : activeGenerationState != null && - activeGenerationState.phase !== 'ready' && - activeGenerationState.phase !== 'failed'; + const progressTickDecision = + resolvePlatformGenerationProgressTickDecision({ + selectionStage, + miniGameStates: { + puzzle: puzzleGenerationState, + match3d: match3dGenerationState, + 'big-fish': bigFishGenerationState, + 'square-hole': squareHoleGenerationState, + 'jump-hop': jumpHopGenerationState, + 'wooden-fish': woodenFishGenerationState, + 'baby-object-match': babyObjectMatchGenerationState, + }, + visualNovel: { + startedAtMs: visualNovelGenerationStartedAtMs, + phase: visualNovelGenerationPhase, + }, + }); - if (!shouldTickProgress) { + if (!progressTickDecision.shouldTick) { return undefined; } diff --git a/src/components/platform-entry/platformGenerationProgressTickModel.test.ts b/src/components/platform-entry/platformGenerationProgressTickModel.test.ts new file mode 100644 index 00000000..9e0d8a48 --- /dev/null +++ b/src/components/platform-entry/platformGenerationProgressTickModel.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from 'vitest'; + +import type { + MiniGameDraftGenerationKind, + MiniGameDraftGenerationPhase, + MiniGameDraftGenerationState, +} from '../../services/miniGameDraftGenerationProgress'; +import type { SelectionStage } from './platformEntryTypes'; +import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel'; + +function buildGenerationState( + kind: MiniGameDraftGenerationKind, + phase: MiniGameDraftGenerationPhase = 'compile', +): MiniGameDraftGenerationState { + return { + kind, + phase, + startedAtMs: 1000, + completedAssetCount: 0, + totalAssetCount: 1, + error: null, + }; +} + +describe('platformGenerationProgressTickModel', () => { + test('ticks while a mini-game generation stage has a running state', () => { + const cases: Array< + [stage: SelectionStage, kind: MiniGameDraftGenerationKind] + > = [ + ['puzzle-generating', 'puzzle'], + ['match3d-generating', 'match3d'], + ['big-fish-generating', 'big-fish'], + ['square-hole-generating', 'square-hole'], + ['jump-hop-generating', 'jump-hop'], + ['wooden-fish-generating', 'wooden-fish'], + ['baby-object-match-generating', 'baby-object-match'], + ]; + + for (const [selectionStage, kind] of cases) { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage, + miniGameStates: { + [kind]: buildGenerationState(kind), + }, + visualNovel: { + startedAtMs: null, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: kind, + shouldTick: true, + }); + } + }); + + test('does not tick mini-game generation when state is missing or terminal', () => { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'puzzle-generating', + miniGameStates: {}, + visualNovel: { + startedAtMs: null, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: 'puzzle', + shouldTick: false, + }); + + for (const phase of ['ready', 'failed'] as const) { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'puzzle-generating', + miniGameStates: { + puzzle: buildGenerationState('puzzle', phase), + }, + visualNovel: { + startedAtMs: null, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: 'puzzle', + shouldTick: false, + }); + } + }); + + test('does not tick when stage and mini-game state do not match', () => { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'puzzle-generating', + miniGameStates: { + match3d: buildGenerationState('match3d'), + }, + visualNovel: { + startedAtMs: null, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: 'puzzle', + shouldTick: false, + }); + }); + + test('ticks visual novel generation only after it has started and before terminal phases', () => { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'visual-novel-generating', + miniGameStates: {}, + visualNovel: { + startedAtMs: 1000, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: 'visual-novel', + shouldTick: true, + }); + + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'visual-novel-generating', + miniGameStates: {}, + visualNovel: { + startedAtMs: null, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: 'visual-novel', + shouldTick: false, + }); + + for (const phase of ['ready', 'failed'] as const) { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'visual-novel-generating', + miniGameStates: {}, + visualNovel: { + startedAtMs: 1000, + phase, + }, + }), + ).toEqual({ + activeKind: 'visual-novel', + shouldTick: false, + }); + } + }); + + test('does not tick non-generation stages even when states are present', () => { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'platform', + miniGameStates: { + puzzle: buildGenerationState('puzzle'), + }, + visualNovel: { + startedAtMs: 1000, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: null, + shouldTick: false, + }); + }); +}); diff --git a/src/components/platform-entry/platformGenerationProgressTickModel.ts b/src/components/platform-entry/platformGenerationProgressTickModel.ts new file mode 100644 index 00000000..67a29bdd --- /dev/null +++ b/src/components/platform-entry/platformGenerationProgressTickModel.ts @@ -0,0 +1,79 @@ +import type { + MiniGameDraftGenerationKind, + MiniGameDraftGenerationState, +} from '../../services/miniGameDraftGenerationProgress'; +import type { SelectionStage } from './platformEntryTypes'; + +export type PlatformVisualNovelGenerationPhase = + | 'generating' + | 'ready' + | 'failed'; + +export type PlatformGenerationProgressTickKind = + | MiniGameDraftGenerationKind + | 'visual-novel'; + +export type PlatformGenerationProgressTickInput = { + selectionStage: SelectionStage; + miniGameStates: Partial< + Record + >; + visualNovel: { + startedAtMs: number | null; + phase: PlatformVisualNovelGenerationPhase; + }; +}; + +export type PlatformGenerationProgressTickDecision = { + activeKind: PlatformGenerationProgressTickKind | null; + shouldTick: boolean; +}; + +const MINI_GAME_GENERATION_STAGE_TO_KIND: Partial< + Record +> = { + 'puzzle-generating': 'puzzle', + 'match3d-generating': 'match3d', + 'big-fish-generating': 'big-fish', + 'square-hole-generating': 'square-hole', + 'jump-hop-generating': 'jump-hop', + 'wooden-fish-generating': 'wooden-fish', + 'baby-object-match-generating': 'baby-object-match', +}; + +function shouldTickMiniGameGenerationState( + state: MiniGameDraftGenerationState | null | undefined, +) { + return state != null && state.phase !== 'ready' && state.phase !== 'failed'; +} + +/** 收口生成页进度 tick 判定,壳层只保留 interval 副作用。 */ +export function resolvePlatformGenerationProgressTickDecision( + input: PlatformGenerationProgressTickInput, +): PlatformGenerationProgressTickDecision { + if (input.selectionStage === 'visual-novel-generating') { + return { + activeKind: 'visual-novel', + shouldTick: + input.visualNovel.startedAtMs != null && + input.visualNovel.phase !== 'ready' && + input.visualNovel.phase !== 'failed', + }; + } + + const activeKind = + MINI_GAME_GENERATION_STAGE_TO_KIND[input.selectionStage] ?? null; + if (!activeKind) { + return { + activeKind: null, + shouldTick: false, + }; + } + + return { + activeKind, + shouldTick: shouldTickMiniGameGenerationState( + input.miniGameStates[activeKind], + ), + }; +} From 671f5da86aba2bcb2d84d803ceeacf52712b78ca Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 02:59:42 +0800 Subject: [PATCH 43/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=B0=8F?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E4=BC=9A=E8=AF=9D=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...iniGameSessionMappingModel收口计划-2026-06-04.md | 40 ++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 124 +------ ...latformMiniGameSessionMappingModel.test.ts | 344 ++++++++++++++++++ .../platformMiniGameSessionMappingModel.ts | 136 +++++++ 7 files changed, 538 insertions(+), 118 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts create mode 100644 src/components/platform-entry/platformMiniGameSessionMappingModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 87e5fb67..805c89f7 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1379,6 +1379,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformGenerationProgressTickModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform Mini Game Session Mapping Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 顶部仍保留拼图 runtime 恢复、跳一跳 pending session、敲木鱼 detail 恢复和敲木鱼 pending session 四段纯 DTO 映射,壳层需要理解 sessionId 优先级、拼图稳定 ID 和 pending draft 默认值。 +- 决策:新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,收口 `buildPuzzleRuntimeWorkFromSession`、`buildJumpHopPendingSession`、`buildWoodenFishSessionFromWorkDetail` 与 `buildWoodenFishPendingSession`。Module 复用 `normalizeCreationUrlValue` 与 `platformPuzzleIdentityModel`;壳层只保留网络读取、React state、URL 写入和 stage 切换副作用。 +- 影响范围:拼图 runtime URL 恢复、跳一跳生成中作品架打开、敲木鱼生成中作品架打开和敲木鱼草稿 detail 恢复。 +- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 22d1010e..38233467 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,6 +55,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口生成页进度 tick 的 stage 到生成状态映射、终态判定和视觉小说轻量生成特例收口到 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,壳层只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,规则见 [【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md)。 +平台壳的拼图 runtime 恢复 work、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 + 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md new file mode 100644 index 00000000..c6a88d34 --- /dev/null +++ b/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md @@ -0,0 +1,40 @@ +# 【前端架构】Platform Mini Game Session Mapping Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 顶部曾保留拼图 runtime 恢复、跳一跳 pending session、敲木鱼 work detail 恢复和敲木鱼 pending session 四段纯 DTO 映射。它们没有 React state、网络请求、路由、弹窗或计时副作用,却住在大型平台壳内;新增或修正生成中草稿恢复时,需要在壳层里理解 sessionId 优先级、拼图稳定 ID、pending draft 默认值和木鱼 fallback 规则。 + +这些规则属于平台壳 session / work 恢复映射,应成为可测试的 **Module**。壳层只负责调用网络、写 React state、写 URL 和切换 stage。 + +## 决策 + +新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts` 作为 Platform Mini Game Session Mapping **Module**。其公开 **Interface** 为: + +- `buildPuzzleRuntimeWorkFromSession(session, owner)`:从拼图 Agent session 构造可进入 runtime 的 draft `PuzzleWorkSummary`,缺草稿、缺 profile 或缺封面时返回 `null`。 +- `buildJumpHopPendingSession(item)`:从跳一跳作品架 summary 构造生成中 pending session。 +- `buildWoodenFishSessionFromWorkDetail(work, fallbackItem?)`:从敲木鱼 work detail 恢复 session,并按 summary / fallback / profileId 决定 sessionId。 +- `buildWoodenFishPendingSession(item)`:从敲木鱼作品架 summary 构造生成中 pending session。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:调用这些映射后继续负责 `set*Session`、`set*Work`、`set*Run`、`createMiniGameDraftGenerationState(...)`、`writeCreationUrlState(...)`、`enterCreateTab()` 和 `setSelectionStage(...)`。 + +## Interface 约束 + +- 拼图 runtime work 必须保留 `draft.coverImageSrc` 非空门槛,避免启动缺封面的草稿运行态。 +- 拼图 profileId 优先 `publishedProfileId`,否则用 `buildPuzzleResultProfileId(sessionId)`;workId 使用 `buildPuzzleResultWorkId(sessionId)`,缺失时回退 profileId。 +- 拼图 owner 缺省为 `current-user` / `玩家`;`publishReady` 来自 `session.resultPreview?.publishReady`。 +- 跳一跳 pending sessionId 优先 `sourceSessionId`,缺失时用 `profileId`;素材、路径和 prompt 维持空值兜底。 +- 敲木鱼 detail sessionId 优先级固定为 `work.summary.sourceSessionId > fallbackItem.sourceSessionId > profileId`。 +- 敲木鱼 pending session 保持 `floatingWords=['功德 +1']`、素材 / 音效 / 背景为空的旧默认。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以四个函数取得恢复用 DTO;ID 优先级和默认 draft 字段藏入 Module Implementation。 +- **Leverage**:后续新增生成中作品恢复或修改 sessionId 规则时,先改 Module 与单测,再保持壳层 Adapter 副作用不变。 +- **Locality**:拼图、跳一跳和敲木鱼的恢复映射集中在一个纯测试面,避免在大型壳层顶部继续堆积 DTO 构造。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts` +- `npx eslint src/components/platform-entry/platformMiniGameSessionMappingModel.ts src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 7e618026..3846b968 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -12,6 +12,8 @@ 生成页进度 tick 是否启动统一由 `platformGenerationProgressTickModel.ts` 判定:各小游戏生成页只在当前 stage 与对应生成状态匹配、状态存在且 phase 非 `ready` / `failed` 时 tick;视觉小说继续使用 `startedAtMs` 与轻量 phase 判定,不强行转成小游戏生成状态。平台壳只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,不在壳层重复维护 stage 到 state 的三元链。 +拼图 runtime 刷新恢复、跳一跳生成中草稿打开和敲木鱼生成中 / detail 草稿恢复所需的 session / work DTO 映射统一由 `platformMiniGameSessionMappingModel.ts` 构造。平台壳只负责读取后端、写入本地 state、写 URL 和切换 stage;不得在壳层重新手写 sessionId 优先级、pending draft 空素材默认值或拼图稳定 ID 映射。 + 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index a84d8fa8..cfb07489 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -513,6 +513,12 @@ import { resolveMatch3DRuntimeGeneratedBackgroundAsset, resolveMatch3DRuntimeGeneratedItemAssets, } from './platformMatch3DRuntimeProfile'; +import { + buildJumpHopPendingSession, + buildPuzzleRuntimeWorkFromSession, + buildWoodenFishPendingSession, + buildWoodenFishSessionFromWorkDetail, +} from './platformMiniGameSessionMappingModel'; import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel'; import { type PlatformPublicCodeSearchStep, @@ -1156,124 +1162,6 @@ function openPuzzleRuntimeStage( writePuzzleRuntimeUrlState(state); } -function buildPuzzleRuntimeWorkFromSession( - session: PuzzleAgentSessionSnapshot, - owner: { userId?: string | null; displayName?: string | null }, -): PuzzleWorkSummary | null { - const draft = session.draft; - const profileId = - session.publishedProfileId ?? buildPuzzleResultProfileId(session.sessionId); - if (!draft || !profileId || !draft.coverImageSrc?.trim()) { - return null; - } - - return { - workId: buildPuzzleResultWorkId(session.sessionId) ?? profileId, - profileId, - ownerUserId: owner.userId ?? 'current-user', - sourceSessionId: session.sessionId, - authorDisplayName: owner.displayName ?? '玩家', - workTitle: draft.workTitle, - workDescription: draft.workDescription, - levelName: draft.levelName, - summary: draft.summary, - themeTags: draft.themeTags, - coverImageSrc: draft.coverImageSrc, - coverAssetId: draft.coverAssetId, - publicationStatus: 'draft', - updatedAt: session.updatedAt, - publishedAt: null, - playCount: 0, - remixCount: 0, - likeCount: 0, - publishReady: Boolean(session.resultPreview?.publishReady), - levels: draft.levels, - }; -} - -function buildJumpHopPendingSession( - item: JumpHopWorkSummaryResponse, -): JumpHopSessionSnapshotResponse { - const sessionId = - normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId; - return { - sessionId, - ownerUserId: item.ownerUserId, - status: item.generationStatus, - draft: { - templateId: 'jump-hop', - templateName: '跳一跳', - profileId: item.profileId, - workTitle: item.workTitle, - workDescription: item.workDescription, - themeTags: item.themeTags, - difficulty: item.difficulty, - stylePreset: item.stylePreset, - characterPrompt: '', - tilePrompt: '', - endMoodPrompt: null, - characterAsset: null, - tileAtlasAsset: null, - tileAssets: [], - path: null, - coverComposite: item.coverImageSrc, - generationStatus: item.generationStatus, - }, - createdAt: item.updatedAt, - updatedAt: item.updatedAt, - }; -} - -function buildWoodenFishSessionFromWorkDetail( - work: WoodenFishWorkProfileResponse, - fallbackItem?: WoodenFishWorkSummaryResponse | null, -): WoodenFishSessionSnapshotResponse { - const sessionId = - normalizeCreationUrlValue(work.summary.sourceSessionId) ?? - normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ?? - work.summary.profileId; - return { - sessionId, - ownerUserId: work.summary.ownerUserId, - status: work.summary.generationStatus, - draft: work.draft, - createdAt: work.summary.updatedAt, - updatedAt: work.summary.updatedAt, - }; -} - -function buildWoodenFishPendingSession( - item: WoodenFishWorkSummaryResponse, -): WoodenFishSessionSnapshotResponse { - const sessionId = - normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId; - return { - sessionId, - ownerUserId: item.ownerUserId, - status: item.generationStatus, - draft: { - templateId: 'wooden-fish', - templateName: '敲木鱼', - profileId: item.profileId, - workTitle: item.workTitle, - workDescription: item.workDescription, - themeTags: item.themeTags, - hitObjectPrompt: '', - hitObjectReferenceImageSrc: null, - hitSoundPrompt: null, - floatingWords: ['功德 +1'], - hitObjectAsset: null, - backgroundAsset: null, - backButtonAsset: null, - hitSoundAsset: null, - coverImageSrc: item.coverImageSrc, - generationStatus: item.generationStatus, - }, - createdAt: item.updatedAt, - updatedAt: item.updatedAt, - }; -} - /** 为恢复的小游戏草稿重建生成态,保留后端开始时间作为进度事实源。 */ function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, diff --git a/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts b/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts new file mode 100644 index 00000000..f83824f6 --- /dev/null +++ b/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, test } from 'vitest'; + +import type { + JumpHopWorkSummaryResponse, +} from '../../../packages/shared/src/contracts/jumpHop'; +import type { + PuzzleAnchorPack, + PuzzleResultDraft, +} from '../../../packages/shared/src/contracts/puzzleAgentDraft'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import type { + WoodenFishAudioAsset, + WoodenFishImageAsset, + WoodenFishWorkProfileResponse, + WoodenFishWorkSummaryResponse, +} from '../../../packages/shared/src/contracts/woodenFish'; +import { + buildJumpHopPendingSession, + buildPuzzleRuntimeWorkFromSession, + buildWoodenFishPendingSession, + buildWoodenFishSessionFromWorkDetail, +} from './platformMiniGameSessionMappingModel'; + +function buildAnchorPack(): PuzzleAnchorPack { + const item = { + key: 'theme', + label: '主题', + value: '星桥机关', + status: 'confirmed' as const, + }; + return { + themePromise: item, + visualSubject: item, + visualMood: item, + compositionHooks: item, + tagsAndForbidden: item, + }; +} + +function buildPuzzleDraft( + overrides: Partial = {}, +): PuzzleResultDraft { + const anchorPack = buildAnchorPack(); + return { + workTitle: '星桥拼图', + workDescription: '修复星桥机关。', + levelName: '星桥机关', + summary: '把星桥碎片拼回原位。', + themeTags: ['星桥'], + forbiddenDirectives: [], + creatorIntent: null, + anchorPack, + candidates: [], + selectedCandidateId: null, + coverImageSrc: '/puzzle-cover.png', + coverAssetId: 'asset-cover', + generationStatus: 'ready', + levels: [ + { + levelId: 'level-1', + levelName: '星桥机关', + pictureDescription: '星桥', + candidates: [], + selectedCandidateId: null, + coverImageSrc: '/puzzle-level-cover.png', + coverAssetId: 'asset-level-cover', + generationStatus: 'ready', + }, + ], + ...overrides, + }; +} + +function buildPuzzleSession( + overrides: Partial = {}, +): PuzzleAgentSessionSnapshot { + const draft = buildPuzzleDraft(); + return { + sessionId: 'puzzle-session-12345678', + seedText: '星桥', + currentTurn: 1, + progressPercent: 100, + stage: 'ready_to_publish', + anchorPack: draft.anchorPack, + draft, + messages: [], + lastAssistantReply: null, + publishedProfileId: null, + suggestedActions: [], + resultPreview: { + draft, + blockers: [], + qualityFindings: [], + publishReady: true, + }, + updatedAt: '2026-06-01T10:00:00.000Z', + ...overrides, + }; +} + +function buildJumpHopSummary( + overrides: Partial = {}, +): JumpHopWorkSummaryResponse { + return { + runtimeKind: 'jump-hop', + workId: 'jump-hop-work-1', + profileId: 'jump-hop-profile-1', + ownerUserId: 'user-1', + sourceSessionId: ' jump-hop-session-1 ', + workTitle: '云阶跳跃', + workDescription: '越过云阶。', + themeTags: ['云阶'], + difficulty: 'standard', + stylePreset: 'paper-toy', + coverImageSrc: '/jump-hop-cover.png', + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-01T11:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + ...overrides, + }; +} + +const woodenFishImageAsset: WoodenFishImageAsset = { + assetId: 'asset-hit', + imageSrc: '/hit.png', + imageObjectKey: 'hit.png', + assetObjectId: 'asset-object-hit', + generationProvider: 'test', + prompt: '木鱼', + width: 512, + height: 512, +}; + +const woodenFishAudioAsset: WoodenFishAudioAsset = { + assetId: 'asset-sound', + audioSrc: '/hit.mp3', + audioObjectKey: 'hit.mp3', + assetObjectId: 'asset-object-sound', + source: 'test', +}; + +function buildWoodenFishSummary( + overrides: Partial = {}, +): WoodenFishWorkSummaryResponse { + return { + runtimeKind: 'wooden-fish', + workId: 'wooden-fish-work-1', + profileId: 'wooden-fish-profile-1', + ownerUserId: 'user-1', + sourceSessionId: ' wooden-fish-session-1 ', + workTitle: '星灯木鱼', + workDescription: '敲亮星灯。', + themeTags: ['星灯'], + coverImageSrc: '/wooden-fish-cover.png', + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-01T12:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + ...overrides, + }; +} + +function buildWoodenFishWorkProfile( + overrides: Partial = {}, +): WoodenFishWorkProfileResponse { + const summary = buildWoodenFishSummary(); + const draft = { + templateId: 'wooden-fish', + templateName: '敲木鱼', + profileId: summary.profileId, + workTitle: summary.workTitle, + workDescription: summary.workDescription, + themeTags: summary.themeTags, + hitObjectPrompt: '星灯', + hitObjectReferenceImageSrc: null, + hitSoundPrompt: null, + floatingWords: ['功德 +1'], + hitObjectAsset: woodenFishImageAsset, + backgroundAsset: null, + backButtonAsset: null, + hitSoundAsset: woodenFishAudioAsset, + coverImageSrc: summary.coverImageSrc, + generationStatus: summary.generationStatus, + }; + return { + summary, + draft, + hitObjectAsset: woodenFishImageAsset, + backgroundAsset: null, + backButtonAsset: null, + hitSoundAsset: woodenFishAudioAsset, + floatingWords: ['功德 +1'], + ...overrides, + }; +} + +describe('platformMiniGameSessionMappingModel', () => { + test('builds a draft puzzle runtime work from a session', () => { + expect( + buildPuzzleRuntimeWorkFromSession(buildPuzzleSession(), { + userId: 'user-1', + displayName: '玩家一号', + }), + ).toMatchObject({ + workId: 'puzzle-work-12345678', + profileId: 'puzzle-profile-12345678', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-12345678', + authorDisplayName: '玩家一号', + workTitle: '星桥拼图', + coverImageSrc: '/puzzle-cover.png', + publicationStatus: 'draft', + publishedAt: null, + publishReady: true, + }); + }); + + test('prefers published puzzle profile id when present', () => { + expect( + buildPuzzleRuntimeWorkFromSession( + buildPuzzleSession({ + publishedProfileId: 'published-puzzle-profile', + }), + {}, + ), + ).toMatchObject({ + profileId: 'published-puzzle-profile', + workId: 'puzzle-work-12345678', + ownerUserId: 'current-user', + authorDisplayName: '玩家', + }); + }); + + test('returns null for puzzle runtime work without draft or cover', () => { + expect( + buildPuzzleRuntimeWorkFromSession( + buildPuzzleSession({ + draft: null, + }), + {}, + ), + ).toBeNull(); + expect( + buildPuzzleRuntimeWorkFromSession( + buildPuzzleSession({ + draft: buildPuzzleDraft({ coverImageSrc: ' ' }), + }), + {}, + ), + ).toBeNull(); + }); + + test('builds jump hop pending session from work summary', () => { + expect(buildJumpHopPendingSession(buildJumpHopSummary())).toEqual({ + sessionId: 'jump-hop-session-1', + ownerUserId: 'user-1', + status: 'generating', + draft: { + templateId: 'jump-hop', + templateName: '跳一跳', + profileId: 'jump-hop-profile-1', + workTitle: '云阶跳跃', + workDescription: '越过云阶。', + themeTags: ['云阶'], + difficulty: 'standard', + stylePreset: 'paper-toy', + characterPrompt: '', + tilePrompt: '', + endMoodPrompt: null, + characterAsset: null, + tileAtlasAsset: null, + tileAssets: [], + path: null, + coverComposite: '/jump-hop-cover.png', + generationStatus: 'generating', + }, + createdAt: '2026-06-01T11:00:00.000Z', + updatedAt: '2026-06-01T11:00:00.000Z', + }); + }); + + test('builds wooden fish pending session from work summary', () => { + expect(buildWoodenFishPendingSession(buildWoodenFishSummary())).toEqual({ + sessionId: 'wooden-fish-session-1', + ownerUserId: 'user-1', + status: 'generating', + draft: { + templateId: 'wooden-fish', + templateName: '敲木鱼', + profileId: 'wooden-fish-profile-1', + workTitle: '星灯木鱼', + workDescription: '敲亮星灯。', + themeTags: ['星灯'], + hitObjectPrompt: '', + hitObjectReferenceImageSrc: null, + hitSoundPrompt: null, + floatingWords: ['功德 +1'], + hitObjectAsset: null, + backgroundAsset: null, + backButtonAsset: null, + hitSoundAsset: null, + coverImageSrc: '/wooden-fish-cover.png', + generationStatus: 'generating', + }, + createdAt: '2026-06-01T12:00:00.000Z', + updatedAt: '2026-06-01T12:00:00.000Z', + }); + }); + + test('builds wooden fish recovered session with summary, fallback and profile id priority', () => { + expect( + buildWoodenFishSessionFromWorkDetail( + buildWoodenFishWorkProfile({ + summary: buildWoodenFishSummary({ + sourceSessionId: null, + }), + }), + buildWoodenFishSummary({ + sourceSessionId: ' fallback-session ', + }), + ), + ).toMatchObject({ + sessionId: 'fallback-session', + ownerUserId: 'user-1', + status: 'generating', + }); + + expect( + buildWoodenFishSessionFromWorkDetail( + buildWoodenFishWorkProfile({ + summary: buildWoodenFishSummary({ + sourceSessionId: null, + }), + }), + null, + ).sessionId, + ).toBe('wooden-fish-profile-1'); + }); +}); diff --git a/src/components/platform-entry/platformMiniGameSessionMappingModel.ts b/src/components/platform-entry/platformMiniGameSessionMappingModel.ts new file mode 100644 index 00000000..cadf3d12 --- /dev/null +++ b/src/components/platform-entry/platformMiniGameSessionMappingModel.ts @@ -0,0 +1,136 @@ +import type { JumpHopSessionSnapshotResponse, JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { + WoodenFishSessionSnapshotResponse, + WoodenFishWorkProfileResponse, + WoodenFishWorkSummaryResponse, +} from '../../../packages/shared/src/contracts/woodenFish'; +import { normalizeCreationUrlValue } from './platformCreationUrlStateModel'; +import { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, +} from './platformPuzzleIdentityModel'; + +export type PlatformMiniGameSessionOwner = { + userId?: string | null; + displayName?: string | null; +}; + +export function buildPuzzleRuntimeWorkFromSession( + session: PuzzleAgentSessionSnapshot, + owner: PlatformMiniGameSessionOwner, +): PuzzleWorkSummary | null { + const draft = session.draft; + const profileId = + session.publishedProfileId ?? buildPuzzleResultProfileId(session.sessionId); + if (!draft || !profileId || !draft.coverImageSrc?.trim()) { + return null; + } + + return { + workId: buildPuzzleResultWorkId(session.sessionId) ?? profileId, + profileId, + ownerUserId: owner.userId ?? 'current-user', + sourceSessionId: session.sessionId, + authorDisplayName: owner.displayName ?? '玩家', + workTitle: draft.workTitle, + workDescription: draft.workDescription, + levelName: draft.levelName, + summary: draft.summary, + themeTags: draft.themeTags, + coverImageSrc: draft.coverImageSrc, + coverAssetId: draft.coverAssetId, + publicationStatus: 'draft', + updatedAt: session.updatedAt, + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: Boolean(session.resultPreview?.publishReady), + levels: draft.levels, + }; +} + +export function buildJumpHopPendingSession( + item: JumpHopWorkSummaryResponse, +): JumpHopSessionSnapshotResponse { + const sessionId = + normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId; + return { + sessionId, + ownerUserId: item.ownerUserId, + status: item.generationStatus, + draft: { + templateId: 'jump-hop', + templateName: '跳一跳', + profileId: item.profileId, + workTitle: item.workTitle, + workDescription: item.workDescription, + themeTags: item.themeTags, + difficulty: item.difficulty, + stylePreset: item.stylePreset, + characterPrompt: '', + tilePrompt: '', + endMoodPrompt: null, + characterAsset: null, + tileAtlasAsset: null, + tileAssets: [], + path: null, + coverComposite: item.coverImageSrc, + generationStatus: item.generationStatus, + }, + createdAt: item.updatedAt, + updatedAt: item.updatedAt, + }; +} + +export function buildWoodenFishSessionFromWorkDetail( + work: WoodenFishWorkProfileResponse, + fallbackItem?: WoodenFishWorkSummaryResponse | null, +): WoodenFishSessionSnapshotResponse { + const sessionId = + normalizeCreationUrlValue(work.summary.sourceSessionId) ?? + normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ?? + work.summary.profileId; + return { + sessionId, + ownerUserId: work.summary.ownerUserId, + status: work.summary.generationStatus, + draft: work.draft, + createdAt: work.summary.updatedAt, + updatedAt: work.summary.updatedAt, + }; +} + +export function buildWoodenFishPendingSession( + item: WoodenFishWorkSummaryResponse, +): WoodenFishSessionSnapshotResponse { + const sessionId = + normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId; + return { + sessionId, + ownerUserId: item.ownerUserId, + status: item.generationStatus, + draft: { + templateId: 'wooden-fish', + templateName: '敲木鱼', + profileId: item.profileId, + workTitle: item.workTitle, + workDescription: item.workDescription, + themeTags: item.themeTags, + hitObjectPrompt: '', + hitObjectReferenceImageSrc: null, + hitSoundPrompt: null, + floatingWords: ['功德 +1'], + hitObjectAsset: null, + backgroundAsset: null, + backButtonAsset: null, + hitSoundAsset: null, + coverImageSrc: item.coverImageSrc, + generationStatus: item.generationStatus, + }, + createdAt: item.updatedAt, + updatedAt: item.updatedAt, + }; +} From 23314e62aa75d7128cea6e83648ae7fe3998e822 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 03:09:13 +0800 Subject: [PATCH 44/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20RPG=20?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E9=A2=84=E8=A7=88=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...RpgAgentResultPreviewModel收口计划-2026-06-04.md | 38 ++++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 159 +---------------- ...platformRpgAgentResultPreviewModel.test.ts | 168 ++++++++++++++++++ .../platformRpgAgentResultPreviewModel.ts | 159 +++++++++++++++++ 7 files changed, 385 insertions(+), 151 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts create mode 100644 src/components/platform-entry/platformRpgAgentResultPreviewModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 805c89f7..b833b173 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1387,6 +1387,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform RPG Agent Result Preview Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护 RPG Agent 结果页发布门禁展示修正和 result preview source label 映射,壳层需要理解 `CustomWorldProfile` 顶层字段、`creatorIntent`、`anchorContent`、章节蓝图和首幕 acts。 +- 决策:新增 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,收口 `buildPlatformRpgAgentResultPublishGateView` 与 `resolvePlatformRpgAgentResultPreviewSourceLabel`。Module 只做展示层纯判定;壳层继续负责 session/profile 编排、发布副作用和结果页 props 传递。 +- 影响范围:RPG Agent 结果页发布按钮门禁 blockers、publishReady 展示修正和预览来源 label。 +- 验证方式:`npm run test -- src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 38233467..6a656a7c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台壳的拼图 runtime 恢复 work、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 +RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 + 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md new file mode 100644 index 00000000..0b4c33c4 --- /dev/null +++ b/docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md @@ -0,0 +1,38 @@ +# 【前端架构】Platform RPG Agent Result Preview Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联维护 RPG Agent 结果页的发布门禁展示规则:从 `CustomWorldProfile` 顶层字段、`creatorIntent`、`anchorContent`、章节蓝图和场景章节中反证服务端返回的 legacy blocker 是否已经被当前结果页 profile 补齐,并同时在壳层内把 result preview source 映射成展示标签。 + +这些逻辑不读取 React state,不请求网络,不写 URL,也不操作弹窗;它们属于 RPG Agent 结果预览展示的纯判定。壳层继续负责 session、profile、发布动作和结果页 props 编排。 + +## 决策 + +新增 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts` 作为 Platform RPG Agent Result Preview **Module**。其公开 **Interface** 为: + +- `buildPlatformRpgAgentResultPublishGateView(profile, fallbackBlockers, fallbackPublishReady)`:无 profile 时沿用服务端 fallback;有 profile 时过滤已经被当前 profile 结构字段满足的发布 blocker,并按剩余 blocker 重算展示态 `publishReady`。 +- `resolvePlatformRpgAgentResultPreviewSourceLabel(source)`:把 `published_profile`、`session_preview` 和未知 future source 映射成结果页预览来源标签。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它只把 `agentResultPreview` 与 `generatedCustomWorldProfile` 交给 Module,并将返回的 blocker / label 传入结果页组件。 + +## Interface 约束 + +- 无 profile 时不得自行修正 blocker,必须保留 fallback blocker message 与 fallback `publishReady`。 +- 有 profile 时只过滤已知结构 blocker:`publish_missing_world_hook`、`publish_missing_player_premise`、`publish_missing_core_conflict`、`publish_missing_main_chapter`、`publish_missing_first_act`。 +- 世界钩子兼容读取 `worldHook`、`creatorIntent.worldHook`、`anchorContent.worldPromise`、`anchorContent.worldPromise.hook` 和 `settingText`。 +- 玩家前提兼容读取 `playerPremise`、`creatorIntent.playerPremise`、`anchorContent.playerEntryPoint.openingIdentity`、`openingProblem`、`entryMotivation`。 +- 主章节兼容读取 `chapters`、`sceneChapterBlueprints`、`sceneChapters`;首幕读取 `sceneChapterBlueprints` / `sceneChapters` 下的 `acts`。 +- 未知 blocker code 不得被前端过滤;未知 source 保留“服务端预览”兜底,不做穷尽删除。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以两个函数取得发布门禁展示和 source label;profile 兼容字段路径、legacy blocker code 与兜底规则藏入 Module Implementation。 +- **Leverage**:后续后端调整 RPG result preview blocker 或新增 source 时,先改 Module 与单测,再让壳层 Adapter 保持结果页 props 编排不变。 +- **Locality**:RPG Agent 结果预览展示规则集中到一个纯测试面,避免在大型平台壳中继续混杂 profile 结构探测。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts` +- `npx eslint src/components/platform-entry/platformRpgAgentResultPreviewModel.ts src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 3846b968..a2a78a17 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -14,6 +14,8 @@ 拼图 runtime 刷新恢复、跳一跳生成中草稿打开和敲木鱼生成中 / detail 草稿恢复所需的 session / work DTO 映射统一由 `platformMiniGameSessionMappingModel.ts` 构造。平台壳只负责读取后端、写入本地 state、写 URL 和切换 stage;不得在壳层重新手写 sessionId 优先级、pending draft 空素材默认值或拼图稳定 ID 映射。 +RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。 + 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index cfb07489..63495893 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -556,6 +556,10 @@ import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, } from './platformPuzzleIdentityModel'; +import { + buildPlatformRpgAgentResultPublishGateView, + resolvePlatformRpgAgentResultPreviewSourceLabel, +} from './platformRpgAgentResultPreviewModel'; import { resolveSelectionStageAfterMissingCreationState, resolveSelectionStageAfterProtectedDataLoss, @@ -567,10 +571,6 @@ import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail'; import { usePlatformEntryNavigation } from './usePlatformEntryNavigation'; -type AgentResultPublishGateView = { - blockers: string[]; - publishReady: boolean; -}; type CreationWorkShelfKind = CreationWorkShelfItem['kind']; type CreationFlowReturnTarget = 'create' | 'draft-shelf'; type Match3DBackgroundCompileTask = { @@ -653,18 +653,6 @@ async function resumePuzzleProfileSaveArchiveRaw(worldKey: string) { ); } -type AgentResultBlockerView = { - code?: string; - message: string; -}; - -const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([ - 'publish_missing_world_hook', - 'publish_missing_player_premise', - 'publish_missing_core_conflict', - 'publish_missing_main_chapter', - 'publish_missing_first_act', -]); const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = BACKGROUND_AUTH_REQUEST_OPTIONS; const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS = @@ -1029,130 +1017,6 @@ async function resolvePublicWorkAuthorSummary( return null; } -function readProfileTextField( - profile: CustomWorldProfile | null, - paths: string[], -) { - for (const path of paths) { - let current: unknown = profile; - for (const segment of path.split('.')) { - if (!current || typeof current !== 'object') { - current = null; - break; - } - current = (current as Record)[segment]; - } - if (typeof current === 'string' && current.trim()) { - return current.trim(); - } - } - return null; -} - -function hasProfileTextArray(profile: CustomWorldProfile | null, key: string) { - const value = profile - ? (profile as unknown as Record)[key] - : null; - return Array.isArray(value) - ? value.some((entry) => typeof entry === 'string' && entry.trim()) - : false; -} - -function hasProfileArray(profile: CustomWorldProfile | null, key: string) { - const value = profile - ? (profile as unknown as Record)[key] - : null; - return Array.isArray(value) && value.length > 0; -} - -function hasSceneAct(profile: CustomWorldProfile | null) { - const rawProfile = profile as unknown as Record | null; - const chapters = - rawProfile && - (Array.isArray(rawProfile.sceneChapterBlueprints) - ? rawProfile.sceneChapterBlueprints - : Array.isArray(rawProfile.sceneChapters) - ? rawProfile.sceneChapters - : []); - return Array.isArray(chapters) - ? chapters.some((chapter) => { - const acts = - chapter && typeof chapter === 'object' - ? (chapter as Record).acts - : null; - return Array.isArray(acts) && acts.length > 0; - }) - : false; -} - -function isAgentResultStructuralBlockerResolved( - profile: CustomWorldProfile, - code: string | undefined, -) { - if (!code || !AGENT_RESULT_STRUCTURAL_BLOCKER_CODES.has(code)) { - return false; - } - - if (code === 'publish_missing_world_hook') { - return Boolean( - readProfileTextField(profile, [ - 'worldHook', - 'creatorIntent.worldHook', - 'anchorContent.worldPromise', - 'anchorContent.worldPromise.hook', - 'settingText', - ]), - ); - } - if (code === 'publish_missing_player_premise') { - return Boolean( - readProfileTextField(profile, [ - 'playerPremise', - 'creatorIntent.playerPremise', - 'anchorContent.playerEntryPoint', - 'anchorContent.playerEntryPoint.openingIdentity', - 'anchorContent.playerEntryPoint.openingProblem', - 'anchorContent.playerEntryPoint.entryMotivation', - ]), - ); - } - if (code === 'publish_missing_core_conflict') { - return hasProfileTextArray(profile, 'coreConflicts'); - } - if (code === 'publish_missing_main_chapter') { - return ( - hasProfileArray(profile, 'chapters') || - hasProfileArray(profile, 'sceneChapterBlueprints') || - hasProfileArray(profile, 'sceneChapters') - ); - } - return hasSceneAct(profile); -} - -function buildAgentResultPublishGateView( - profile: CustomWorldProfile | null, - fallbackBlockers: AgentResultBlockerView[], - fallbackPublishReady: boolean, -): AgentResultPublishGateView { - if (!profile) { - return { - blockers: fallbackBlockers.map((entry) => entry.message), - publishReady: fallbackPublishReady, - }; - } - - const blockers = fallbackBlockers - .filter( - (entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code), - ) - .map((entry) => entry.message); - - return { - blockers, - publishReady: blockers.length === 0, - }; -} - function openPuzzleRuntimeStage( setSelectionStage: (stage: SelectionStage) => void, state: PuzzleRuntimeUrlState, @@ -3337,7 +3201,7 @@ export function PlatformEntryFlowShellImpl({ ); const agentResultPublishGateView = useMemo( () => - buildAgentResultPublishGateView( + buildPlatformRpgAgentResultPublishGateView( sessionController.generatedCustomWorldProfile, agentResultPreviewBlockers, Boolean(agentResultPreview?.publishReady), @@ -3397,16 +3261,9 @@ export function PlatformEntryFlowShellImpl({ [openPublishShareModal, platformBootstrap], ); const agentResultPreviewSourceLabel = useMemo(() => { - if (!agentResultPreview?.source) { - return null; - } - if (agentResultPreview.source === 'published_profile') { - return '已发布世界'; - } - if (agentResultPreview.source === 'session_preview') { - return '会话预览'; - } - return '服务端预览'; + return resolvePlatformRpgAgentResultPreviewSourceLabel( + agentResultPreview?.source, + ); }, [agentResultPreview]); const featuredGalleryEntries = useMemo(() => { const bigFishPublicEntries = isBigFishCreationVisible diff --git a/src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts b/src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts new file mode 100644 index 00000000..90c40d47 --- /dev/null +++ b/src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from 'vitest'; + +import { createRpgCreationPublishedProfileFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures'; +import type { CustomWorldProfile } from '../../types'; +import { + buildPlatformRpgAgentResultPublishGateView, + type PlatformRpgAgentResultBlockerView, + resolvePlatformRpgAgentResultPreviewSourceLabel, +} from './platformRpgAgentResultPreviewModel'; + +function buildProfile( + overrides: Record = {}, +): CustomWorldProfile { + return { + ...createRpgCreationPublishedProfileFixture(), + worldHook: '潮雾列岛旧灯塔重新点亮。', + playerPremise: '玩家从回潮旧灯塔切入沉船旧案。', + coreConflicts: ['守灯会与沉船旧案的冲突'], + sceneChapterBlueprints: [ + { + id: 'chapter-1', + acts: [ + { + id: 'act-1', + }, + ], + }, + ], + ...overrides, + } as unknown as CustomWorldProfile; +} + +const missingWorldHookBlocker: PlatformRpgAgentResultBlockerView = { + code: 'publish_missing_world_hook', + message: '缺少世界钩子', +}; +const missingPlayerPremiseBlocker: PlatformRpgAgentResultBlockerView = { + code: 'publish_missing_player_premise', + message: '缺少玩家前提', +}; +const missingCoreConflictBlocker: PlatformRpgAgentResultBlockerView = { + code: 'publish_missing_core_conflict', + message: '缺少核心冲突', +}; +const missingMainChapterBlocker: PlatformRpgAgentResultBlockerView = { + code: 'publish_missing_main_chapter', + message: '缺少主章节', +}; +const missingFirstActBlocker: PlatformRpgAgentResultBlockerView = { + code: 'publish_missing_first_act', + message: '缺少首幕', +}; +const structuralBlockers: PlatformRpgAgentResultBlockerView[] = [ + missingWorldHookBlocker, + missingPlayerPremiseBlocker, + missingCoreConflictBlocker, + missingMainChapterBlocker, + missingFirstActBlocker, +]; + +describe('platformRpgAgentResultPreviewModel', () => { + test('uses fallback blockers and publish readiness without a profile', () => { + expect( + buildPlatformRpgAgentResultPublishGateView( + null, + structuralBlockers.slice(0, 2), + false, + ), + ).toEqual({ + blockers: ['缺少世界钩子', '缺少玩家前提'], + publishReady: false, + }); + }); + + test('filters structural blockers already satisfied by the profile', () => { + expect( + buildPlatformRpgAgentResultPublishGateView( + buildProfile(), + [ + ...structuralBlockers, + { + code: 'future_blocker', + message: '未知服务端阻断', + }, + ], + false, + ), + ).toEqual({ + blockers: ['未知服务端阻断'], + publishReady: false, + }); + }); + + test('keeps unresolved structural blockers when profile fields are empty', () => { + expect( + buildPlatformRpgAgentResultPublishGateView( + buildProfile({ + worldHook: '', + playerPremise: '', + settingText: '', + creatorIntent: null, + anchorContent: null, + coreConflicts: [], + chapters: [], + sceneChapterBlueprints: [], + sceneChapters: [], + }), + structuralBlockers, + true, + ), + ).toEqual({ + blockers: structuralBlockers.map((entry) => entry.message), + publishReady: false, + }); + }); + + test('resolves structural blockers from nested profile compatibility fields', () => { + expect( + buildPlatformRpgAgentResultPublishGateView( + buildProfile({ + worldHook: '', + playerPremise: '', + settingText: '', + creatorIntent: { + worldHook: '旧灯塔潮路重新开启。', + }, + anchorContent: { + playerEntryPoint: { + openingProblem: '玩家被卷入沉船旧案。', + }, + }, + coreConflicts: [''], + chapters: [], + sceneChapterBlueprints: null, + sceneChapters: [ + { + acts: [{}], + }, + ], + }), + [ + missingWorldHookBlocker, + missingPlayerPremiseBlocker, + missingFirstActBlocker, + ], + false, + ), + ).toEqual({ + blockers: [], + publishReady: true, + }); + }); + + test('maps preview source to result label', () => { + expect(resolvePlatformRpgAgentResultPreviewSourceLabel(null)).toBeNull(); + expect( + resolvePlatformRpgAgentResultPreviewSourceLabel('published_profile'), + ).toBe('已发布世界'); + expect( + resolvePlatformRpgAgentResultPreviewSourceLabel('session_preview'), + ).toBe('会话预览'); + expect( + resolvePlatformRpgAgentResultPreviewSourceLabel('future_source'), + ).toBe( + '服务端预览', + ); + }); +}); diff --git a/src/components/platform-entry/platformRpgAgentResultPreviewModel.ts b/src/components/platform-entry/platformRpgAgentResultPreviewModel.ts new file mode 100644 index 00000000..8ecf5ed8 --- /dev/null +++ b/src/components/platform-entry/platformRpgAgentResultPreviewModel.ts @@ -0,0 +1,159 @@ +import type { RpgCreationPreviewSource } from '../../../packages/shared/src/contracts/rpgCreationPreview'; +import type { CustomWorldProfile } from '../../types'; + +export type PlatformRpgAgentResultBlockerView = { + code?: string | null; + message: string; +}; + +export type PlatformRpgAgentResultPublishGateView = { + blockers: string[]; + publishReady: boolean; +}; + +const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([ + 'publish_missing_world_hook', + 'publish_missing_player_premise', + 'publish_missing_core_conflict', + 'publish_missing_main_chapter', + 'publish_missing_first_act', +]); + +function readProfileTextField( + profile: CustomWorldProfile | null, + paths: string[], +) { + for (const path of paths) { + let current: unknown = profile; + for (const segment of path.split('.')) { + if (!current || typeof current !== 'object') { + current = null; + break; + } + current = (current as Record)[segment]; + } + if (typeof current === 'string' && current.trim()) { + return current.trim(); + } + } + return null; +} + +function hasProfileTextArray(profile: CustomWorldProfile | null, key: string) { + const value = profile + ? (profile as unknown as Record)[key] + : null; + return Array.isArray(value) + ? value.some((entry) => typeof entry === 'string' && entry.trim()) + : false; +} + +function hasProfileArray(profile: CustomWorldProfile | null, key: string) { + const value = profile + ? (profile as unknown as Record)[key] + : null; + return Array.isArray(value) && value.length > 0; +} + +function hasSceneAct(profile: CustomWorldProfile | null) { + const rawProfile = profile as unknown as Record | null; + const chapters = + rawProfile && + (Array.isArray(rawProfile.sceneChapterBlueprints) + ? rawProfile.sceneChapterBlueprints + : Array.isArray(rawProfile.sceneChapters) + ? rawProfile.sceneChapters + : []); + return Array.isArray(chapters) + ? chapters.some((chapter) => { + const acts = + chapter && typeof chapter === 'object' + ? (chapter as Record).acts + : null; + return Array.isArray(acts) && acts.length > 0; + }) + : false; +} + +function isAgentResultStructuralBlockerResolved( + profile: CustomWorldProfile, + code: string | null | undefined, +) { + if (!code || !AGENT_RESULT_STRUCTURAL_BLOCKER_CODES.has(code)) { + return false; + } + + if (code === 'publish_missing_world_hook') { + return Boolean( + readProfileTextField(profile, [ + 'worldHook', + 'creatorIntent.worldHook', + 'anchorContent.worldPromise', + 'anchorContent.worldPromise.hook', + 'settingText', + ]), + ); + } + if (code === 'publish_missing_player_premise') { + return Boolean( + readProfileTextField(profile, [ + 'playerPremise', + 'creatorIntent.playerPremise', + 'anchorContent.playerEntryPoint', + 'anchorContent.playerEntryPoint.openingIdentity', + 'anchorContent.playerEntryPoint.openingProblem', + 'anchorContent.playerEntryPoint.entryMotivation', + ]), + ); + } + if (code === 'publish_missing_core_conflict') { + return hasProfileTextArray(profile, 'coreConflicts'); + } + if (code === 'publish_missing_main_chapter') { + return ( + hasProfileArray(profile, 'chapters') || + hasProfileArray(profile, 'sceneChapterBlueprints') || + hasProfileArray(profile, 'sceneChapters') + ); + } + return hasSceneAct(profile); +} + +export function buildPlatformRpgAgentResultPublishGateView( + profile: CustomWorldProfile | null, + fallbackBlockers: PlatformRpgAgentResultBlockerView[], + fallbackPublishReady: boolean, +): PlatformRpgAgentResultPublishGateView { + if (!profile) { + return { + blockers: fallbackBlockers.map((entry) => entry.message), + publishReady: fallbackPublishReady, + }; + } + + const blockers = fallbackBlockers + .filter( + (entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code), + ) + .map((entry) => entry.message); + + return { + blockers, + publishReady: blockers.length === 0, + }; +} + +export function resolvePlatformRpgAgentResultPreviewSourceLabel( + source: RpgCreationPreviewSource | string | null | undefined, +) { + if (!source) { + return null; + } + if (source === 'published_profile') { + return '已发布世界'; + } + if (source === 'session_preview') { + return '会话预览'; + } + return '服务端预览'; +} From 5114a230ae3010dbd9454e1790d62701c1fb2311 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 03:16:32 +0800 Subject: [PATCH 45/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=B0=8F?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E7=94=9F=E6=88=90=E7=8A=B6=E6=80=81=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...eDraftGenerationStateModel收口计划-2026-06-04.md | 43 +++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 166 +--------- ...mMiniGameDraftGenerationStateModel.test.ts | 303 ++++++++++++++++++ ...atformMiniGameDraftGenerationStateModel.ts | 167 ++++++++++ 7 files changed, 536 insertions(+), 155 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts create mode 100644 src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b833b173..fa2c5a69 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1395,6 +1395,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform Mini Game Draft Generation State Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护小游戏生成状态恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定,壳层同时承担 API / background task 副作用和 `MiniGameDraftGenerationState` 生命周期细节。 +- 决策:新增 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,收口恢复态、失败态、完成态、展示 rebase、拼图 progress phase 阈值和进度 metadata 合并。壳层继续负责 API、后台任务、React state 写入、作品架刷新、URL 和 stage 切换。 +- 影响范围:拼图 / 抓大鹅 / 大鱼吃小鱼 / 方洞 / 跳一跳 / 敲木鱼 / 宝贝识物生成状态恢复、完成失败收尾、生成页返回展示和拼图轮询进度合并。 +- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 6a656a7c..82fbcb7f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台壳的拼图 runtime 恢复 work、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 +平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 + RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md new file mode 100644 index 00000000..bbfb5ed8 --- /dev/null +++ b/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md @@ -0,0 +1,43 @@ +# 【前端架构】Platform Mini Game Draft Generation State Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联维护小游戏生成状态的恢复、失败/完成收尾、展示 rebase、拼图后端进度合并和生成中 / ready 判定。壳层因此既要处理 API 回包、React state、后台任务、URL 和 stage,又要记住 `MiniGameDraftGenerationState` 的生命周期细节。 + +这些状态变换不读取 DOM,不请求网络,也不写 React state;它们属于平台层小游戏草稿生成状态 **Module**。壳层只应决定何时调用、把返回值写入对应 state。 + +## 决策 + +新增 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts` 作为 Platform Mini Game Draft Generation State **Module**。其公开 **Interface** 为: + +- `createMiniGameDraftGenerationStateForRestoredDraft(kind, metadata?, startedAtMs?)`:为恢复的草稿重建生成态,并保留后端开始时间作为进度事实源。 +- `createFailedMiniGameDraftGenerationStateForRestoredDraft(kind, updatedAt, error, metadata?)`:恢复失败草稿时按后端 `updatedAt` 建立失败态。 +- `rebaseMiniGameDraftGenerationStateForDisplay(state)` 与 `rebaseMiniGameDraftBackgroundCompileTaskForDisplay(task)`:清理展示用 `finishedAtMs`,避免返回生成页后沿用结束态计时。 +- `createPuzzleDraftGenerationStateFromPayload(payload, session?)`、`resolvePuzzlePhaseFromSessionProgress(state, session)`、`mergePuzzleSessionProgressIntoGenerationState(state, session)`:集中处理拼图生成的 aiRedraw、后端进度百分比和 phase 推进。 +- `resolveFinishedMiniGameDraftGenerationState(state, phase, options?)`:统一完成 / 失败收尾的 `finishedAtMs`、错误与资产计数合并。 +- `isMiniGameDraftReady(state)` 与 `isMiniGameDraftGenerating(state)`:统一生成态轻量判定。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责 API、background task、React state 写入、作品架刷新、URL 与 stage 切换。 + +## Interface 约束 + +- 恢复草稿状态必须允许调用方传入 `startedAtMs`;未传时使用当前时间,与旧逻辑一致。 +- 恢复失败状态必须通过 `resolveMiniGameDraftGenerationStartedAtMs(updatedAt)` 解析后端时间,并保留传入 metadata。 +- `resolveFinishedMiniGameDraftGenerationState` 只覆盖显式传入的 `error`、`completedAssetCount`、`totalAssetCount`;未传时沿用原 state。 +- 拼图 session 只有在 `draft` 存在且不是 `formDraft` 时才视为后端编译生成中 session,才写入 `puzzleProgressPercent` 并推进 phase。 +- 拼图进度阈值保持旧值:`>=96` 到 `puzzle-select-image`,`>=94` 到 `puzzle-ui-assets`,`>=88` 时按 `puzzleAiRedraw=false` 进入 `puzzle-level-scene`,否则进入 `puzzle-cover-image`。 +- phase 变化时 `puzzleActiveStepStartedAtMs` 使用 session `updatedAt` 解析值;phase 不变时保留旧值。 +- 展示 rebase 只清理 `finishedAtMs`,不得修改 phase、error、资产计数或 metadata。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以状态变换函数表达意图;生成态字段、拼图阈值、时间解析与计数合并藏入 Module Implementation。 +- **Leverage**:后续新增小游戏生成恢复或调整拼图后端进度阈值时,先改 Module 与单测,再让壳层 Adapter 保持调用点不变。 +- **Locality**:小游戏生成状态规则集中到一个纯测试面,避免在大型壳层的 API callback、background task 和恢复流程中重复推理 `MiniGameDraftGenerationState`。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts` +- `npx eslint src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index a2a78a17..964d9423 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -14,6 +14,8 @@ 拼图 runtime 刷新恢复、跳一跳生成中草稿打开和敲木鱼生成中 / detail 草稿恢复所需的 session / work DTO 映射统一由 `platformMiniGameSessionMappingModel.ts` 构造。平台壳只负责读取后端、写入本地 state、写 URL 和切换 stage;不得在壳层重新手写 sessionId 优先级、pending draft 空素材默认值或拼图稳定 ID 映射。 +平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定统一由 `platformMiniGameDraftGenerationStateModel.ts` 处理。平台壳只决定何时调用并写入对应 React state,不得在壳层重新维护 `MiniGameDraftGenerationState` 的 phase 阈值、`finishedAtMs` 清理或拼图进度 metadata 合并规则。 + RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 63495893..ace14e6e 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -213,8 +213,6 @@ import { buildSquareHoleGenerationAnchorEntries, buildWoodenFishGenerationAnchorEntries, createMiniGameDraftGenerationState, - type MiniGameDraftGenerationKind, - type MiniGameDraftGenerationPhase, type MiniGameDraftGenerationState, resolveMiniGameDraftGenerationStartedAtMs, } from '../../services/miniGameDraftGenerationProgress'; @@ -513,6 +511,17 @@ import { resolveMatch3DRuntimeGeneratedBackgroundAsset, resolveMatch3DRuntimeGeneratedItemAssets, } from './platformMatch3DRuntimeProfile'; +import { + createFailedMiniGameDraftGenerationStateForRestoredDraft, + createMiniGameDraftGenerationStateForRestoredDraft, + createPuzzleDraftGenerationStateFromPayload, + isMiniGameDraftGenerating, + isMiniGameDraftReady, + mergePuzzleSessionProgressIntoGenerationState, + rebaseMiniGameDraftBackgroundCompileTaskForDisplay, + rebaseMiniGameDraftGenerationStateForDisplay, + resolveFinishedMiniGameDraftGenerationState, +} from './platformMiniGameDraftGenerationStateModel'; import { buildJumpHopPendingSession, buildPuzzleRuntimeWorkFromSession, @@ -1026,35 +1035,6 @@ function openPuzzleRuntimeStage( writePuzzleRuntimeUrlState(state); } -/** 为恢复的小游戏草稿重建生成态,保留后端开始时间作为进度事实源。 */ -function createMiniGameDraftGenerationStateForRestoredDraft( - kind: MiniGameDraftGenerationKind, - metadata?: MiniGameDraftGenerationState['metadata'], - startedAtMs = Date.now(), -): MiniGameDraftGenerationState { - return { - ...createMiniGameDraftGenerationState(kind, startedAtMs), - ...(metadata ? { metadata } : {}), - }; -} - -function createFailedMiniGameDraftGenerationStateForRestoredDraft( - kind: MiniGameDraftGenerationKind, - updatedAt: string | null | undefined, - error: string, - metadata?: MiniGameDraftGenerationState['metadata'], -): MiniGameDraftGenerationState { - return resolveFinishedMiniGameDraftGenerationState( - createMiniGameDraftGenerationStateForRestoredDraft( - kind, - metadata, - resolveMiniGameDraftGenerationStartedAtMs(updatedAt), - ), - 'failed', - { error }, - ); -} - function buildPuzzleFormPayloadFromWork( item: PuzzleWorkSummary, ): CreatePuzzleAgentSessionRequest { @@ -1138,122 +1118,6 @@ function buildMatch3DFormPayloadFromWork( }; } -/** 清理生成态完成时间,避免返回生成页后继续沿用结束态计时。 */ -function rebaseMiniGameDraftGenerationStateForDisplay( - state: MiniGameDraftGenerationState, -): MiniGameDraftGenerationState { - return { - ...state, - finishedAtMs: undefined, - }; -} - -function rebaseMiniGameDraftBackgroundCompileTaskForDisplay< - T extends PuzzleBackgroundCompileTask | Match3DBackgroundCompileTask, ->(task: T): T { - return { - ...task, - generationState: rebaseMiniGameDraftGenerationStateForDisplay( - task.generationState, - ), - }; -} - -function createPuzzleDraftGenerationStateFromPayload( - payload: CreatePuzzleAgentSessionRequest | null | undefined, - session: PuzzleAgentSessionSnapshot | null | undefined = null, -): MiniGameDraftGenerationState { - const puzzleProgressPercent = - session?.draft && !session.draft.formDraft - ? session.progressPercent - : undefined; - - return { - ...createMiniGameDraftGenerationState( - 'puzzle', - resolveMiniGameDraftGenerationStartedAtMs(session?.updatedAt), - ), - metadata: { - puzzleAiRedraw: payload?.aiRedraw ?? true, - puzzleActivePhaseId: - typeof puzzleProgressPercent === 'number' ? 'compile' : undefined, - puzzleActiveStepStartedAtMs: - typeof puzzleProgressPercent === 'number' ? Date.now() : undefined, - puzzleProgressPercent, - }, - }; -} - -function resolvePuzzlePhaseFromSessionProgress( - state: MiniGameDraftGenerationState, - session: PuzzleAgentSessionSnapshot, -): MiniGameDraftGenerationPhase { - if (session.progressPercent >= 96) { - return 'puzzle-select-image'; - } - if (session.progressPercent >= 94) { - return 'puzzle-ui-assets'; - } - if (session.progressPercent >= 88) { - return state.metadata?.puzzleAiRedraw === false - ? 'puzzle-level-scene' - : 'puzzle-cover-image'; - } - - return 'compile'; -} - -function mergePuzzleSessionProgressIntoGenerationState( - state: MiniGameDraftGenerationState, - session: PuzzleAgentSessionSnapshot, -): MiniGameDraftGenerationState { - const isCompiledGenerationSession = Boolean( - session.draft && !session.draft.formDraft, - ); - - const nextPhaseId = isCompiledGenerationSession - ? resolvePuzzlePhaseFromSessionProgress(state, session) - : state.metadata?.puzzleActivePhaseId; - const shouldResetActiveStepStart = - isCompiledGenerationSession && - nextPhaseId != null && - nextPhaseId !== state.metadata?.puzzleActivePhaseId; - - return { - ...state, - metadata: { - ...state.metadata, - puzzleActivePhaseId: nextPhaseId, - puzzleActiveStepStartedAtMs: shouldResetActiveStepStart - ? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt) - : state.metadata?.puzzleActiveStepStartedAtMs, - puzzleProgressPercent: isCompiledGenerationSession - ? session.progressPercent - : state.metadata?.puzzleProgressPercent, - }, - }; -} - -function resolveFinishedMiniGameDraftGenerationState( - state: MiniGameDraftGenerationState, - phase: 'ready' | 'failed', - options: { - error?: string | null; - completedAssetCount?: number; - totalAssetCount?: number; - } = {}, -): MiniGameDraftGenerationState { - return { - ...state, - phase, - finishedAtMs: Date.now(), - error: options.error ?? state.error, - completedAssetCount: - options.completedAssetCount ?? state.completedAssetCount, - totalAssetCount: options.totalAssetCount ?? state.totalAssetCount, - }; -} - function normalizeRecoveredPuzzleDraftSession( session: PuzzleAgentSessionSnapshot, ): PuzzleAgentSessionSnapshot { @@ -1325,14 +1189,6 @@ function hasRecoverableGeneratedPuzzleDraft( ); } -function isMiniGameDraftReady(state: MiniGameDraftGenerationState | null) { - return state?.phase === 'ready'; -} - -function isMiniGameDraftGenerating(state: MiniGameDraftGenerationState | null) { - return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed'); -} - function resolveProfileWalletBalance( dashboard: { walletBalance?: number | null } | null | undefined, ) { diff --git a/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts new file mode 100644 index 00000000..df06fd1c --- /dev/null +++ b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts @@ -0,0 +1,303 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { PuzzleAnchorPack } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; +import type { + CreatePuzzleAgentSessionRequest, + PuzzleAgentSessionSnapshot, +} from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import type { MiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress'; +import { + createFailedMiniGameDraftGenerationStateForRestoredDraft, + createMiniGameDraftGenerationStateForRestoredDraft, + createPuzzleDraftGenerationStateFromPayload, + isMiniGameDraftGenerating, + isMiniGameDraftReady, + mergePuzzleSessionProgressIntoGenerationState, + rebaseMiniGameDraftBackgroundCompileTaskForDisplay, + rebaseMiniGameDraftGenerationStateForDisplay, + resolveFinishedMiniGameDraftGenerationState, + resolvePuzzlePhaseFromSessionProgress, +} from './platformMiniGameDraftGenerationStateModel'; + +const NOW = Date.parse('2026-06-04T03:00:00.000Z'); +const SESSION_UPDATED_AT = '2026-06-01T10:00:00.000Z'; +const SESSION_UPDATED_AT_MS = Date.parse(SESSION_UPDATED_AT); + +function buildAnchorPack(): PuzzleAnchorPack { + const item = { + key: 'theme', + label: '主题', + value: '星桥机关', + status: 'confirmed' as const, + }; + return { + themePromise: item, + visualSubject: item, + visualMood: item, + compositionHooks: item, + tagsAndForbidden: item, + }; +} + +function buildPuzzleSession( + overrides: Partial = {}, +): PuzzleAgentSessionSnapshot { + const anchorPack = buildAnchorPack(); + return { + sessionId: 'puzzle-session-1', + seedText: '星桥', + currentTurn: 1, + progressPercent: 90, + stage: 'draft_ready', + anchorPack, + draft: { + workTitle: '星桥拼图', + workDescription: '修复星桥机关。', + levelName: '星桥机关', + summary: '把星桥碎片拼回原位。', + themeTags: ['星桥'], + forbiddenDirectives: [], + creatorIntent: null, + anchorPack, + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'generating', + levels: [], + }, + messages: [], + lastAssistantReply: null, + publishedProfileId: null, + suggestedActions: [], + resultPreview: null, + updatedAt: SESSION_UPDATED_AT, + ...overrides, + }; +} + +function buildState( + overrides: Partial = {}, +): MiniGameDraftGenerationState { + return { + kind: 'puzzle', + phase: 'compile', + startedAtMs: 100, + completedAssetCount: 0, + totalAssetCount: 0, + error: null, + metadata: { + puzzleAiRedraw: true, + puzzleActivePhaseId: 'compile', + puzzleActiveStepStartedAtMs: 200, + puzzleProgressPercent: 20, + }, + ...overrides, + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('platformMiniGameDraftGenerationStateModel', () => { + test('creates restored generation state with metadata and explicit start time', () => { + expect( + createMiniGameDraftGenerationStateForRestoredDraft( + 'match3d', + { puzzleAiRedraw: false }, + 123, + ), + ).toMatchObject({ + kind: 'match3d', + phase: 'match3d-work-title', + startedAtMs: 123, + metadata: { + puzzleAiRedraw: false, + }, + }); + }); + + test('creates failed restored state from backend updated time', () => { + expect( + createFailedMiniGameDraftGenerationStateForRestoredDraft( + 'puzzle', + SESSION_UPDATED_AT, + '生成失败', + { puzzleAiRedraw: true }, + ), + ).toMatchObject({ + kind: 'puzzle', + phase: 'failed', + startedAtMs: SESSION_UPDATED_AT_MS, + finishedAtMs: NOW, + error: '生成失败', + metadata: { + puzzleAiRedraw: true, + }, + }); + }); + + test('rebases finished state for display without changing other fields', () => { + const state = buildState({ + phase: 'ready', + finishedAtMs: 300, + completedAssetCount: 2, + totalAssetCount: 3, + }); + + expect(rebaseMiniGameDraftGenerationStateForDisplay(state)).toEqual({ + ...state, + finishedAtMs: undefined, + }); + expect( + rebaseMiniGameDraftBackgroundCompileTaskForDisplay({ + sessionId: 'task-1', + generationState: state, + }), + ).toEqual({ + sessionId: 'task-1', + generationState: { + ...state, + finishedAtMs: undefined, + }, + }); + }); + + test('creates puzzle generation state from payload and compiled session', () => { + const payload: CreatePuzzleAgentSessionRequest = { + seedText: '星桥', + aiRedraw: false, + }; + + expect(createPuzzleDraftGenerationStateFromPayload(payload)).toMatchObject({ + kind: 'puzzle', + phase: 'compile', + startedAtMs: NOW, + metadata: { + puzzleAiRedraw: false, + puzzleActivePhaseId: undefined, + puzzleActiveStepStartedAtMs: undefined, + puzzleProgressPercent: undefined, + }, + }); + + expect( + createPuzzleDraftGenerationStateFromPayload(payload, buildPuzzleSession()), + ).toMatchObject({ + kind: 'puzzle', + phase: 'compile', + startedAtMs: SESSION_UPDATED_AT_MS, + metadata: { + puzzleAiRedraw: false, + puzzleActivePhaseId: 'compile', + puzzleActiveStepStartedAtMs: NOW, + puzzleProgressPercent: 90, + }, + }); + }); + + test('resolves puzzle phase from backend progress thresholds', () => { + const state = buildState(); + expect( + resolvePuzzlePhaseFromSessionProgress( + state, + buildPuzzleSession({ progressPercent: 96 }), + ), + ).toBe('puzzle-select-image'); + expect( + resolvePuzzlePhaseFromSessionProgress( + state, + buildPuzzleSession({ progressPercent: 94 }), + ), + ).toBe('puzzle-ui-assets'); + expect( + resolvePuzzlePhaseFromSessionProgress( + buildState({ metadata: { puzzleAiRedraw: false } }), + buildPuzzleSession({ progressPercent: 88 }), + ), + ).toBe('puzzle-level-scene'); + expect( + resolvePuzzlePhaseFromSessionProgress( + state, + buildPuzzleSession({ progressPercent: 88 }), + ), + ).toBe('puzzle-cover-image'); + expect( + resolvePuzzlePhaseFromSessionProgress( + state, + buildPuzzleSession({ progressPercent: 20 }), + ), + ).toBe('compile'); + }); + + test('merges compiled puzzle session progress into generation state', () => { + expect( + mergePuzzleSessionProgressIntoGenerationState( + buildState({ + metadata: { + puzzleAiRedraw: false, + puzzleActivePhaseId: 'compile', + puzzleActiveStepStartedAtMs: 200, + puzzleProgressPercent: 20, + }, + }), + buildPuzzleSession({ progressPercent: 90 }), + ), + ).toMatchObject({ + metadata: { + puzzleAiRedraw: false, + puzzleActivePhaseId: 'puzzle-level-scene', + puzzleActiveStepStartedAtMs: SESSION_UPDATED_AT_MS, + puzzleProgressPercent: 90, + }, + }); + + expect( + mergePuzzleSessionProgressIntoGenerationState( + buildState(), + buildPuzzleSession({ + draft: { + ...buildPuzzleSession().draft!, + formDraft: { + pictureDescription: '星桥', + }, + }, + }), + ).metadata, + ).toMatchObject({ + puzzleActivePhaseId: 'compile', + puzzleActiveStepStartedAtMs: 200, + puzzleProgressPercent: 20, + }); + }); + + test('finishes generation state and resolves ready/generating flags', () => { + const failedState = resolveFinishedMiniGameDraftGenerationState( + buildState({ error: '旧错误' }), + 'failed', + { + completedAssetCount: 1, + totalAssetCount: 2, + }, + ); + + expect(failedState).toMatchObject({ + phase: 'failed', + finishedAtMs: NOW, + error: '旧错误', + completedAssetCount: 1, + totalAssetCount: 2, + }); + expect(isMiniGameDraftReady(failedState)).toBe(false); + expect(isMiniGameDraftGenerating(failedState)).toBe(false); + expect(isMiniGameDraftReady({ ...failedState, phase: 'ready' })).toBe(true); + expect(isMiniGameDraftGenerating(buildState())).toBe(true); + expect(isMiniGameDraftGenerating(null)).toBe(false); + }); +}); diff --git a/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts new file mode 100644 index 00000000..00ee2e39 --- /dev/null +++ b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts @@ -0,0 +1,167 @@ +import type { + CreatePuzzleAgentSessionRequest, + PuzzleAgentSessionSnapshot, +} from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import { + createMiniGameDraftGenerationState, + type MiniGameDraftGenerationKind, + type MiniGameDraftGenerationPhase, + type MiniGameDraftGenerationState, + resolveMiniGameDraftGenerationStartedAtMs, +} from '../../services/miniGameDraftGenerationProgress'; + +export function createMiniGameDraftGenerationStateForRestoredDraft( + kind: MiniGameDraftGenerationKind, + metadata?: MiniGameDraftGenerationState['metadata'], + startedAtMs = Date.now(), +): MiniGameDraftGenerationState { + return { + ...createMiniGameDraftGenerationState(kind, startedAtMs), + ...(metadata ? { metadata } : {}), + }; +} + +export function createFailedMiniGameDraftGenerationStateForRestoredDraft( + kind: MiniGameDraftGenerationKind, + updatedAt: string | null | undefined, + error: string, + metadata?: MiniGameDraftGenerationState['metadata'], +): MiniGameDraftGenerationState { + return resolveFinishedMiniGameDraftGenerationState( + createMiniGameDraftGenerationStateForRestoredDraft( + kind, + metadata, + resolveMiniGameDraftGenerationStartedAtMs(updatedAt), + ), + 'failed', + { error }, + ); +} + +/** 清理生成态完成时间,避免返回生成页后继续沿用结束态计时。 */ +export function rebaseMiniGameDraftGenerationStateForDisplay( + state: MiniGameDraftGenerationState, +): MiniGameDraftGenerationState { + return { + ...state, + finishedAtMs: undefined, + }; +} + +export function rebaseMiniGameDraftBackgroundCompileTaskForDisplay< + T extends { generationState: MiniGameDraftGenerationState }, +>(task: T): T { + return { + ...task, + generationState: rebaseMiniGameDraftGenerationStateForDisplay( + task.generationState, + ), + }; +} + +export function createPuzzleDraftGenerationStateFromPayload( + payload: CreatePuzzleAgentSessionRequest | null | undefined, + session: PuzzleAgentSessionSnapshot | null | undefined = null, +): MiniGameDraftGenerationState { + const puzzleProgressPercent = + session?.draft && !session.draft.formDraft + ? session.progressPercent + : undefined; + + return { + ...createMiniGameDraftGenerationState( + 'puzzle', + resolveMiniGameDraftGenerationStartedAtMs(session?.updatedAt), + ), + metadata: { + puzzleAiRedraw: payload?.aiRedraw ?? true, + puzzleActivePhaseId: + typeof puzzleProgressPercent === 'number' ? 'compile' : undefined, + puzzleActiveStepStartedAtMs: + typeof puzzleProgressPercent === 'number' ? Date.now() : undefined, + puzzleProgressPercent, + }, + }; +} + +export function resolvePuzzlePhaseFromSessionProgress( + state: MiniGameDraftGenerationState, + session: PuzzleAgentSessionSnapshot, +): MiniGameDraftGenerationPhase { + if (session.progressPercent >= 96) { + return 'puzzle-select-image'; + } + if (session.progressPercent >= 94) { + return 'puzzle-ui-assets'; + } + if (session.progressPercent >= 88) { + return state.metadata?.puzzleAiRedraw === false + ? 'puzzle-level-scene' + : 'puzzle-cover-image'; + } + + return 'compile'; +} + +export function mergePuzzleSessionProgressIntoGenerationState( + state: MiniGameDraftGenerationState, + session: PuzzleAgentSessionSnapshot, +): MiniGameDraftGenerationState { + const isCompiledGenerationSession = Boolean( + session.draft && !session.draft.formDraft, + ); + + const nextPhaseId = isCompiledGenerationSession + ? resolvePuzzlePhaseFromSessionProgress(state, session) + : state.metadata?.puzzleActivePhaseId; + const shouldResetActiveStepStart = + isCompiledGenerationSession && + nextPhaseId != null && + nextPhaseId !== state.metadata?.puzzleActivePhaseId; + + return { + ...state, + metadata: { + ...state.metadata, + puzzleActivePhaseId: nextPhaseId, + puzzleActiveStepStartedAtMs: shouldResetActiveStepStart + ? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt) + : state.metadata?.puzzleActiveStepStartedAtMs, + puzzleProgressPercent: isCompiledGenerationSession + ? session.progressPercent + : state.metadata?.puzzleProgressPercent, + }, + }; +} + +export function resolveFinishedMiniGameDraftGenerationState( + state: MiniGameDraftGenerationState, + phase: 'ready' | 'failed', + options: { + error?: string | null; + completedAssetCount?: number; + totalAssetCount?: number; + } = {}, +): MiniGameDraftGenerationState { + return { + ...state, + phase, + finishedAtMs: Date.now(), + error: options.error ?? state.error, + completedAssetCount: + options.completedAssetCount ?? state.completedAssetCount, + totalAssetCount: options.totalAssetCount ?? state.totalAssetCount, + }; +} + +export function isMiniGameDraftReady( + state: MiniGameDraftGenerationState | null, +) { + return state?.phase === 'ready'; +} + +export function isMiniGameDraftGenerating( + state: MiniGameDraftGenerationState | null, +) { + return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed'); +} From cd959b4095f7f15420c5d50ab5645f54d926954f Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 03:25:28 +0800 Subject: [PATCH 46/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=B0=8F?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E8=8D=89=E7=A8=BF=20payload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...mMiniGameDraftPayloadModel收口计划-2026-06-04.md | 44 ++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 210 +--------- .../platformMiniGameDraftPayloadModel.test.ts | 392 ++++++++++++++++++ .../platformMiniGameDraftPayloadModel.ts | 213 ++++++++++ 7 files changed, 671 insertions(+), 200 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts create mode 100644 src/components/platform-entry/platformMiniGameDraftPayloadModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index fa2c5a69..b437fecd 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1403,6 +1403,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform Mini Game Draft Payload Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护拼图 / 抓大鹅表单 payload、拼图编译 action、作品摘要回填 payload 和 pending 草稿 metadata,壳层需要理解描述字段优先级、formDraft 回退、Match3D config / draft / anchorPack 优先级和数字解析。 +- 决策:新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,收口 `buildPuzzleFormPayloadFromWork`、`buildPuzzleFormPayloadFromSession`、`buildPuzzleFormPayloadFromAction`、`buildPuzzleCompileActionFromFormPayload`、`buildPendingPuzzleDraftMetadata`、`buildMatch3DFormPayloadFromSession`、`buildMatch3DFormPayloadFromWork` 与 `buildPendingMatch3DDraftMetadata`;`parseOptionalFiniteNumber` 留在 Module 内部。 +- 影响范围:拼图 action 完成 / 执行前 / 失败恢复、拼图表单直生草稿、拼图草稿架恢复、抓大鹅表单直生草稿与失败恢复。 +- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 82fbcb7f..008afbd3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,6 +59,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 +平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图编译 action 与 pending metadata 收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 + RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md new file mode 100644 index 00000000..b8ac88c5 --- /dev/null +++ b/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md @@ -0,0 +1,44 @@ +# 【前端架构】Platform Mini Game Draft Payload Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图和抓大鹅草稿恢复所需的表单 payload、拼图编译 action payload、作品摘要回填 payload 和 pending 草稿 metadata。壳层因此需要理解拼图描述字段优先级、formDraft 回退、Match3D config / draft / anchorPack 优先级,以及 pending 作品架标题摘要如何从 payload 派生。 + +这些逻辑都是 DTO 变换;不读取 React state,不请求网络,也不写 URL。壳层只应决定何时恢复、何时提交 action、何时写入生成状态。 + +## 决策 + +新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts` 作为 Platform Mini Game Draft Payload **Module**。其公开 **Interface** 为: + +- `buildPuzzleFormPayloadFromWork(item)`:从拼图作品摘要恢复创作表单 payload。 +- `buildPuzzleFormPayloadFromSession(session)`:从拼图 session 恢复创作表单 payload。 +- `buildPuzzleFormPayloadFromAction(payload)`:从拼图 action 还原表单 payload,仅接受 `compile_puzzle_draft` 与 `save_puzzle_form_draft`。 +- `buildPuzzleCompileActionFromFormPayload(payload)`:从表单 payload 构造拼图编译 action。 +- `buildPendingPuzzleDraftMetadata(payload)`:从拼图 payload 派生 pending 作品架 metadata。 +- `buildMatch3DFormPayloadFromSession(session)` 与 `buildMatch3DFormPayloadFromWork(item)`:从抓大鹅 session / work 恢复表单 payload。 +- `buildPendingMatch3DDraftMetadata(payload)`:从抓大鹅 payload 派生 pending metadata。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责 API、Action 执行、background task、生成状态、错误提示、作品架和阶段切换。 + +## Interface 约束 + +- 拼图 work payload 的 `pictureDescription` 优先级固定为 `workDescription > summary > first level pictureDescription > levelName > workTitle > ''`。 +- 拼图 session payload 的 `pictureDescription` 优先级固定为 `formDraft.pictureDescription > first level pictureDescription > anchorPack.visualSubject.value > seedText > ''`。 +- 拼图编译 action 的 `promptText` 来自 `pictureDescription || seedText`;`workDescription` 缺省回退到图片描述;`candidateCount` 固定为 `1`。 +- 拼图 action 还原只接受 `compile_puzzle_draft` 与 `save_puzzle_form_draft`;其它 action 返回 `null`。 +- 抓大鹅 session payload 优先读取 `config`,其次 `draft`,最后 `anchorPack`;`anchorPack.clearCount` 与 `anchorPack.difficulty` 只接受有限数字字符串或数字。 +- 抓大鹅 work payload 的 `themeText` 优先 `themeText`,缺失回退 `gameName`。 +- pending metadata 只收非空 trim 后标题和摘要;抓大鹅 metadata 用 `themeText || seedText` 同时作为 title 和 summary。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以一组表意函数取得 payload / metadata;字段优先级、默认空资产和数字解析藏入 Module Implementation。 +- **Leverage**:后续调整拼图或抓大鹅草稿恢复表单时,先改 Module 与单测,再保持壳层 API / state 副作用不变。 +- **Locality**:表单恢复与 action payload 规则集中到一个纯测试面,避免在大型平台壳的生成、重试和恢复流程里重复散落 DTO 拼装。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts` +- `npx eslint src/components/platform-entry/platformMiniGameDraftPayloadModel.ts src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 964d9423..2f5f5cdf 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -16,6 +16,8 @@ 平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定统一由 `platformMiniGameDraftGenerationStateModel.ts` 处理。平台壳只决定何时调用并写入对应 React state,不得在壳层重新维护 `MiniGameDraftGenerationState` 的 phase 阈值、`finishedAtMs` 清理或拼图进度 metadata 合并规则。 +拼图 / 抓大鹅草稿恢复和提交所需的表单 payload、拼图编译 action 与 pending metadata 统一由 `platformMiniGameDraftPayloadModel.ts` 构造。平台壳不得重新手写拼图描述字段优先级、formDraft 回退、Match3D config / draft / anchorPack 优先级、数字解析或 pending 标题摘要派生规则。 + RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index ace14e6e..19788c5d 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -522,6 +522,16 @@ import { rebaseMiniGameDraftGenerationStateForDisplay, resolveFinishedMiniGameDraftGenerationState, } from './platformMiniGameDraftGenerationStateModel'; +import { + buildMatch3DFormPayloadFromSession, + buildMatch3DFormPayloadFromWork, + buildPendingMatch3DDraftMetadata, + buildPendingPuzzleDraftMetadata, + buildPuzzleCompileActionFromFormPayload, + buildPuzzleFormPayloadFromAction, + buildPuzzleFormPayloadFromSession, + buildPuzzleFormPayloadFromWork, +} from './platformMiniGameDraftPayloadModel'; import { buildJumpHopPendingSession, buildPuzzleRuntimeWorkFromSession, @@ -1035,89 +1045,6 @@ function openPuzzleRuntimeStage( writePuzzleRuntimeUrlState(state); } -function buildPuzzleFormPayloadFromWork( - item: PuzzleWorkSummary, -): CreatePuzzleAgentSessionRequest { - const pictureDescription = - item.workDescription?.trim() || - item.summary?.trim() || - item.levels?.[0]?.pictureDescription?.trim() || - item.levelName?.trim() || - item.workTitle?.trim() || - ''; - - return { - seedText: pictureDescription, - workTitle: item.workTitle?.trim() || item.levelName?.trim() || undefined, - workDescription: item.workDescription?.trim() || item.summary?.trim(), - pictureDescription, - referenceImageSrc: null, - referenceImageSrcs: [], - referenceImageAssetObjectId: null, - referenceImageAssetObjectIds: [], - imageModel: null, - aiRedraw: true, - }; -} - -function parseOptionalFiniteNumber(value: string | number | null | undefined) { - if (typeof value === 'number') { - return Number.isFinite(value) ? value : undefined; - } - - const normalizedValue = value?.trim(); - if (!normalizedValue) { - return undefined; - } - - const parsedValue = Number(normalizedValue); - return Number.isFinite(parsedValue) ? parsedValue : undefined; -} - -function buildMatch3DFormPayloadFromSession( - session: Match3DAgentSessionSnapshot, -): CreateMatch3DSessionRequest { - const themeText = - session.config?.themeText?.trim() || - session.draft?.themeText?.trim() || - session.anchorPack.theme.value.trim() || - ''; - - return { - seedText: themeText, - themeText, - referenceImageSrc: - session.config?.referenceImageSrc ?? session.draft?.referenceImageSrc ?? null, - clearCount: - session.config?.clearCount ?? - session.draft?.clearCount ?? - parseOptionalFiniteNumber(session.anchorPack.clearCount.value) ?? - undefined, - difficulty: - session.config?.difficulty ?? - session.draft?.difficulty ?? - parseOptionalFiniteNumber(session.anchorPack.difficulty.value) ?? - undefined, - assetStyleId: session.config?.assetStyleId ?? null, - assetStyleLabel: session.config?.assetStyleLabel ?? null, - assetStylePrompt: session.config?.assetStylePrompt ?? null, - generateClickSound: session.config?.generateClickSound, - }; -} - -function buildMatch3DFormPayloadFromWork( - item: Match3DWorkSummary, -): CreateMatch3DSessionRequest { - const themeText = item.themeText?.trim() || item.gameName?.trim() || ''; - return { - seedText: themeText, - themeText, - referenceImageSrc: item.referenceImageSrc ?? null, - clearCount: item.clearCount, - difficulty: item.difficulty, - }; -} - function normalizeRecoveredPuzzleDraftSession( session: PuzzleAgentSessionSnapshot, ): PuzzleAgentSessionSnapshot { @@ -1243,123 +1170,6 @@ function reconcileProfileWalletLocalDeltaWithServerDashboard( return Math.max(0, normalizedDelta - reflectedCredit); } -function buildPuzzleCompileActionFromFormPayload( - payload: CreatePuzzleAgentSessionRequest | null, -): PuzzleAgentActionRequest { - const pictureDescription = - payload?.pictureDescription?.trim() || payload?.seedText?.trim(); - const workTitle = payload?.workTitle?.trim(); - const workDescription = payload?.workDescription?.trim() || pictureDescription; - - return { - action: 'compile_puzzle_draft', - promptText: pictureDescription, - ...(workTitle ? { workTitle } : {}), - ...(workDescription ? { workDescription } : {}), - ...(pictureDescription ? { pictureDescription } : {}), - referenceImageSrc: payload?.referenceImageSrc || null, - referenceImageSrcs: payload?.referenceImageSrcs ?? [], - referenceImageAssetObjectId: payload?.referenceImageAssetObjectId ?? null, - referenceImageAssetObjectIds: payload?.referenceImageAssetObjectIds ?? [], - imageModel: payload?.imageModel ?? null, - aiRedraw: payload?.aiRedraw ?? true, - candidateCount: 1, - }; -} - -function buildPuzzleFormPayloadFromSession( - session: PuzzleAgentSessionSnapshot, -): CreatePuzzleAgentSessionRequest { - const formDraft = session.draft?.formDraft; - const pictureDescription = - formDraft?.pictureDescription?.trim() || - session.draft?.levels?.[0]?.pictureDescription?.trim() || - session.anchorPack.visualSubject.value.trim() || - session.seedText?.trim() || - ''; - const workTitle = - formDraft?.workTitle?.trim() || session.draft?.workTitle?.trim(); - const workDescription = - formDraft?.workDescription?.trim() || - session.draft?.workDescription?.trim() || - session.draft?.summary?.trim() || - pictureDescription; - - return { - seedText: pictureDescription, - ...(workTitle ? { workTitle } : {}), - ...(workDescription ? { workDescription } : {}), - pictureDescription, - referenceImageSrc: null, - referenceImageSrcs: [], - referenceImageAssetObjectId: null, - referenceImageAssetObjectIds: [], - imageModel: null, - aiRedraw: true, - }; -} - -function buildPendingPuzzleDraftMetadata( - payload: CreatePuzzleAgentSessionRequest | null | undefined, -) { - const title = payload?.workTitle?.trim(); - const summary = - payload?.workDescription?.trim() || - payload?.pictureDescription?.trim() || - payload?.seedText?.trim(); - return { - ...(title ? { title } : {}), - ...(summary ? { summary } : {}), - }; -} - -function buildPendingMatch3DDraftMetadata( - payload: CreateMatch3DSessionRequest | null | undefined, -) { - const themeText = payload?.themeText?.trim() || payload?.seedText?.trim(); - return { - ...(themeText ? { title: themeText, summary: themeText } : {}), - }; -} - -function buildPuzzleFormPayloadFromAction( - payload: PuzzleAgentActionRequest, -): CreatePuzzleAgentSessionRequest | null { - if ( - payload.action !== 'compile_puzzle_draft' && - payload.action !== 'save_puzzle_form_draft' - ) { - return null; - } - - const workTitle = payload.workTitle?.trim() ?? ''; - const workDescription = payload.workDescription?.trim() ?? ''; - const pictureDescription = - payload.pictureDescription?.trim() || payload.promptText?.trim() || ''; - - return { - seedText: pictureDescription, - ...(workTitle ? { workTitle } : {}), - ...(workDescription ? { workDescription } : {}), - pictureDescription, - referenceImageSrc: - payload.action === 'compile_puzzle_draft' - ? (payload.referenceImageSrc ?? null) - : (payload.referenceImageSrc ?? null), - referenceImageSrcs: payload.referenceImageSrcs ?? [], - referenceImageAssetObjectId: payload.referenceImageAssetObjectId ?? null, - referenceImageAssetObjectIds: payload.referenceImageAssetObjectIds ?? [], - imageModel: - payload.action === 'compile_puzzle_draft' - ? (payload.imageModel ?? null) - : (payload.imageModel ?? null), - aiRedraw: - payload.action === 'compile_puzzle_draft' - ? (payload.aiRedraw ?? true) - : (payload.aiRedraw ?? true), - }; -} - function isPuzzleFormOnlyDraft(session: PuzzleAgentSessionSnapshot | null) { return Boolean( session?.stage === 'collecting_anchors' && session.draft?.formDraft, diff --git a/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts b/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts new file mode 100644 index 00000000..056404c6 --- /dev/null +++ b/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, test } from 'vitest'; + +import type { + Match3DAgentSessionSnapshot, + Match3DAnchorPackResponse, +} from '../../../packages/shared/src/contracts/match3dAgent'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions'; +import type { + PuzzleAnchorPack, + PuzzleDraftLevel, +} from '../../../packages/shared/src/contracts/puzzleAgentDraft'; +import type { + CreatePuzzleAgentSessionRequest, + PuzzleAgentSessionSnapshot, +} from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import { + buildMatch3DFormPayloadFromSession, + buildMatch3DFormPayloadFromWork, + buildPendingMatch3DDraftMetadata, + buildPendingPuzzleDraftMetadata, + buildPuzzleCompileActionFromFormPayload, + buildPuzzleFormPayloadFromAction, + buildPuzzleFormPayloadFromSession, + buildPuzzleFormPayloadFromWork, +} from './platformMiniGameDraftPayloadModel'; + +function buildPuzzleAnchorPack(): PuzzleAnchorPack { + const item = { + key: 'theme', + label: '主题', + value: '星桥机关', + status: 'confirmed' as const, + }; + return { + themePromise: item, + visualSubject: item, + visualMood: item, + compositionHooks: item, + tagsAndForbidden: item, + }; +} + +function buildPuzzleLevel( + overrides: Partial = {}, +): PuzzleDraftLevel { + return { + levelId: 'level-1', + levelName: '星桥机关', + pictureDescription: '关卡画面描述', + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'idle', + ...overrides, + }; +} + +function buildPuzzleWork( + overrides: Partial = {}, +): PuzzleWorkSummary { + return { + workId: 'puzzle-work-1', + profileId: 'puzzle-profile-1', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + workTitle: ' 星桥拼图 ', + workDescription: ' 修复星桥机关。 ', + levelName: '星桥机关', + summary: '把碎片拼回原位。', + themeTags: ['星桥'], + coverImageSrc: '/cover.png', + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-06-01T10:00:00.000Z', + publishedAt: null, + publishReady: false, + levels: [buildPuzzleLevel()], + ...overrides, + }; +} + +function buildPuzzleSession( + overrides: Partial = {}, +): PuzzleAgentSessionSnapshot { + const anchorPack = buildPuzzleAnchorPack(); + return { + sessionId: 'puzzle-session-1', + seedText: '种子描述', + currentTurn: 1, + progressPercent: 20, + stage: 'collecting_anchors', + anchorPack, + draft: { + workTitle: '会话标题', + workDescription: '会话描述', + levelName: '星桥机关', + summary: '会话摘要', + themeTags: ['星桥'], + forbiddenDirectives: [], + creatorIntent: null, + anchorPack, + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'idle', + levels: [buildPuzzleLevel()], + formDraft: { + workTitle: '表单标题', + workDescription: '表单描述', + pictureDescription: '表单画面', + }, + }, + messages: [], + lastAssistantReply: null, + publishedProfileId: null, + suggestedActions: [], + resultPreview: null, + updatedAt: '2026-06-01T10:00:00.000Z', + ...overrides, + }; +} + +function buildMatch3DAnchorPack( + overrides: Partial = {}, +): Match3DAnchorPackResponse { + return { + theme: { + key: 'theme', + label: '主题', + value: '海岛玩具', + status: 'confirmed', + }, + clearCount: { + key: 'clearCount', + label: '消除次数', + value: '12', + status: 'confirmed', + }, + difficulty: { + key: 'difficulty', + label: '难度', + value: '3', + status: 'confirmed', + }, + ...overrides, + }; +} + +function buildMatch3DSession( + overrides: Partial = {}, +): Match3DAgentSessionSnapshot { + return { + sessionId: 'match3d-session-1', + currentTurn: 1, + progressPercent: 20, + stage: 'collecting', + anchorPack: buildMatch3DAnchorPack(), + config: null, + draft: null, + messages: [], + lastAssistantReply: null, + publishedProfileId: null, + updatedAt: '2026-06-01T11:00:00.000Z', + ...overrides, + }; +} + +function buildMatch3DWork( + overrides: Partial = {}, +): Match3DWorkSummary { + return { + workId: 'match3d-work-1', + profileId: 'match3d-profile-1', + ownerUserId: 'user-1', + gameName: '海岛抓大鹅', + themeText: ' 海岛玩具 ', + summary: '收集海岛玩具。', + tags: ['海岛'], + coverImageSrc: '/match3d-cover.png', + referenceImageSrc: '/match3d-reference.png', + clearCount: 12, + difficulty: 3, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-01T11:00:00.000Z', + publishedAt: null, + publishReady: false, + ...overrides, + }; +} + +describe('platformMiniGameDraftPayloadModel', () => { + test('builds puzzle form payload from work with fallback description priority', () => { + expect( + buildPuzzleFormPayloadFromWork( + buildPuzzleWork({ + workDescription: ' ', + summary: ' 摘要描述 ', + levelName: ' 关卡标题 ', + }), + ), + ).toEqual({ + seedText: '摘要描述', + workTitle: '星桥拼图', + workDescription: '摘要描述', + pictureDescription: '摘要描述', + referenceImageSrc: null, + referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], + imageModel: null, + aiRedraw: true, + }); + }); + + test('builds puzzle form payload from session form draft and fallbacks', () => { + expect(buildPuzzleFormPayloadFromSession(buildPuzzleSession())).toEqual({ + seedText: '表单画面', + workTitle: '表单标题', + workDescription: '表单描述', + pictureDescription: '表单画面', + referenceImageSrc: null, + referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], + imageModel: null, + aiRedraw: true, + }); + + expect( + buildPuzzleFormPayloadFromSession( + buildPuzzleSession({ + draft: { + ...buildPuzzleSession().draft!, + formDraft: null, + levels: [buildPuzzleLevel({ pictureDescription: '关卡优先' })], + }, + }), + ).pictureDescription, + ).toBe('关卡优先'); + }); + + test('builds puzzle compile action and restores form payload from action', () => { + const payload: CreatePuzzleAgentSessionRequest = { + seedText: '种子', + workTitle: ' 标题 ', + workDescription: '', + pictureDescription: ' 画面 ', + referenceImageSrc: '/ref.png', + referenceImageSrcs: ['/ref-a.png'], + referenceImageAssetObjectId: 'asset-ref', + referenceImageAssetObjectIds: ['asset-ref-a'], + imageModel: 'image-model', + aiRedraw: false, + }; + const action = buildPuzzleCompileActionFromFormPayload(payload); + + expect(action).toEqual({ + action: 'compile_puzzle_draft', + promptText: '画面', + workTitle: '标题', + workDescription: '画面', + pictureDescription: '画面', + referenceImageSrc: '/ref.png', + referenceImageSrcs: ['/ref-a.png'], + referenceImageAssetObjectId: 'asset-ref', + referenceImageAssetObjectIds: ['asset-ref-a'], + imageModel: 'image-model', + aiRedraw: false, + candidateCount: 1, + }); + expect(buildPuzzleFormPayloadFromAction(action)).toEqual({ + seedText: '画面', + workTitle: '标题', + workDescription: '画面', + pictureDescription: '画面', + referenceImageSrc: '/ref.png', + referenceImageSrcs: ['/ref-a.png'], + referenceImageAssetObjectId: 'asset-ref', + referenceImageAssetObjectIds: ['asset-ref-a'], + imageModel: 'image-model', + aiRedraw: false, + }); + expect( + buildPuzzleFormPayloadFromAction({ + action: 'publish_puzzle_work', + } as PuzzleAgentActionRequest), + ).toBeNull(); + }); + + test('builds pending puzzle metadata from non-empty payload fields', () => { + expect( + buildPendingPuzzleDraftMetadata({ + workTitle: ' 标题 ', + workDescription: ' ', + pictureDescription: ' 画面 ', + seedText: '种子', + }), + ).toEqual({ + title: '标题', + summary: '画面', + }); + expect(buildPendingPuzzleDraftMetadata(null)).toEqual({}); + }); + + test('builds match3d form payload from session config, draft and anchors', () => { + expect( + buildMatch3DFormPayloadFromSession( + buildMatch3DSession({ + config: { + themeText: ' 配置主题 ', + referenceImageSrc: '/config-ref.png', + clearCount: 9, + difficulty: 4, + assetStyleId: 'style-1', + assetStyleLabel: '手办', + assetStylePrompt: '软陶手办', + generateClickSound: true, + }, + draft: { + profileId: 'profile-1', + gameName: '草稿标题', + themeText: '草稿主题', + tags: [], + referenceImageSrc: '/draft-ref.png', + clearCount: 6, + difficulty: 2, + }, + }), + ), + ).toEqual({ + seedText: '配置主题', + themeText: '配置主题', + referenceImageSrc: '/config-ref.png', + clearCount: 9, + difficulty: 4, + assetStyleId: 'style-1', + assetStyleLabel: '手办', + assetStylePrompt: '软陶手办', + generateClickSound: true, + }); + + expect( + buildMatch3DFormPayloadFromSession( + buildMatch3DSession({ + anchorPack: buildMatch3DAnchorPack({ + clearCount: { + key: 'clearCount', + label: '消除次数', + value: 'not-number', + status: 'confirmed', + }, + }), + }), + ), + ).toMatchObject({ + seedText: '海岛玩具', + clearCount: undefined, + difficulty: 3, + }); + }); + + test('builds match3d form payload from work and pending metadata', () => { + expect( + buildMatch3DFormPayloadFromWork( + buildMatch3DWork({ + themeText: ' ', + }), + ), + ).toEqual({ + seedText: '海岛抓大鹅', + themeText: '海岛抓大鹅', + referenceImageSrc: '/match3d-reference.png', + clearCount: 12, + difficulty: 3, + }); + + expect( + buildPendingMatch3DDraftMetadata({ + themeText: ' ', + seedText: ' 海岛抓大鹅 ', + }), + ).toEqual({ + title: '海岛抓大鹅', + summary: '海岛抓大鹅', + }); + }); +}); diff --git a/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts b/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts new file mode 100644 index 00000000..f889b3f0 --- /dev/null +++ b/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts @@ -0,0 +1,213 @@ +import type { + CreateMatch3DSessionRequest, + Match3DAgentSessionSnapshot, +} from '../../../packages/shared/src/contracts/match3dAgent'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions'; +import type { + CreatePuzzleAgentSessionRequest, + PuzzleAgentSessionSnapshot, +} from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; + +export function buildPuzzleFormPayloadFromWork( + item: PuzzleWorkSummary, +): CreatePuzzleAgentSessionRequest { + const pictureDescription = + item.workDescription?.trim() || + item.summary?.trim() || + item.levels?.[0]?.pictureDescription?.trim() || + item.levelName?.trim() || + item.workTitle?.trim() || + ''; + + return { + seedText: pictureDescription, + workTitle: item.workTitle?.trim() || item.levelName?.trim() || undefined, + workDescription: item.workDescription?.trim() || item.summary?.trim(), + pictureDescription, + referenceImageSrc: null, + referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], + imageModel: null, + aiRedraw: true, + }; +} + +function parseOptionalFiniteNumber(value: string | number | null | undefined) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined; + } + + const normalizedValue = value?.trim(); + if (!normalizedValue) { + return undefined; + } + + const parsedValue = Number(normalizedValue); + return Number.isFinite(parsedValue) ? parsedValue : undefined; +} + +export function buildMatch3DFormPayloadFromSession( + session: Match3DAgentSessionSnapshot, +): CreateMatch3DSessionRequest { + const themeText = + session.config?.themeText?.trim() || + session.draft?.themeText?.trim() || + session.anchorPack.theme.value.trim() || + ''; + + return { + seedText: themeText, + themeText, + referenceImageSrc: + session.config?.referenceImageSrc ?? + session.draft?.referenceImageSrc ?? + null, + clearCount: + session.config?.clearCount ?? + session.draft?.clearCount ?? + parseOptionalFiniteNumber(session.anchorPack.clearCount.value) ?? + undefined, + difficulty: + session.config?.difficulty ?? + session.draft?.difficulty ?? + parseOptionalFiniteNumber(session.anchorPack.difficulty.value) ?? + undefined, + assetStyleId: session.config?.assetStyleId ?? null, + assetStyleLabel: session.config?.assetStyleLabel ?? null, + assetStylePrompt: session.config?.assetStylePrompt ?? null, + generateClickSound: session.config?.generateClickSound, + }; +} + +export function buildMatch3DFormPayloadFromWork( + item: Match3DWorkSummary, +): CreateMatch3DSessionRequest { + const themeText = item.themeText?.trim() || item.gameName?.trim() || ''; + return { + seedText: themeText, + themeText, + referenceImageSrc: item.referenceImageSrc ?? null, + clearCount: item.clearCount, + difficulty: item.difficulty, + }; +} + +export function buildPuzzleCompileActionFromFormPayload( + payload: CreatePuzzleAgentSessionRequest | null, +): PuzzleAgentActionRequest { + const pictureDescription = + payload?.pictureDescription?.trim() || payload?.seedText?.trim(); + const workTitle = payload?.workTitle?.trim(); + const workDescription = payload?.workDescription?.trim() || pictureDescription; + + return { + action: 'compile_puzzle_draft', + promptText: pictureDescription, + ...(workTitle ? { workTitle } : {}), + ...(workDescription ? { workDescription } : {}), + ...(pictureDescription ? { pictureDescription } : {}), + referenceImageSrc: payload?.referenceImageSrc || null, + referenceImageSrcs: payload?.referenceImageSrcs ?? [], + referenceImageAssetObjectId: payload?.referenceImageAssetObjectId ?? null, + referenceImageAssetObjectIds: payload?.referenceImageAssetObjectIds ?? [], + imageModel: payload?.imageModel ?? null, + aiRedraw: payload?.aiRedraw ?? true, + candidateCount: 1, + }; +} + +export function buildPuzzleFormPayloadFromSession( + session: PuzzleAgentSessionSnapshot, +): CreatePuzzleAgentSessionRequest { + const formDraft = session.draft?.formDraft; + const pictureDescription = + formDraft?.pictureDescription?.trim() || + session.draft?.levels?.[0]?.pictureDescription?.trim() || + session.anchorPack.visualSubject.value.trim() || + session.seedText?.trim() || + ''; + const workTitle = + formDraft?.workTitle?.trim() || session.draft?.workTitle?.trim(); + const workDescription = + formDraft?.workDescription?.trim() || + session.draft?.workDescription?.trim() || + session.draft?.summary?.trim() || + pictureDescription; + + return { + seedText: pictureDescription, + ...(workTitle ? { workTitle } : {}), + ...(workDescription ? { workDescription } : {}), + pictureDescription, + referenceImageSrc: null, + referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], + imageModel: null, + aiRedraw: true, + }; +} + +export function buildPendingPuzzleDraftMetadata( + payload: CreatePuzzleAgentSessionRequest | null | undefined, +) { + const title = payload?.workTitle?.trim(); + const summary = + payload?.workDescription?.trim() || + payload?.pictureDescription?.trim() || + payload?.seedText?.trim(); + return { + ...(title ? { title } : {}), + ...(summary ? { summary } : {}), + }; +} + +export function buildPendingMatch3DDraftMetadata( + payload: CreateMatch3DSessionRequest | null | undefined, +) { + const themeText = payload?.themeText?.trim() || payload?.seedText?.trim(); + return { + ...(themeText ? { title: themeText, summary: themeText } : {}), + }; +} + +export function buildPuzzleFormPayloadFromAction( + payload: PuzzleAgentActionRequest, +): CreatePuzzleAgentSessionRequest | null { + if ( + payload.action !== 'compile_puzzle_draft' && + payload.action !== 'save_puzzle_form_draft' + ) { + return null; + } + + const workTitle = payload.workTitle?.trim() ?? ''; + const workDescription = payload.workDescription?.trim() ?? ''; + const pictureDescription = + payload.pictureDescription?.trim() || payload.promptText?.trim() || ''; + + return { + seedText: pictureDescription, + ...(workTitle ? { workTitle } : {}), + ...(workDescription ? { workDescription } : {}), + pictureDescription, + referenceImageSrc: + payload.action === 'compile_puzzle_draft' + ? (payload.referenceImageSrc ?? null) + : (payload.referenceImageSrc ?? null), + referenceImageSrcs: payload.referenceImageSrcs ?? [], + referenceImageAssetObjectId: payload.referenceImageAssetObjectId ?? null, + referenceImageAssetObjectIds: payload.referenceImageAssetObjectIds ?? [], + imageModel: + payload.action === 'compile_puzzle_draft' + ? (payload.imageModel ?? null) + : (payload.imageModel ?? null), + aiRedraw: + payload.action === 'compile_puzzle_draft' + ? (payload.aiRedraw ?? true) + : (payload.aiRedraw ?? true), + }; +} From 46a36222cbb79cf443a79db0ac45c42af78e4fdc Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 03:34:06 +0800 Subject: [PATCH 47/67] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E6=8B=BC?= =?UTF-8?q?=E5=9B=BE=E8=8D=89=E7=A8=BF=E6=81=A2=E5=A4=8D=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/pitfalls.md | 8 +- docs/README.md | 2 + ...rmPuzzleDraftRecoveryModel收口计划-2026-06-04.md | 44 ++++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 75 +------ .../platformPuzzleDraftRecoveryModel.test.ts | 195 ++++++++++++++++++ .../platformPuzzleDraftRecoveryModel.ts | 154 ++++++++++++++ 8 files changed, 413 insertions(+), 75 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts create mode 100644 src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b437fecd..b245459b 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1411,6 +1411,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform Puzzle Draft Recovery Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 的拼图恢复链路只要 cover 或候选图存在就会把恢复 session 抬为 ready,可能让缺关卡画面、UI spritesheet 或关卡背景的半成品直接进入结果页完成态。 +- 决策:新增 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,收口 `normalizeRecoveredPuzzleDraftSession` 与 `hasRecoverableGeneratedPuzzleDraft`。恢复完成态必须同时具备首图、`levelSceneImage*`、`uiSpritesheetImage*` 与 `levelBackgroundImage*`;只有完整资产包成立时才把 draft 与首关 `generationStatus` 抬为 `ready`。 +- 影响范围:拼图生成完成后刷新恢复、拼图 background compile task 完成态写入和结果页自动打开。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 4aff3fc0..f9043f96 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1244,10 +1244,10 @@ ## 拼图会过早进入待发布态,结果页可能空图但仍显示可发布 - 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。 -- 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements` 和 `is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src`、`ui_spritesheet_image_src`、`level_background_image_src` 等完整资产都齐;前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。 -- 处理:待修复时要把“待发布”门槛收紧到整套拼图资产包完整,再让恢复逻辑只在完整草稿下抬高为完成态,避免半成品直接进入结果页。 -- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,不应再进入 `ready_to_publish`;结果页也不应把这类草稿误判为已完成。 -- 关联:`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/api-server/src/puzzle/tags.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`。 +- 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements` 和 `is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src`、`ui_spritesheet_image_src`、`level_background_image_src` 等完整资产都齐;历史前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。 +- 处理:前端恢复链路已收口到 `platformPuzzleDraftRecoveryModel.ts`,只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才把恢复草稿抬为完成态;后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的待发布门槛仍需后续收紧到整套拼图资产包完整。 +- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,前端恢复链路不应把它误判为已完成;后端后续修复后也不应进入 `ready_to_publish`。 +- 关联:`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/api-server/src/puzzle/tags.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`、`src/components/puzzle-result/PuzzleResultView.tsx`。 ## WebGL 画布在高 DPR 移动端放大溢出 diff --git a/docs/README.md b/docs/README.md index 008afbd3..6923e065 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,6 +61,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图编译 action 与 pending metadata 收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 +平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready,规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md)。 + RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md new file mode 100644 index 00000000..6301b5b1 --- /dev/null +++ b/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md @@ -0,0 +1,44 @@ +# 【前端架构】Platform Puzzle Draft Recovery Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图生成完成后刷新恢复的两个纯函数:`normalizeRecoveredPuzzleDraftSession` 与 `hasRecoverableGeneratedPuzzleDraft`。旧逻辑只要草稿有 `coverImageSrc`、首关 cover 或候选图,就会把恢复会话的 draft 和首关 `generationStatus` 抬成 `ready`,再进入结果页。 + +`.hermes/shared-memory/pitfalls.md` 已记录:拼图待发布判定偏弱时,只有首图但缺关卡画面、UI spritesheet 或关卡背景的半成品会被误当完成,用户进入结果页后仍可能空图或无法发布。 + +本切片先修前端恢复链路:只有完整首关资产包存在时,恢复流程才视为可完成。后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的发布门槛收紧另作后续切片,不混入本次前端模型收口。 + +## 决策 + +新增 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts` 作为 Platform Puzzle Draft Recovery **Module**。其公开 **Interface** 为: + +- `normalizeRecoveredPuzzleDraftSession(session)`:从恢复会话里补齐首图 cover、assetId 和 selectedCandidateId;只有完整资产包满足时,才把 draft 与首关 `generationStatus` 改为 `ready`。 +- `hasRecoverableGeneratedPuzzleDraft(session)`:判断恢复会话是否拥有完整首关资产包。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责拉取 session、写 background task、写 React state、打开结果页和切换 stage。 + +## Interface 约束 + +- 无 draft 时保持原 session,并判定不可恢复完成态。 +- 首图可来自 `draft.coverImageSrc`、首关 `coverImageSrc` 或选中 / 首个候选图。 +- 完整首关资产包必须同时具备: + - 首图 cover; + - `levelSceneImageSrc` 或 `levelSceneImageObjectKey`; + - `uiSpritesheetImageSrc` 或 `uiSpritesheetImageObjectKey`; + - `levelBackgroundImageSrc` 或 `levelBackgroundImageObjectKey`。 +- cover / assetId / selectedCandidateId 可按旧优先级从 draft、首关、候选图回填;但若完整资产包不满足,不得把 `generationStatus` 抬为 `ready`。 +- 只修复前端恢复判定,不改变拼图发布接口、后端 session stage 或后端 preview compiler。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以两个函数表达“恢复会话归一化”和“是否可作为生成完成态恢复”;完整资产门槛和候选图 fallback 藏入 Module Implementation。 +- **Leverage**:后续后端补齐发布门槛时,可用同一资产语言对齐前端恢复模型,避免壳层再散落条件判断。 +- **Locality**:拼图恢复判定集中到纯测试面,避免在异步恢复 callback 中把半成品 ready 规则继续隐身。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts` +- `npx eslint src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 2f5f5cdf..15efac60 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -18,6 +18,8 @@ 拼图 / 抓大鹅草稿恢复和提交所需的表单 payload、拼图编译 action 与 pending metadata 统一由 `platformMiniGameDraftPayloadModel.ts` 构造。平台壳不得重新手写拼图描述字段优先级、formDraft 回退、Match3D config / draft / anchorPack 优先级、数字解析或 pending 标题摘要派生规则。 +拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定统一由 `platformPuzzleDraftRecoveryModel.ts` 处理。恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才可把 draft 和首关状态抬为 `ready`;只有 cover 或候选图的半成品不得直接进入结果页完成态。 + RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 19788c5d..467d46ae 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -571,6 +571,10 @@ import { resolvePlatformPublicWorkStartIntent, resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; +import { + hasRecoverableGeneratedPuzzleDraft, + normalizeRecoveredPuzzleDraftSession, +} from './platformPuzzleDraftRecoveryModel'; import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, @@ -1045,77 +1049,6 @@ function openPuzzleRuntimeStage( writePuzzleRuntimeUrlState(state); } -function normalizeRecoveredPuzzleDraftSession( - session: PuzzleAgentSessionSnapshot, -): PuzzleAgentSessionSnapshot { - const draft = session.draft; - if (!draft) { - return session; - } - - const primaryLevel = draft.levels?.[0]; - const selectedCandidate = - primaryLevel?.candidates.find((candidate) => candidate.selected) ?? - primaryLevel?.candidates[0] ?? - draft.candidates.find((candidate) => candidate.selected) ?? - draft.candidates[0] ?? - null; - const coverImageSrc = - draft.coverImageSrc?.trim() || - primaryLevel?.coverImageSrc?.trim() || - selectedCandidate?.imageSrc.trim() || - null; - const coverAssetId = - draft.coverAssetId?.trim() || - primaryLevel?.coverAssetId?.trim() || - selectedCandidate?.assetId.trim() || - null; - const selectedCandidateId = - draft.selectedCandidateId ?? - primaryLevel?.selectedCandidateId ?? - selectedCandidate?.candidateId ?? - null; - - return { - ...session, - draft: { - ...draft, - coverImageSrc, - coverAssetId, - selectedCandidateId, - generationStatus: 'ready', - levels: draft.levels?.map((level, index) => - index === 0 - ? { - ...level, - coverImageSrc: level.coverImageSrc ?? coverImageSrc, - coverAssetId: level.coverAssetId ?? coverAssetId, - selectedCandidateId: - level.selectedCandidateId ?? selectedCandidateId, - generationStatus: 'ready', - } - : level, - ), - }, - }; -} - -function hasRecoverableGeneratedPuzzleDraft( - session: PuzzleAgentSessionSnapshot, -) { - const draft = session.draft; - if (!draft) { - return false; - } - - const firstLevel = draft.levels?.[0]; - return Boolean( - draft.coverImageSrc?.trim() || - firstLevel?.coverImageSrc?.trim() || - firstLevel?.candidates.some((candidate) => candidate.imageSrc.trim()), - ); -} - function resolveProfileWalletBalance( dashboard: { walletBalance?: number | null } | null | undefined, ) { diff --git a/src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts b/src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts new file mode 100644 index 00000000..f2adbd05 --- /dev/null +++ b/src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from 'vitest'; + +import type { + PuzzleAnchorPack, + PuzzleDraftLevel, + PuzzleGeneratedImageCandidate, + PuzzleResultDraft, +} from '../../../packages/shared/src/contracts/puzzleAgentDraft'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import { + hasRecoverableGeneratedPuzzleDraft, + normalizeRecoveredPuzzleDraftSession, +} from './platformPuzzleDraftRecoveryModel'; + +function buildAnchorPack(): PuzzleAnchorPack { + const item = { + key: 'theme', + label: '主题', + value: '星桥机关', + status: 'confirmed' as const, + }; + return { + themePromise: item, + visualSubject: item, + visualMood: item, + compositionHooks: item, + tagsAndForbidden: item, + }; +} + +function buildCandidate( + overrides: Partial = {}, +): PuzzleGeneratedImageCandidate { + return { + candidateId: 'candidate-1', + imageSrc: '/candidate-cover.png', + assetId: 'asset-candidate-cover', + prompt: '星桥机关', + sourceType: 'generated', + selected: true, + ...overrides, + }; +} + +function buildLevel(overrides: Partial = {}): PuzzleDraftLevel { + return { + levelId: 'level-1', + levelName: '星桥机关', + pictureDescription: '星桥机关画面', + candidates: [buildCandidate()], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'generating', + ...overrides, + }; +} + +function buildDraft(overrides: Partial = {}): PuzzleResultDraft { + const anchorPack = buildAnchorPack(); + return { + workTitle: '星桥拼图', + workDescription: '修复星桥机关。', + levelName: '星桥机关', + summary: '把碎片拼回原位。', + themeTags: ['星桥', '机关', '修复'], + forbiddenDirectives: [], + creatorIntent: null, + anchorPack, + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'generating', + levels: [buildLevel()], + ...overrides, + }; +} + +function buildSession( + overrides: Partial = {}, +): PuzzleAgentSessionSnapshot { + const anchorPack = buildAnchorPack(); + return { + sessionId: 'puzzle-session-1', + seedText: '星桥', + currentTurn: 1, + progressPercent: 100, + stage: 'draft_ready', + anchorPack, + draft: buildDraft(), + messages: [], + lastAssistantReply: null, + publishedProfileId: null, + suggestedActions: [], + resultPreview: null, + updatedAt: '2026-06-01T10:00:00.000Z', + ...overrides, + }; +} + +function withCompleteLevelAssets( + overrides: Partial = {}, +): PuzzleDraftLevel { + return buildLevel({ + levelSceneImageSrc: '/level-scene.png', + uiSpritesheetImageSrc: '/ui-spritesheet.png', + levelBackgroundImageSrc: '/level-background.png', + ...overrides, + }); +} + +describe('platformPuzzleDraftRecoveryModel', () => { + test('normalizes and marks recovered puzzle draft ready when asset pack is complete', () => { + const normalized = normalizeRecoveredPuzzleDraftSession( + buildSession({ + draft: buildDraft({ + levels: [withCompleteLevelAssets()], + }), + }), + ); + + expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(true); + expect(normalized.draft).toMatchObject({ + coverImageSrc: '/candidate-cover.png', + coverAssetId: 'asset-candidate-cover', + selectedCandidateId: 'candidate-1', + generationStatus: 'ready', + }); + expect(normalized.draft?.levels?.[0]).toMatchObject({ + coverImageSrc: '/candidate-cover.png', + coverAssetId: 'asset-candidate-cover', + selectedCandidateId: 'candidate-1', + generationStatus: 'ready', + }); + }); + + test('keeps half-finished draft generating when only cover candidate exists', () => { + const normalized = normalizeRecoveredPuzzleDraftSession(buildSession()); + + expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(false); + expect(normalized.draft).toMatchObject({ + coverImageSrc: '/candidate-cover.png', + generationStatus: 'generating', + }); + expect(normalized.draft?.levels?.[0]).toMatchObject({ + coverImageSrc: '/candidate-cover.png', + generationStatus: 'generating', + }); + }); + + test('requires level scene, ui spritesheet and level background assets together', () => { + expect( + hasRecoverableGeneratedPuzzleDraft( + buildSession({ + draft: buildDraft({ + coverImageSrc: '/draft-cover.png', + levels: [ + withCompleteLevelAssets({ + uiSpritesheetImageSrc: null, + uiSpritesheetImageObjectKey: null, + }), + ], + }), + }), + ), + ).toBe(false); + }); + + test('accepts object keys as recovered asset references', () => { + expect( + hasRecoverableGeneratedPuzzleDraft( + buildSession({ + draft: buildDraft({ + coverImageSrc: '/draft-cover.png', + levels: [ + buildLevel({ + levelSceneImageObjectKey: 'level-scene.png', + uiSpritesheetImageObjectKey: 'ui-spritesheet.png', + levelBackgroundImageObjectKey: 'level-background.png', + }), + ], + }), + }), + ), + ).toBe(true); + }); + + test('leaves sessions without draft unchanged and unrecoverable', () => { + const session = buildSession({ draft: null }); + + expect(normalizeRecoveredPuzzleDraftSession(session)).toBe(session); + expect(hasRecoverableGeneratedPuzzleDraft(session)).toBe(false); + }); +}); diff --git a/src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts b/src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts new file mode 100644 index 00000000..ccec512f --- /dev/null +++ b/src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts @@ -0,0 +1,154 @@ +import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; + +function normalizeRecoveryText(value: string | null | undefined) { + return value?.trim() || null; +} + +function hasPuzzleAssetReference( + imageSrc: string | null | undefined, + objectKey: string | null | undefined, +) { + return Boolean(normalizeRecoveryText(imageSrc) || normalizeRecoveryText(objectKey)); +} + +function resolvePrimaryPuzzleLevel(session: PuzzleAgentSessionSnapshot) { + return session.draft?.levels?.[0] ?? null; +} + +function resolvePuzzleRecoveryCandidate( + session: PuzzleAgentSessionSnapshot, + primaryLevel: PuzzleDraftLevel | null, +) { + const draft = session.draft; + if (!draft) { + return null; + } + + return ( + primaryLevel?.candidates.find((candidate) => candidate.selected) ?? + primaryLevel?.candidates[0] ?? + draft.candidates.find((candidate) => candidate.selected) ?? + draft.candidates[0] ?? + null + ); +} + +function resolvePuzzleRecoveryCoverFields( + session: PuzzleAgentSessionSnapshot, +) { + const draft = session.draft; + const primaryLevel = resolvePrimaryPuzzleLevel(session); + const selectedCandidate = resolvePuzzleRecoveryCandidate( + session, + primaryLevel, + ); + + return { + coverImageSrc: + normalizeRecoveryText(draft?.coverImageSrc) ?? + normalizeRecoveryText(primaryLevel?.coverImageSrc) ?? + normalizeRecoveryText(selectedCandidate?.imageSrc), + coverAssetId: + normalizeRecoveryText(draft?.coverAssetId) ?? + normalizeRecoveryText(primaryLevel?.coverAssetId) ?? + normalizeRecoveryText(selectedCandidate?.assetId), + selectedCandidateId: + draft?.selectedCandidateId ?? + primaryLevel?.selectedCandidateId ?? + selectedCandidate?.candidateId ?? + null, + }; +} + +function hasCompleteGeneratedPuzzleLevelAssets( + level: PuzzleDraftLevel | null, + coverImageSrc: string | null, +) { + return Boolean( + normalizeRecoveryText(coverImageSrc) && + hasPuzzleAssetReference( + level?.levelSceneImageSrc, + level?.levelSceneImageObjectKey, + ) && + hasPuzzleAssetReference( + level?.uiSpritesheetImageSrc, + level?.uiSpritesheetImageObjectKey, + ) && + hasPuzzleAssetReference( + level?.levelBackgroundImageSrc, + level?.levelBackgroundImageObjectKey, + ), + ); +} + +export function hasRecoverableGeneratedPuzzleDraft( + session: PuzzleAgentSessionSnapshot, +) { + const draft = session.draft; + if (!draft) { + return false; + } + + const primaryLevel = resolvePrimaryPuzzleLevel(session); + const { coverImageSrc } = resolvePuzzleRecoveryCoverFields(session); + return hasCompleteGeneratedPuzzleLevelAssets(primaryLevel, coverImageSrc); +} + +export function normalizeRecoveredPuzzleDraftSession( + session: PuzzleAgentSessionSnapshot, +): PuzzleAgentSessionSnapshot { + const draft = session.draft; + if (!draft) { + return session; + } + + const { coverImageSrc, coverAssetId, selectedCandidateId } = + resolvePuzzleRecoveryCoverFields(session); + const nextLevels = draft.levels?.map((level, index) => + index === 0 + ? { + ...level, + coverImageSrc: normalizeRecoveryText(level.coverImageSrc) + ? level.coverImageSrc + : coverImageSrc, + coverAssetId: normalizeRecoveryText(level.coverAssetId) + ? level.coverAssetId + : coverAssetId, + selectedCandidateId: + level.selectedCandidateId ?? selectedCandidateId, + } + : level, + ); + const nextSession = { + ...session, + draft: { + ...draft, + coverImageSrc, + coverAssetId, + selectedCandidateId, + levels: nextLevels, + }, + } satisfies PuzzleAgentSessionSnapshot; + const isRecoverable = hasRecoverableGeneratedPuzzleDraft(nextSession); + + if (!isRecoverable) { + return nextSession; + } + + return { + ...nextSession, + draft: { + ...nextSession.draft, + generationStatus: 'ready', + levels: nextSession.draft.levels?.map((level, index) => + index === 0 + ? { + ...level, + generationStatus: 'ready', + } + : level, + ), + }, + }; +} From 4b4af11dbcc7e596c14ab4e915d6dd64488668b4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 03:50:09 +0800 Subject: [PATCH 48/67] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E6=8B=BC?= =?UTF-8?q?=E5=9B=BE=E5=8F=91=E5=B8=83=E8=B5=84=E4=BA=A7=E9=97=A8=E6=A7=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ .hermes/shared-memory/pitfalls.md | 4 +- docs/README.md | 2 + ...】PuzzlePublishAssetGate收紧计划-2026-06-04.md | 38 ++++++++++ ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 + ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + server-rs/crates/api-server/src/app.rs | 3 +- .../crates/api-server/src/puzzle/draft.rs | 13 +++- .../crates/api-server/src/puzzle/tags.rs | 23 ++++++ .../crates/api-server/src/puzzle/tests.rs | 58 ++++++++++++++- .../crates/module-puzzle/src/application.rs | 74 +++++++++++++++++++ .../crates/module-runtime/src/application.rs | 12 +-- .../src/creation_entry_config.rs | 14 +--- 13 files changed, 226 insertions(+), 27 deletions(-) create mode 100644 docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b245459b..ce1e68d5 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1419,6 +1419,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Puzzle Publish Asset Gate 收紧 + +- 背景:后端拼图待发布门槛与前端历史恢复逻辑一样偏弱,只要求标题、描述、标签、关卡名和 cover,导致缺关卡画面、UI spritesheet 或关卡背景的半成品可能被标为 `publishReady` / `ready_to_publish`。 +- 决策:`module-puzzle::validate_publish_requirements` 新增三类资产 blocker,要求每关具备 `level_scene_image_*`、`ui_spritesheet_image_*` 与 `level_background_image_*`;`api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready` 同步使用完整资产包判定。 +- 影响范围:拼图 result preview blockers、publishReady、标签生成后 session stage、从 action payload 构造 fallback session 的 ready 判定。 +- 验证方式:`cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_builds_fallback_session_from_levels_snapshot`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_fallback_session_ready_when_asset_pack_complete`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index f9043f96..032cd00d 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1245,8 +1245,8 @@ - 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。 - 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements` 和 `is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src`、`ui_spritesheet_image_src`、`level_background_image_src` 等完整资产都齐;历史前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。 -- 处理:前端恢复链路已收口到 `platformPuzzleDraftRecoveryModel.ts`,只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才把恢复草稿抬为完成态;后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的待发布门槛仍需后续收紧到整套拼图资产包完整。 -- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,前端恢复链路不应把它误判为已完成;后端后续修复后也不应进入 `ready_to_publish`。 +- 处理:前端恢复链路已收口到 `platformPuzzleDraftRecoveryModel.ts`,只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才把恢复草稿抬为完成态;后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 也已收紧到同一完整资产包门槛。 +- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,前端恢复链路不应把它误判为已完成,后端也不应进入 `ready_to_publish` 或返回 `publishReady=true`。 - 关联:`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/api-server/src/puzzle/tags.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`、`src/components/puzzle-result/PuzzleResultView.tsx`。 ## WebGL 画布在高 DPR 移动端放大溢出 diff --git a/docs/README.md b/docs/README.md index 6923e065..0834bc7c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,6 +63,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready,规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md)。 +后端拼图发布 / 待发布门槛收紧到首图、关卡画面、UI spritesheet 与关卡背景资产包完整,`module-puzzle` 的 preview blockers 与 `api-server` 的 session stage 判定保持同一规则,方案见 [【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md](./technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md)。 + RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md b/docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md new file mode 100644 index 00000000..9b0f263b --- /dev/null +++ b/docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md @@ -0,0 +1,38 @@ +# 【后端架构】Puzzle Publish Asset Gate 收紧计划 + +## 背景 + +拼图前端恢复链路已由 `platformPuzzleDraftRecoveryModel.ts` 收紧:只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时,才把恢复草稿抬为完成态。但后端仍有两处待发布门槛偏弱: + +- `module-puzzle::validate_publish_requirements(...)` 只校验作品名、描述、标签、关卡名与 cover。 +- `api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready(...)` 也只校验同一组轻字段,并据此把 session stage 置为 `ready_to_publish`。 + +这会让只有首图但缺关卡正式画面、UI spritesheet 或关卡背景的半成品显示为可发布或进入待发布 stage。 + +## 决策 + +后端拼图待发布门槛统一收紧到完整首关资产包: + +- `module-puzzle` 在 `validate_publish_requirements` 中新增资产 blocker,业务规则继续留在领域模块。 +- `api-server` 的 session snapshot ready 判定复用同一资产语言,避免标签生成后把半成品 session stage 改成 `ready_to_publish`。 +- 本切片不改 SpacetimeDB schema、不改 DTO 字段、不改路由、不改计费和发布动作副作用。 + +## 接口约束 + +- 仍保留既有作品名、描述、标签数量、关卡名、cover 校验。 +- 每个关卡必须具备: + - `cover_image_src`; + - `level_scene_image_src` 或 `level_scene_image_object_key`; + - `ui_spritesheet_image_src` 或 `ui_spritesheet_image_object_key`; + - `level_background_image_src` 或 `level_background_image_object_key`。 +- 缺正式关卡画面、UI spritesheet、关卡背景时,各自输出明确 blocker。 +- `build_result_preview(...).publish_ready` 与 `is_puzzle_session_snapshot_publish_ready(...)` 必须在同一类缺资产草稿上返回 false。 +- `api-server` 从 action payload 构造 fallback session 时,缺资产 levels snapshot 应停留 `image_refining`,不得进入 `ready_to_publish`。 + +## 验收 + +- `cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements` +- `cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation` +- `npm run check:encoding` +- `git diff --check` +- 修改后按仓规尝试 `npm run api-server` 拉起后端并确认 `/healthz`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 053fe85c..c5d48c72 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -104,6 +104,8 @@ npm run check:server-rs-ddd - `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。 - `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。 +拼图发布 / 待发布门槛必须同时要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整;`module-puzzle::validate_publish_requirements` 与 `api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready` 使用同一资产语言,不得只凭 cover、标题、描述和标签把半成品标为 `publishReady` 或 `ready_to_publish`。 + 该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。 `/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求,应由前端在登录态下继续携带账号 access token;匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 15efac60..05e7171e 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -20,6 +20,8 @@ 拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定统一由 `platformPuzzleDraftRecoveryModel.ts` 处理。恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才可把 draft 和首关状态抬为 `ready`;只有 cover 或候选图的半成品不得直接进入结果页完成态。 +后端拼图发布 / 待发布门槛同样必须要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整:`module-puzzle` preview blockers 与 `api-server` session stage 判定不得只凭 cover、标题、描述和标签把半成品标为 `publishReady` 或 `ready_to_publish`。 + RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 106c7d77..a9fbf3d5 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -4053,8 +4053,7 @@ mod tests { .await .expect("banners body should collect") .to_bytes(); - let payload: Value = - serde_json::from_slice(&body).expect("banners payload should be json"); + let payload: Value = serde_json::from_slice(&body).expect("banners payload should be json"); assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告"); assert_eq!(payload["eventBanners"][0]["renderMode"], "html"); diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index 276a29f5..f5849970 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -307,13 +307,18 @@ pub(crate) fn build_puzzle_session_snapshot_from_action_payload( levels, form_draft: None, }; + let stage = if is_puzzle_session_snapshot_publish_ready(&draft) { + "ready_to_publish" + } else { + "image_refining" + }; Ok(PuzzleAgentSessionRecord { session_id: session_id.to_string(), seed_text: String::new(), current_turn: 0, progress_percent: 94, - stage: "ready_to_publish".to_string(), + stage: stage.to_string(), anchor_pack, draft: Some(draft), messages: Vec::new(), @@ -1788,7 +1793,11 @@ pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot( sync_puzzle_primary_draft_fields_from_level(draft); } session.progress_percent = session.progress_percent.max(94); - session.stage = "ready_to_publish".to_string(); + session.stage = if is_puzzle_session_snapshot_publish_ready(draft) { + "ready_to_publish".to_string() + } else { + "image_refining".to_string() + }; session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string()); session.updated_at = format_timestamp_micros(updated_at_micros); session diff --git a/server-rs/crates/api-server/src/puzzle/tags.rs b/server-rs/crates/api-server/src/puzzle/tags.rs index f49cc84e..c6ce0693 100644 --- a/server-rs/crates/api-server/src/puzzle/tags.rs +++ b/server-rs/crates/api-server/src/puzzle/tags.rs @@ -248,6 +248,17 @@ pub(super) fn apply_generated_puzzle_tags_to_session_snapshot( session } +fn has_required_puzzle_asset_ref(image_src: &Option, object_key: &Option) -> bool { + image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || object_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) +} + pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool { !draft.work_title.trim().is_empty() && !draft.work_description.trim().is_empty() @@ -261,6 +272,18 @@ pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraft .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()) + && has_required_puzzle_asset_ref( + &level.level_scene_image_src, + &level.level_scene_image_object_key, + ) + && has_required_puzzle_asset_ref( + &level.ui_spritesheet_image_src, + &level.ui_spritesheet_image_object_key, + ) + && has_required_puzzle_asset_ref( + &level.level_background_image_src, + &level.level_background_image_object_key, + ) }) } diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index 86512e7d..1d62d2b0 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -474,7 +474,7 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { .expect("fallback session"); let draft = session.draft.expect("draft"); - assert_eq!(session.stage, "ready_to_publish"); + assert_eq!(session.stage, "image_refining"); assert_eq!(draft.work_title, "暖灯猫街作品"); assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]); assert_eq!(draft.levels[0].level_id, "puzzle-level-1"); @@ -484,6 +484,62 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { ); } +#[test] +fn puzzle_image_generation_fallback_session_ready_when_asset_pack_complete() { + let levels_json = serde_json::to_string(&vec![json!({ + "level_id": "puzzle-level-1", + "level_name": "雨夜猫街", + "picture_description": "一只猫在雨夜灯牌下回头。", + "candidates": [], + "selected_candidate_id": null, + "cover_image_src": "/generated/puzzle/cover.png", + "cover_asset_id": "asset-cover", + "level_scene_image_src": "/generated/puzzle/level-scene.png", + "level_scene_image_object_key": "generated/puzzle/level-scene.png", + "ui_spritesheet_image_src": "/generated/puzzle/ui-spritesheet.png", + "ui_spritesheet_image_object_key": "generated/puzzle/ui-spritesheet.png", + "level_background_image_src": "/generated/puzzle/level-background.png", + "level_background_image_object_key": "generated/puzzle/level-background.png", + "generation_status": "ready", + })]) + .expect("levels json"); + let payload = ExecutePuzzleAgentActionRequest { + action: "generate_puzzle_images".to_string(), + prompt_text: None, + reference_image_src: None, + reference_image_srcs: Vec::new(), + reference_image_asset_object_id: None, + reference_image_asset_object_ids: Vec::new(), + image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), + ai_redraw: None, + candidate_count: Some(1), + should_auto_name_level: None, + candidate_id: None, + level_id: Some("puzzle-level-1".to_string()), + work_title: Some("暖灯猫街作品".to_string()), + work_description: Some("一套雨夜猫街主题拼图。".to_string()), + picture_description: None, + level_name: None, + summary: Some("当前关卡画面。".to_string()), + theme_tags: Some(vec![ + "猫咪".to_string(), + "雨夜".to_string(), + "灯牌".to_string(), + ]), + levels_json: Some(levels_json.clone()), + }; + + let session = build_puzzle_session_snapshot_from_action_payload( + "puzzle-session-1", + &payload, + Some(levels_json.as_str()), + 1_713_686_401_234_567, + ) + .expect("fallback session"); + + assert_eq!(session.stage, "ready_to_publish"); +} + #[test] fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() { assert_eq!( diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index df8b1c4f..eca056e2 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -541,6 +541,17 @@ pub fn build_result_preview( } } +fn has_required_puzzle_asset_ref(image_src: &Option, object_key: &Option) -> bool { + image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || object_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) +} + pub fn validate_publish_requirements( draft: &PuzzleResultDraft, author_display_name: Option<&str>, @@ -582,6 +593,36 @@ pub fn validate_publish_requirements( message: "正式拼图图片尚未确定".to_string(), }); } + if !has_required_puzzle_asset_ref( + &level.level_scene_image_src, + &level.level_scene_image_object_key, + ) { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-level-scene-image-{}", level.level_id), + code: "MISSING_LEVEL_SCENE_IMAGE".to_string(), + message: "正式关卡画面尚未生成".to_string(), + }); + } + if !has_required_puzzle_asset_ref( + &level.ui_spritesheet_image_src, + &level.ui_spritesheet_image_object_key, + ) { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-ui-spritesheet-image-{}", level.level_id), + code: "MISSING_UI_SPRITESHEET_IMAGE".to_string(), + message: "UI spritesheet 尚未生成".to_string(), + }); + } + if !has_required_puzzle_asset_ref( + &level.level_background_image_src, + &level.level_background_image_object_key, + ) { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-level-background-image-{}", level.level_id), + code: "MISSING_LEVEL_BACKGROUND_IMAGE".to_string(), + message: "关卡背景图尚未生成".to_string(), + }); + } } if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT || draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT @@ -4011,4 +4052,37 @@ mod tests { .any(|blocker| blocker.code == "MISSING_LEVEL_NAME") ); } + + #[test] + fn validate_publish_requirements_requires_generated_level_asset_pack() { + let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙")); + let mut draft = compile_result_draft(&anchor_pack, &[]); + draft.levels[0].cover_image_src = Some("/cover.png".to_string()); + + let blockers = validate_publish_requirements(&draft, Some("玩家")); + let blocker_codes = blockers + .iter() + .map(|blocker| blocker.code.as_str()) + .collect::>(); + assert!(blocker_codes.contains(&"MISSING_LEVEL_SCENE_IMAGE")); + assert!(blocker_codes.contains(&"MISSING_UI_SPRITESHEET_IMAGE")); + assert!(blocker_codes.contains(&"MISSING_LEVEL_BACKGROUND_IMAGE")); + + draft.levels[0].level_scene_image_object_key = + Some("generated/puzzle/level-scene.png".to_string()); + draft.levels[0].ui_spritesheet_image_object_key = + Some("generated/puzzle/ui-spritesheet.png".to_string()); + draft.levels[0].level_background_image_object_key = + Some("generated/puzzle/level-background.png".to_string()); + + let blockers = validate_publish_requirements(&draft, Some("玩家")); + assert!(!blockers.iter().any(|blocker| { + matches!( + blocker.code.as_str(), + "MISSING_LEVEL_SCENE_IMAGE" + | "MISSING_UI_SPRITESHEET_IMAGE" + | "MISSING_LEVEL_BACKGROUND_IMAGE" + ) + })); + } } diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 902089a5..ab997c7f 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -161,10 +161,9 @@ fn normalize_creation_entry_announcement_banner_value( ); } - let banner = serde_json::from_value::(Value::Object( - object.clone(), - )) - .map_err(|error| format!("第 {} 条公告对象非法:{error}", index + 1))?; + let banner = + serde_json::from_value::(Value::Object(object.clone())) + .map_err(|error| format!("第 {} 条公告对象非法:{error}", index + 1))?; normalize_creation_entry_event_banner_response(index, banner) } @@ -327,10 +326,7 @@ fn normalize_banner_html_code( } let lower_html_code = html_code.to_ascii_lowercase(); if lower_html_code.contains(" Option Date: Thu, 4 Jun 2026 03:57:51 +0800 Subject: [PATCH 49/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E9=92=B1=E5=8C=85=E4=BD=99=E9=A2=9D=20delta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...ormProfileWalletDeltaModel收口计划-2026-06-04.md | 32 +++++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 59 +-------- .../platformProfileWalletDeltaModel.test.ts | 117 ++++++++++++++++++ .../platformProfileWalletDeltaModel.ts | 61 +++++++++ 7 files changed, 227 insertions(+), 54 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformProfileWalletDeltaModel.test.ts create mode 100644 src/components/platform-entry/platformProfileWalletDeltaModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index ce1e68d5..5c4e2af0 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1427,6 +1427,14 @@ - 验证方式:`cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_builds_fallback_session_from_levels_snapshot`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_fallback_session_ready_when_asset_pack_complete`、`npm run check:encoding`。 - 关联文档:`docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform Profile Wallet Delta Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护钱包余额归一、本地 delta 乐观更新和服务端 dashboard 刷新后的 delta 抵消,壳层需要理解余额非负、整数截断、借贷方向和服务端快照对账。 +- 决策:新增 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,收口 `resolveProfileWalletBalance`、`adjustProfileDashboardWalletBalance` 与 `reconcileProfileWalletLocalDeltaWithServerDashboard`。壳层只保留 API 请求、React ref、state 写入和刷新触发副作用。 +- 影响范围:创作入口泥点展示、生成前泥点校验、扣点 / 返还后的个人 dashboard 乐观更新、后台刷新 dashboard 时的本地 delta 对账。 +- 验证方式:`npm run test -- src/components/platform-entry/platformProfileWalletDeltaModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 0834bc7c..d126b650 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,6 +65,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 后端拼图发布 / 待发布门槛收紧到首图、关卡画面、UI spritesheet 与关卡背景资产包完整,`module-puzzle` 的 preview blockers 与 `api-server` 的 session stage 判定保持同一规则,方案见 [【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md](./technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md)。 +平台入口个人钱包本地 delta、dashboard 乐观更新与服务端快照对账规则收口到 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,平台壳只保留 API、ref 与 state 副作用,规则见 [【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md)。 + RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md new file mode 100644 index 00000000..f45a636e --- /dev/null +++ b/docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md @@ -0,0 +1,32 @@ +# 【前端架构】Platform Profile Wallet Delta Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 仍内联维护个人钱包余额的本地 delta 规则:余额归一化、本地扣点 / 返还后的 dashboard 乐观更新,以及刷新服务端 dashboard 时如何抵消已经被服务端反映的本地 delta。 + +这些规则是纯展示状态计算,但留在平台壳层会让壳层同时理解钱包余额边界、整数截断、负数保护和服务端快照对账。 + +## 决策 + +新增 `platformProfileWalletDeltaModel.ts`,收口钱包余额本地 delta 的纯规则: + +- `resolveProfileWalletBalance(...)` 负责把 dashboard 余额归一为非负整数。 +- `adjustProfileDashboardWalletBalance(...)` 负责把本地 delta 应用到 dashboard,并刷新 `updatedAt`。 +- `reconcileProfileWalletLocalDeltaWithServerDashboard(...)` 负责在拿到新服务端 dashboard 后扣除已被服务端反映的本地借贷变化。 + +`PlatformEntryFlowShellImpl.tsx` 继续保留 API 请求、React ref、state 写入和刷新触发副作用。 + +## 接口约束 + +- 非数字、无穷值或空 dashboard 的余额按 `0` 处理。 +- 本地 delta 必须先 `Math.trunc`,余额不得低于 `0`。 +- 当服务端最新余额已经反映本地扣点时,剩余负 delta 应减少;已经全部反映时归零。 +- 当服务端最新余额已经反映本地返还 / 奖励时,剩余正 delta 应减少;已经全部反映时归零。 +- 服务端余额变化方向与本地 delta 相反时,不得错误抵消。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformProfileWalletDeltaModel.test.ts` +- 针对新 Module 与 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint。 +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 05e7171e..50ac693a 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -22,6 +22,8 @@ 后端拼图发布 / 待发布门槛同样必须要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整:`module-puzzle` preview blockers 与 `api-server` session stage 判定不得只凭 cover、标题、描述和标签把半成品标为 `publishReady` 或 `ready_to_publish`。 +平台入口个人钱包本地 delta 由 `platformProfileWalletDeltaModel.ts` 判定:余额归一、本地扣点 / 返还后的 dashboard 乐观更新,以及服务端 dashboard 刷新后的 delta 对账不得散落在平台壳层;壳层只负责 API、React ref 和 state 写入。 + RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 467d46ae..5cfa4655 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -539,6 +539,11 @@ import { buildWoodenFishSessionFromWorkDetail, } from './platformMiniGameSessionMappingModel'; import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel'; +import { + adjustProfileDashboardWalletBalance, + reconcileProfileWalletLocalDeltaWithServerDashboard, + resolveProfileWalletBalance, +} from './platformProfileWalletDeltaModel'; import { type PlatformPublicCodeSearchStep, resolvePlatformPublicCodeSearchPlan, @@ -1049,60 +1054,6 @@ function openPuzzleRuntimeStage( writePuzzleRuntimeUrlState(state); } -function resolveProfileWalletBalance( - dashboard: { walletBalance?: number | null } | null | undefined, -) { - const walletBalance = dashboard?.walletBalance; - return typeof walletBalance === 'number' && Number.isFinite(walletBalance) - ? Math.max(0, Math.floor(walletBalance)) - : 0; -} - -function adjustProfileDashboardWalletBalance( - dashboard: ProfileDashboardSummary | null, - delta: number, -): ProfileDashboardSummary | null { - if (!dashboard || !Number.isFinite(delta) || delta === 0) { - return dashboard; - } - - return { - ...dashboard, - walletBalance: Math.max( - 0, - resolveProfileWalletBalance(dashboard) + Math.trunc(delta), - ), - updatedAt: new Date().toISOString(), - }; -} - -function reconcileProfileWalletLocalDeltaWithServerDashboard( - previousDashboard: ProfileDashboardSummary | null, - latestDashboard: ProfileDashboardSummary | null, - localDelta: number, -) { - if ( - !previousDashboard || - !latestDashboard || - !Number.isFinite(localDelta) || - localDelta === 0 - ) { - return Number.isFinite(localDelta) ? Math.trunc(localDelta) : 0; - } - - const previousBalance = resolveProfileWalletBalance(previousDashboard); - const latestBalance = resolveProfileWalletBalance(latestDashboard); - const normalizedDelta = Math.trunc(localDelta); - - if (normalizedDelta < 0) { - const reflectedDebit = Math.max(0, previousBalance - latestBalance); - return Math.min(0, normalizedDelta + reflectedDebit); - } - - const reflectedCredit = Math.max(0, latestBalance - previousBalance); - return Math.max(0, normalizedDelta - reflectedCredit); -} - function isPuzzleFormOnlyDraft(session: PuzzleAgentSessionSnapshot | null) { return Boolean( session?.stage === 'collecting_anchors' && session.draft?.formDraft, diff --git a/src/components/platform-entry/platformProfileWalletDeltaModel.test.ts b/src/components/platform-entry/platformProfileWalletDeltaModel.test.ts new file mode 100644 index 00000000..301305bf --- /dev/null +++ b/src/components/platform-entry/platformProfileWalletDeltaModel.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime'; +import { + adjustProfileDashboardWalletBalance, + reconcileProfileWalletLocalDeltaWithServerDashboard, + resolveProfileWalletBalance, +} from './platformProfileWalletDeltaModel'; + +const NOW = Date.parse('2026-06-04T04:30:00.000Z'); + +function buildDashboard( + overrides: Partial = {}, +): ProfileDashboardSummary { + return { + walletBalance: 100, + totalPlayTimeMs: 0, + playedWorldCount: 0, + updatedAt: '2026-06-01T00:00:00.000Z', + ...overrides, + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('platformProfileWalletDeltaModel', () => { + test('normalizes wallet balance to a non-negative integer', () => { + expect(resolveProfileWalletBalance(buildDashboard({ walletBalance: 12.8 }))).toBe( + 12, + ); + expect( + resolveProfileWalletBalance(buildDashboard({ walletBalance: -4 })), + ).toBe(0); + expect(resolveProfileWalletBalance({ walletBalance: Number.NaN })).toBe(0); + expect(resolveProfileWalletBalance(null)).toBe(0); + }); + + test('applies local delta and refreshes dashboard timestamp', () => { + expect( + adjustProfileDashboardWalletBalance(buildDashboard(), -3.8), + ).toMatchObject({ + walletBalance: 97, + updatedAt: '2026-06-04T04:30:00.000Z', + }); + expect( + adjustProfileDashboardWalletBalance(buildDashboard({ walletBalance: 2 }), -10), + ).toMatchObject({ + walletBalance: 0, + }); + expect(adjustProfileDashboardWalletBalance(null, 5)).toBeNull(); + const dashboard = buildDashboard(); + expect(adjustProfileDashboardWalletBalance(dashboard, Number.POSITIVE_INFINITY)).toBe( + dashboard, + ); + }); + + test('reconciles debit delta already reflected by latest server dashboard', () => { + const previous = buildDashboard({ walletBalance: 100 }); + expect( + reconcileProfileWalletLocalDeltaWithServerDashboard( + previous, + buildDashboard({ walletBalance: 98 }), + -5, + ), + ).toBe(-3); + expect( + reconcileProfileWalletLocalDeltaWithServerDashboard( + previous, + buildDashboard({ walletBalance: 92 }), + -5, + ), + ).toBe(0); + }); + + test('reconciles credit delta already reflected by latest server dashboard', () => { + const previous = buildDashboard({ walletBalance: 100 }); + expect( + reconcileProfileWalletLocalDeltaWithServerDashboard( + previous, + buildDashboard({ walletBalance: 103 }), + 8, + ), + ).toBe(5); + expect( + reconcileProfileWalletLocalDeltaWithServerDashboard( + previous, + buildDashboard({ walletBalance: 120 }), + 8, + ), + ).toBe(0); + }); + + test('does not reconcile when server balance moves against local delta', () => { + const previous = buildDashboard({ walletBalance: 100 }); + expect( + reconcileProfileWalletLocalDeltaWithServerDashboard( + previous, + buildDashboard({ walletBalance: 104 }), + -5, + ), + ).toBe(-5); + expect( + reconcileProfileWalletLocalDeltaWithServerDashboard( + previous, + buildDashboard({ walletBalance: 96 }), + 8, + ), + ).toBe(8); + }); +}); diff --git a/src/components/platform-entry/platformProfileWalletDeltaModel.ts b/src/components/platform-entry/platformProfileWalletDeltaModel.ts new file mode 100644 index 00000000..9293767a --- /dev/null +++ b/src/components/platform-entry/platformProfileWalletDeltaModel.ts @@ -0,0 +1,61 @@ +import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime'; + +type ProfileWalletBalanceSource = + | Pick + | { walletBalance?: number | null } + | null + | undefined; + +export function resolveProfileWalletBalance( + dashboard: ProfileWalletBalanceSource, +) { + const walletBalance = dashboard?.walletBalance; + return typeof walletBalance === 'number' && Number.isFinite(walletBalance) + ? Math.max(0, Math.floor(walletBalance)) + : 0; +} + +export function adjustProfileDashboardWalletBalance( + dashboard: ProfileDashboardSummary | null, + delta: number, +): ProfileDashboardSummary | null { + if (!dashboard || !Number.isFinite(delta) || delta === 0) { + return dashboard; + } + + return { + ...dashboard, + walletBalance: Math.max( + 0, + resolveProfileWalletBalance(dashboard) + Math.trunc(delta), + ), + updatedAt: new Date().toISOString(), + }; +} + +export function reconcileProfileWalletLocalDeltaWithServerDashboard( + previousDashboard: ProfileDashboardSummary | null, + latestDashboard: ProfileDashboardSummary | null, + localDelta: number, +) { + if ( + !previousDashboard || + !latestDashboard || + !Number.isFinite(localDelta) || + localDelta === 0 + ) { + return Number.isFinite(localDelta) ? Math.trunc(localDelta) : 0; + } + + const previousBalance = resolveProfileWalletBalance(previousDashboard); + const latestBalance = resolveProfileWalletBalance(latestDashboard); + const normalizedDelta = Math.trunc(localDelta); + + if (normalizedDelta < 0) { + const reflectedDebit = Math.max(0, previousBalance - latestBalance); + return Math.min(0, normalizedDelta + reflectedDebit); + } + + const reflectedCredit = Math.max(0, latestBalance - previousBalance); + return Math.max(0, normalizedDelta - reflectedCredit); +} From c31676a0e1775c584f2818adb3dd757c944c4e99 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 04:03:11 +0800 Subject: [PATCH 50/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=8B=BC?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=E5=8D=95=E8=8D=89=E7=A8=BF=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 4 +-- docs/README.md | 2 +- ...mMiniGameDraftPayloadModel收口计划-2026-06-04.md | 5 +++- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- .../PlatformEntryFlowShellImpl.tsx | 24 ++---------------- .../platformMiniGameDraftPayloadModel.test.ts | 25 +++++++++++++++++++ .../platformMiniGameDraftPayloadModel.ts | 24 ++++++++++++++++++ 7 files changed, 59 insertions(+), 27 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 5c4e2af0..2316b2f3 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1406,8 +1406,8 @@ ## 2026-06-04 Platform Mini Game Draft Payload Model 收口 - 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护拼图 / 抓大鹅表单 payload、拼图编译 action、作品摘要回填 payload 和 pending 草稿 metadata,壳层需要理解描述字段优先级、formDraft 回退、Match3D config / draft / anchorPack 优先级和数字解析。 -- 决策:新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,收口 `buildPuzzleFormPayloadFromWork`、`buildPuzzleFormPayloadFromSession`、`buildPuzzleFormPayloadFromAction`、`buildPuzzleCompileActionFromFormPayload`、`buildPendingPuzzleDraftMetadata`、`buildMatch3DFormPayloadFromSession`、`buildMatch3DFormPayloadFromWork` 与 `buildPendingMatch3DDraftMetadata`;`parseOptionalFiniteNumber` 留在 Module 内部。 -- 影响范围:拼图 action 完成 / 执行前 / 失败恢复、拼图表单直生草稿、拼图草稿架恢复、抓大鹅表单直生草稿与失败恢复。 +- 决策:新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,收口 `buildPuzzleFormPayloadFromWork`、`buildPuzzleFormPayloadFromSession`、`buildPuzzleFormPayloadFromAction`、`buildPuzzleCompileActionFromFormPayload`、`buildPendingPuzzleDraftMetadata`、`isPuzzleFormOnlyDraft`、`isEmptyPuzzleFormOnlyDraft`、`buildMatch3DFormPayloadFromSession`、`buildMatch3DFormPayloadFromWork` 与 `buildPendingMatch3DDraftMetadata`;`parseOptionalFiniteNumber` 留在 Module 内部。 +- 影响范围:拼图 action 完成 / 执行前 / 失败恢复、拼图表单直生草稿、拼图 form-only 草稿恢复 / 分流 / 结果页渲染、拼图草稿架恢复、抓大鹅表单直生草稿与失败恢复。 - 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/README.md b/docs/README.md index d126b650..26416215 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,7 +59,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 -平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图编译 action 与 pending metadata 收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 +平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图编译 action、pending metadata 与拼图 form-only 草稿判定收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready,规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md)。 diff --git a/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md index b8ac88c5..f294bbd3 100644 --- a/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md @@ -2,7 +2,7 @@ ## 背景 -`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图和抓大鹅草稿恢复所需的表单 payload、拼图编译 action payload、作品摘要回填 payload 和 pending 草稿 metadata。壳层因此需要理解拼图描述字段优先级、formDraft 回退、Match3D config / draft / anchorPack 优先级,以及 pending 作品架标题摘要如何从 payload 派生。 +`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图和抓大鹅草稿恢复所需的表单 payload、拼图编译 action payload、作品摘要回填 payload 和 pending 草稿 metadata。壳层因此需要理解拼图描述字段优先级、formDraft 回退、Match3D config / draft / anchorPack 优先级,以及 pending 作品架标题摘要如何从 payload 派生。后续还残留拼图 form-only 草稿判定,影响 action 分流、草稿恢复阶段和结果页渲染。 这些逻辑都是 DTO 变换;不读取 React state,不请求网络,也不写 URL。壳层只应决定何时恢复、何时提交 action、何时写入生成状态。 @@ -15,6 +15,7 @@ - `buildPuzzleFormPayloadFromAction(payload)`:从拼图 action 还原表单 payload,仅接受 `compile_puzzle_draft` 与 `save_puzzle_form_draft`。 - `buildPuzzleCompileActionFromFormPayload(payload)`:从表单 payload 构造拼图编译 action。 - `buildPendingPuzzleDraftMetadata(payload)`:从拼图 payload 派生 pending 作品架 metadata。 +- `isPuzzleFormOnlyDraft(session)` 与 `isEmptyPuzzleFormOnlyDraft(session)`:判断拼图 session 是否仍只是表单草稿,以及表单草稿是否没有任何可提交内容。 - `buildMatch3DFormPayloadFromSession(session)` 与 `buildMatch3DFormPayloadFromWork(item)`:从抓大鹅 session / work 恢复表单 payload。 - `buildPendingMatch3DDraftMetadata(payload)`:从抓大鹅 payload 派生 pending metadata。 @@ -26,6 +27,8 @@ - 拼图 session payload 的 `pictureDescription` 优先级固定为 `formDraft.pictureDescription > first level pictureDescription > anchorPack.visualSubject.value > seedText > ''`。 - 拼图编译 action 的 `promptText` 来自 `pictureDescription || seedText`;`workDescription` 缺省回退到图片描述;`candidateCount` 固定为 `1`。 - 拼图 action 还原只接受 `compile_puzzle_draft` 与 `save_puzzle_form_draft`;其它 action 返回 `null`。 +- 拼图 form-only 草稿只在 `session.stage === 'collecting_anchors'` 且存在 `draft.formDraft` 时成立。 +- 空 form-only 草稿必须同时缺少 `seedText`、`formDraft.workTitle`、`formDraft.workDescription` 与 `formDraft.pictureDescription`。 - 抓大鹅 session payload 优先读取 `config`,其次 `draft`,最后 `anchorPack`;`anchorPack.clearCount` 与 `anchorPack.difficulty` 只接受有限数字字符串或数字。 - 抓大鹅 work payload 的 `themeText` 优先 `themeText`,缺失回退 `gameName`。 - pending metadata 只收非空 trim 后标题和摘要;抓大鹅 metadata 用 `themeText || seedText` 同时作为 title 和 summary。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 50ac693a..a46a9786 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -16,7 +16,7 @@ 平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定统一由 `platformMiniGameDraftGenerationStateModel.ts` 处理。平台壳只决定何时调用并写入对应 React state,不得在壳层重新维护 `MiniGameDraftGenerationState` 的 phase 阈值、`finishedAtMs` 清理或拼图进度 metadata 合并规则。 -拼图 / 抓大鹅草稿恢复和提交所需的表单 payload、拼图编译 action 与 pending metadata 统一由 `platformMiniGameDraftPayloadModel.ts` 构造。平台壳不得重新手写拼图描述字段优先级、formDraft 回退、Match3D config / draft / anchorPack 优先级、数字解析或 pending 标题摘要派生规则。 +拼图 / 抓大鹅草稿恢复和提交所需的表单 payload、拼图编译 action、pending metadata 与拼图 form-only 草稿判定统一由 `platformMiniGameDraftPayloadModel.ts` 构造。平台壳不得重新手写拼图描述字段优先级、formDraft 回退、form-only 空草稿判定、Match3D config / draft / anchorPack 优先级、数字解析或 pending 标题摘要派生规则。 拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定统一由 `platformPuzzleDraftRecoveryModel.ts` 处理。恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才可把 draft 和首关状态抬为 `ready`;只有 cover 或候选图的半成品不得直接进入结果页完成态。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 5cfa4655..57581040 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -531,6 +531,8 @@ import { buildPuzzleFormPayloadFromAction, buildPuzzleFormPayloadFromSession, buildPuzzleFormPayloadFromWork, + isEmptyPuzzleFormOnlyDraft, + isPuzzleFormOnlyDraft, } from './platformMiniGameDraftPayloadModel'; import { buildJumpHopPendingSession, @@ -1054,28 +1056,6 @@ function openPuzzleRuntimeStage( writePuzzleRuntimeUrlState(state); } -function isPuzzleFormOnlyDraft(session: PuzzleAgentSessionSnapshot | null) { - return Boolean( - session?.stage === 'collecting_anchors' && session.draft?.formDraft, - ); -} - -function isEmptyPuzzleFormOnlyDraft( - session: PuzzleAgentSessionSnapshot | null, -) { - if (!isPuzzleFormOnlyDraft(session)) { - return false; - } - - const formDraft = session?.draft?.formDraft; - return !( - session?.seedText?.trim() || - formDraft?.workTitle?.trim() || - formDraft?.workDescription?.trim() || - formDraft?.pictureDescription?.trim() - ); -} - const CustomWorldGenerationView = lazy(async () => { const module = await import('../CustomWorldGenerationView'); return { diff --git a/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts b/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts index 056404c6..9fdeb7a0 100644 --- a/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts +++ b/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts @@ -24,6 +24,8 @@ import { buildPuzzleFormPayloadFromAction, buildPuzzleFormPayloadFromSession, buildPuzzleFormPayloadFromWork, + isEmptyPuzzleFormOnlyDraft, + isPuzzleFormOnlyDraft, } from './platformMiniGameDraftPayloadModel'; function buildPuzzleAnchorPack(): PuzzleAnchorPack { @@ -244,6 +246,29 @@ describe('platformMiniGameDraftPayloadModel', () => { ).toBe('关卡优先'); }); + test('resolves puzzle form-only draft state for empty and filled forms', () => { + const baseDraft = buildPuzzleSession().draft!; + const emptySession = buildPuzzleSession({ + seedText: ' ', + draft: { + ...baseDraft, + formDraft: { + workTitle: ' ', + workDescription: ' ', + pictureDescription: ' ', + }, + }, + }); + + expect(isPuzzleFormOnlyDraft(emptySession)).toBe(true); + expect(isEmptyPuzzleFormOnlyDraft(emptySession)).toBe(true); + expect(isPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(true); + expect(isEmptyPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(false); + expect( + isPuzzleFormOnlyDraft(buildPuzzleSession({ stage: 'ready_to_publish' })), + ).toBe(false); + }); + test('builds puzzle compile action and restores form payload from action', () => { const payload: CreatePuzzleAgentSessionRequest = { seedText: '种子', diff --git a/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts b/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts index f889b3f0..f5181e61 100644 --- a/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts +++ b/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts @@ -151,6 +151,30 @@ export function buildPuzzleFormPayloadFromSession( }; } +export function isPuzzleFormOnlyDraft( + session: PuzzleAgentSessionSnapshot | null, +) { + return Boolean( + session?.stage === 'collecting_anchors' && session.draft?.formDraft, + ); +} + +export function isEmptyPuzzleFormOnlyDraft( + session: PuzzleAgentSessionSnapshot | null, +) { + if (!isPuzzleFormOnlyDraft(session)) { + return false; + } + + const formDraft = session?.draft?.formDraft; + return !( + session?.seedText?.trim() || + formDraft?.workTitle?.trim() || + formDraft?.workDescription?.trim() || + formDraft?.pictureDescription?.trim() + ); +} + export function buildPendingPuzzleDraftMetadata( payload: CreatePuzzleAgentSessionRequest | null | undefined, ) { From df17f51edff6232d793a77bc26e93a70f273e972 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 04:18:16 +0800 Subject: [PATCH 51/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E4=BD=9C=E5=93=81=E6=B5=81=E8=81=9A=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 4 +- docs/README.md | 2 +- ...入口PublicGalleryFlowModule收口计划-2026-06-03.md | 4 +- .../PlatformEntryFlowShellImpl.tsx | 131 ++-------- .../platformPublicGalleryFlow.test.ts | 224 ++++++++++++++++++ .../platformPublicGalleryFlow.ts | 94 +++++++- 6 files changed, 345 insertions(+), 114 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 2316b2f3..2a6e4dd8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -74,8 +74,8 @@ ## 2026-06-03 平台入口公开作品流身份规则收口 - 背景:平台入口公开作品推荐流需要同时处理 RPG、拼图、抓大鹅、跳一跳、敲木鱼、视觉小说、Bark Battle、宝贝识物等卡片,公开作品身份、跨玩法去重、排序和推荐运行态 kind 判定曾放在 `PlatformEntryFlowShellImpl.tsx` 巨型实现里。 -- 决策:公开作品身份、排序规则、推荐 runtime 启动意图和 ready 判定统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey`、`getPlatformRecommendRuntimeKind`、`resolvePlatformRecommendRuntimeStartIntent`、`isPlatformRecommendRuntimeReadyForEntry`、`isSamePlatformPublicGalleryEntry` 和 `mergePlatformPublicGalleryEntries`。`edutainment` key 必须带 `templateId`,RPG 卡片回退为 `rpg`。推荐 runtime 启动 intent 只返回启动目标、`embedded` / `returnStage` 参数、阻断文案和错误落点;ready 判定只接布尔值与拼图 profile id,避免把各玩法 run snapshot 类型拖入 Module。壳层仍执行 request key、运行态 API、错误 setter 与 UI 状态。 -- 影响范围:平台入口推荐流、公开作品详情、推荐 runtime 启动、跨玩法公开作品合并,以及后续新增玩法的入口接入。 +- 决策:公开作品身份、排序规则、公开作品流聚合矩阵、推荐 runtime 启动意图和 ready 判定统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey`、`getPlatformRecommendRuntimeKind`、`buildPlatformPublicGalleryFeeds`、`resolvePlatformRecommendRuntimeStartIntent`、`isPlatformRecommendRuntimeReadyForEntry`、`isSamePlatformPublicGalleryEntry` 和 `mergePlatformPublicGalleryEntries`。`edutainment` key 必须带 `templateId`,RPG 卡片回退为 `rpg`。公开作品流聚合负责 featured / latest、玩法可见性 gate、汪汪声浪 works fallback 和首屏 `slice(0, 6)`;推荐 runtime 启动 intent 只返回启动目标、`embedded` / `returnStage` 参数、阻断文案和错误落点;ready 判定只接布尔值与拼图 profile id,避免把各玩法 run snapshot 类型拖入 Module。壳层仍执行 request key、运行态 API、错误 setter 与 UI 状态。 +- 影响范围:平台入口推荐流、最新公开作品流、公开作品详情、推荐 runtime 启动、跨玩法公开作品合并,以及后续新增玩法的入口接入。 - 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index 26416215..728645d3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,7 +39,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`,事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。 -平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定、推荐 runtime 启动意图、ready 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +平台入口公开作品身份、跨玩法去重、公开作品流聚合、推荐运行态 kind 判定、推荐 runtime 启动意图、ready 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、编辑 / 点赞 / 改造 / 启动意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射与启动 / 编辑 Adapter 的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md index 4fdebc80..8d109725 100644 --- a/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md @@ -14,8 +14,9 @@ - `isPlatformRecommendRuntimeReadyForEntry(entry, state)`:用标量 ready state 判定当前推荐 runtime 是否已能承接该公开作品。 - `isSamePlatformPublicGalleryEntry(left, right)`:按公开作品身份比较。 - `mergePlatformPublicGalleryEntries(rpgEntries, puzzleEntries)`:统一完成 RPG 与各玩法公开作品去重、覆盖和倒序排序。 +- `buildPlatformPublicGalleryFeeds(input)`:统一构造 `featuredEntries` 与 `latestEntries`,收口各玩法可见性 gate、mapper 矩阵、汪汪声浪 works fallback 和推荐首屏 `slice(0, 6)`。 -入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份、排序规则、推荐 runtime 启动能力矩阵和 ready 判定。ready 判定只接布尔值与拼图 profile id,不把各玩法 run snapshot 类型拖入 Module。 +入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份、排序规则、公开作品流聚合矩阵、推荐 runtime 启动能力矩阵和 ready 判定。ready 判定只接布尔值与拼图 profile id,不把各玩法 run snapshot 类型拖入 Module。 ## 玩法身份规则 @@ -24,6 +25,7 @@ - 没有 `sourceType` 的 RPG 公开作品回退为 `rpg`。 - 最终 key 格式为 `${kind}:${ownerUserId}:${profileId}`。 - 合并时后进入的相同 key 会覆盖先进入的卡片,然后按 `publishedAt ?? updatedAt` 新到旧排序;非法时间按 `0` 处理。 +- 公开作品流聚合时,大鱼吃小鱼、宝贝识物和视觉小说必须受各自可见性 gate 控制;汪汪声浪优先用 gallery entries,gallery 为空时才从 works 中筛 `status === 'published'` 作为 fallback。 ## 推荐 runtime 启动意图 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 57581040..89f42158 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -361,12 +361,7 @@ import { isEdutainmentGalleryEntry, mapBabyObjectMatchDraftToPlatformGalleryCard, mapBarkBattleWorkToPlatformGalleryCard, - mapBigFishWorkToPlatformGalleryCard, - mapJumpHopWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard, - mapSquareHoleWorkToPlatformGalleryCard, - mapVisualNovelWorkToPlatformGalleryCard, - mapWoodenFishWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, resolvePlatformPublicWorkCode, } from '../rpg-entry/rpgEntryWorldPresentation'; @@ -551,11 +546,11 @@ import { resolvePlatformPublicCodeSearchPlan, } from './platformPublicCodeSearchModel'; import { + buildPlatformPublicGalleryFeeds, getPlatformPublicGalleryEntryKey, getPlatformRecommendRuntimeKind, isPlatformRecommendRuntimeReadyForEntry, isSamePlatformPublicGalleryEntry, - mergePlatformPublicGalleryEntries, type RecommendRuntimeKind, resolvePlatformRecommendRuntimeStartIntent, } from './platformPublicGalleryFlow'; @@ -2795,123 +2790,43 @@ export function PlatformEntryFlowShellImpl({ agentResultPreview?.source, ); }, [agentResultPreview]); - const featuredGalleryEntries = useMemo(() => { - const bigFishPublicEntries = isBigFishCreationVisible - ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard) - : []; - const babyObjectMatchPublicEntries = isBabyObjectMatchVisible - ? babyObjectMatchDrafts - .filter((draft) => draft.publicationStatus === 'published') - .map(mapBabyObjectMatchDraftToPlatformGalleryCard) - : []; - const match3dPublicEntries = match3dGalleryEntries.map( - mapMatch3DWorkToPublicWorkDetail, - ); - const puzzlePublicEntries = puzzleGalleryEntries.map( - mapPuzzleWorkToPlatformGalleryCard, - ); - const barkBattlePublicEntries = - barkBattleGalleryEntries.length > 0 - ? barkBattleGalleryEntries.map(mapBarkBattleWorkToPlatformGalleryCard) - : barkBattleWorks - .filter((work) => work.status === 'published') - .map(mapBarkBattleWorkToPlatformGalleryCard); - const squareHolePublicEntries = squareHoleGalleryEntries.map( - mapSquareHoleWorkToPlatformGalleryCard, - ); - const jumpHopPublicEntries = jumpHopGalleryEntries.map( - mapJumpHopWorkToPlatformGalleryCard, - ); - const woodenFishPublicEntries = woodenFishGalleryEntries.map( - mapWoodenFishWorkToPlatformGalleryCard, - ); - const visualNovelPublicEntries = visualNovelGalleryEntries.map( - mapVisualNovelWorkToPlatformGalleryCard, - ); - return mergePlatformPublicGalleryEntries( - platformBootstrap.publishedGalleryEntries, - [ - ...bigFishPublicEntries, - ...match3dPublicEntries, - ...puzzlePublicEntries, - ...barkBattlePublicEntries, - ...squareHolePublicEntries, - ...jumpHopPublicEntries, - ...woodenFishPublicEntries, - ...(isVisualNovelCreationOpen ? visualNovelPublicEntries : []), - ...babyObjectMatchPublicEntries, - ], - ).slice(0, 6); - }, [ - babyObjectMatchDrafts, - isBigFishCreationVisible, - isBabyObjectMatchVisible, - isVisualNovelCreationOpen, - bigFishGalleryEntries, - jumpHopGalleryEntries, - match3dGalleryEntries, - platformBootstrap.publishedGalleryEntries, - puzzleGalleryEntries, - barkBattleGalleryEntries, - barkBattleWorks, - squareHoleGalleryEntries, - visualNovelGalleryEntries, - woodenFishGalleryEntries, - ]); - const latestGalleryEntries = useMemo( + const publicGalleryFeeds = useMemo( () => - mergePlatformPublicGalleryEntries( - platformBootstrap.publishedGalleryEntries, - [ - ...(isBigFishCreationVisible - ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard) - : []), - ...match3dGalleryEntries.map(mapMatch3DWorkToPublicWorkDetail), - ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), - ...barkBattleGalleryEntries.map( - mapBarkBattleWorkToPlatformGalleryCard, - ), - ...jumpHopGalleryEntries.map(mapJumpHopWorkToPlatformGalleryCard), - ...(barkBattleGalleryEntries.length === 0 - ? barkBattleWorks - .filter((work) => work.status === 'published') - .map(mapBarkBattleWorkToPlatformGalleryCard) - : []), - ...woodenFishGalleryEntries.map( - mapWoodenFishWorkToPlatformGalleryCard, - ), - ...squareHoleGalleryEntries.map( - mapSquareHoleWorkToPlatformGalleryCard, - ), - ...(isVisualNovelCreationOpen - ? visualNovelGalleryEntries.map( - mapVisualNovelWorkToPlatformGalleryCard, - ) - : []), - ...(isBabyObjectMatchVisible - ? babyObjectMatchDrafts - .filter((draft) => draft.publicationStatus === 'published') - .map(mapBabyObjectMatchDraftToPlatformGalleryCard) - : []), - ], - ), + buildPlatformPublicGalleryFeeds({ + rpgEntries: platformBootstrap.publishedGalleryEntries, + bigFishEntries: bigFishGalleryEntries, + match3dEntries: match3dGalleryEntries, + puzzleEntries: puzzleGalleryEntries, + barkBattleGalleryEntries, + barkBattleWorks, + jumpHopEntries: jumpHopGalleryEntries, + woodenFishEntries: woodenFishGalleryEntries, + squareHoleEntries: squareHoleGalleryEntries, + visualNovelEntries: visualNovelGalleryEntries, + babyObjectMatchDrafts, + isBigFishCreationVisible, + isBabyObjectMatchVisible, + isVisualNovelCreationOpen, + }), [ babyObjectMatchDrafts, + barkBattleGalleryEntries, + barkBattleWorks, + bigFishGalleryEntries, isBabyObjectMatchVisible, isBigFishCreationVisible, isVisualNovelCreationOpen, - bigFishGalleryEntries, jumpHopGalleryEntries, match3dGalleryEntries, platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries, squareHoleGalleryEntries, visualNovelGalleryEntries, - barkBattleGalleryEntries, - barkBattleWorks, woodenFishGalleryEntries, ], ); + const { featuredEntries: featuredGalleryEntries, latestEntries: latestGalleryEntries } = + publicGalleryFeeds; const recommendRuntimeEntries = useMemo( () => buildPlatformRecommendFeedEntries( diff --git a/src/components/platform-entry/platformPublicGalleryFlow.test.ts b/src/components/platform-entry/platformPublicGalleryFlow.test.ts index 5c53d3e6..c3c1f3bc 100644 --- a/src/components/platform-entry/platformPublicGalleryFlow.test.ts +++ b/src/components/platform-entry/platformPublicGalleryFlow.test.ts @@ -1,6 +1,9 @@ import { expect, test } from 'vitest'; import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; @@ -10,6 +13,7 @@ import { type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; import { + buildPlatformPublicGalleryFeeds, getPlatformPublicGalleryEntryKey, getPlatformPublicGalleryEntryTime, getPlatformRecommendRuntimeKind, @@ -59,6 +63,85 @@ function buildRpgEntry( }; } +function buildBigFishWork( + overrides: Partial = {}, +): BigFishWorkSummary { + return { + workId: 'big-fish-work', + sourceSessionId: 'big-fish-session', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + title: '大鱼吃小鱼', + subtitle: '海湾', + summary: '一路长大。', + coverImageSrc: '/big-fish-cover.png', + status: 'published', + updatedAt: '2026-06-01T02:00:00.000Z', + publishedAt: '2026-06-01T02:00:00.000Z', + publishReady: true, + levelCount: 1, + levelMainImageReadyCount: 1, + levelMotionReadyCount: 1, + backgroundReady: true, + playCount: 1, + ...overrides, + }; +} + +function buildBabyObjectMatchDraft( + overrides: Partial = {}, +): BabyObjectMatchDraft { + const itemAsset = { + itemId: 'item-a', + itemName: '苹果', + imageSrc: '/apple.png', + assetObjectId: null, + generationProvider: 'placeholder' as const, + prompt: '苹果', + }; + return { + draftId: 'baby-draft', + profileId: 'baby-profile', + templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + workTitle: '宝贝识物', + workDescription: '认识水果。', + itemNames: ['苹果', '香蕉'], + itemAssets: [itemAsset, { ...itemAsset, itemId: 'item-b', itemName: '香蕉' }], + visualPackage: null, + themeTags: ['寓教于乐'], + publicationStatus: 'published', + createdAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T03:00:00.000Z', + publishedAt: '2026-06-01T03:00:00.000Z', + ...overrides, + }; +} + +function buildJumpHopEntry( + overrides: Partial = {}, +): JumpHopGalleryCardResponse { + return { + publicWorkCode: 'JH-JUMP', + workId: 'jump-hop-work', + profileId: 'jump-hop-profile', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + workTitle: '跳一跳', + workDescription: '一路向前。', + coverImageSrc: '/jump-hop-cover.png', + themeTags: ['跳一跳'], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + publicationStatus: 'published', + playCount: 1, + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: '2026-06-04T00:00:00.000Z', + generationStatus: 'ready', + ...overrides, + }; +} + function buildTypedEntry( sourceType: PlatformGallerySourceType, overrides: TypedPlatformPublicGalleryCardOverrides = {}, @@ -570,3 +653,144 @@ test('platform public gallery flow merges duplicate identities and sorts newest expect(merged[0]?.worldName).toBe('新版 RPG'); expect(getPlatformPublicGalleryEntryTime(invalidTimeEntry)).toBe(0); }); + +test('platform public gallery flow builds feeds with visibility gates and bark battle fallback', () => { + const hiddenBigFish = buildBigFishWork({ + workId: 'hidden-big-fish', + sourceSessionId: 'hidden-big-fish-session', + }); + const hiddenBabyDraft = buildBabyObjectMatchDraft({ + profileId: 'hidden-baby', + }); + const publishedBarkFallback = buildBarkBattleWork({ + workId: 'fallback-bark', + publishedAt: '2026-06-04T00:00:00.000Z', + updatedAt: '2026-06-04T00:00:00.000Z', + }); + const draftBarkFallback = buildBarkBattleWork({ + workId: 'draft-bark', + status: 'draft', + }); + + const hiddenFeeds = buildPlatformPublicGalleryFeeds({ + rpgEntries: [ + buildRpgEntry({ + profileId: 'rpg-visible', + publishedAt: '2026-06-01T00:00:00.000Z', + }), + ], + bigFishEntries: [hiddenBigFish], + match3dEntries: [], + puzzleEntries: [], + barkBattleGalleryEntries: [], + barkBattleWorks: [draftBarkFallback, publishedBarkFallback], + jumpHopEntries: [], + woodenFishEntries: [], + squareHoleEntries: [], + visualNovelEntries: [], + babyObjectMatchDrafts: [hiddenBabyDraft], + isBigFishCreationVisible: false, + isBabyObjectMatchVisible: false, + isVisualNovelCreationOpen: false, + }); + + expect( + hiddenFeeds.latestEntries.map((entry) => + 'sourceType' in entry ? entry.sourceType : 'rpg', + ), + ).toEqual(['bark-battle', 'rpg']); + expect(hiddenFeeds.latestEntries[0]?.profileId).toBe('fallback-bark'); + + const visibleFeeds = buildPlatformPublicGalleryFeeds({ + rpgEntries: [], + bigFishEntries: [hiddenBigFish], + match3dEntries: [], + puzzleEntries: [], + barkBattleGalleryEntries: [ + buildBarkBattleWork({ + workId: 'gallery-bark', + publishedAt: '2026-06-05T00:00:00.000Z', + updatedAt: '2026-06-05T00:00:00.000Z', + }), + ], + barkBattleWorks: [publishedBarkFallback], + jumpHopEntries: [], + woodenFishEntries: [], + squareHoleEntries: [], + visualNovelEntries: [], + babyObjectMatchDrafts: [hiddenBabyDraft], + isBigFishCreationVisible: true, + isBabyObjectMatchVisible: true, + isVisualNovelCreationOpen: false, + }); + + expect(visibleFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([ + 'gallery-bark', + 'hidden-baby', + 'hidden-big-fish-session', + ]); + expect(visibleFeeds.featuredEntries).toEqual( + visibleFeeds.latestEntries.slice(0, 6), + ); +}); + +test('platform public gallery flow preserves feed tie order and featured slice', () => { + const sameTime = '2026-06-04T00:00:00.000Z'; + const tieFeeds = buildPlatformPublicGalleryFeeds({ + rpgEntries: [], + bigFishEntries: [], + match3dEntries: [], + puzzleEntries: [], + barkBattleGalleryEntries: [], + barkBattleWorks: [ + buildBarkBattleWork({ + workId: 'fallback-bark', + publishedAt: sameTime, + updatedAt: sameTime, + }), + ], + jumpHopEntries: [ + buildJumpHopEntry({ + profileId: 'jump-hop', + publishedAt: sameTime, + updatedAt: sameTime, + }), + ], + woodenFishEntries: [], + squareHoleEntries: [], + visualNovelEntries: [], + babyObjectMatchDrafts: [], + isBigFishCreationVisible: false, + isBabyObjectMatchVisible: false, + isVisualNovelCreationOpen: false, + }); + + expect(tieFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([ + 'jump-hop', + 'fallback-bark', + ]); + + const sliceFeeds = buildPlatformPublicGalleryFeeds({ + rpgEntries: Array.from({ length: 7 }, (_, index) => + buildRpgEntry({ + profileId: `rpg-${index}`, + publishedAt: `2026-06-0${index + 1}T00:00:00.000Z`, + updatedAt: `2026-06-0${index + 1}T00:00:00.000Z`, + }), + ), + bigFishEntries: [], + match3dEntries: [], + puzzleEntries: [], + barkBattleGalleryEntries: [], + barkBattleWorks: [], + jumpHopEntries: [], + woodenFishEntries: [], + squareHoleEntries: [], + visualNovelEntries: [], + babyObjectMatchDrafts: [], + isBigFishCreationVisible: false, + isBabyObjectMatchVisible: false, + isVisualNovelCreationOpen: false, + }); + expect(sliceFeeds.featuredEntries).toHaveLength(6); +}); diff --git a/src/components/platform-entry/platformPublicGalleryFlow.ts b/src/components/platform-entry/platformPublicGalleryFlow.ts index 914d64bb..c1475d5a 100644 --- a/src/components/platform-entry/platformPublicGalleryFlow.ts +++ b/src/components/platform-entry/platformPublicGalleryFlow.ts @@ -1,9 +1,13 @@ import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { isBarkBattleGalleryEntry, isBigFishGalleryEntry, @@ -14,8 +18,17 @@ import { isSquareHoleGalleryEntry, isVisualNovelGalleryEntry, isWoodenFishGalleryEntry, + mapBabyObjectMatchDraftToPlatformGalleryCard, + mapBarkBattleWorkToPlatformGalleryCard, + mapBigFishWorkToPlatformGalleryCard, + mapJumpHopWorkToPlatformGalleryCard, + mapPuzzleWorkToPlatformGalleryCard, + mapSquareHoleWorkToPlatformGalleryCard, + mapVisualNovelWorkToPlatformGalleryCard, + mapWoodenFishWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; +import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile'; import { mapBarkBattlePublicDetailToWorkSummary, mapPublicWorkDetailToBigFishWork, @@ -127,6 +140,28 @@ export type PlatformRecommendRuntimeReadyState = { puzzleRunCurrentLevelProfileId?: string | null; }; +export type PlatformPublicGalleryFeedsInput = { + rpgEntries: readonly CustomWorldGalleryCard[]; + bigFishEntries: readonly BigFishWorkSummary[]; + match3dEntries: readonly Match3DWorkSummary[]; + puzzleEntries: readonly PuzzleWorkSummary[]; + barkBattleGalleryEntries: readonly BarkBattleWorkSummary[]; + barkBattleWorks: readonly BarkBattleWorkSummary[]; + jumpHopEntries: readonly JumpHopGalleryCardResponse[]; + woodenFishEntries: readonly WoodenFishGalleryCardResponse[]; + squareHoleEntries: readonly SquareHoleWorkSummary[]; + visualNovelEntries: readonly VisualNovelWorkSummary[]; + babyObjectMatchDrafts: readonly BabyObjectMatchDraft[]; + isBigFishCreationVisible: boolean; + isBabyObjectMatchVisible: boolean; + isVisualNovelCreationOpen: boolean; +}; + +export type PlatformPublicGalleryFeeds = { + featuredEntries: PlatformPublicGalleryCard[]; + latestEntries: PlatformPublicGalleryCard[]; +}; + export function getPlatformPublicGalleryEntryTime( entry: PlatformPublicGalleryCard, ) { @@ -399,8 +434,8 @@ export function isSamePlatformPublicGalleryEntry( } export function mergePlatformPublicGalleryEntries( - rpgEntries: CustomWorldGalleryCard[], - puzzleEntries: PlatformPublicGalleryCard[], + rpgEntries: readonly CustomWorldGalleryCard[], + puzzleEntries: readonly PlatformPublicGalleryCard[], ) { const entryMap = new Map(); @@ -414,3 +449,58 @@ export function mergePlatformPublicGalleryEntries( getPlatformPublicGalleryEntryTime(left), ); } + +export function buildPlatformPublicGalleryFeeds( + input: PlatformPublicGalleryFeedsInput, +): PlatformPublicGalleryFeeds { + const bigFishEntries = input.isBigFishCreationVisible + ? input.bigFishEntries.map(mapBigFishWorkToPlatformGalleryCard) + : []; + const babyObjectMatchEntries = input.isBabyObjectMatchVisible + ? input.babyObjectMatchDrafts + .filter((draft) => draft.publicationStatus === 'published') + .map(mapBabyObjectMatchDraftToPlatformGalleryCard) + : []; + const barkBattleGalleryEntries = input.barkBattleGalleryEntries.map( + mapBarkBattleWorkToPlatformGalleryCard, + ); + const barkBattleFallbackEntries = + input.barkBattleGalleryEntries.length === 0 + ? input.barkBattleWorks + .filter((work) => work.status === 'published') + .map(mapBarkBattleWorkToPlatformGalleryCard) + : []; + const visualNovelEntries = input.isVisualNovelCreationOpen + ? input.visualNovelEntries.map(mapVisualNovelWorkToPlatformGalleryCard) + : []; + const latestEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [ + ...bigFishEntries, + ...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail), + ...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard), + ...barkBattleGalleryEntries, + ...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard), + ...barkBattleFallbackEntries, + ...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard), + ...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard), + ...visualNovelEntries, + ...babyObjectMatchEntries, + ]); + const featuredEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [ + ...bigFishEntries, + ...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail), + ...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard), + ...(barkBattleGalleryEntries.length > 0 + ? barkBattleGalleryEntries + : barkBattleFallbackEntries), + ...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard), + ...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard), + ...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard), + ...visualNovelEntries, + ...babyObjectMatchEntries, + ]).slice(0, 6); + + return { + featuredEntries, + latestEntries, + }; +} From f9f22e56630108e49c293777dc587a20995cbf4c Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 04:30:36 +0800 Subject: [PATCH 52/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20Bark=20B?= =?UTF-8?q?attle=20work=20cache=20=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...arkBattleWorkCache草稿状态收口计划-2026-06-04.md | 44 ++++++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 4 +- .../PlatformEntryFlowShellImpl.tsx | 115 +++------------- .../barkBattleWorkCache.test.ts | 126 +++++++++++++++++- .../platform-entry/barkBattleWorkCache.ts | 112 +++++++++++++++- 7 files changed, 305 insertions(+), 106 deletions(-) create mode 100644 docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 2a6e4dd8..00b7f77e 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-04 Bark Battle Work Cache 草稿状态收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 字段映射,导致结果页试玩、作品架启动和公开详情启动都要理解同一份资产字段清单。 +- 决策:扩展 `src/components/platform-entry/barkBattleWorkCache.ts`,以 `hasBarkBattleDraftRequiredImages`、`resolveBarkBattleDraftGenerationStatus`、`buildBarkBattlePublishedConfigFromDraft`、`buildBarkBattlePublishedConfigFromWork`、`buildBarkBattlePublishSnapshot` 和 `mergeBarkBattlePublishedConfigAssets` 收口 Bark Battle 纯规则。平台壳只保留 API、缓存刷新、React state、URL 和 stage 副作用。 +- 影响范围:Bark Battle 草稿生成完成、结果页保存、草稿试玩、作品架 / 公开详情启动正式 runtime,以及后续 Bark Battle 资产字段或 ruleset 默认值调整。 +- 验证方式:`npm run test -- src/components/platform-entry/barkBattleWorkCache.test.ts`、针对 Bark Battle Work Cache Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md`。 + ## 2026-06-04 Platform Creation Launch Model 收口 - 背景:平台创作入口点击回调曾在 `PlatformEntryFlowShellImpl.tsx` 内联判断 `airp` 占位、隐藏的 `baby-object-match`、未知入口和各玩法工作台启动目标,壳层同时承接入口 ID 规则、启动前准备顺序和副作用。 diff --git a/docs/README.md b/docs/README.md index 728645d3..cadcc080 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,6 +67,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口个人钱包本地 delta、dashboard 乐观更新与服务端快照对账规则收口到 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,平台壳只保留 API、ref 与 state 副作用,规则见 [【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md)。 +Bark Battle 草稿三图完整性、生成状态归一、发布快照 / 发布回包资产兜底和草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射收口到 `src/components/platform-entry/barkBattleWorkCache.ts`,规则见 [【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md](./technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md)。 + RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md b/docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md new file mode 100644 index 00000000..14936e1e --- /dev/null +++ b/docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md @@ -0,0 +1,44 @@ +# 【前端架构】Bark Battle Work Cache 草稿状态收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射。壳层因此需要同时理解三图资产字段、`partial_failed` 与 `pending_assets` 的差异、`publishedAt` 兜底和草稿试玩配置默认值。 + +这些规则属于 Bark Battle 作品摘要与草稿缓存的纯模型。若留在平台壳层,后续发布、作品架刷新、公开详情启动或草稿试玩都容易重复一份字段清单。 + +## 决策 + +扩展 `src/components/platform-entry/barkBattleWorkCache.ts`,作为 Bark Battle Work Cache **Module** 继续承接作品摘要缓存和草稿 runtime 配置规则。新增公开 **Interface**: + +- `hasBarkBattleDraftRequiredImages(draft)`:判断草稿是否已具备玩家形象、对手形象和竞技背景三图。 +- `resolveBarkBattleDraftGenerationStatus(draft, partialFailed)`:三图齐备返回 `ready`,否则按是否部分失败返回 `partial_failed` 或 `pending_assets`。 +- `buildBarkBattlePublishedConfigFromDraft(draft)`:把草稿结果页试玩所需配置映射为 `BarkBattlePublishedConfig`。 +- `buildBarkBattlePublishedConfigFromWork(work)`:把作品架 / 公开详情启动正式 runtime 所需配置映射为 `BarkBattlePublishedConfig`。 +- `buildBarkBattlePublishSnapshot(draft)`:拼装发布接口所需的最终草稿快照。 +- `mergeBarkBattlePublishedConfigAssets(published, draft)`:发布回包缺少三图字段时沿用结果页草稿图。 + +`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它只负责 API 请求、React state、URL、运行态 stage 切换和错误提示,不再持有 Bark Battle 三图完整性与 runtime config 字段清单。 + +## Interface 约束 + +- 草稿三图必须同时具备 `playerCharacterImageSrc`、`opponentCharacterImageSrc` 和 `uiBackgroundImageSrc` 的非空值,才视为 `ready`。 +- 未齐三图且 `partialFailed=true` 时返回 `partial_failed`,否则返回 `pending_assets`。 +- 草稿试玩配置的 `workId` 优先使用草稿稳定 `workId`,缺失时回退 `draftId`。 +- 草稿试玩配置的 `configVersion` 与 `rulesetVersion` 使用草稿值,缺失时回退 `1` 与 `bark-battle-ruleset-v1`。 +- 已发布作品配置的 `publishedAt` 缺失时回退 `updatedAt`,保持旧 runtime 启动语义。 +- 发布快照只携带草稿已有的三图字段,不凭空补空字符串。 +- 发布接口回包缺少三图字段时,结果页草稿图继续作为 runtime 和作品摘要的兜底。 + +## Depth / Leverage / Locality + +- **Depth**:壳层传入草稿或作品摘要,即可得到生成状态或 runtime 配置;字段归一、默认值和三图完整性藏入 Module Implementation。 +- **Leverage**:结果页试玩、作品架启动、公开详情启动和缓存刷新可复用同一组 Bark Battle 规则。 +- **Locality**:Bark Battle 资产完整性与配置映射集中到纯测试面,后续变更三图字段或规则集默认值时无需搜索巨型平台壳。 + +## 验收 + +- `npm run test -- src/components/platform-entry/barkBattleWorkCache.test.ts` +- `npx eslint --max-warnings 0 src/components/platform-entry/barkBattleWorkCache.ts src/components/platform-entry/barkBattleWorkCache.test.ts` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index a46a9786..1bcbf2b8 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -306,8 +306,8 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 结果页:围绕三图槽位展示错误态与已生成结果,只保留单槽重试、重新生成和上传,不再提供一次生成按钮、音频配置入口或排名配置;生成回写 `partial_failed` 时作品架不再显示整卡“生成中”遮罩,由结果页槽位错误承接失败。 - 手动上传:结果页通过平台资产直传 `/api/assets/direct-upload-tickets` 与 `/api/assets/objects/confirm` 写入私有资产,再把返回的历史 generated 路径写回草稿配置。 - 发布:结果页确认后必须携带草稿返回的同一个 `workId` 和结果页最终 `publishedSnapshot` 调用 `POST /api/creation/bark-battle/works/publish`;SpacetimeDB 发布态的 `config_json` 必须使用该最终快照,works summary 若拿到 `publishedSnapshotJson` 也优先使用最终快照映射封面三图。发布成功后先进入统一作品详情页,再由详情页进入正式 runtime;缺少 `workId` 的旧草稿状态需要重新生成草稿。 -- 作品架:Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。 -- 试玩与正式 runtime:草稿试玩使用 `runtimeMode=draft` 和 mock 输入,不写正式 run;正式 runtime 使用 `runtimeMode=published`,进入运行态后直接申请真实麦克风权限,授权成功后立刻进入倒计时,启动对局时调用 `POST /api/runtime/bark-battle/works/{workId}/runs` 登记 start run,并以返回的 `runtimeConfig` 作为本局前端规则参数;结算时调用 `POST /api/runtime/bark-battle/runs/{runId}/finish` 写入基础统计派生指标;对局会在能量条推到任一侧边界时提前结算并弹出独立结算弹窗,运行态内固定提供返回按钮。 +- 作品架:Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。草稿三图完整性、`pending_assets` / `partial_failed` / `ready` 生成状态归一和作品摘要合并规则统一由 `barkBattleWorkCache.ts` 承接,平台壳只执行读取、刷新与 React state 副作用。 +- 试玩与正式 runtime:草稿试玩使用 `runtimeMode=draft` 和 mock 输入,不写正式 run;正式 runtime 使用 `runtimeMode=published`,进入运行态后直接申请真实麦克风权限,授权成功后立刻进入倒计时,启动对局时调用 `POST /api/runtime/bark-battle/works/{workId}/runs` 登记 start run,并以返回的 `runtimeConfig` 作为本局前端规则参数;结算时调用 `POST /api/runtime/bark-battle/runs/{runId}/finish` 写入基础统计派生指标;对局会在能量条推到任一侧边界时提前结算并弹出独立结算弹窗,运行态内固定提供返回按钮。发布快照拼装、发布回包缺图时沿用草稿图,以及草稿 / 已发布作品进入前端 runtime 前的 `BarkBattlePublishedConfig` 映射也统一由 `barkBattleWorkCache.ts` 提供,缺失 `publishedAt` 时仍按 `updatedAt` 兜底。 支持的创作者可替换内容: diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 89f42158..b9d7807c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -381,10 +381,14 @@ import { } from '../visual-novel-creation/visualNovelEntryGeneration'; import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData'; import { - type BarkBattleGenerationStatus, + buildBarkBattlePublishedConfigFromDraft, + buildBarkBattlePublishedConfigFromWork, + buildBarkBattlePublishSnapshot, buildBarkBattleWorkSummaryFromDraft, + mergeBarkBattlePublishedConfigAssets, mergeBarkBattleWorksByWorkId, mergeBarkBattleWorkSummary, + resolveBarkBattleDraftGenerationStatus, shouldPreserveLocalBarkBattleWorkOnRefresh, } from './barkBattleWorkCache'; import { @@ -712,30 +716,6 @@ const PUZZLE_DRAFT_GENERATION_POINT_COST = 2; const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3; -function mapBarkBattleWorkToPublishedConfig( - work: BarkBattleWorkSummary, -): BarkBattlePublishedConfig { - return { - workId: work.workId, - draftId: work.draftId ?? null, - configVersion: 1, - rulesetVersion: 'bark-battle-ruleset-v1', - playTypeId: 'bark-battle', - title: work.title, - description: work.summary, - themeDescription: work.themeDescription, - playerImageDescription: work.playerImageDescription, - opponentImageDescription: work.opponentImageDescription, - onomatopoeia: work.onomatopoeia, - playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined, - opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined, - uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined, - difficultyPreset: work.difficultyPreset, - updatedAt: work.updatedAt, - publishedAt: work.publishedAt ?? work.updatedAt, - }; -} - function mapVisualNovelWorkDetailToSession( work: VisualNovelWorkDetail, ): VisualNovelAgentSessionSnapshot { @@ -1004,24 +984,6 @@ function mergeBigFishWorkSummary( : current; } -function hasBarkBattleDraftRequiredImages(draft: BarkBattleDraftConfig) { - return Boolean( - draft.playerCharacterImageSrc?.trim() && - draft.opponentCharacterImageSrc?.trim() && - draft.uiBackgroundImageSrc?.trim(), - ); -} - -function resolveBarkBattleDraftGenerationStatus( - draft: BarkBattleDraftConfig, - partialFailed: boolean, -): BarkBattleGenerationStatus { - if (hasBarkBattleDraftRequiredImages(draft)) { - return 'ready'; - } - return partialFailed ? 'partial_failed' : 'pending_assets'; -} - async function resolvePublicWorkAuthorSummary( entry: PlatformPublicGalleryCard, ): Promise { @@ -6190,39 +6152,18 @@ export function PlatformEntryFlowShellImpl({ ], ); - const buildBarkBattleDraftRuntimeConfig = useCallback( - (draft: BarkBattleDraftConfig): BarkBattlePublishedConfig => ({ - workId: draft.workId ?? draft.draftId, - draftId: draft.draftId, - configVersion: draft.configVersion ?? 1, - rulesetVersion: draft.rulesetVersion ?? 'bark-battle-ruleset-v1', - playTypeId: 'bark-battle', - title: draft.title, - description: draft.description, - themeDescription: draft.themeDescription, - playerImageDescription: draft.playerImageDescription, - opponentImageDescription: draft.opponentImageDescription, - onomatopoeia: draft.onomatopoeia, - playerCharacterImageSrc: draft.playerCharacterImageSrc, - opponentCharacterImageSrc: draft.opponentCharacterImageSrc, - uiBackgroundImageSrc: draft.uiBackgroundImageSrc, - difficultyPreset: draft.difficultyPreset, - updatedAt: draft.updatedAt, - publishedAt: draft.updatedAt, - }), - [], - ); - const testBarkBattleDraft = useCallback( (draft: BarkBattleDraftConfig) => { setBarkBattleError(null); setBarkBattleRuntimeMode('draft'); setBarkBattleRuntimeReturnStage('bark-battle-result'); - setBarkBattlePublishedConfig(buildBarkBattleDraftRuntimeConfig(draft)); + setBarkBattlePublishedConfig( + buildBarkBattlePublishedConfigFromDraft(draft), + ); selectionStageRef.current = 'bark-battle-runtime'; setSelectionStage('bark-battle-runtime'); }, - [buildBarkBattleDraftRuntimeConfig, setSelectionStage], + [setSelectionStage], ); const publishBarkBattleDraft = useCallback( @@ -6237,39 +6178,15 @@ export function PlatformEntryFlowShellImpl({ } setIsBarkBattleBusy(true); try { - const publishedSnapshot: BarkBattleConfigEditorPayload = { - title: draft.title, - description: draft.description, - themeDescription: draft.themeDescription, - playerImageDescription: draft.playerImageDescription, - opponentImageDescription: draft.opponentImageDescription, - onomatopoeia: draft.onomatopoeia, - ...(draft.playerCharacterImageSrc - ? { playerCharacterImageSrc: draft.playerCharacterImageSrc } - : {}), - ...(draft.opponentCharacterImageSrc - ? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc } - : {}), - ...(draft.uiBackgroundImageSrc - ? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc } - : {}), - difficultyPreset: draft.difficultyPreset, - }; const published = await publishBarkBattleWork({ draftId: draft.draftId, workId, - publishedSnapshot, + publishedSnapshot: buildBarkBattlePublishSnapshot(draft), }); - const publishedWithAssets: BarkBattlePublishedConfig = { - ...published, - playerCharacterImageSrc: - published.playerCharacterImageSrc ?? draft.playerCharacterImageSrc, - opponentCharacterImageSrc: - published.opponentCharacterImageSrc ?? - draft.opponentCharacterImageSrc, - uiBackgroundImageSrc: - published.uiBackgroundImageSrc ?? draft.uiBackgroundImageSrc, - }; + const publishedWithAssets = mergeBarkBattlePublishedConfigAssets( + published, + draft, + ); const publicWorkCode = buildBarkBattlePublicWorkCode( publishedWithAssets.workId, ); @@ -11597,7 +11514,9 @@ export function PlatformEntryFlowShellImpl({ setBarkBattleError(null); setBarkBattleGenerationPartialFailed(false); setBarkBattleRuntimeMode('published'); - setBarkBattlePublishedConfig(mapBarkBattleWorkToPublishedConfig(item)); + setBarkBattlePublishedConfig( + buildBarkBattlePublishedConfigFromWork(item), + ); setBarkBattleRuntimeReturnStage(returnStage); try { const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions( diff --git a/src/components/platform-entry/barkBattleWorkCache.test.ts b/src/components/platform-entry/barkBattleWorkCache.test.ts index 9d84f972..8115dde9 100644 --- a/src/components/platform-entry/barkBattleWorkCache.test.ts +++ b/src/components/platform-entry/barkBattleWorkCache.test.ts @@ -1,9 +1,18 @@ import { expect, test } from 'vitest'; -import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { + BarkBattleDraftConfig, + BarkBattlePublishedConfig, + BarkBattleWorkSummary, +} from '../../../packages/shared/src/contracts/barkBattle'; import { + buildBarkBattlePublishedConfigFromDraft, + buildBarkBattlePublishedConfigFromWork, + buildBarkBattlePublishSnapshot, + mergeBarkBattlePublishedConfigAssets, mergeBarkBattleWorksByWorkId, mergeBarkBattleWorkSummary, + resolveBarkBattleDraftGenerationStatus, shouldPreserveLocalBarkBattleWorkOnRefresh, } from './barkBattleWorkCache'; @@ -34,6 +43,29 @@ function buildBarkBattleWork( }; } +function buildBarkBattleDraft( + overrides: Partial = {}, +): BarkBattleDraftConfig { + return { + draftId: 'bark-battle-draft-1', + workId: 'BB-cache-race-12345678', + configVersion: 2, + rulesetVersion: 'bark-battle-ruleset-v2', + title: '汪汪测试杯', + description: '测试声浪赛', + themeDescription: '阳光草坪声浪竞技场', + playerImageDescription: '戴红色围巾的柯基选手', + opponentImageDescription: '蓝色护目镜哈士奇对手', + onomatopoeia: ['汪', '破阵'], + playerCharacterImageSrc: '/generated-bark-battle/player.png', + opponentCharacterImageSrc: '/generated-bark-battle/opponent.png', + uiBackgroundImageSrc: '/generated-bark-battle/background.png', + difficultyPreset: 'normal', + updatedAt: '2026-05-21T10:00:00.000Z', + ...overrides, + }; +} + test('preserves local published bark battle when refresh only returns same work draft', () => { const published = buildBarkBattleWork({ status: 'published', @@ -106,3 +138,95 @@ test('preserves local ready bark battle draft when refresh has not returned it y expect(merged[0]?.generationStatus).toBe('ready'); }); +test('resolves bark battle draft generation status from required images', () => { + expect( + resolveBarkBattleDraftGenerationStatus( + buildBarkBattleDraft({ uiBackgroundImageSrc: undefined }), + false, + ), + ).toBe('pending_assets'); + expect( + resolveBarkBattleDraftGenerationStatus( + buildBarkBattleDraft({ opponentCharacterImageSrc: '' }), + true, + ), + ).toBe('partial_failed'); + expect(resolveBarkBattleDraftGenerationStatus(buildBarkBattleDraft(), true)).toBe( + 'ready', + ); +}); + +test('builds draft runtime config with stable defaults', () => { + const config = buildBarkBattlePublishedConfigFromDraft( + buildBarkBattleDraft({ + workId: undefined, + configVersion: undefined, + rulesetVersion: undefined, + }), + ); + + expect(config.workId).toBe('bark-battle-draft-1'); + expect(config.draftId).toBe('bark-battle-draft-1'); + expect(config.configVersion).toBe(1); + expect(config.rulesetVersion).toBe('bark-battle-ruleset-v1'); + expect(config.playTypeId).toBe('bark-battle'); + expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z'); +}); + +test('builds work runtime config with publishedAt fallback', () => { + const config = buildBarkBattlePublishedConfigFromWork( + buildBarkBattleWork({ publishedAt: null }), + ); + + expect(config.workId).toBe('BB-cache-race-12345678'); + expect(config.description).toBe('测试声浪赛'); + expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z'); + expect(config.playerCharacterImageSrc).toBe('/generated-bark-battle/player.png'); +}); + +test('builds publish snapshot without empty asset fields', () => { + const snapshot = buildBarkBattlePublishSnapshot( + buildBarkBattleDraft({ + playerCharacterImageSrc: '', + opponentCharacterImageSrc: undefined, + }), + ); + + expect(snapshot).not.toHaveProperty('playerCharacterImageSrc'); + expect(snapshot).not.toHaveProperty('opponentCharacterImageSrc'); + expect(snapshot.uiBackgroundImageSrc).toBe( + '/generated-bark-battle/background.png', + ); +}); + +test('merges draft assets into published config when publish response omits them', () => { + const draft = buildBarkBattleDraft(); + const published: BarkBattlePublishedConfig = { + workId: 'BB-cache-race-12345678', + draftId: 'bark-battle-draft-1', + configVersion: 2, + rulesetVersion: 'bark-battle-ruleset-v2', + playTypeId: 'bark-battle', + title: '汪汪测试杯', + description: '测试声浪赛', + themeDescription: '阳光草坪声浪竞技场', + playerImageDescription: '戴红色围巾的柯基选手', + opponentImageDescription: '蓝色护目镜哈士奇对手', + onomatopoeia: ['汪', '破阵'], + difficultyPreset: 'normal', + updatedAt: '2026-05-21T10:01:00.000Z', + publishedAt: '2026-05-21T10:01:00.000Z', + }; + + const merged = mergeBarkBattlePublishedConfigAssets(published, draft); + + expect(merged.playerCharacterImageSrc).toBe( + '/generated-bark-battle/player.png', + ); + expect(merged.opponentCharacterImageSrc).toBe( + '/generated-bark-battle/opponent.png', + ); + expect(merged.uiBackgroundImageSrc).toBe( + '/generated-bark-battle/background.png', + ); +}); diff --git a/src/components/platform-entry/barkBattleWorkCache.ts b/src/components/platform-entry/barkBattleWorkCache.ts index c6ae45cf..b56520db 100644 --- a/src/components/platform-entry/barkBattleWorkCache.ts +++ b/src/components/platform-entry/barkBattleWorkCache.ts @@ -1,6 +1,8 @@ import type { + BarkBattleConfigEditorPayload, BarkBattleDraftConfig, BarkBattleGenerationStatus as SharedBarkBattleGenerationStatus, + BarkBattlePublishedConfig, BarkBattleWorkSummary, } from '../../../packages/shared/src/contracts/barkBattle'; @@ -36,6 +38,110 @@ export function hasBarkBattleSummaryRequiredImages(item: BarkBattleWorkSummary) ); } +export function hasBarkBattleDraftRequiredImages(draft: BarkBattleDraftConfig) { + return Boolean( + draft.playerCharacterImageSrc?.trim() && + draft.opponentCharacterImageSrc?.trim() && + draft.uiBackgroundImageSrc?.trim(), + ); +} + +export function resolveBarkBattleDraftGenerationStatus( + draft: BarkBattleDraftConfig, + partialFailed: boolean, +): BarkBattleGenerationStatus { + if (hasBarkBattleDraftRequiredImages(draft)) { + return 'ready'; + } + return partialFailed ? 'partial_failed' : 'pending_assets'; +} + +export function buildBarkBattlePublishedConfigFromDraft( + draft: BarkBattleDraftConfig, +): BarkBattlePublishedConfig { + return { + workId: draft.workId ?? draft.draftId, + draftId: draft.draftId, + configVersion: draft.configVersion ?? 1, + rulesetVersion: draft.rulesetVersion ?? 'bark-battle-ruleset-v1', + playTypeId: 'bark-battle', + title: draft.title, + description: draft.description, + themeDescription: draft.themeDescription, + playerImageDescription: draft.playerImageDescription, + opponentImageDescription: draft.opponentImageDescription, + onomatopoeia: draft.onomatopoeia, + playerCharacterImageSrc: draft.playerCharacterImageSrc, + opponentCharacterImageSrc: draft.opponentCharacterImageSrc, + uiBackgroundImageSrc: draft.uiBackgroundImageSrc, + difficultyPreset: draft.difficultyPreset, + updatedAt: draft.updatedAt, + publishedAt: draft.updatedAt, + }; +} + +export function buildBarkBattlePublishSnapshot( + draft: BarkBattleDraftConfig, +): BarkBattleConfigEditorPayload { + return { + title: draft.title, + description: draft.description, + themeDescription: draft.themeDescription, + playerImageDescription: draft.playerImageDescription, + opponentImageDescription: draft.opponentImageDescription, + onomatopoeia: draft.onomatopoeia, + ...(draft.playerCharacterImageSrc + ? { playerCharacterImageSrc: draft.playerCharacterImageSrc } + : {}), + ...(draft.opponentCharacterImageSrc + ? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc } + : {}), + ...(draft.uiBackgroundImageSrc + ? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc } + : {}), + difficultyPreset: draft.difficultyPreset, + }; +} + +export function mergeBarkBattlePublishedConfigAssets( + published: BarkBattlePublishedConfig, + draft: BarkBattleDraftConfig, +): BarkBattlePublishedConfig { + return { + ...published, + playerCharacterImageSrc: + published.playerCharacterImageSrc ?? draft.playerCharacterImageSrc, + opponentCharacterImageSrc: + published.opponentCharacterImageSrc ?? draft.opponentCharacterImageSrc, + uiBackgroundImageSrc: + published.uiBackgroundImageSrc ?? draft.uiBackgroundImageSrc, + }; +} + +export function buildBarkBattlePublishedConfigFromWork( + work: BarkBattleWorkSummary, +): BarkBattlePublishedConfig { + return { + workId: work.workId, + draftId: work.draftId ?? null, + configVersion: 1, + rulesetVersion: 'bark-battle-ruleset-v1', + playTypeId: 'bark-battle', + title: work.title, + description: work.summary, + themeDescription: work.themeDescription, + playerImageDescription: work.playerImageDescription, + opponentImageDescription: work.opponentImageDescription, + onomatopoeia: work.onomatopoeia, + playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined, + opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined, + uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined, + difficultyPreset: work.difficultyPreset, + updatedAt: work.updatedAt, + publishedAt: work.publishedAt ?? work.updatedAt, + }; +} + export function shouldPreserveLocalBarkBattleWorkOnRefresh( item: BarkBattleWorkSummary, refreshed: readonly BarkBattleWorkSummary[], @@ -85,11 +191,7 @@ export function buildBarkBattleWorkSummaryFromDraft( difficultyPreset: draft.difficultyPreset, status: 'draft', generationStatus, - publishReady: Boolean( - draft.playerCharacterImageSrc?.trim() && - draft.opponentCharacterImageSrc?.trim() && - draft.uiBackgroundImageSrc?.trim(), - ), + publishReady: hasBarkBattleDraftRequiredImages(draft), playCount: 0, updatedAt: draft.updatedAt, publishedAt: null, From 4e23995347f0e49da834591d23932674e27392c4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 04:38:01 +0800 Subject: [PATCH 53/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=20runtime=20=E9=89=B4=E6=9D=83=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...mRecommendRuntimeAuthModel收口计划-2026-06-04.md | 36 +++++++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- .../PlatformEntryFlowShellImpl.tsx | 74 +++++++++----- .../platformRecommendRuntimeAuthModel.test.ts | 96 +++++++++++++++++++ .../platformRecommendRuntimeAuthModel.ts | 58 +++++++++++ 7 files changed, 249 insertions(+), 27 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts create mode 100644 src/components/platform-entry/platformRecommendRuntimeAuthModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 00b7f77e..53fb6e1a 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,6 +24,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/barkBattleWorkCache.test.ts`、针对 Bark Battle Work Cache Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md`。 +## 2026-06-04 Platform Recommend Runtime Auth Model 收口 + +- 背景:平台推荐 runtime 的 embedded 启动需要在匿名 Runtime Guest Token、已登录 background auth 和非 embedded 默认鉴权之间分流,拼图还额外维护 `isolated` / `default` runtime auth mode;旧规则散在顶层 helper 与多个启动 callback。 +- 决策:新增 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,以 `resolvePlatformRecommendRuntimeAuthPlan(input)` 和 `shouldUsePlatformRecommendRuntimeGuestAuth(input)` 收口纯鉴权计划。壳层仍负责读取 `getStoredAccessToken()`、申请 `ensureRuntimeGuestToken()`、拼装 request options 和写入拼图 runtime auth mode。 +- 影响范围:推荐 Tab 内嵌 runtime 启动、拼图公开详情 isolated 入口、推荐运行态后续 action 的局部鉴权口径,以及后续新增可嵌入推荐 runtime 的玩法。 +- 验证方式:`npm run test -- src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts`、针对新 Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md`。 + ## 2026-06-04 Platform Creation Launch Model 收口 - 背景:平台创作入口点击回调曾在 `PlatformEntryFlowShellImpl.tsx` 内联判断 `airp` 占位、隐藏的 `baby-object-match`、未知入口和各玩法工作台启动目标,壳层同时承接入口 ID 规则、启动前准备顺序和副作用。 diff --git a/docs/README.md b/docs/README.md index cadcc080..32dacdc3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -69,6 +69,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ Bark Battle 草稿三图完整性、生成状态归一、发布快照 / 发布回包资产兜底和草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射收口到 `src/components/platform-entry/barkBattleWorkCache.ts`,规则见 [【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md](./technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md)。 +平台首页推荐 runtime 的匿名 Runtime Guest Token、已登录 background auth、非 embedded no-op 和拼图 isolated/default auth mode 计划收口到 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md)。 + RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md new file mode 100644 index 00000000..c2c8e32d --- /dev/null +++ b/docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md @@ -0,0 +1,36 @@ +# 【前端架构】Platform Recommend Runtime Auth Model 收口计划 + +## 背景 + +平台首页推荐流会以 embedded runtime 方式启动跳一跳、抓大鹅、方洞挑战、拼图、敲木鱼、视觉小说、大鱼吃小鱼和汪汪声浪等玩法。旧规则散在 `PlatformEntryFlowShellImpl.tsx` 顶层 helper 与多个启动 callback:匿名访客应申请 Runtime Guest Token,已登录或已有 access token 时应走 background auth,非 embedded 正常启动则不改普通鉴权。拼图还额外维护 `isolated` / `default` runtime auth mode,容易与通用推荐流口径漂移。 + +## 决策 + +新增 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,以纯 **Module** 收口推荐 runtime 鉴权计划: + +- `resolvePlatformRecommendRuntimeAuthPlan(input)`:返回 `requestKind` 为 `none`、`background` 或 `runtime-guest`,并给出拼图 runtime 应落到 `default` 还是 `isolated`。 +- `shouldUsePlatformRecommendRuntimeGuestAuth(input)`:只判断当前用户状态和是否允许 guest auth,不读取本地 token。 + +`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它读取 `getStoredAccessToken()`、调用 `ensureRuntimeGuestToken()`、拼装具体 request options,并在启动拼图时写入 `setPuzzleRuntimeAuthMode(...)`。 + +## Interface 约束 + +- 非 embedded 且未显式允许 runtime guest auth 时,计划为 `none`。 +- embedded 推荐 runtime 若无登录用户且无本地 access token,计划为 `runtime-guest`。 +- embedded 推荐 runtime 若已有登录用户或本地 access token,计划为 `background`。 +- 拼图公开详情要求 `authMode='isolated'` 时,匿名状态应返回 `runtime-guest` 且 `puzzleRuntimeAuthMode='isolated'`。 +- 拼图公开详情要求 `authMode='isolated'` 但已登录或已有 access token 时,应回到 `default`,避免把账号态伪装成匿名 isolated guest。 + +## Depth / Leverage / Locality + +- **Depth**:壳层传入 embedded、是否允许 guest、用户 ID 与本地 token 布尔值,即得 request 计划和拼图 runtime auth mode。 +- **Leverage**:所有推荐 runtime 启动复用同一鉴权矩阵;新增玩法只需消费计划,不再重写匿名 / 已登录分支。 +- **Locality**:guest token 选择规则集中在纯测试面,具体 token 获取和 request options 仍留在壳层副作用 Adapter。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts` +- `npx eslint --max-warnings 0 src/components/platform-entry/platformRecommendRuntimeAuthModel.ts src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 1bcbf2b8..cd1a00cd 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -171,7 +171,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。 -推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。推荐 runtime 的 `none` / `background` / `runtime-guest` 请求计划和拼图 `default` / `isolated` runtime auth mode 由 `platformRecommendRuntimeAuthModel.ts` 统一判定,平台壳只负责读取 token、申请 Runtime Guest Token 和传递 request options。 ## 敲木鱼 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index b9d7807c..23174933 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -585,6 +585,10 @@ import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, } from './platformPuzzleIdentityModel'; +import { + type PlatformPuzzleRuntimeAuthMode, + resolvePlatformRecommendRuntimeAuthPlan, +} from './platformRecommendRuntimeAuthModel'; import { buildPlatformRpgAgentResultPublishGateView, resolvePlatformRpgAgentResultPreviewSourceLabel, @@ -624,7 +628,7 @@ type PuzzleRuntimeReturnStage = | 'puzzle-gallery-detail' | 'work-detail' | 'platform'; -type PuzzleRuntimeAuthMode = 'default' | 'isolated'; +type PuzzleRuntimeAuthMode = PlatformPuzzleRuntimeAuthMode; type PuzzleOnboardingDraft = { promptText: string; @@ -686,6 +690,10 @@ const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = BACKGROUND_AUTH_REQUEST_OPTIONS; const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS = RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS; +type RecommendRuntimeAuthUi = + | { user?: { id?: string } | null } + | null + | undefined; async function buildRecommendRuntimeGuestOptions() { const { token } = await ensureRuntimeGuestToken(); return { @@ -693,24 +701,35 @@ async function buildRecommendRuntimeGuestOptions() { runtimeGuestToken: token, }; } -function shouldUseRecommendRuntimeGuestAuth( - authUi: { user?: { id?: string } | null } | null | undefined, +function resolveCurrentRecommendRuntimeAuthPlan( + authUi: RecommendRuntimeAuthUi, + input: { embedded?: boolean; allowRuntimeGuestAuth?: boolean } = {}, ) { - return !authUi?.user?.id?.trim() && !getStoredAccessToken(); + return resolvePlatformRecommendRuntimeAuthPlan({ + embedded: input.embedded, + allowRuntimeGuestAuth: input.allowRuntimeGuestAuth, + authUserId: authUi?.user?.id ?? null, + hasStoredAccessToken: Boolean(getStoredAccessToken()), + }); } -async function buildRecommendRuntimeAuthOptions( - authUi: { user?: { id?: string } | null } | null | undefined, - embedded?: boolean, +async function buildRecommendRuntimeOptionsFromAuthPlan( + plan: ReturnType, ) { - if (!embedded) { - return {}; - } - - if (shouldUseRecommendRuntimeGuestAuth(authUi)) { + if (plan.requestKind === 'runtime-guest') { return buildRecommendRuntimeGuestOptions(); } - - return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS; + if (plan.requestKind === 'background') { + return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS; + } + return {}; +} +async function buildRecommendRuntimeAuthOptions( + authUi: RecommendRuntimeAuthUi, + embedded?: boolean, +) { + return buildRecommendRuntimeOptionsFromAuthPlan( + resolveCurrentRecommendRuntimeAuthPlan(authUi, { embedded }), + ); } const PUZZLE_DRAFT_GENERATION_POINT_COST = 2; const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; @@ -8106,19 +8125,22 @@ export function PlatformEntryFlowShellImpl({ profileId: item.profileId, levelId: normalizedLevelId || null, }; - const canUseRuntimeGuestAuth = - options.embedded || options.authMode === 'isolated'; - const useRuntimeGuestAuth = - canUseRuntimeGuestAuth && shouldUseRecommendRuntimeGuestAuth(authUi); - const runtimeGuestOptions = useRuntimeGuestAuth - ? await buildRecommendRuntimeGuestOptions() - : {}; - const authMode = useRuntimeGuestAuth ? 'isolated' : 'default'; - const runtimeAuthOptions = useRuntimeGuestAuth - ? runtimeGuestOptions - : canUseRuntimeGuestAuth - ? RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS + const authPlan = resolveCurrentRecommendRuntimeAuthPlan(authUi, { + embedded: options.embedded, + allowRuntimeGuestAuth: + options.embedded || options.authMode === 'isolated', + }); + const runtimeGuestOptions = + authPlan.requestKind === 'runtime-guest' + ? await buildRecommendRuntimeGuestOptions() : {}; + const authMode = authPlan.puzzleRuntimeAuthMode; + const runtimeAuthOptions = + authPlan.requestKind === 'runtime-guest' + ? runtimeGuestOptions + : authPlan.requestKind === 'background' + ? RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS + : {}; const { run } = authMode === 'isolated' ? await startPuzzleRun(startRunPayload, runtimeGuestOptions) diff --git a/src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts b/src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts new file mode 100644 index 00000000..82a05576 --- /dev/null +++ b/src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from 'vitest'; + +import { + resolvePlatformRecommendRuntimeAuthPlan, + shouldUsePlatformRecommendRuntimeGuestAuth, +} from './platformRecommendRuntimeAuthModel'; + +test('uses runtime guest auth for anonymous embedded recommendation runtime', () => { + expect( + resolvePlatformRecommendRuntimeAuthPlan({ + embedded: true, + authUserId: null, + hasStoredAccessToken: false, + }), + ).toEqual({ + requestKind: 'runtime-guest', + puzzleRuntimeAuthMode: 'isolated', + }); +}); + +test('uses background auth for signed-in embedded recommendation runtime', () => { + expect( + resolvePlatformRecommendRuntimeAuthPlan({ + embedded: true, + authUserId: 'user-1', + hasStoredAccessToken: false, + }), + ).toEqual({ + requestKind: 'background', + puzzleRuntimeAuthMode: 'default', + }); +}); + +test('uses background auth when embedded runtime has only a stored access token', () => { + expect( + resolvePlatformRecommendRuntimeAuthPlan({ + embedded: true, + authUserId: null, + hasStoredAccessToken: true, + }), + ).toEqual({ + requestKind: 'background', + puzzleRuntimeAuthMode: 'default', + }); +}); + +test('does not alter auth for non-embedded runtime launches by default', () => { + expect( + resolvePlatformRecommendRuntimeAuthPlan({ + embedded: false, + authUserId: null, + hasStoredAccessToken: false, + }), + ).toEqual({ + requestKind: 'none', + puzzleRuntimeAuthMode: 'default', + }); +}); + +test('uses isolated guest auth for anonymous puzzle isolated launch', () => { + expect( + resolvePlatformRecommendRuntimeAuthPlan({ + embedded: false, + allowRuntimeGuestAuth: true, + authUserId: null, + hasStoredAccessToken: false, + }), + ).toEqual({ + requestKind: 'runtime-guest', + puzzleRuntimeAuthMode: 'isolated', + }); +}); + +test('falls back to default puzzle auth when isolated launch has account auth', () => { + expect( + resolvePlatformRecommendRuntimeAuthPlan({ + embedded: false, + allowRuntimeGuestAuth: true, + authUserId: 'user-1', + hasStoredAccessToken: false, + }), + ).toEqual({ + requestKind: 'none', + puzzleRuntimeAuthMode: 'default', + }); +}); + +test('guest auth decision trims user id before treating account as signed in', () => { + expect( + shouldUsePlatformRecommendRuntimeGuestAuth({ + allowRuntimeGuestAuth: true, + authUserId: ' ', + hasStoredAccessToken: false, + }), + ).toBe(true); +}); diff --git a/src/components/platform-entry/platformRecommendRuntimeAuthModel.ts b/src/components/platform-entry/platformRecommendRuntimeAuthModel.ts new file mode 100644 index 00000000..6db1007a --- /dev/null +++ b/src/components/platform-entry/platformRecommendRuntimeAuthModel.ts @@ -0,0 +1,58 @@ +export type PlatformRecommendRuntimeRequestKind = + | 'none' + | 'background' + | 'runtime-guest'; + +export type PlatformPuzzleRuntimeAuthMode = 'default' | 'isolated'; + +export type PlatformRecommendRuntimeAuthPlan = { + requestKind: PlatformRecommendRuntimeRequestKind; + puzzleRuntimeAuthMode: PlatformPuzzleRuntimeAuthMode; +}; + +export type PlatformRecommendRuntimeAuthInput = { + embedded?: boolean; + allowRuntimeGuestAuth?: boolean; + authUserId?: string | null; + hasStoredAccessToken?: boolean; +}; + +function hasAccountAuth(input: { + authUserId?: string | null; + hasStoredAccessToken?: boolean; +}) { + return Boolean(input.authUserId?.trim() || input.hasStoredAccessToken); +} + +export function shouldUsePlatformRecommendRuntimeGuestAuth( + input: Pick< + PlatformRecommendRuntimeAuthInput, + 'allowRuntimeGuestAuth' | 'authUserId' | 'hasStoredAccessToken' + >, +) { + return Boolean(input.allowRuntimeGuestAuth) && !hasAccountAuth(input); +} + +export function resolvePlatformRecommendRuntimeAuthPlan( + input: PlatformRecommendRuntimeAuthInput, +): PlatformRecommendRuntimeAuthPlan { + const embedded = Boolean(input.embedded); + const allowRuntimeGuestAuth = input.allowRuntimeGuestAuth ?? embedded; + const useRuntimeGuestAuth = shouldUsePlatformRecommendRuntimeGuestAuth({ + allowRuntimeGuestAuth, + authUserId: input.authUserId, + hasStoredAccessToken: input.hasStoredAccessToken, + }); + + if (useRuntimeGuestAuth) { + return { + requestKind: 'runtime-guest', + puzzleRuntimeAuthMode: 'isolated', + }; + } + + return { + requestKind: embedded ? 'background' : 'none', + puzzleRuntimeAuthMode: 'default', + }; +} From 05713e1d3bb9b23f2027ae1ff3596dd14cf0d9e1 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 04:44:22 +0800 Subject: [PATCH 54/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=20runtime=20=E8=87=AA=E5=8A=A8=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...mRecommendRuntimeAutoStart收口计划-2026-06-04.md | 40 +++++++++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- .../PlatformEntryFlowShellImpl.tsx | 56 +++++------- .../platformPublicGalleryFlow.test.ts | 87 +++++++++++++++++++ .../platformPublicGalleryFlow.ts | 50 +++++++++++ 7 files changed, 209 insertions(+), 36 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 53fb6e1a..52dbd032 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -32,6 +32,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts`、针对新 Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md`。 +## 2026-06-04 Platform Recommend Runtime Auto Start 收口 + +- 背景:推荐 runtime 自动启动 effect 同时判断桌面断点、stage、Tab、loading、推荐列表、active entry、ready 状态和启动中状态,导致壳层 effect 依赖过长且混合推荐流状态机知识。 +- 决策:扩展 `src/components/platform-entry/platformPublicGalleryFlow.ts`,新增 `resolvePlatformRecommendRuntimeAutoStartDecision(input)`,只返回 `noop`、`clear` 或 `start(entry)`。平台壳只执行清空 active runtime state 或调用 `selectRecommendRuntimeEntry(entry)`。 +- 影响范围:移动端首页推荐 runtime 自动启动、推荐列表为空时清空状态、active entry ready 判定,以及后续新增推荐 runtime 玩法的启动时机。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、针对 Flow Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md`。 + ## 2026-06-04 Platform Creation Launch Model 收口 - 背景:平台创作入口点击回调曾在 `PlatformEntryFlowShellImpl.tsx` 内联判断 `airp` 占位、隐藏的 `baby-object-match`、未知入口和各玩法工作台启动目标,壳层同时承接入口 ID 规则、启动前准备顺序和副作用。 diff --git a/docs/README.md b/docs/README.md index 32dacdc3..94a90752 100644 --- a/docs/README.md +++ b/docs/README.md @@ -71,6 +71,8 @@ Bark Battle 草稿三图完整性、生成状态归一、发布快照 / 发布 平台首页推荐 runtime 的匿名 Runtime Guest Token、已登录 background auth、非 embedded no-op 和拼图 isolated/default auth mode 计划收口到 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md)。 +平台首页推荐 runtime 自动启动的桌面 / Tab / stage / loading gate、active entry 查找、ready 判定和 clear/start/noop 决策收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md)。 + RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md new file mode 100644 index 00000000..efb106f9 --- /dev/null +++ b/docs/technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md @@ -0,0 +1,40 @@ +# 【前端架构】Platform Recommend Runtime Auto Start 收口计划 + +## 背景 + +平台推荐页的 embedded runtime 会在移动端首页自动选择当前推荐作品并启动对应玩法。旧 `useEffect` 同时判断桌面断点、当前 stage、当前 Tab、平台 loading、推荐列表是否为空、active entry 是否仍存在、对应 runtime 是否 ready、是否已有启动请求,以及下一条 entry 应选谁。 + +这组判断是纯推荐流自动启动决策,但留在 `PlatformEntryFlowShellImpl.tsx` 会让 effect 依赖很长,也让后续新增玩法时容易把 ready 判定和启动时机混在副作用里。 + +## 决策 + +扩展 `src/components/platform-entry/platformPublicGalleryFlow.ts`,新增 `resolvePlatformRecommendRuntimeAutoStartDecision(input)`: + +- `noop`:当前不需要改变推荐 runtime。 +- `clear`:推荐列表为空,壳层应清空 active entry、runtime kind 和错误。 +- `start`:壳层应调用既有 `selectRecommendRuntimeEntry(entry)` 启动指定作品。 + +`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它负责收集 React state、清空 state、调用 `selectRecommendRuntimeEntry(...)` 和执行各玩法 runtime 副作用。 + +## Interface 约束 + +- 桌面端、非 `platform` stage、非 `home` Tab 或平台仍在 loading 时返回 `noop`。 +- 推荐列表为空时返回 `clear`。 +- active entry 存在且对应 runtime 已 ready 时返回 `noop`。 +- 当前已有启动请求时返回 `noop`。 +- active entry 存在但未 ready 时返回 `start(activeEntry)`。 +- active key 缺失或已不在列表中时返回 `start(firstEntry)`。 + +## Depth / Leverage / Locality + +- **Depth**:壳层只消费三态决策;列表查找、ready 判定和自动启动门禁藏入 Flow Module Implementation。 +- **Leverage**:后续推荐流新增玩法或改 ready 判定,只需补 `platformPublicGalleryFlow.ts` 的模型测试。 +- **Locality**:effect 只保留副作用动作,不再承载推荐流状态机知识。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts` +- `npx eslint --max-warnings 0 src/components/platform-entry/platformPublicGalleryFlow.ts src/components/platform-entry/platformPublicGalleryFlow.test.ts` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index cd1a00cd..a7d03114 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -171,7 +171,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。 -推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。推荐 runtime 的 `none` / `background` / `runtime-guest` 请求计划和拼图 `default` / `isolated` runtime auth mode 由 `platformRecommendRuntimeAuthModel.ts` 统一判定,平台壳只负责读取 token、申请 Runtime Guest Token 和传递 request options。 +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。推荐 runtime 的 `none` / `background` / `runtime-guest` 请求计划和拼图 `default` / `isolated` runtime auth mode 由 `platformRecommendRuntimeAuthModel.ts` 统一判定,平台壳只负责读取 token、申请 Runtime Guest Token 和传递 request options。推荐 runtime 自动启动只由 `platformPublicGalleryFlow.ts` 输出 `noop` / `clear` / `start(entry)` 决策,平台壳只执行清空 state 或启动指定作品。 ## 敲木鱼 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 23174933..c55f45cd 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -553,9 +553,9 @@ import { buildPlatformPublicGalleryFeeds, getPlatformPublicGalleryEntryKey, getPlatformRecommendRuntimeKind, - isPlatformRecommendRuntimeReadyForEntry, isSamePlatformPublicGalleryEntry, type RecommendRuntimeKind, + resolvePlatformRecommendRuntimeAutoStartDecision, resolvePlatformRecommendRuntimeStartIntent, } from './platformPublicGalleryFlow'; import { @@ -12309,31 +12309,15 @@ export function PlatformEntryFlowShellImpl({ ]); useEffect(() => { - if ( - isDesktopLayout || - selectionStage !== 'platform' || - platformBootstrap.platformTab !== 'home' || - platformBootstrap.isLoadingPlatform - ) { - return; - } - - if (recommendRuntimeEntries.length === 0) { - setActiveRecommendEntryKey(null); - setActiveRecommendRuntimeKind(null); - setActiveRecommendRuntimeError(null); - return; - } - - const activeRecommendEntry = activeRecommendEntryKey - ? (recommendRuntimeEntries.find( - (entry) => - getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey, - ) ?? null) - : null; - const isActiveRecommendRuntimeReady = - activeRecommendEntry !== null && - isPlatformRecommendRuntimeReadyForEntry(activeRecommendEntry, { + const decision = resolvePlatformRecommendRuntimeAutoStartDecision({ + isDesktopLayout, + selectionStage, + platformTab: platformBootstrap.platformTab, + isLoadingPlatform: platformBootstrap.isLoadingPlatform, + entries: recommendRuntimeEntries, + activeEntryKey: activeRecommendEntryKey, + isStarting: isStartingRecommendEntry, + readyState: { activeKind: activeRecommendRuntimeKind, hasBabyObjectMatchDraft: Boolean(babyObjectMatchDraft), hasBigFishRun: Boolean(bigFishRun), @@ -12345,19 +12329,21 @@ export function PlatformEntryFlowShellImpl({ puzzleRunEntryProfileId: puzzleRun?.entryProfileId ?? null, puzzleRunCurrentLevelProfileId: puzzleRun?.currentLevel?.profileId ?? null, - }); - if ( - (activeRecommendEntry !== null && isActiveRecommendRuntimeReady) || - isStartingRecommendEntry - ) { + }, + }); + + if (decision.type === 'noop') { return; } - const nextRecommendEntry = - activeRecommendEntry ?? recommendRuntimeEntries[0]; - if (nextRecommendEntry) { - void selectRecommendRuntimeEntry(nextRecommendEntry); + if (decision.type === 'clear') { + setActiveRecommendEntryKey(null); + setActiveRecommendRuntimeKind(null); + setActiveRecommendRuntimeError(null); + return; } + + void selectRecommendRuntimeEntry(decision.entry); }, [ activeRecommendEntryKey, activeRecommendRuntimeKind, diff --git a/src/components/platform-entry/platformPublicGalleryFlow.test.ts b/src/components/platform-entry/platformPublicGalleryFlow.test.ts index c3c1f3bc..c5df79fc 100644 --- a/src/components/platform-entry/platformPublicGalleryFlow.test.ts +++ b/src/components/platform-entry/platformPublicGalleryFlow.test.ts @@ -22,6 +22,7 @@ import { mergePlatformPublicGalleryEntries, type PlatformRecommendRuntimeStartIntentDeps, type RecommendRuntimeKind, + resolvePlatformRecommendRuntimeAutoStartDecision, resolvePlatformRecommendRuntimeStartIntent, } from './platformPublicGalleryFlow'; import { @@ -611,6 +612,92 @@ test('platform public gallery flow resolves puzzle and edutainment readiness det ).toBe(false); }); +test('platform public gallery flow resolves recommend runtime auto-start gates', () => { + const entry = buildTypedEntry('big-fish'); + const baseInput: Parameters< + typeof resolvePlatformRecommendRuntimeAutoStartDecision + >[0] = { + isDesktopLayout: false, + selectionStage: 'platform', + platformTab: 'home', + isLoadingPlatform: false, + entries: [entry], + activeEntryKey: null, + isStarting: false, + readyState: { activeKind: null }, + }; + + expect( + resolvePlatformRecommendRuntimeAutoStartDecision({ + ...baseInput, + isDesktopLayout: true, + }), + ).toEqual({ type: 'noop' }); + expect( + resolvePlatformRecommendRuntimeAutoStartDecision({ + ...baseInput, + platformTab: 'discover', + }), + ).toEqual({ type: 'noop' }); + expect( + resolvePlatformRecommendRuntimeAutoStartDecision({ + ...baseInput, + entries: [], + }), + ).toEqual({ type: 'clear' }); + expect( + resolvePlatformRecommendRuntimeAutoStartDecision({ + ...baseInput, + isStarting: true, + }), + ).toEqual({ type: 'noop' }); +}); + +test('platform public gallery flow resolves recommend runtime auto-start target', () => { + const firstEntry = buildTypedEntry('big-fish', { + profileId: 'big-fish-first', + }); + const activeEntry = buildTypedEntry('puzzle', { + profileId: 'puzzle-active', + }); + const activeEntryKey = getPlatformPublicGalleryEntryKey(activeEntry); + const baseInput: Parameters< + typeof resolvePlatformRecommendRuntimeAutoStartDecision + >[0] = { + isDesktopLayout: false, + selectionStage: 'platform', + platformTab: 'home', + isLoadingPlatform: false, + entries: [firstEntry, activeEntry], + activeEntryKey, + isStarting: false, + readyState: { activeKind: 'puzzle' }, + }; + + expect( + resolvePlatformRecommendRuntimeAutoStartDecision({ + ...baseInput, + readyState: { + activeKind: 'puzzle', + puzzleRunEntryProfileId: 'puzzle-active', + }, + }), + ).toEqual({ type: 'noop' }); + expect(resolvePlatformRecommendRuntimeAutoStartDecision(baseInput)).toEqual({ + type: 'start', + entry: activeEntry, + }); + expect( + resolvePlatformRecommendRuntimeAutoStartDecision({ + ...baseInput, + activeEntryKey: 'missing-entry', + }), + ).toEqual({ + type: 'start', + entry: firstEntry, + }); +}); + test('platform public gallery flow merges duplicate identities and sorts newest first', () => { const staleRpgEntry = buildRpgEntry({ profileId: 'shared-rpg', diff --git a/src/components/platform-entry/platformPublicGalleryFlow.ts b/src/components/platform-entry/platformPublicGalleryFlow.ts index c1475d5a..b4cdc1df 100644 --- a/src/components/platform-entry/platformPublicGalleryFlow.ts +++ b/src/components/platform-entry/platformPublicGalleryFlow.ts @@ -140,6 +140,22 @@ export type PlatformRecommendRuntimeReadyState = { puzzleRunCurrentLevelProfileId?: string | null; }; +export type PlatformRecommendRuntimeAutoStartDecision = + | { type: 'noop' } + | { type: 'clear' } + | { type: 'start'; entry: PlatformPublicGalleryCard }; + +export type PlatformRecommendRuntimeAutoStartInput = { + isDesktopLayout: boolean; + selectionStage: string; + platformTab: string; + isLoadingPlatform: boolean; + entries: readonly PlatformPublicGalleryCard[]; + activeEntryKey: string | null; + isStarting: boolean; + readyState: PlatformRecommendRuntimeReadyState; +}; + export type PlatformPublicGalleryFeedsInput = { rpgEntries: readonly CustomWorldGalleryCard[]; bigFishEntries: readonly BigFishWorkSummary[]; @@ -423,6 +439,40 @@ export function isPlatformRecommendRuntimeReadyForEntry( return true; } +export function resolvePlatformRecommendRuntimeAutoStartDecision( + input: PlatformRecommendRuntimeAutoStartInput, +): PlatformRecommendRuntimeAutoStartDecision { + if ( + input.isDesktopLayout || + input.selectionStage !== 'platform' || + input.platformTab !== 'home' || + input.isLoadingPlatform + ) { + return { type: 'noop' }; + } + + if (input.entries.length === 0) { + return { type: 'clear' }; + } + + const activeEntry = input.activeEntryKey + ? (input.entries.find( + (entry) => + getPlatformPublicGalleryEntryKey(entry) === input.activeEntryKey, + ) ?? null) + : null; + const isActiveRuntimeReady = + activeEntry !== null && + isPlatformRecommendRuntimeReadyForEntry(activeEntry, input.readyState); + + if ((activeEntry !== null && isActiveRuntimeReady) || input.isStarting) { + return { type: 'noop' }; + } + + const nextEntry = activeEntry ?? input.entries[0]; + return nextEntry ? { type: 'start', entry: nextEntry } : { type: 'clear' }; +} + export function isSamePlatformPublicGalleryEntry( left: PlatformPublicGalleryCard, right: PlatformPublicGalleryCard, From df5e20d550e090f9d86ee4fc5ee6b2b219c420a9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 04:56:21 +0800 Subject: [PATCH 55/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20Match3D?= =?UTF-8?q?=20=E7=94=9F=E6=88=90=E8=B5=84=E4=BA=A7=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 1 + docs/README.md | 2 +- ...eDraftGenerationStateModel收口计划-2026-06-04.md | 10 ++- .../PlatformEntryFlowShellImpl.tsx | 33 +------- ...mMiniGameDraftGenerationStateModel.test.ts | 81 +++++++++++++++++++ ...atformMiniGameDraftGenerationStateModel.ts | 30 +++++++ 6 files changed, 122 insertions(+), 35 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 52dbd032..c4e689ac 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1423,6 +1423,7 @@ - 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护小游戏生成状态恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定,壳层同时承担 API / background task 副作用和 `MiniGameDraftGenerationState` 生命周期细节。 - 决策:新增 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,收口恢复态、失败态、完成态、展示 rebase、拼图 progress phase 阈值和进度 metadata 合并。壳层继续负责 API、后台任务、React state 写入、作品架刷新、URL 和 stage 切换。 +- 追加决策:抓大鹅轮询作品素材时的旁路进度合并也归入该 Module,由 `mergeMatch3DGeneratedAssetsIntoGenerationState(state, assets)` 统一统计可用图片素材、至少 5 个总素材计数、`match3d-generate-views` phase 推进和首个素材错误传播;壳层只负责轮询 session / work detail 与写入 state。 - 影响范围:拼图 / 抓大鹅 / 大鱼吃小鱼 / 方洞 / 跳一跳 / 敲木鱼 / 宝贝识物生成状态恢复、完成失败收尾、生成页返回展示和拼图轮询进度合并。 - 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/README.md b/docs/README.md index 94a90752..57dac10e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,7 +57,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台壳的拼图 runtime 恢复 work、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 -平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 +平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并、抓大鹅生成资产旁路进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图编译 action、pending metadata 与拼图 form-only 草稿判定收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 diff --git a/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md index bbfb5ed8..1b6e6cc7 100644 --- a/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md @@ -2,7 +2,7 @@ ## 背景 -`PlatformEntryFlowShellImpl.tsx` 曾内联维护小游戏生成状态的恢复、失败/完成收尾、展示 rebase、拼图后端进度合并和生成中 / ready 判定。壳层因此既要处理 API 回包、React state、后台任务、URL 和 stage,又要记住 `MiniGameDraftGenerationState` 的生命周期细节。 +`PlatformEntryFlowShellImpl.tsx` 曾内联维护小游戏生成状态的恢复、失败/完成收尾、展示 rebase、拼图后端进度合并、抓大鹅生成资产旁路进度合并和生成中 / ready 判定。壳层因此既要处理 API 回包、React state、后台任务、URL 和 stage,又要记住 `MiniGameDraftGenerationState` 的生命周期细节。 这些状态变换不读取 DOM,不请求网络,也不写 React state;它们属于平台层小游戏草稿生成状态 **Module**。壳层只应决定何时调用、把返回值写入对应 state。 @@ -14,6 +14,7 @@ - `createFailedMiniGameDraftGenerationStateForRestoredDraft(kind, updatedAt, error, metadata?)`:恢复失败草稿时按后端 `updatedAt` 建立失败态。 - `rebaseMiniGameDraftGenerationStateForDisplay(state)` 与 `rebaseMiniGameDraftBackgroundCompileTaskForDisplay(task)`:清理展示用 `finishedAtMs`,避免返回生成页后沿用结束态计时。 - `createPuzzleDraftGenerationStateFromPayload(payload, session?)`、`resolvePuzzlePhaseFromSessionProgress(state, session)`、`mergePuzzleSessionProgressIntoGenerationState(state, session)`:集中处理拼图生成的 aiRedraw、后端进度百分比和 phase 推进。 +- `mergeMatch3DGeneratedAssetsIntoGenerationState(state, assets)`:抓大鹅轮询到作品素材后,按可用图片数量推进生成页资产计数,并把首个素材错误传播到生成态。 - `resolveFinishedMiniGameDraftGenerationState(state, phase, options?)`:统一完成 / 失败收尾的 `finishedAtMs`、错误与资产计数合并。 - `isMiniGameDraftReady(state)` 与 `isMiniGameDraftGenerating(state)`:统一生成态轻量判定。 @@ -27,12 +28,15 @@ - 拼图 session 只有在 `draft` 存在且不是 `formDraft` 时才视为后端编译生成中 session,才写入 `puzzleProgressPercent` 并推进 phase。 - 拼图进度阈值保持旧值:`>=96` 到 `puzzle-select-image`,`>=94` 到 `puzzle-ui-assets`,`>=88` 时按 `puzzleAiRedraw=false` 进入 `puzzle-level-scene`,否则进入 `puzzle-cover-image`。 - phase 变化时 `puzzleActiveStepStartedAtMs` 使用 session `updatedAt` 解析值;phase 不变时保留旧值。 +- 抓大鹅资产旁路进度不得覆盖 `ready` 或 `failed` 终态;非终态下只统计有 `imageViews[].imageObjectKey` / `imageViews[].imageSrc`、顶层 `imageObjectKey` 或顶层 `imageSrc` 的素材。 +- 抓大鹅资产旁路进度的 `totalAssetCount` 至少为 `5`,保留当前五物品首批生成节奏;已有素材数量超过 `5` 时按真实素材数量展示。 +- 抓大鹅已有可用素材时 phase 推进到 `match3d-generate-views`;无可用素材时保留原 phase;首个素材错误写入 `error`,无素材错误时保留原错误。 - 展示 rebase 只清理 `finishedAtMs`,不得修改 phase、error、资产计数或 metadata。 ## Depth / Leverage / Locality -- **Depth**:壳层以状态变换函数表达意图;生成态字段、拼图阈值、时间解析与计数合并藏入 Module Implementation。 -- **Leverage**:后续新增小游戏生成恢复或调整拼图后端进度阈值时,先改 Module 与单测,再让壳层 Adapter 保持调用点不变。 +- **Depth**:壳层以状态变换函数表达意图;生成态字段、拼图阈值、抓大鹅素材计数、时间解析与计数合并藏入 Module Implementation。 +- **Leverage**:后续新增小游戏生成恢复、调整拼图后端进度阈值或改变抓大鹅素材批次展示时,先改 Module 与单测,再让壳层 Adapter 保持调用点不变。 - **Locality**:小游戏生成状态规则集中到一个纯测试面,避免在大型壳层的 API callback、background task 和恢复流程中重复推理 `MiniGameDraftGenerationState`。 ## 验收 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index c55f45cd..6de3218b 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -49,7 +49,6 @@ import type { } from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { - Match3DGeneratedItemAsset, Match3DWorkProfile, Match3DWorkSummary, } from '../../../packages/shared/src/contracts/match3dWorks'; @@ -516,6 +515,7 @@ import { createPuzzleDraftGenerationStateFromPayload, isMiniGameDraftGenerating, isMiniGameDraftReady, + mergeMatch3DGeneratedAssetsIntoGenerationState, mergePuzzleSessionProgressIntoGenerationState, rebaseMiniGameDraftBackgroundCompileTaskForDisplay, rebaseMiniGameDraftGenerationStateForDisplay, @@ -751,35 +751,6 @@ function mapVisualNovelWorkDetailToSession( }; } -function resolveMatch3DGenerationStateFromAssets( - current: MiniGameDraftGenerationState | null, - assets: readonly Match3DGeneratedItemAsset[] | null | undefined, -): MiniGameDraftGenerationState | null { - if (!current || current.phase === 'ready' || current.phase === 'failed') { - return current; - } - - const assetList = assets ?? []; - const imageReadyCount = assetList.filter( - (asset) => - asset.imageViews?.some( - (view) => view.imageObjectKey?.trim() || view.imageSrc?.trim(), - ) || - asset.imageObjectKey?.trim() || - asset.imageSrc?.trim(), - ).length; - const totalAssetCount = Math.max(5, assetList.length); - const failedAsset = assetList.find((asset) => asset.error?.trim()); - - return { - ...current, - phase: imageReadyCount > 0 ? 'match3d-generate-views' : current.phase, - completedAssetCount: imageReadyCount, - totalAssetCount, - error: failedAsset?.error?.trim() || current.error, - }; -} - function buildSquareHoleProfileFromSession( session: SquareHoleSessionSnapshot | null, ): SquareHoleWorkProfile | null { @@ -4807,7 +4778,7 @@ export function PlatformEntryFlowShellImpl({ const normalizedItem = normalizeMatch3DWorkForRuntimeUi(item); setMatch3DProfile(normalizedItem); setMatch3DGenerationState((current) => - resolveMatch3DGenerationStateFromAssets( + mergeMatch3DGeneratedAssetsIntoGenerationState( current, normalizedItem.generatedItemAssets, ), diff --git a/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts index df06fd1c..d398ee27 100644 --- a/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts +++ b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleAnchorPack } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { CreatePuzzleAgentSessionRequest, @@ -12,6 +13,7 @@ import { createPuzzleDraftGenerationStateFromPayload, isMiniGameDraftGenerating, isMiniGameDraftReady, + mergeMatch3DGeneratedAssetsIntoGenerationState, mergePuzzleSessionProgressIntoGenerationState, rebaseMiniGameDraftBackgroundCompileTaskForDisplay, rebaseMiniGameDraftGenerationStateForDisplay, @@ -96,6 +98,17 @@ function buildState( }; } +function buildMatch3DAsset( + overrides: Partial = {}, +): Match3DGeneratedItemAsset { + return { + itemId: 'item-1', + itemName: '红宝石', + status: 'pending', + ...overrides, + }; +} + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); @@ -277,6 +290,74 @@ describe('platformMiniGameDraftGenerationStateModel', () => { }); }); + test('merges match3d generated assets into active generation state', () => { + const state = buildState({ + kind: 'match3d', + phase: 'match3d-material-sheet', + completedAssetCount: 0, + totalAssetCount: 0, + error: '旧错误', + }); + + expect( + mergeMatch3DGeneratedAssetsIntoGenerationState(state, [ + buildMatch3DAsset({ + itemId: 'item-with-view', + imageViews: [ + { + viewId: 'front', + viewIndex: 0, + imageObjectKey: 'objects/front.png', + }, + ], + }), + buildMatch3DAsset({ + itemId: 'item-with-src', + imageSrc: '/generated/item.png', + }), + buildMatch3DAsset({ + itemId: 'item-with-error', + error: '切图失败', + }), + ]), + ).toMatchObject({ + phase: 'match3d-generate-views', + completedAssetCount: 2, + totalAssetCount: 5, + error: '切图失败', + }); + }); + + test('keeps match3d generated asset merge away from finished states', () => { + const readyState = buildState({ + kind: 'match3d', + phase: 'ready', + completedAssetCount: 5, + totalAssetCount: 5, + }); + const failedState = buildState({ + kind: 'match3d', + phase: 'failed', + error: '已失败', + }); + + expect( + mergeMatch3DGeneratedAssetsIntoGenerationState(readyState, [ + buildMatch3DAsset({ imageSrc: '/generated/new.png' }), + ]), + ).toBe(readyState); + expect( + mergeMatch3DGeneratedAssetsIntoGenerationState(failedState, [ + buildMatch3DAsset({ imageSrc: '/generated/new.png' }), + ]), + ).toBe(failedState); + expect( + mergeMatch3DGeneratedAssetsIntoGenerationState(null, [ + buildMatch3DAsset({ imageSrc: '/generated/new.png' }), + ]), + ).toBeNull(); + }); + test('finishes generation state and resolves ready/generating flags', () => { const failedState = resolveFinishedMiniGameDraftGenerationState( buildState({ error: '旧错误' }), diff --git a/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts index 00ee2e39..2dc18268 100644 --- a/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts +++ b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts @@ -1,3 +1,4 @@ +import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks'; import type { CreatePuzzleAgentSessionRequest, PuzzleAgentSessionSnapshot, @@ -134,6 +135,35 @@ export function mergePuzzleSessionProgressIntoGenerationState( }; } +export function mergeMatch3DGeneratedAssetsIntoGenerationState( + current: MiniGameDraftGenerationState | null, + assets: readonly Match3DGeneratedItemAsset[] | null | undefined, +): MiniGameDraftGenerationState | null { + if (!current || current.phase === 'ready' || current.phase === 'failed') { + return current; + } + + const assetList = assets ?? []; + const imageReadyCount = assetList.filter( + (asset) => + asset.imageViews?.some( + (view) => view.imageObjectKey?.trim() || view.imageSrc?.trim(), + ) || + asset.imageObjectKey?.trim() || + asset.imageSrc?.trim(), + ).length; + const totalAssetCount = Math.max(5, assetList.length); + const failedAsset = assetList.find((asset) => asset.error?.trim()); + + return { + ...current, + phase: imageReadyCount > 0 ? 'match3d-generate-views' : current.phase, + completedAssetCount: imageReadyCount, + totalAssetCount, + error: failedAsset?.error?.trim() || current.error, + }; +} + export function resolveFinishedMiniGameDraftGenerationState( state: MiniGameDraftGenerationState, phase: 'ready' | 'failed', From 0dc326b79ea4446927b18edb04e0efee291a780a Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 05:03:15 +0800 Subject: [PATCH 56/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=96=B9?= =?UTF-8?q?=E6=B4=9E=20session=20profile=20=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 6 +- docs/README.md | 2 +- ...iniGameSessionMappingModel收口计划-2026-06-04.md | 9 +- .../PlatformEntryFlowShellImpl.tsx | 35 +---- ...latformMiniGameSessionMappingModel.test.ts | 143 ++++++++++++++++++ .../platformMiniGameSessionMappingModel.ts | 36 +++++ 6 files changed, 190 insertions(+), 41 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index c4e689ac..80ee8ae5 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1405,9 +1405,9 @@ ## 2026-06-04 Platform Mini Game Session Mapping Model 收口 -- 背景:`PlatformEntryFlowShellImpl.tsx` 顶部仍保留拼图 runtime 恢复、跳一跳 pending session、敲木鱼 detail 恢复和敲木鱼 pending session 四段纯 DTO 映射,壳层需要理解 sessionId 优先级、拼图稳定 ID 和 pending draft 默认值。 -- 决策:新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,收口 `buildPuzzleRuntimeWorkFromSession`、`buildJumpHopPendingSession`、`buildWoodenFishSessionFromWorkDetail` 与 `buildWoodenFishPendingSession`。Module 复用 `normalizeCreationUrlValue` 与 `platformPuzzleIdentityModel`;壳层只保留网络读取、React state、URL 写入和 stage 切换副作用。 -- 影响范围:拼图 runtime URL 恢复、跳一跳生成中作品架打开、敲木鱼生成中作品架打开和敲木鱼草稿 detail 恢复。 +- 背景:`PlatformEntryFlowShellImpl.tsx` 顶部仍保留拼图 runtime 恢复、方洞 session draft 转 profile、跳一跳 pending session、敲木鱼 detail 恢复和敲木鱼 pending session 等纯 DTO 映射,壳层需要理解 sessionId 优先级、拼图稳定 ID、方洞草稿 profile 默认值和 pending draft 默认值。 +- 决策:新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,收口 `buildPuzzleRuntimeWorkFromSession`、`buildSquareHoleProfileFromSession`、`buildJumpHopPendingSession`、`buildWoodenFishSessionFromWorkDetail` 与 `buildWoodenFishPendingSession`。Module 复用 `normalizeCreationUrlValue` 与 `platformPuzzleIdentityModel`;壳层只保留网络读取、React state、URL 写入和 stage 切换副作用。 +- 影响范围:拼图 runtime URL 恢复、方洞挑战草稿 profile 构造、跳一跳生成中作品架打开、敲木鱼生成中作品架打开和敲木鱼草稿 detail 恢复。 - 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/README.md b/docs/README.md index 57dac10e..b4bfa104 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,7 +55,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口生成页进度 tick 的 stage 到生成状态映射、终态判定和视觉小说轻量生成特例收口到 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,壳层只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,规则见 [【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md)。 -平台壳的拼图 runtime 恢复 work、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 +平台壳的拼图 runtime 恢复 work、方洞 session draft 转 profile、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并、抓大鹅生成资产旁路进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 diff --git a/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md index c6a88d34..38ed5064 100644 --- a/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md @@ -2,7 +2,7 @@ ## 背景 -`PlatformEntryFlowShellImpl.tsx` 顶部曾保留拼图 runtime 恢复、跳一跳 pending session、敲木鱼 work detail 恢复和敲木鱼 pending session 四段纯 DTO 映射。它们没有 React state、网络请求、路由、弹窗或计时副作用,却住在大型平台壳内;新增或修正生成中草稿恢复时,需要在壳层里理解 sessionId 优先级、拼图稳定 ID、pending draft 默认值和木鱼 fallback 规则。 +`PlatformEntryFlowShellImpl.tsx` 顶部曾保留拼图 runtime 恢复、方洞挑战 session draft 转 profile、跳一跳 pending session、敲木鱼 work detail 恢复和敲木鱼 pending session 多段纯 DTO 映射。它们没有 React state、网络请求、路由、弹窗或计时副作用,却住在大型平台壳内;新增或修正生成中草稿恢复时,需要在壳层里理解 sessionId 优先级、拼图稳定 ID、方洞 profile 默认值、pending draft 默认值和木鱼 fallback 规则。 这些规则属于平台壳 session / work 恢复映射,应成为可测试的 **Module**。壳层只负责调用网络、写 React state、写 URL 和切换 stage。 @@ -11,6 +11,7 @@ 新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts` 作为 Platform Mini Game Session Mapping **Module**。其公开 **Interface** 为: - `buildPuzzleRuntimeWorkFromSession(session, owner)`:从拼图 Agent session 构造可进入 runtime 的 draft `PuzzleWorkSummary`,缺草稿、缺 profile 或缺封面时返回 `null`。 +- `buildSquareHoleProfileFromSession(session)`:从方洞挑战 Agent session draft 构造草稿 `SquareHoleWorkProfile`,缺 session、缺 draft 或缺 profileId 时返回 `null`。 - `buildJumpHopPendingSession(item)`:从跳一跳作品架 summary 构造生成中 pending session。 - `buildWoodenFishSessionFromWorkDetail(work, fallbackItem?)`:从敲木鱼 work detail 恢复 session,并按 summary / fallback / profileId 决定 sessionId。 - `buildWoodenFishPendingSession(item)`:从敲木鱼作品架 summary 构造生成中 pending session。 @@ -22,15 +23,17 @@ - 拼图 runtime work 必须保留 `draft.coverImageSrc` 非空门槛,避免启动缺封面的草稿运行态。 - 拼图 profileId 优先 `publishedProfileId`,否则用 `buildPuzzleResultProfileId(sessionId)`;workId 使用 `buildPuzzleResultWorkId(sessionId)`,缺失时回退 profileId。 - 拼图 owner 缺省为 `current-user` / `玩家`;`publishReady` 来自 `session.resultPreview?.publishReady`。 +- 方洞 profile 的 `workId` 与 `profileId` 都来自 draft `profileId`;owner 固定为 `current-user`,`sourceSessionId` 来自 sessionId。 +- 方洞 profile 的 `updatedAt` 优先 session `updatedAt`,缺失时使用当前时间;`publicationStatus='draft'`、`playCount=0`、`publishedAt=null`,`publishReady` 来自 draft。 - 跳一跳 pending sessionId 优先 `sourceSessionId`,缺失时用 `profileId`;素材、路径和 prompt 维持空值兜底。 - 敲木鱼 detail sessionId 优先级固定为 `work.summary.sourceSessionId > fallbackItem.sourceSessionId > profileId`。 - 敲木鱼 pending session 保持 `floatingWords=['功德 +1']`、素材 / 音效 / 背景为空的旧默认。 ## Depth / Leverage / Locality -- **Depth**:壳层以四个函数取得恢复用 DTO;ID 优先级和默认 draft 字段藏入 Module Implementation。 +- **Depth**:壳层以少量函数取得恢复用 DTO;ID 优先级、方洞 profile 默认值和 pending draft 字段藏入 Module Implementation。 - **Leverage**:后续新增生成中作品恢复或修改 sessionId 规则时,先改 Module 与单测,再保持壳层 Adapter 副作用不变。 -- **Locality**:拼图、跳一跳和敲木鱼的恢复映射集中在一个纯测试面,避免在大型壳层顶部继续堆积 DTO 构造。 +- **Locality**:拼图、方洞、跳一跳和敲木鱼的恢复映射集中在一个纯测试面,避免在大型壳层顶部继续堆积 DTO 构造。 ## 验收 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 6de3218b..a53aaffc 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -536,6 +536,7 @@ import { import { buildJumpHopPendingSession, buildPuzzleRuntimeWorkFromSession, + buildSquareHoleProfileFromSession, buildWoodenFishPendingSession, buildWoodenFishSessionFromWorkDetail, } from './platformMiniGameSessionMappingModel'; @@ -751,40 +752,6 @@ function mapVisualNovelWorkDetailToSession( }; } -function buildSquareHoleProfileFromSession( - session: SquareHoleSessionSnapshot | null, -): SquareHoleWorkProfile | null { - const draft = session?.draft; - if (!session || !draft?.profileId) { - return null; - } - - const now = session.updatedAt || new Date().toISOString(); - return { - workId: draft.profileId, - profileId: draft.profileId, - ownerUserId: 'current-user', - sourceSessionId: session.sessionId, - gameName: draft.gameName, - themeText: draft.themeText, - twistRule: draft.twistRule, - summary: draft.summary, - tags: draft.tags, - coverImageSrc: draft.coverImageSrc ?? null, - backgroundPrompt: draft.backgroundPrompt, - backgroundImageSrc: draft.backgroundImageSrc ?? null, - shapeOptions: draft.shapeOptions, - holeOptions: draft.holeOptions, - shapeCount: draft.shapeCount, - difficulty: draft.difficulty, - publicationStatus: 'draft', - playCount: 0, - updatedAt: now, - publishedAt: null, - publishReady: Boolean(draft.publishReady), - }; -} - function mergePuzzleWorkSummary( current: PuzzleWorkSummary, updated: PuzzleWorkSummary, diff --git a/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts b/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts index f83824f6..02754bae 100644 --- a/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts +++ b/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts @@ -8,6 +8,10 @@ import type { PuzzleResultDraft, } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import type { + SquareHoleResultDraft, + SquareHoleSessionSnapshot, +} from '../../../packages/shared/src/contracts/squareHoleAgent'; import type { WoodenFishAudioAsset, WoodenFishImageAsset, @@ -17,6 +21,7 @@ import type { import { buildJumpHopPendingSession, buildPuzzleRuntimeWorkFromSession, + buildSquareHoleProfileFromSession, buildWoodenFishPendingSession, buildWoodenFishSessionFromWorkDetail, } from './platformMiniGameSessionMappingModel'; @@ -123,6 +128,100 @@ function buildJumpHopSummary( }; } +function buildSquareHoleDraft( + overrides: Partial = {}, +): SquareHoleResultDraft { + return { + profileId: 'square-hole-profile-1', + gameName: '星桥方洞', + themeText: '星桥机关', + twistRule: '只允许相同颜色形状入洞', + summary: '把星桥机关里的形状送入正确孔洞。', + tags: ['星桥', '机关'], + coverImageSrc: '/square-hole-cover.png', + backgroundPrompt: '星桥机关背景', + backgroundImageSrc: '/square-hole-background.png', + shapeOptions: [ + { + optionId: 'shape-1', + shapeKind: 'star', + label: '星形', + targetHoleId: 'hole-1', + imagePrompt: '星形积木', + imageSrc: '/shape-star.png', + }, + ], + holeOptions: [ + { + holeId: 'hole-1', + holeKind: 'star-hole', + label: '星形洞', + imagePrompt: '星形洞口', + imageSrc: '/hole-star.png', + }, + ], + shapeCount: 6, + difficulty: 3, + publishReady: true, + blockers: [], + ...overrides, + }; +} + +function buildSquareHoleSession( + overrides: Partial = {}, +): SquareHoleSessionSnapshot { + return { + sessionId: 'square-hole-session-1', + currentTurn: 2, + progressPercent: 100, + stage: 'draft_ready', + anchorPack: { + theme: { + key: 'theme', + label: '主题', + value: '星桥机关', + status: 'confirmed', + }, + twistRule: { + key: 'twistRule', + label: '扭转规则', + value: '只允许相同颜色形状入洞', + status: 'confirmed', + }, + shapeCount: { + key: 'shapeCount', + label: '形状数量', + value: '6', + status: 'confirmed', + }, + difficulty: { + key: 'difficulty', + label: '难度', + value: '3', + status: 'confirmed', + }, + }, + config: { + themeText: '星桥机关', + twistRule: '只允许相同颜色形状入洞', + shapeCount: 6, + difficulty: 3, + shapeOptions: [], + holeOptions: [], + backgroundPrompt: '星桥机关背景', + coverImageSrc: null, + backgroundImageSrc: null, + }, + draft: buildSquareHoleDraft(), + messages: [], + lastAssistantReply: null, + publishedProfileId: null, + updatedAt: '2026-06-01T12:30:00.000Z', + ...overrides, + }; +} + const woodenFishImageAsset: WoodenFishImageAsset = { assetId: 'asset-hit', imageSrc: '/hit.png', @@ -284,6 +383,50 @@ describe('platformMiniGameSessionMappingModel', () => { }); }); + test('builds square hole draft profile from session', () => { + expect(buildSquareHoleProfileFromSession(buildSquareHoleSession())).toEqual({ + workId: 'square-hole-profile-1', + profileId: 'square-hole-profile-1', + ownerUserId: 'current-user', + sourceSessionId: 'square-hole-session-1', + gameName: '星桥方洞', + themeText: '星桥机关', + twistRule: '只允许相同颜色形状入洞', + summary: '把星桥机关里的形状送入正确孔洞。', + tags: ['星桥', '机关'], + coverImageSrc: '/square-hole-cover.png', + backgroundPrompt: '星桥机关背景', + backgroundImageSrc: '/square-hole-background.png', + shapeOptions: buildSquareHoleDraft().shapeOptions, + holeOptions: buildSquareHoleDraft().holeOptions, + shapeCount: 6, + difficulty: 3, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-01T12:30:00.000Z', + publishedAt: null, + publishReady: true, + }); + }); + + test('returns null for square hole profile without session draft or profile id', () => { + expect(buildSquareHoleProfileFromSession(null)).toBeNull(); + expect( + buildSquareHoleProfileFromSession( + buildSquareHoleSession({ + draft: null, + }), + ), + ).toBeNull(); + expect( + buildSquareHoleProfileFromSession( + buildSquareHoleSession({ + draft: buildSquareHoleDraft({ profileId: '' }), + }), + ), + ).toBeNull(); + }); + test('builds wooden fish pending session from work summary', () => { expect(buildWoodenFishPendingSession(buildWoodenFishSummary())).toEqual({ sessionId: 'wooden-fish-session-1', diff --git a/src/components/platform-entry/platformMiniGameSessionMappingModel.ts b/src/components/platform-entry/platformMiniGameSessionMappingModel.ts index cadf3d12..c0e327b3 100644 --- a/src/components/platform-entry/platformMiniGameSessionMappingModel.ts +++ b/src/components/platform-entry/platformMiniGameSessionMappingModel.ts @@ -1,6 +1,8 @@ import type { JumpHopSessionSnapshotResponse, JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent'; +import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { WoodenFishSessionSnapshotResponse, WoodenFishWorkProfileResponse, @@ -52,6 +54,40 @@ export function buildPuzzleRuntimeWorkFromSession( }; } +export function buildSquareHoleProfileFromSession( + session: SquareHoleSessionSnapshot | null, +): SquareHoleWorkProfile | null { + const draft = session?.draft; + if (!session || !draft?.profileId) { + return null; + } + + const now = session.updatedAt || new Date().toISOString(); + return { + workId: draft.profileId, + profileId: draft.profileId, + ownerUserId: 'current-user', + sourceSessionId: session.sessionId, + gameName: draft.gameName, + themeText: draft.themeText, + twistRule: draft.twistRule, + summary: draft.summary, + tags: draft.tags, + coverImageSrc: draft.coverImageSrc ?? null, + backgroundPrompt: draft.backgroundPrompt, + backgroundImageSrc: draft.backgroundImageSrc ?? null, + shapeOptions: draft.shapeOptions, + holeOptions: draft.holeOptions, + shapeCount: draft.shapeCount, + difficulty: draft.difficulty, + publicationStatus: 'draft', + playCount: 0, + updatedAt: now, + publishedAt: null, + publishReady: Boolean(draft.publishReady), + }; +} + export function buildJumpHopPendingSession( item: JumpHopWorkSummaryResponse, ): JumpHopSessionSnapshotResponse { From 20a21ee78bd85f96ae906427ea0391869b710c5f Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 05:09:49 +0800 Subject: [PATCH 57/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E5=B0=8F=E8=AF=B4=E8=AF=A6=E6=83=85=20session=20?= =?UTF-8?q?=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 6 +- docs/README.md | 2 +- ...iniGameSessionMappingModel收口计划-2026-06-04.md | 8 +- .../PlatformEntryFlowShellImpl.tsx | 19 +-- ...latformMiniGameSessionMappingModel.test.ts | 152 ++++++++++++++++++ .../platformMiniGameSessionMappingModel.ts | 20 +++ 6 files changed, 183 insertions(+), 24 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 80ee8ae5..8f23f1bc 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1405,9 +1405,9 @@ ## 2026-06-04 Platform Mini Game Session Mapping Model 收口 -- 背景:`PlatformEntryFlowShellImpl.tsx` 顶部仍保留拼图 runtime 恢复、方洞 session draft 转 profile、跳一跳 pending session、敲木鱼 detail 恢复和敲木鱼 pending session 等纯 DTO 映射,壳层需要理解 sessionId 优先级、拼图稳定 ID、方洞草稿 profile 默认值和 pending draft 默认值。 -- 决策:新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,收口 `buildPuzzleRuntimeWorkFromSession`、`buildSquareHoleProfileFromSession`、`buildJumpHopPendingSession`、`buildWoodenFishSessionFromWorkDetail` 与 `buildWoodenFishPendingSession`。Module 复用 `normalizeCreationUrlValue` 与 `platformPuzzleIdentityModel`;壳层只保留网络读取、React state、URL 写入和 stage 切换副作用。 -- 影响范围:拼图 runtime URL 恢复、方洞挑战草稿 profile 构造、跳一跳生成中作品架打开、敲木鱼生成中作品架打开和敲木鱼草稿 detail 恢复。 +- 背景:`PlatformEntryFlowShellImpl.tsx` 顶部仍保留拼图 runtime 恢复、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复和敲木鱼 pending session 等纯 DTO 映射,壳层需要理解 sessionId 优先级、拼图稳定 ID、方洞草稿 profile 默认值、视觉小说 work/session fallback 和 pending draft 默认值。 +- 决策:新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,收口 `buildPuzzleRuntimeWorkFromSession`、`buildSquareHoleProfileFromSession`、`buildVisualNovelSessionFromWorkDetail`、`buildJumpHopPendingSession`、`buildWoodenFishSessionFromWorkDetail` 与 `buildWoodenFishPendingSession`。Module 复用 `normalizeCreationUrlValue` 与 `platformPuzzleIdentityModel`;壳层只保留网络读取、React state、URL 写入和 stage 切换副作用。 +- 影响范围:拼图 runtime URL 恢复、方洞挑战草稿 profile 构造、视觉小说草稿作品架恢复、跳一跳生成中作品架打开、敲木鱼生成中作品架打开和敲木鱼草稿 detail 恢复。 - 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/README.md b/docs/README.md index b4bfa104..4c79cdd5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,7 +55,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口生成页进度 tick 的 stage 到生成状态映射、终态判定和视觉小说轻量生成特例收口到 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,壳层只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,规则见 [【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md)。 -平台壳的拼图 runtime 恢复 work、方洞 session draft 转 profile、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 +平台壳的拼图 runtime 恢复 work、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并、抓大鹅生成资产旁路进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 diff --git a/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md index 38ed5064..77b680c1 100644 --- a/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md @@ -2,7 +2,7 @@ ## 背景 -`PlatformEntryFlowShellImpl.tsx` 顶部曾保留拼图 runtime 恢复、方洞挑战 session draft 转 profile、跳一跳 pending session、敲木鱼 work detail 恢复和敲木鱼 pending session 多段纯 DTO 映射。它们没有 React state、网络请求、路由、弹窗或计时副作用,却住在大型平台壳内;新增或修正生成中草稿恢复时,需要在壳层里理解 sessionId 优先级、拼图稳定 ID、方洞 profile 默认值、pending draft 默认值和木鱼 fallback 规则。 +`PlatformEntryFlowShellImpl.tsx` 顶部曾保留拼图 runtime 恢复、方洞挑战 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 work detail 恢复和敲木鱼 pending session 多段纯 DTO 映射。它们没有 React state、网络请求、路由、弹窗或计时副作用,却住在大型平台壳内;新增或修正生成中草稿恢复时,需要在壳层里理解 sessionId 优先级、拼图稳定 ID、方洞 profile 默认值、视觉小说 work/session fallback、pending draft 默认值和木鱼 fallback 规则。 这些规则属于平台壳 session / work 恢复映射,应成为可测试的 **Module**。壳层只负责调用网络、写 React state、写 URL 和切换 stage。 @@ -12,6 +12,7 @@ - `buildPuzzleRuntimeWorkFromSession(session, owner)`:从拼图 Agent session 构造可进入 runtime 的 draft `PuzzleWorkSummary`,缺草稿、缺 profile 或缺封面时返回 `null`。 - `buildSquareHoleProfileFromSession(session)`:从方洞挑战 Agent session draft 构造草稿 `SquareHoleWorkProfile`,缺 session、缺 draft 或缺 profileId 时返回 `null`。 +- `buildVisualNovelSessionFromWorkDetail(work)`:从视觉小说 work detail 恢复 `VisualNovelAgentSessionSnapshot`,供草稿作品架回到结果页继续编辑。 - `buildJumpHopPendingSession(item)`:从跳一跳作品架 summary 构造生成中 pending session。 - `buildWoodenFishSessionFromWorkDetail(work, fallbackItem?)`:从敲木鱼 work detail 恢复 session,并按 summary / fallback / profileId 决定 sessionId。 - `buildWoodenFishPendingSession(item)`:从敲木鱼作品架 summary 构造生成中 pending session。 @@ -25,15 +26,16 @@ - 拼图 owner 缺省为 `current-user` / `玩家`;`publishReady` 来自 `session.resultPreview?.publishReady`。 - 方洞 profile 的 `workId` 与 `profileId` 都来自 draft `profileId`;owner 固定为 `current-user`,`sourceSessionId` 来自 sessionId。 - 方洞 profile 的 `updatedAt` 优先 session `updatedAt`,缺失时使用当前时间;`publicationStatus='draft'`、`playCount=0`、`publishedAt=null`,`publishReady` 来自 draft。 +- 视觉小说恢复 session 的 `sessionId` 优先归一化后的 `sourceSessionId`,为空时回退 `workId`;`status='ready'`,`messages=[]`,`pendingAction=null`,`sourceMode` 来自 draft,`updatedAt` 来自 summary。 - 跳一跳 pending sessionId 优先 `sourceSessionId`,缺失时用 `profileId`;素材、路径和 prompt 维持空值兜底。 - 敲木鱼 detail sessionId 优先级固定为 `work.summary.sourceSessionId > fallbackItem.sourceSessionId > profileId`。 - 敲木鱼 pending session 保持 `floatingWords=['功德 +1']`、素材 / 音效 / 背景为空的旧默认。 ## Depth / Leverage / Locality -- **Depth**:壳层以少量函数取得恢复用 DTO;ID 优先级、方洞 profile 默认值和 pending draft 字段藏入 Module Implementation。 +- **Depth**:壳层以少量函数取得恢复用 DTO;ID 优先级、方洞 profile 默认值、视觉小说 session fallback 和 pending draft 字段藏入 Module Implementation。 - **Leverage**:后续新增生成中作品恢复或修改 sessionId 规则时,先改 Module 与单测,再保持壳层 Adapter 副作用不变。 -- **Locality**:拼图、方洞、跳一跳和敲木鱼的恢复映射集中在一个纯测试面,避免在大型壳层顶部继续堆积 DTO 构造。 +- **Locality**:拼图、方洞、视觉小说、跳一跳和敲木鱼的恢复映射集中在一个纯测试面,避免在大型壳层顶部继续堆积 DTO 构造。 ## 验收 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index a53aaffc..d2ebd2bf 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -537,6 +537,7 @@ import { buildJumpHopPendingSession, buildPuzzleRuntimeWorkFromSession, buildSquareHoleProfileFromSession, + buildVisualNovelSessionFromWorkDetail, buildWoodenFishPendingSession, buildWoodenFishSessionFromWorkDetail, } from './platformMiniGameSessionMappingModel'; @@ -736,22 +737,6 @@ const PUZZLE_DRAFT_GENERATION_POINT_COST = 2; const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3; -function mapVisualNovelWorkDetailToSession( - work: VisualNovelWorkDetail, -): VisualNovelAgentSessionSnapshot { - return { - sessionId: work.sourceSessionId?.trim() || work.workId, - ownerUserId: work.summary.ownerUserId, - sourceMode: work.draft.sourceMode, - status: 'ready', - messages: [], - draft: work.draft, - pendingAction: null, - createdAt: work.createdAt, - updatedAt: work.summary.updatedAt, - }; -} - function mergePuzzleWorkSummary( current: PuzzleWorkSummary, updated: PuzzleWorkSummary, @@ -11094,7 +11079,7 @@ export function PlatformEntryFlowShellImpl({ try { const { work } = await getVisualNovelWorkDetail(item.profileId); setVisualNovelWork(work); - setVisualNovelSession(mapVisualNovelWorkDetailToSession(work)); + setVisualNovelSession(buildVisualNovelSessionFromWorkDetail(work)); enterCreateTab(); setSelectionStage('visual-novel-result'); } catch (error) { diff --git a/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts b/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts index 02754bae..6e557059 100644 --- a/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts +++ b/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts @@ -12,6 +12,10 @@ import type { SquareHoleResultDraft, SquareHoleSessionSnapshot, } from '../../../packages/shared/src/contracts/squareHoleAgent'; +import type { + VisualNovelResultDraft, + VisualNovelWorkDetail, +} from '../../../packages/shared/src/contracts/visualNovel'; import type { WoodenFishAudioAsset, WoodenFishImageAsset, @@ -22,6 +26,7 @@ import { buildJumpHopPendingSession, buildPuzzleRuntimeWorkFromSession, buildSquareHoleProfileFromSession, + buildVisualNovelSessionFromWorkDetail, buildWoodenFishPendingSession, buildWoodenFishSessionFromWorkDetail, } from './platformMiniGameSessionMappingModel'; @@ -222,6 +227,126 @@ function buildSquareHoleSession( }; } +function buildVisualNovelDraft( + overrides: Partial = {}, +): VisualNovelResultDraft { + return { + profileId: 'visual-novel-profile-1', + workTitle: '雪线电台', + workDescription: '旧电台牵出雪夜列车谜案。', + workTags: ['雪夜', '电台'], + coverImageSrc: '/visual-novel-cover.png', + sourceMode: 'idea', + sourceAssetIds: ['asset-source-1'], + world: { + title: '北境终点线', + summary: '边境小城与旧电台。', + background: '十二年前的雪崩留下夜间广播。', + premise: '玩家需要在日出前找出列车停摆的原因。', + literaryStyle: '克制冷光感。', + playerRole: '临时广播员', + defaultTone: '安静紧张', + }, + characters: [ + { + characterId: 'vn-char-1', + name: '林遥', + gender: '女', + role: 'main', + appearance: '灰色长外套。', + personality: '谨慎敏锐。', + tone: '短句多。', + background: '旧电台夜班实习生。', + relationshipToPlayer: '临时搭档', + imageAssets: [], + defaultExpression: 'calm', + isPlayerVisible: false, + }, + ], + scenes: [ + { + sceneId: 'vn-scene-1', + name: '风雪站台', + description: '站灯忽明忽暗。', + backgroundImageSrc: null, + musicSrc: null, + ambientSoundSrc: null, + availability: 'opening', + phaseIds: ['vn-phase-1'], + }, + ], + storyPhases: [ + { + phaseId: 'vn-phase-1', + title: '重启站台', + goal: '确认列车为何停在废弃站台。', + summary: '玩家抵达风雪站台。', + entryCondition: '开场进入', + exitCondition: '找到车长日志', + sceneIds: ['vn-scene-1'], + characterIds: ['vn-char-1'], + suggestedChoices: ['检查广播柜'], + }, + ], + opening: { + sceneId: 'vn-scene-1', + narration: '雪落得很慢。', + speakerCharacterId: 'vn-char-1', + firstDialogue: '你听见了吗?', + initialChoices: [ + { + choiceId: 'vn-choice-1', + text: '靠近广播柜。', + actionHint: 'inspect_radio', + }, + ], + }, + runtimeConfig: { + textModeEnabled: true, + defaultTextMode: false, + maxHistoryEntries: 80, + maxAssistantStepCountPerTurn: 8, + allowFreeTextAction: true, + allowHistoryRegeneration: true, + attributePanelMode: 'template_config', + saveArchiveEnabled: true, + }, + publishReady: true, + validationIssues: [], + updatedAt: '2026-06-01T13:00:00.000Z', + ...overrides, + }; +} + +function buildVisualNovelWorkDetail( + overrides: Partial = {}, +): VisualNovelWorkDetail { + const draft = buildVisualNovelDraft(); + return { + workId: 'visual-novel-work-1', + summary: { + runtimeKind: 'visual-novel', + profileId: 'visual-novel-profile-1', + ownerUserId: 'user-visual-novel-1', + title: draft.workTitle, + description: draft.workDescription, + coverImageSrc: draft.coverImageSrc, + tags: draft.workTags, + publishStatus: 'draft', + publishReady: draft.publishReady, + playCount: 0, + updatedAt: '2026-06-01T13:30:00.000Z', + publishedAt: null, + }, + sourceSessionId: ' visual-novel-session-1 ', + authorDisplayName: '视觉小说作者', + sourceAssetIds: draft.sourceAssetIds, + draft, + createdAt: '2026-06-01T12:50:00.000Z', + ...overrides, + }; +} + const woodenFishImageAsset: WoodenFishImageAsset = { assetId: 'asset-hit', imageSrc: '/hit.png', @@ -427,6 +552,33 @@ describe('platformMiniGameSessionMappingModel', () => { ).toBeNull(); }); + test('builds visual novel recovered session from work detail', () => { + const work = buildVisualNovelWorkDetail(); + + expect(buildVisualNovelSessionFromWorkDetail(work)).toEqual({ + sessionId: 'visual-novel-session-1', + ownerUserId: 'user-visual-novel-1', + sourceMode: 'idea', + status: 'ready', + messages: [], + draft: work.draft, + pendingAction: null, + createdAt: '2026-06-01T12:50:00.000Z', + updatedAt: '2026-06-01T13:30:00.000Z', + }); + }); + + test('falls back visual novel recovered session id to work id', () => { + expect( + buildVisualNovelSessionFromWorkDetail( + buildVisualNovelWorkDetail({ + sourceSessionId: ' ', + workId: 'visual-novel-work-fallback', + }), + ).sessionId, + ).toBe('visual-novel-work-fallback'); + }); + test('builds wooden fish pending session from work summary', () => { expect(buildWoodenFishPendingSession(buildWoodenFishSummary())).toEqual({ sessionId: 'wooden-fish-session-1', diff --git a/src/components/platform-entry/platformMiniGameSessionMappingModel.ts b/src/components/platform-entry/platformMiniGameSessionMappingModel.ts index c0e327b3..a59e9a6a 100644 --- a/src/components/platform-entry/platformMiniGameSessionMappingModel.ts +++ b/src/components/platform-entry/platformMiniGameSessionMappingModel.ts @@ -3,6 +3,10 @@ import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/co import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent'; import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { + VisualNovelAgentSessionSnapshot, + VisualNovelWorkDetail, +} from '../../../packages/shared/src/contracts/visualNovel'; import type { WoodenFishSessionSnapshotResponse, WoodenFishWorkProfileResponse, @@ -88,6 +92,22 @@ export function buildSquareHoleProfileFromSession( }; } +export function buildVisualNovelSessionFromWorkDetail( + work: VisualNovelWorkDetail, +): VisualNovelAgentSessionSnapshot { + return { + sessionId: normalizeCreationUrlValue(work.sourceSessionId) ?? work.workId, + ownerUserId: work.summary.ownerUserId, + sourceMode: work.draft.sourceMode, + status: 'ready', + messages: [], + draft: work.draft, + pendingAction: null, + createdAt: work.createdAt, + updatedAt: work.summary.updatedAt, + }; +} + export function buildJumpHopPendingSession( item: JumpHopWorkSummaryResponse, ): JumpHopSessionSnapshotResponse { From 9c96535073684a619adc7e161f588894cc4c44cf Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 05:14:23 +0800 Subject: [PATCH 58/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20Bark=20B?= =?UTF-8?q?attle=20=E8=8D=89=E7=A8=BF=E6=81=A2=E5=A4=8D=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 6 ++-- docs/README.md | 2 +- ...arkBattleWorkCache草稿状态收口计划-2026-06-04.md | 8 +++-- .../PlatformEntryFlowShellImpl.tsx | 19 ++---------- .../barkBattleWorkCache.test.ts | 31 +++++++++++++++++++ .../platform-entry/barkBattleWorkCache.ts | 22 +++++++++++++ 6 files changed, 64 insertions(+), 24 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 8f23f1bc..46ec8486 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -18,9 +18,9 @@ ## 2026-06-04 Bark Battle Work Cache 草稿状态收口 -- 背景:`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 字段映射,导致结果页试玩、作品架启动和公开详情启动都要理解同一份资产字段清单。 -- 决策:扩展 `src/components/platform-entry/barkBattleWorkCache.ts`,以 `hasBarkBattleDraftRequiredImages`、`resolveBarkBattleDraftGenerationStatus`、`buildBarkBattlePublishedConfigFromDraft`、`buildBarkBattlePublishedConfigFromWork`、`buildBarkBattlePublishSnapshot` 和 `mergeBarkBattlePublishedConfigAssets` 收口 Bark Battle 纯规则。平台壳只保留 API、缓存刷新、React state、URL 和 stage 副作用。 -- 影响范围:Bark Battle 草稿生成完成、结果页保存、草稿试玩、作品架 / 公开详情启动正式 runtime,以及后续 Bark Battle 资产字段或 ruleset 默认值调整。 +- 背景:`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 字段映射,导致结果页试玩、作品架启动、草稿恢复和公开详情启动都要理解同一份资产字段清单。 +- 决策:扩展 `src/components/platform-entry/barkBattleWorkCache.ts`,以 `hasBarkBattleDraftRequiredImages`、`resolveBarkBattleDraftGenerationStatus`、`buildBarkBattleDraftConfigFromWorkSummary`、`buildBarkBattlePublishedConfigFromDraft`、`buildBarkBattlePublishedConfigFromWork`、`buildBarkBattlePublishSnapshot` 和 `mergeBarkBattlePublishedConfigAssets` 收口 Bark Battle 纯规则。平台壳只保留 API、缓存刷新、React state、URL 和 stage 副作用。 +- 影响范围:Bark Battle 草稿生成完成、结果页保存、作品架摘要恢复草稿、草稿试玩、作品架 / 公开详情启动正式 runtime,以及后续 Bark Battle 资产字段或 ruleset 默认值调整。 - 验证方式:`npm run test -- src/components/platform-entry/barkBattleWorkCache.test.ts`、针对 Bark Battle Work Cache Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md`。 diff --git a/docs/README.md b/docs/README.md index 4c79cdd5..8b402e03 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,7 +67,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口个人钱包本地 delta、dashboard 乐观更新与服务端快照对账规则收口到 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,平台壳只保留 API、ref 与 state 副作用,规则见 [【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md)。 -Bark Battle 草稿三图完整性、生成状态归一、发布快照 / 发布回包资产兜底和草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射收口到 `src/components/platform-entry/barkBattleWorkCache.ts`,规则见 [【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md](./technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md)。 +Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置、发布快照 / 发布回包资产兜底和草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射收口到 `src/components/platform-entry/barkBattleWorkCache.ts`,规则见 [【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md](./technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md)。 平台首页推荐 runtime 的匿名 Runtime Guest Token、已登录 background auth、非 embedded no-op 和拼图 isolated/default auth mode 计划收口到 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md)。 diff --git a/docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md b/docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md index 14936e1e..2db7284f 100644 --- a/docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md @@ -2,7 +2,7 @@ ## 背景 -`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射。壳层因此需要同时理解三图资产字段、`partial_failed` 与 `pending_assets` 的差异、`publishedAt` 兜底和草稿试玩配置默认值。 +`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射。壳层因此需要同时理解三图资产字段、`partial_failed` 与 `pending_assets` 的差异、`publishedAt` 兜底、作品摘要字段和草稿试玩配置默认值。 这些规则属于 Bark Battle 作品摘要与草稿缓存的纯模型。若留在平台壳层,后续发布、作品架刷新、公开详情启动或草稿试玩都容易重复一份字段清单。 @@ -12,6 +12,7 @@ - `hasBarkBattleDraftRequiredImages(draft)`:判断草稿是否已具备玩家形象、对手形象和竞技背景三图。 - `resolveBarkBattleDraftGenerationStatus(draft, partialFailed)`:三图齐备返回 `ready`,否则按是否部分失败返回 `partial_failed` 或 `pending_assets`。 +- `buildBarkBattleDraftConfigFromWorkSummary(work)`:把作品架摘要恢复成可编辑 / 可试玩的 `BarkBattleDraftConfig`。 - `buildBarkBattlePublishedConfigFromDraft(draft)`:把草稿结果页试玩所需配置映射为 `BarkBattlePublishedConfig`。 - `buildBarkBattlePublishedConfigFromWork(work)`:把作品架 / 公开详情启动正式 runtime 所需配置映射为 `BarkBattlePublishedConfig`。 - `buildBarkBattlePublishSnapshot(draft)`:拼装发布接口所需的最终草稿快照。 @@ -23,6 +24,7 @@ - 草稿三图必须同时具备 `playerCharacterImageSrc`、`opponentCharacterImageSrc` 和 `uiBackgroundImageSrc` 的非空值,才视为 `ready`。 - 未齐三图且 `partialFailed=true` 时返回 `partial_failed`,否则返回 `pending_assets`。 +- 作品摘要恢复草稿时,`draftId` 缺失回退 `workId`,`description` 来自 summary,三图 null 归一为 `undefined`,`configVersion=1` 且 `rulesetVersion='bark-battle-ruleset-v1'`。 - 草稿试玩配置的 `workId` 优先使用草稿稳定 `workId`,缺失时回退 `draftId`。 - 草稿试玩配置的 `configVersion` 与 `rulesetVersion` 使用草稿值,缺失时回退 `1` 与 `bark-battle-ruleset-v1`。 - 已发布作品配置的 `publishedAt` 缺失时回退 `updatedAt`,保持旧 runtime 启动语义。 @@ -31,8 +33,8 @@ ## Depth / Leverage / Locality -- **Depth**:壳层传入草稿或作品摘要,即可得到生成状态或 runtime 配置;字段归一、默认值和三图完整性藏入 Module Implementation。 -- **Leverage**:结果页试玩、作品架启动、公开详情启动和缓存刷新可复用同一组 Bark Battle 规则。 +- **Depth**:壳层传入草稿或作品摘要,即可得到生成状态、草稿配置或 runtime 配置;字段归一、默认值和三图完整性藏入 Module Implementation。 +- **Leverage**:作品架草稿恢复、结果页试玩、作品架启动、公开详情启动和缓存刷新可复用同一组 Bark Battle 规则。 - **Locality**:Bark Battle 资产完整性与配置映射集中到纯测试面,后续变更三图字段或规则集默认值时无需搜索巨型平台壳。 ## 验收 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index d2ebd2bf..d6b7b155 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -380,6 +380,7 @@ import { } from '../visual-novel-creation/visualNovelEntryGeneration'; import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData'; import { + buildBarkBattleDraftConfigFromWorkSummary, buildBarkBattlePublishedConfigFromDraft, buildBarkBattlePublishedConfigFromWork, buildBarkBattlePublishSnapshot, @@ -11006,23 +11007,7 @@ export function PlatformEntryFlowShellImpl({ return; } - const nextDraft: BarkBattleDraftConfig = { - draftId: item.draftId ?? item.workId, - workId: item.workId, - title: item.title, - description: item.summary, - themeDescription: item.themeDescription, - playerImageDescription: item.playerImageDescription, - opponentImageDescription: item.opponentImageDescription, - onomatopoeia: item.onomatopoeia, - playerCharacterImageSrc: item.playerCharacterImageSrc ?? undefined, - opponentCharacterImageSrc: item.opponentCharacterImageSrc ?? undefined, - uiBackgroundImageSrc: item.uiBackgroundImageSrc ?? undefined, - difficultyPreset: item.difficultyPreset, - configVersion: 1, - rulesetVersion: 'bark-battle-ruleset-v1', - updatedAt: item.updatedAt, - }; + const nextDraft = buildBarkBattleDraftConfigFromWorkSummary(item); setBarkBattleDraftConfig(nextDraft); enterCreateTab(); selectionStageRef.current = isPersistedBarkBattleDraftGenerating(item) diff --git a/src/components/platform-entry/barkBattleWorkCache.test.ts b/src/components/platform-entry/barkBattleWorkCache.test.ts index 8115dde9..feeecf3e 100644 --- a/src/components/platform-entry/barkBattleWorkCache.test.ts +++ b/src/components/platform-entry/barkBattleWorkCache.test.ts @@ -6,6 +6,7 @@ import type { BarkBattleWorkSummary, } from '../../../packages/shared/src/contracts/barkBattle'; import { + buildBarkBattleDraftConfigFromWorkSummary, buildBarkBattlePublishedConfigFromDraft, buildBarkBattlePublishedConfigFromWork, buildBarkBattlePublishSnapshot, @@ -29,6 +30,7 @@ function buildBarkBattleWork( themeDescription: '阳光草坪声浪竞技场', playerImageDescription: '戴红色围巾的柯基选手', opponentImageDescription: '蓝色护目镜哈士奇对手', + onomatopoeia: ['汪', '破阵'], playerCharacterImageSrc: '/generated-bark-battle/player.png', opponentCharacterImageSrc: '/generated-bark-battle/opponent.png', uiBackgroundImageSrc: '/generated-bark-battle/background.png', @@ -184,6 +186,35 @@ test('builds work runtime config with publishedAt fallback', () => { expect(config.playerCharacterImageSrc).toBe('/generated-bark-battle/player.png'); }); +test('builds draft config from work summary with stable defaults', () => { + const config = buildBarkBattleDraftConfigFromWorkSummary( + buildBarkBattleWork({ + draftId: null, + playerCharacterImageSrc: null, + opponentCharacterImageSrc: null, + uiBackgroundImageSrc: null, + }), + ); + + expect(config).toMatchObject({ + draftId: 'BB-cache-race-12345678', + workId: 'BB-cache-race-12345678', + title: '汪汪测试杯', + description: '测试声浪赛', + themeDescription: '阳光草坪声浪竞技场', + playerImageDescription: '戴红色围巾的柯基选手', + opponentImageDescription: '蓝色护目镜哈士奇对手', + onomatopoeia: ['汪', '破阵'], + difficultyPreset: 'normal', + configVersion: 1, + rulesetVersion: 'bark-battle-ruleset-v1', + updatedAt: '2026-05-21T10:00:00.000Z', + }); + expect(config.playerCharacterImageSrc).toBeUndefined(); + expect(config.opponentCharacterImageSrc).toBeUndefined(); + expect(config.uiBackgroundImageSrc).toBeUndefined(); +}); + test('builds publish snapshot without empty asset fields', () => { const snapshot = buildBarkBattlePublishSnapshot( buildBarkBattleDraft({ diff --git a/src/components/platform-entry/barkBattleWorkCache.ts b/src/components/platform-entry/barkBattleWorkCache.ts index b56520db..26450eb6 100644 --- a/src/components/platform-entry/barkBattleWorkCache.ts +++ b/src/components/platform-entry/barkBattleWorkCache.ts @@ -142,6 +142,28 @@ export function buildBarkBattlePublishedConfigFromWork( }; } +export function buildBarkBattleDraftConfigFromWorkSummary( + work: BarkBattleWorkSummary, +): BarkBattleDraftConfig { + return { + draftId: work.draftId ?? work.workId, + workId: work.workId, + title: work.title, + description: work.summary, + themeDescription: work.themeDescription, + playerImageDescription: work.playerImageDescription, + opponentImageDescription: work.opponentImageDescription, + onomatopoeia: work.onomatopoeia, + playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined, + opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined, + uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined, + difficultyPreset: work.difficultyPreset, + configVersion: 1, + rulesetVersion: 'bark-battle-ruleset-v1', + updatedAt: work.updatedAt, + }; +} + export function shouldPreserveLocalBarkBattleWorkOnRefresh( item: BarkBattleWorkSummary, refreshed: readonly BarkBattleWorkSummary[], From d44560f3309363c81616a4a82a1561e2e9663b28 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 05:19:23 +0800 Subject: [PATCH 59/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=8B=BC?= =?UTF-8?q?=E5=9B=BE=E4=BD=9C=E5=93=81=E6=9B=B4=E6=96=B0=20payload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 6 +- docs/README.md | 2 +- ...mMiniGameDraftPayloadModel收口计划-2026-06-04.md | 10 ++-- .../PlatformEntryFlowShellImpl.tsx | 57 ++++++------------- .../platformMiniGameDraftPayloadModel.test.ts | 23 ++++++++ .../platformMiniGameDraftPayloadModel.ts | 30 ++++++++++ 6 files changed, 80 insertions(+), 48 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 46ec8486..2c7eea2f 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1430,9 +1430,9 @@ ## 2026-06-04 Platform Mini Game Draft Payload Model 收口 -- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护拼图 / 抓大鹅表单 payload、拼图编译 action、作品摘要回填 payload 和 pending 草稿 metadata,壳层需要理解描述字段优先级、formDraft 回退、Match3D config / draft / anchorPack 优先级和数字解析。 -- 决策:新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,收口 `buildPuzzleFormPayloadFromWork`、`buildPuzzleFormPayloadFromSession`、`buildPuzzleFormPayloadFromAction`、`buildPuzzleCompileActionFromFormPayload`、`buildPendingPuzzleDraftMetadata`、`isPuzzleFormOnlyDraft`、`isEmptyPuzzleFormOnlyDraft`、`buildMatch3DFormPayloadFromSession`、`buildMatch3DFormPayloadFromWork` 与 `buildPendingMatch3DDraftMetadata`;`parseOptionalFiniteNumber` 留在 Module 内部。 -- 影响范围:拼图 action 完成 / 执行前 / 失败恢复、拼图表单直生草稿、拼图 form-only 草稿恢复 / 分流 / 结果页渲染、拼图草稿架恢复、抓大鹅表单直生草稿与失败恢复。 +- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、作品摘要回填 payload 和 pending 草稿 metadata,壳层需要理解描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、Match3D config / draft / anchorPack 优先级和数字解析。 +- 决策:新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,收口 `buildPuzzleFormPayloadFromWork`、`buildPuzzleFormPayloadFromSession`、`buildPuzzleFormPayloadFromAction`、`buildPuzzleCompileActionFromFormPayload`、`buildPuzzleWorkUpdatePayloadFromDraft`、`buildPendingPuzzleDraftMetadata`、`isPuzzleFormOnlyDraft`、`isEmptyPuzzleFormOnlyDraft`、`buildMatch3DFormPayloadFromSession`、`buildMatch3DFormPayloadFromWork` 与 `buildPendingMatch3DDraftMetadata`;`parseOptionalFiniteNumber` 留在 Module 内部。 +- 影响范围:拼图 action 完成 / 执行前 / 失败恢复、拼图结果页试玩前作品更新、拼图表单直生草稿、拼图 form-only 草稿恢复 / 分流 / 结果页渲染、拼图草稿架恢复、抓大鹅表单直生草稿与失败恢复。 - 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/README.md b/docs/README.md index 8b402e03..b6d29a6a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,7 +59,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并、抓大鹅生成资产旁路进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 -平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图编译 action、pending metadata 与拼图 form-only 草稿判定收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 +平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、pending metadata 与拼图 form-only 草稿判定收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready,规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md)。 diff --git a/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md index f294bbd3..34a7d312 100644 --- a/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md @@ -2,7 +2,7 @@ ## 背景 -`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图和抓大鹅草稿恢复所需的表单 payload、拼图编译 action payload、作品摘要回填 payload 和 pending 草稿 metadata。壳层因此需要理解拼图描述字段优先级、formDraft 回退、Match3D config / draft / anchorPack 优先级,以及 pending 作品架标题摘要如何从 payload 派生。后续还残留拼图 form-only 草稿判定,影响 action 分流、草稿恢复阶段和结果页渲染。 +`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图和抓大鹅草稿恢复所需的表单 payload、拼图编译 action payload、拼图作品更新 payload、作品摘要回填 payload 和 pending 草稿 metadata。壳层因此需要理解拼图描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、Match3D config / draft / anchorPack 优先级,以及 pending 作品架标题摘要如何从 payload 派生。后续还残留拼图 form-only 草稿判定,影响 action 分流、草稿恢复阶段和结果页渲染。 这些逻辑都是 DTO 变换;不读取 React state,不请求网络,也不写 URL。壳层只应决定何时恢复、何时提交 action、何时写入生成状态。 @@ -14,6 +14,7 @@ - `buildPuzzleFormPayloadFromSession(session)`:从拼图 session 恢复创作表单 payload。 - `buildPuzzleFormPayloadFromAction(payload)`:从拼图 action 还原表单 payload,仅接受 `compile_puzzle_draft` 与 `save_puzzle_form_draft`。 - `buildPuzzleCompileActionFromFormPayload(payload)`:从表单 payload 构造拼图编译 action。 +- `buildPuzzleWorkUpdatePayloadFromDraft(draft)`:从拼图结果 draft 构造 `updatePuzzleWork(...)` 所需 payload。 - `buildPendingPuzzleDraftMetadata(payload)`:从拼图 payload 派生 pending 作品架 metadata。 - `isPuzzleFormOnlyDraft(session)` 与 `isEmptyPuzzleFormOnlyDraft(session)`:判断拼图 session 是否仍只是表单草稿,以及表单草稿是否没有任何可提交内容。 - `buildMatch3DFormPayloadFromSession(session)` 与 `buildMatch3DFormPayloadFromWork(item)`:从抓大鹅 session / work 恢复表单 payload。 @@ -27,6 +28,7 @@ - 拼图 session payload 的 `pictureDescription` 优先级固定为 `formDraft.pictureDescription > first level pictureDescription > anchorPack.visualSubject.value > seedText > ''`。 - 拼图编译 action 的 `promptText` 来自 `pictureDescription || seedText`;`workDescription` 缺省回退到图片描述;`candidateCount` 固定为 `1`。 - 拼图 action 还原只接受 `compile_puzzle_draft` 与 `save_puzzle_form_draft`;其它 action 返回 `null`。 +- 拼图作品更新 payload 必须直接映射 `workTitle`、`workDescription`、`levelName`、`summary`、`themeTags`、`coverImageSrc`、`coverAssetId`,`levels` 缺失时回退空数组。 - 拼图 form-only 草稿只在 `session.stage === 'collecting_anchors'` 且存在 `draft.formDraft` 时成立。 - 空 form-only 草稿必须同时缺少 `seedText`、`formDraft.workTitle`、`formDraft.workDescription` 与 `formDraft.pictureDescription`。 - 抓大鹅 session payload 优先读取 `config`,其次 `draft`,最后 `anchorPack`;`anchorPack.clearCount` 与 `anchorPack.difficulty` 只接受有限数字字符串或数字。 @@ -35,9 +37,9 @@ ## Depth / Leverage / Locality -- **Depth**:壳层以一组表意函数取得 payload / metadata;字段优先级、默认空资产和数字解析藏入 Module Implementation。 -- **Leverage**:后续调整拼图或抓大鹅草稿恢复表单时,先改 Module 与单测,再保持壳层 API / state 副作用不变。 -- **Locality**:表单恢复与 action payload 规则集中到一个纯测试面,避免在大型平台壳的生成、重试和恢复流程里重复散落 DTO 拼装。 +- **Depth**:壳层以一组表意函数取得 payload / metadata;字段优先级、结果页 draft 更新字段、默认空资产和数字解析藏入 Module Implementation。 +- **Leverage**:后续调整拼图或抓大鹅草稿恢复表单、拼图作品更新字段时,先改 Module 与单测,再保持壳层 API / state 副作用不变。 +- **Locality**:表单恢复、作品更新与 action payload 规则集中到一个纯测试面,避免在大型平台壳的生成、重试和恢复流程里重复散落 DTO 拼装。 ## 验收 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index d6b7b155..4c2ab0b0 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -531,6 +531,7 @@ import { buildPuzzleFormPayloadFromAction, buildPuzzleFormPayloadFromSession, buildPuzzleFormPayloadFromWork, + buildPuzzleWorkUpdatePayloadFromDraft, isEmptyPuzzleFormOnlyDraft, isPuzzleFormOnlyDraft, } from './platformMiniGameDraftPayloadModel'; @@ -3818,16 +3819,10 @@ export function PlatformEntryFlowShellImpl({ } try { - const { item } = await updatePuzzleWork(draftProfileId, { - workTitle: draft.workTitle, - workDescription: draft.workDescription, - levelName: draft.levelName, - summary: draft.summary, - themeTags: draft.themeTags, - coverImageSrc: draft.coverImageSrc, - coverAssetId: draft.coverAssetId, - levels: draft.levels ?? [], - }); + const { item } = await updatePuzzleWork( + draftProfileId, + buildPuzzleWorkUpdatePayloadFromDraft(draft), + ); const run = startLocalPuzzleRun(item); setSelectedPuzzleDetail(item); setPuzzleRun(run); @@ -4183,16 +4178,10 @@ export function PlatformEntryFlowShellImpl({ } try { - const { item } = await updatePuzzleWork(profileId, { - workTitle: draft.workTitle, - workDescription: draft.workDescription, - levelName: draft.levelName, - summary: draft.summary, - themeTags: draft.themeTags, - coverImageSrc: draft.coverImageSrc, - coverAssetId: draft.coverAssetId, - levels: draft.levels ?? [], - }); + const { item } = await updatePuzzleWork( + profileId, + buildPuzzleWorkUpdatePayloadFromDraft(draft), + ); const run = startLocalPuzzleRun(item); setSelectedPuzzleDetail(item); setPuzzleRun(run); @@ -5090,16 +5079,10 @@ export function PlatformEntryFlowShellImpl({ } try { - const { item } = await updatePuzzleWork(draftProfileId, { - workTitle: draft.workTitle, - workDescription: draft.workDescription, - levelName: draft.levelName, - summary: draft.summary, - themeTags: draft.themeTags, - coverImageSrc: draft.coverImageSrc, - coverAssetId: draft.coverAssetId, - levels: draft.levels ?? [], - }); + const { item } = await updatePuzzleWork( + draftProfileId, + buildPuzzleWorkUpdatePayloadFromDraft(draft), + ); const run = startLocalPuzzleRun(item); setSelectedPuzzleDetail(item); setPuzzleRun(run); @@ -8353,16 +8336,10 @@ export function PlatformEntryFlowShellImpl({ setIsPuzzleBusy(true); setPuzzleError(null); try { - const { item } = await updatePuzzleWork(profileId, { - workTitle: draft.workTitle, - workDescription: draft.workDescription, - levelName: draft.levelName, - summary: draft.summary, - themeTags: draft.themeTags, - coverImageSrc: draft.coverImageSrc, - coverAssetId: draft.coverAssetId, - levels: draft.levels ?? [], - }); + const { item } = await updatePuzzleWork( + profileId, + buildPuzzleWorkUpdatePayloadFromDraft(draft), + ); const run = startLocalPuzzleRun(item, options.levelId ?? null); setSelectedPuzzleDetail(item); setPuzzleRun(run); diff --git a/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts b/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts index 9fdeb7a0..084fb40f 100644 --- a/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts +++ b/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts @@ -24,6 +24,7 @@ import { buildPuzzleFormPayloadFromAction, buildPuzzleFormPayloadFromSession, buildPuzzleFormPayloadFromWork, + buildPuzzleWorkUpdatePayloadFromDraft, isEmptyPuzzleFormOnlyDraft, isPuzzleFormOnlyDraft, } from './platformMiniGameDraftPayloadModel'; @@ -219,6 +220,28 @@ describe('platformMiniGameDraftPayloadModel', () => { }); }); + test('builds puzzle work update payload from result draft', () => { + const draft = buildPuzzleSession().draft!; + + expect(buildPuzzleWorkUpdatePayloadFromDraft(draft)).toEqual({ + workTitle: '会话标题', + workDescription: '会话描述', + levelName: '星桥机关', + summary: '会话摘要', + themeTags: ['星桥'], + coverImageSrc: null, + coverAssetId: null, + levels: [buildPuzzleLevel()], + }); + + expect( + buildPuzzleWorkUpdatePayloadFromDraft({ + ...draft, + levels: undefined, + }).levels, + ).toEqual([]); + }); + test('builds puzzle form payload from session form draft and fallbacks', () => { expect(buildPuzzleFormPayloadFromSession(buildPuzzleSession())).toEqual({ seedText: '表单画面', diff --git a/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts b/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts index f5181e61..bc9f3f60 100644 --- a/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts +++ b/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts @@ -4,12 +4,27 @@ import type { } from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions'; +import type { + PuzzleDraftLevel, + PuzzleResultDraft, +} from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { CreatePuzzleAgentSessionRequest, PuzzleAgentSessionSnapshot, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +export type PuzzleWorkUpdatePayload = { + workTitle?: string; + workDescription?: string; + levelName: string; + summary: string; + themeTags: string[]; + coverImageSrc?: string | null; + coverAssetId?: string | null; + levels: PuzzleDraftLevel[]; +}; + export function buildPuzzleFormPayloadFromWork( item: PuzzleWorkSummary, ): CreatePuzzleAgentSessionRequest { @@ -35,6 +50,21 @@ export function buildPuzzleFormPayloadFromWork( }; } +export function buildPuzzleWorkUpdatePayloadFromDraft( + draft: PuzzleResultDraft, +): PuzzleWorkUpdatePayload { + return { + workTitle: draft.workTitle, + workDescription: draft.workDescription, + levelName: draft.levelName, + summary: draft.summary, + themeTags: draft.themeTags, + coverImageSrc: draft.coverImageSrc, + coverAssetId: draft.coverAssetId, + levels: draft.levels ?? [], + }; +} + function parseOptionalFiniteNumber(value: string | number | null | undefined) { if (typeof value === 'number') { return Number.isFinite(value) ? value : undefined; From 4069fd58598dc8681a4c49d2e44c20e019e6d7f4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 05:24:16 +0800 Subject: [PATCH 60/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E6=8B=BC?= =?UTF-8?q?=E5=9B=BE=20runtime=20=E7=8A=B6=E6=80=81=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...ormPuzzleRuntimeStateModel收口计划-2026-06-04.md | 36 ++++ .../PlatformEntryFlowShellImpl.tsx | 40 +--- .../platformPuzzleRuntimeStateModel.test.ts | 197 ++++++++++++++++++ .../platformPuzzleRuntimeStateModel.ts | 40 ++++ 6 files changed, 284 insertions(+), 39 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts create mode 100644 src/components/platform-entry/platformPuzzleRuntimeStateModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 2c7eea2f..fa05fc7c 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1444,6 +1444,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform Puzzle Runtime State Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 在拼图排行榜提交回包后内联合并服务端 run 快照,壳层需要理解 `PuzzleRunSnapshot` 中哪些字段由前端即时裁决、哪些字段只由服务端补齐。 +- 决策:新增 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts`,以 `mergePuzzleServiceRuntimeState(currentRun, serviceRun)` 收口服务端 run 合并规则。Module 保留当前前端关卡状态、棋盘和计时,只合并服务端 run 身份、`clearedLevelCount` 上限、排行榜与下一关 handoff;任一 run 缺 `currentLevel` 时直接返回当前 run。 +- 影响范围:拼图排行榜提交、推荐 runtime isolated / default 运行态回包合并、下一关同作品 / 相似作品 handoff,以及后续 Puzzle runtime 快照字段调整。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-04 Puzzle Publish Asset Gate 收紧 - 背景:后端拼图待发布门槛与前端历史恢复逻辑一样偏弱,只要求标题、描述、标签、关卡名和 cover,导致缺关卡画面、UI spritesheet 或关卡背景的半成品可能被标为 `publishReady` / `ready_to_publish`。 diff --git a/docs/README.md b/docs/README.md index b6d29a6a..d425a3a6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,6 +63,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready,规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md)。 +拼图排行榜提交回包后的服务端 run 快照合并收口到 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts`,只合并排行榜、run 身份、通关数上限和下一关 handoff,保留前端即时裁决的关卡状态与棋盘,规则见 [【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md)。 + 后端拼图发布 / 待发布门槛收紧到首图、关卡画面、UI spritesheet 与关卡背景资产包完整,`module-puzzle` 的 preview blockers 与 `api-server` 的 session stage 判定保持同一规则,方案见 [【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md](./technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md)。 平台入口个人钱包本地 delta、dashboard 乐观更新与服务端快照对账规则收口到 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,平台壳只保留 API、ref 与 state 副作用,规则见 [【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md)。 diff --git a/docs/technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md new file mode 100644 index 00000000..3891a897 --- /dev/null +++ b/docs/technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md @@ -0,0 +1,36 @@ +# 【前端架构】Platform Puzzle Runtime State Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联 `mergePuzzleServiceRuntimeState(...)`,在拼图排行榜提交回包后,把服务端 run 快照合并回当前前端 run。此逻辑没有 React state、网络、URL 或弹窗副作用,却需要理解 `PuzzleRunSnapshot` 的局部真相分工:拼块布局、当前关卡状态和计时结果由前端即时裁决;服务端回包只补排行榜、run 身份、通关数上限和下一关 handoff。 + +若该合并规则继续留在平台壳,后续调整排行榜来源、相似作品下一关或本地 / 服务端 run 混合策略时,维护者必须翻大型壳层并同时避开大量副作用代码。 + +## 决策 + +新增 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts` 作为 Platform Puzzle Runtime State **Module**。公开 **Interface**: + +- `mergePuzzleServiceRuntimeState(currentRun, serviceRun)`:当双方都有 `currentLevel` 时,保留当前前端关卡状态与棋盘,只合并服务端 run 身份、`clearedLevelCount` 上限、排行榜与下一关 handoff;任一方缺 `currentLevel` 时返回当前 run。 + +`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它负责提交排行榜、读取回包、写 React state、刷新 archive 和错误提示,不再持有拼图 run 快照合并字段清单。 + +## Interface 约束 + +- 缺少 `currentRun.currentLevel` 或 `serviceRun.currentLevel` 时不得合并,直接返回当前 run。 +- `clearedLevelCount` 取当前 run 与服务端 run 的最大值,避免服务端较旧回包降低本地通关数。 +- 排行榜优先取 `serviceRun.currentLevel.leaderboardEntries`;为空时取 `serviceRun.leaderboardEntries`;两者皆空时保留当前关卡榜单。 +- `currentLevel` 的棋盘、状态、计时和关卡字段来自当前 run,不被服务端回包覆盖。 +- `runId`、`entryProfileId`、`recommendedNextProfileId`、`nextLevelMode`、`nextLevelProfileId`、`nextLevelId`、`recommendedNextWorks` 来自服务端 run。 + +## Depth / Leverage / Locality + +- **Depth**:壳层传入当前 run 与服务端 run,即取得合并后的稳定快照;排行榜来源、下一关 handoff 和前端局部真相保留规则藏入 Module Implementation。 +- **Leverage**:排行榜提交、后续相似作品推荐或服务端 run 字段变化时,先改纯 Module 与单测,壳层提交副作用不变。 +- **Locality**:拼图 runtime 快照合并规则集中到一个纯测试面,避免在平台壳中继续散落 `PuzzleRunSnapshot` 字段判断。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts` +- `npx eslint src/components/platform-entry/platformPuzzleRuntimeStateModel.ts src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 4c2ab0b0..5bc347a9 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -589,6 +589,7 @@ import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, } from './platformPuzzleIdentityModel'; +import { mergePuzzleServiceRuntimeState } from './platformPuzzleRuntimeStateModel'; import { type PlatformPuzzleRuntimeAuthMode, resolvePlatformRecommendRuntimeAuthPlan, @@ -1250,45 +1251,6 @@ function CreationResultRecoveryPanel({ ); } -function mergePuzzleServiceRuntimeState( - currentRun: PuzzleRunSnapshot, - serviceRun: PuzzleRunSnapshot, -): PuzzleRunSnapshot { - if (!currentRun.currentLevel || !serviceRun.currentLevel) { - return currentRun; - } - - const serviceLevel = serviceRun.currentLevel; - const leaderboardEntries = - serviceLevel.leaderboardEntries.length > 0 - ? serviceLevel.leaderboardEntries - : serviceRun.leaderboardEntries; - - // 中文注释:拼块布局和通关状态由前端即时裁决;后端快照只合并榜单与下一关 handoff。 - return { - ...currentRun, - runId: serviceRun.runId, - entryProfileId: serviceRun.entryProfileId, - clearedLevelCount: Math.max( - currentRun.clearedLevelCount, - serviceRun.clearedLevelCount, - ), - recommendedNextProfileId: serviceRun.recommendedNextProfileId, - nextLevelMode: serviceRun.nextLevelMode, - nextLevelProfileId: serviceRun.nextLevelProfileId, - nextLevelId: serviceRun.nextLevelId, - recommendedNextWorks: serviceRun.recommendedNextWorks, - leaderboardEntries, - currentLevel: { - ...currentRun.currentLevel, - leaderboardEntries: - leaderboardEntries.length > 0 - ? leaderboardEntries - : currentRun.currentLevel.leaderboardEntries, - }, - }; -} - export function PlatformEntryFlowShellImpl({ selectionStage, setSelectionStage, diff --git a/src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts b/src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts new file mode 100644 index 00000000..0e8d19da --- /dev/null +++ b/src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from 'vitest'; + +import type { + PuzzleLeaderboardEntry, + PuzzleRunSnapshot, + PuzzleRuntimeLevelSnapshot, +} from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; +import { mergePuzzleServiceRuntimeState } from './platformPuzzleRuntimeStateModel'; + +const currentLeaderboard: PuzzleLeaderboardEntry[] = [ + { + rank: 1, + nickname: '本地玩家', + elapsedMs: 12000, + isCurrentPlayer: true, + }, +]; + +const serviceLevelLeaderboard: PuzzleLeaderboardEntry[] = [ + { + rank: 1, + nickname: '服务端玩家', + elapsedMs: 9000, + }, +]; + +const serviceRunLeaderboard: PuzzleLeaderboardEntry[] = [ + { + rank: 2, + nickname: '全局玩家', + elapsedMs: 15000, + }, +]; + +function buildPuzzleLevel( + overrides: Partial = {}, +): PuzzleRuntimeLevelSnapshot { + return { + runId: 'run-current', + levelIndex: 0, + levelId: 'level-1', + gridSize: 3, + profileId: 'puzzle-profile-current', + levelName: '星桥机关', + authorDisplayName: '玩家', + themeTags: ['星桥'], + coverImageSrc: '/cover.png', + board: { + rows: 3, + cols: 3, + pieces: [], + mergedGroups: [], + selectedPieceId: null, + allTilesResolved: true, + }, + status: 'cleared', + startedAtMs: 1000, + clearedAtMs: 13000, + elapsedMs: 12000, + timeLimitMs: 120000, + remainingMs: 108000, + pausedAccumulatedMs: 0, + pauseStartedAtMs: null, + freezeAccumulatedMs: 0, + freezeStartedAtMs: null, + freezeUntilMs: null, + leaderboardEntries: currentLeaderboard, + ...overrides, + }; +} + +function buildPuzzleRun( + overrides: Partial = {}, +): PuzzleRunSnapshot { + return { + runId: 'run-current', + entryProfileId: 'puzzle-profile-current', + clearedLevelCount: 1, + currentLevelIndex: 0, + currentGridSize: 3, + playedProfileIds: ['puzzle-profile-current'], + previousLevelTags: ['星桥'], + currentLevel: buildPuzzleLevel(), + recommendedNextProfileId: null, + nextLevelMode: 'sameWork', + nextLevelProfileId: null, + nextLevelId: null, + recommendedNextWorks: [], + leaderboardEntries: currentLeaderboard, + ...overrides, + }; +} + +describe('platformPuzzleRuntimeStateModel', () => { + test('keeps current run when either current level is missing', () => { + const currentRun = buildPuzzleRun({ currentLevel: null }); + expect( + mergePuzzleServiceRuntimeState(currentRun, buildPuzzleRun()), + ).toBe(currentRun); + + const serviceRun = buildPuzzleRun({ currentLevel: null }); + const playableCurrentRun = buildPuzzleRun(); + expect( + mergePuzzleServiceRuntimeState(playableCurrentRun, serviceRun), + ).toBe(playableCurrentRun); + }); + + test('merges service leaderboard and next-level handoff without replacing local level state', () => { + const currentRun = buildPuzzleRun({ + clearedLevelCount: 2, + currentLevel: buildPuzzleLevel({ + runId: 'run-current', + status: 'cleared', + board: { + rows: 3, + cols: 3, + pieces: [ + { + pieceId: 'piece-local', + correctRow: 0, + correctCol: 0, + currentRow: 0, + currentCol: 0, + mergedGroupId: null, + }, + ], + mergedGroups: [], + selectedPieceId: 'piece-local', + allTilesResolved: true, + }, + }), + }); + const serviceRun = buildPuzzleRun({ + runId: 'run-service', + entryProfileId: 'puzzle-profile-service', + clearedLevelCount: 1, + recommendedNextProfileId: 'next-recommended', + nextLevelMode: 'similarWorks', + nextLevelProfileId: 'next-profile', + nextLevelId: 'next-level', + recommendedNextWorks: [ + { + profileId: 'next-profile', + levelName: '月桥机关', + authorDisplayName: '推荐作者', + themeTags: ['月桥'], + coverImageSrc: '/next-cover.png', + similarityScore: 0.91, + }, + ], + currentLevel: buildPuzzleLevel({ + runId: 'run-service-level', + status: 'playing', + leaderboardEntries: serviceLevelLeaderboard, + }), + }); + + const merged = mergePuzzleServiceRuntimeState(currentRun, serviceRun); + + expect(merged.runId).toBe('run-service'); + expect(merged.entryProfileId).toBe('puzzle-profile-service'); + expect(merged.clearedLevelCount).toBe(2); + expect(merged.recommendedNextProfileId).toBe('next-recommended'); + expect(merged.nextLevelMode).toBe('similarWorks'); + expect(merged.nextLevelProfileId).toBe('next-profile'); + expect(merged.nextLevelId).toBe('next-level'); + expect(merged.recommendedNextWorks).toEqual(serviceRun.recommendedNextWorks); + expect(merged.leaderboardEntries).toEqual(serviceLevelLeaderboard); + expect(merged.currentLevel?.status).toBe('cleared'); + expect(merged.currentLevel?.board.pieces).toEqual( + currentRun.currentLevel?.board.pieces, + ); + expect(merged.currentLevel?.leaderboardEntries).toEqual( + serviceLevelLeaderboard, + ); + }); + + test('falls back to service run leaderboard, then current level leaderboard', () => { + const currentRun = buildPuzzleRun(); + const serviceRun = buildPuzzleRun({ + currentLevel: buildPuzzleLevel({ leaderboardEntries: [] }), + leaderboardEntries: serviceRunLeaderboard, + }); + + expect( + mergePuzzleServiceRuntimeState(currentRun, serviceRun).currentLevel + ?.leaderboardEntries, + ).toEqual(serviceRunLeaderboard); + + expect( + mergePuzzleServiceRuntimeState(currentRun, { + ...serviceRun, + leaderboardEntries: [], + }).currentLevel?.leaderboardEntries, + ).toEqual(currentLeaderboard); + }); +}); diff --git a/src/components/platform-entry/platformPuzzleRuntimeStateModel.ts b/src/components/platform-entry/platformPuzzleRuntimeStateModel.ts new file mode 100644 index 00000000..2622c729 --- /dev/null +++ b/src/components/platform-entry/platformPuzzleRuntimeStateModel.ts @@ -0,0 +1,40 @@ +import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; + +export function mergePuzzleServiceRuntimeState( + currentRun: PuzzleRunSnapshot, + serviceRun: PuzzleRunSnapshot, +): PuzzleRunSnapshot { + if (!currentRun.currentLevel || !serviceRun.currentLevel) { + return currentRun; + } + + const serviceLevel = serviceRun.currentLevel; + const leaderboardEntries = + serviceLevel.leaderboardEntries.length > 0 + ? serviceLevel.leaderboardEntries + : serviceRun.leaderboardEntries; + + // 中文注释:拼块布局和通关状态由前端即时裁决;后端快照只合并榜单与下一关 handoff。 + return { + ...currentRun, + runId: serviceRun.runId, + entryProfileId: serviceRun.entryProfileId, + clearedLevelCount: Math.max( + currentRun.clearedLevelCount, + serviceRun.clearedLevelCount, + ), + recommendedNextProfileId: serviceRun.recommendedNextProfileId, + nextLevelMode: serviceRun.nextLevelMode, + nextLevelProfileId: serviceRun.nextLevelProfileId, + nextLevelId: serviceRun.nextLevelId, + recommendedNextWorks: serviceRun.recommendedNextWorks, + leaderboardEntries, + currentLevel: { + ...currentRun.currentLevel, + leaderboardEntries: + leaderboardEntries.length > 0 + ? leaderboardEntries + : currentRun.currentLevel.leaderboardEntries, + }, + }; +} From 991efb2eed3ce9477533fe27e0028888e72d42ea Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 05:31:40 +0800 Subject: [PATCH 61/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E6=9E=B6=E6=9B=B4=E6=96=B0=E5=9B=9E=E5=A1=AB=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 6 +- docs/README.md | 2 +- ...DraftGenerationShelfModel收口计划-2026-06-03.md | 4 +- .../PlatformEntryFlowShellImpl.tsx | 18 +---- .../platformDraftGenerationShelfModel.test.ts | 66 +++++++++++++++++++ .../platformDraftGenerationShelfModel.ts | 16 +++++ 6 files changed, 91 insertions(+), 21 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index fa05fc7c..7a3c320d 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1354,9 +1354,9 @@ ## 2026-06-03 Draft Generation Shelf Model 收口 -- 背景:平台壳内散落创作生成 notice key、pending 作品架占位、失败文案覆盖、拼图稳定 ID、持久化 generating/failed 判断与草稿 Tab 未读点,新增或调整玩法时需要在多处理解 `workId` / `profileId` / `sourceSessionId` / `draftId` 形状。 -- 决策:新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf Module,Interface 收口 `collectDraftNoticeKeys`、`getGenerationNoticeShelfKeys`、`createPendingDraftShelfState`、各玩法 `buildPending*Works`、`buildCreationWorkShelfRuntimeState`、`collectVisibleDraftNoticeKeys`、`hasUnreadDraftGenerationUpdates`、拼图稳定 ID 与持久化状态判断;`PlatformEntryFlowShellImpl.tsx` 仅作为 React state、网络刷新、路由和弹窗副作用 Adapter。 -- 影响范围:创作中心草稿 Tab 未读点、作品架生成中遮罩、失败草稿摘要、pending 草稿占位、拼图 / 抓大鹅生成恢复和各玩法生成完成通知。 +- 背景:平台壳内散落创作生成 notice key、pending 作品架占位、作品详情更新回填、失败文案覆盖、拼图稳定 ID、持久化 generating/failed 判断与草稿 Tab 未读点,新增或调整玩法时需要在多处理解 `workId` / `profileId` / `sourceSessionId` / `draftId` 形状。 +- 决策:新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf Module,Interface 收口 `collectDraftNoticeKeys`、`getGenerationNoticeShelfKeys`、`createPendingDraftShelfState`、各玩法 `buildPending*Works`、`buildCreationWorkShelfRuntimeState`、`collectVisibleDraftNoticeKeys`、`hasUnreadDraftGenerationUpdates`、`mergePuzzleWorkSummary`、`mergeBigFishWorkSummary`、拼图稳定 ID 与持久化状态判断;`PlatformEntryFlowShellImpl.tsx` 仅作为 React state、网络刷新、路由和弹窗副作用 Adapter。 +- 影响范围:创作中心草稿 Tab 未读点、作品架生成中遮罩、作品详情更新回填、失败草稿摘要、pending 草稿占位、拼图 / 抓大鹅生成恢复和各玩法生成完成通知。 - 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index d425a3a6..afb54240 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,7 +77,7 @@ Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复 RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 -平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +平台入口创作生成通知、pending 作品架占位、作品详情更新回填、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md index c19c6a3c..942da2b3 100644 --- a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md @@ -2,7 +2,7 @@ ## 背景 -`PlatformEntryFlowShellImpl.tsx` 同时承载创作生成状态、草稿 Tab 未读点、pending 作品架占位、失败文案覆盖和跨玩法 notice key。拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、汪汪声浪、大鱼吃小鱼和宝贝识物各有不同的 `workId` / `profileId` / `sourceSessionId` / `draftId`,这些规则散在平台壳 **Implementation** 内,导致调用方必须理解每种玩法的草稿身份形状。 +`PlatformEntryFlowShellImpl.tsx` 同时承载创作生成状态、草稿 Tab 未读点、pending 作品架占位、失败文案覆盖、作品详情更新回填和跨玩法 notice key。拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、汪汪声浪、大鱼吃小鱼和宝贝识物各有不同的 `workId` / `profileId` / `sourceSessionId` / `draftId`,这些规则散在平台壳 **Implementation** 内,导致调用方必须理解每种玩法的草稿身份形状。 该 **Interface** 过浅:页面看似只关心“生成中 / 已完成未读 / 失败”,却要知道多 ID 去重、pending 草稿去重、失败摘要、拼图空标题兜底和持久化 generating 覆盖规则。 @@ -14,6 +14,7 @@ - `createPendingDraftShelfState(...)` 与 `buildPending*Works(...)`:统一把本地 pending 生成状态映射成作品架占位,并避免与后端已有草稿重复。 - `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。 - `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。 +- `mergePuzzleWorkSummary(current, updated)` 与 `mergeBigFishWorkSummary(current, updated)`:统一作品详情更新后回填作品架和当前详情的身份匹配规则。 - `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)`、`isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**。 `PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、启动生成、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总和作品架 runtime state 规则。 @@ -22,6 +23,7 @@ - 新玩法若需进入草稿生成通知,必须在此 **Module** 补 notice key、pending 占位和 visible key 映射,避免在平台壳里新增散落 switch。 - pending 作品只用于本地生成任务尚未被后端作品架返回时的临时展示;一旦后端已有同一 `sourceSessionId` / `profileId` / `workId`,pending 占位必须让位。 +- 拼图作品详情更新只以 `profileId` 匹配回填;大鱼吃小鱼作品详情更新只以 `sourceSessionId` 匹配回填。 - 失败 notice 优先级高于持久化 generating,且可通过 pending metadata 提供更具体 summary;否则回退玩法默认失败摘要。 - 已有封面的拼图草稿即使局部关卡仍在后台生成,也不得被整卡遮罩为不可打开的生成中状态。 - 本 **Module** 不做网络请求、路由切换、弹窗副作用或 React state 写入,只保留纯 **Implementation**,以提高 **Depth**、**Leverage** 与 **Locality**。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 5bc347a9..a793c15e 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -458,6 +458,8 @@ import { hasUnreadReadyDraftGenerationNotice, isPersistedDraftFailed, isPersistedDraftGenerating, + mergeBigFishWorkSummary, + mergePuzzleWorkSummary, normalizeDraftNoticeId, type PendingDraftShelfKind, type PendingDraftShelfMap, @@ -740,13 +742,6 @@ const PUZZLE_DRAFT_GENERATION_POINT_COST = 2; const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3; -function mergePuzzleWorkSummary( - current: PuzzleWorkSummary, - updated: PuzzleWorkSummary, -): PuzzleWorkSummary { - return current.profileId === updated.profileId ? updated : current; -} - const PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY = 'genarrative.puzzle-onboarding.first-visit.v1'; const PUZZLE_ONBOARDING_COPY = '待定待定待定'; @@ -920,15 +915,6 @@ function markPuzzleOnboardingSeen() { } } -function mergeBigFishWorkSummary( - current: BigFishWorkSummary, - updated: BigFishWorkSummary, -): BigFishWorkSummary { - return current.sourceSessionId === updated.sourceSessionId - ? updated - : current; -} - async function resolvePublicWorkAuthorSummary( entry: PlatformPublicGalleryCard, ): Promise { diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts index 25c70f45..5863cb11 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf'; import { @@ -12,6 +13,8 @@ import { type DraftGenerationNoticeMap, getGenerationNoticeShelfKeys, hasUnreadDraftGenerationUpdates, + mergeBigFishWorkSummary, + mergePuzzleWorkSummary, } from './platformDraftGenerationShelfModel'; describe('platformDraftGenerationShelfModel', () => { @@ -53,6 +56,42 @@ describe('platformDraftGenerationShelfModel', () => { expect(pending).toEqual([]); }); + test('mergePuzzleWorkSummary only replaces the matching profile', () => { + const current = buildPuzzleWork({ + profileId: 'puzzle-profile-1', + workTitle: '旧拼图', + }); + const updated = buildPuzzleWork({ + profileId: 'puzzle-profile-1', + workTitle: '新拼图', + }); + const other = buildPuzzleWork({ + profileId: 'puzzle-profile-2', + workTitle: '别的拼图', + }); + + expect(mergePuzzleWorkSummary(current, updated)).toBe(updated); + expect(mergePuzzleWorkSummary(current, other)).toBe(current); + }); + + test('mergeBigFishWorkSummary only replaces the matching source session', () => { + const current = buildBigFishWork({ + sourceSessionId: 'big-fish-session-1', + title: '旧大鱼', + }); + const updated = buildBigFishWork({ + sourceSessionId: 'big-fish-session-1', + title: '新大鱼', + }); + const other = buildBigFishWork({ + sourceSessionId: 'big-fish-session-2', + title: '别的大鱼', + }); + + expect(mergeBigFishWorkSummary(current, updated)).toBe(updated); + expect(mergeBigFishWorkSummary(current, other)).toBe(current); + }); + test('buildCreationWorkShelfRuntimeState lets failure notice override persisted generating puzzle copy', () => { const [item] = buildCreationWorkShelfItems({ rpgItems: [], @@ -187,3 +226,30 @@ function buildPuzzleWork( ...overrides, }; } + +function buildBigFishWork( + overrides: Partial = {}, +): BigFishWorkSummary { + return { + workId: 'big-fish-work-base', + sourceSessionId: 'big-fish-session-base', + ownerUserId: 'user-1', + authorDisplayName: '测试作者', + title: '潮雾大鱼', + subtitle: '潮雾港口', + summary: '潮雾港口大鱼吃小鱼。', + coverImageSrc: null, + status: 'draft', + updatedAt: '2026-06-03T08:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + levelCount: 1, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + ...overrides, + }; +} diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.ts index b257cbef..dbd0863c 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.ts @@ -479,6 +479,22 @@ export function hasUnreadDraftGenerationUpdates( }); } +export function mergeBigFishWorkSummary( + current: BigFishWorkSummary, + updated: BigFishWorkSummary, +): BigFishWorkSummary { + return current.sourceSessionId === updated.sourceSessionId + ? updated + : current; +} + +export function mergePuzzleWorkSummary( + current: PuzzleWorkSummary, + updated: PuzzleWorkSummary, +): PuzzleWorkSummary { + return current.profileId === updated.profileId ? updated : current; +} + export function buildPendingBigFishWorks( pending: Record | undefined, existingItems: readonly BigFishWorkSummary[], From b037ce1e32a9cfb7ad8e37d59d1450257eb3e5c4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 05:41:44 +0800 Subject: [PATCH 62/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=B0=8F?= =?UTF-8?q?=E7=8E=A9=E6=B3=95=E7=94=9F=E6=88=90=20action=20payload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 12 +- docs/README.md | 4 +- ...mMiniGameDraftPayloadModel收口计划-2026-06-04.md | 9 +- ...iniGameSessionMappingModel收口计划-2026-06-04.md | 8 +- .../PlatformEntryFlowShellImpl.tsx | 100 +++--------- .../platformMiniGameDraftPayloadModel.test.ts | 153 ++++++++++++++++++ .../platformMiniGameDraftPayloadModel.ts | 53 ++++++ ...latformMiniGameSessionMappingModel.test.ts | 75 +++++++++ .../platformMiniGameSessionMappingModel.ts | 26 +++ 9 files changed, 344 insertions(+), 96 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7a3c320d..05e98325 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1405,9 +1405,9 @@ ## 2026-06-04 Platform Mini Game Session Mapping Model 收口 -- 背景:`PlatformEntryFlowShellImpl.tsx` 顶部仍保留拼图 runtime 恢复、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复和敲木鱼 pending session 等纯 DTO 映射,壳层需要理解 sessionId 优先级、拼图稳定 ID、方洞草稿 profile 默认值、视觉小说 work/session fallback 和 pending draft 默认值。 -- 决策:新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,收口 `buildPuzzleRuntimeWorkFromSession`、`buildSquareHoleProfileFromSession`、`buildVisualNovelSessionFromWorkDetail`、`buildJumpHopPendingSession`、`buildWoodenFishSessionFromWorkDetail` 与 `buildWoodenFishPendingSession`。Module 复用 `normalizeCreationUrlValue` 与 `platformPuzzleIdentityModel`;壳层只保留网络读取、React state、URL 写入和 stage 切换副作用。 -- 影响范围:拼图 runtime URL 恢复、方洞挑战草稿 profile 构造、视觉小说草稿作品架恢复、跳一跳生成中作品架打开、敲木鱼生成中作品架打开和敲木鱼草稿 detail 恢复。 +- 背景:`PlatformEntryFlowShellImpl.tsx` 顶部仍保留拼图 runtime 恢复、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复、敲木鱼生成中作品摘要和敲木鱼 pending session 等纯 DTO 映射,壳层需要理解 sessionId 优先级、拼图稳定 ID、方洞草稿 profile 默认值、视觉小说 work/session fallback、敲木鱼生成中摘要和 pending draft 默认值。 +- 决策:新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,收口 `buildPuzzleRuntimeWorkFromSession`、`buildSquareHoleProfileFromSession`、`buildVisualNovelSessionFromWorkDetail`、`buildJumpHopPendingSession`、`buildWoodenFishSessionFromWorkDetail`、`buildWoodenFishGeneratingWorkSummary` 与 `buildWoodenFishPendingSession`。Module 复用 `normalizeCreationUrlValue` 与 `platformPuzzleIdentityModel`;壳层只保留网络读取、React state、URL 写入和 stage 切换副作用。 +- 影响范围:拼图 runtime URL 恢复、方洞挑战草稿 profile 构造、视觉小说草稿作品架恢复、跳一跳生成中作品架打开、敲木鱼生成中作品架摘要 / 作品架打开和敲木鱼草稿 detail 恢复。 - 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -1430,9 +1430,9 @@ ## 2026-06-04 Platform Mini Game Draft Payload Model 收口 -- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、作品摘要回填 payload 和 pending 草稿 metadata,壳层需要理解描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、Match3D config / draft / anchorPack 优先级和数字解析。 -- 决策:新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,收口 `buildPuzzleFormPayloadFromWork`、`buildPuzzleFormPayloadFromSession`、`buildPuzzleFormPayloadFromAction`、`buildPuzzleCompileActionFromFormPayload`、`buildPuzzleWorkUpdatePayloadFromDraft`、`buildPendingPuzzleDraftMetadata`、`isPuzzleFormOnlyDraft`、`isEmptyPuzzleFormOnlyDraft`、`buildMatch3DFormPayloadFromSession`、`buildMatch3DFormPayloadFromWork` 与 `buildPendingMatch3DDraftMetadata`;`parseOptionalFiniteNumber` 留在 Module 内部。 -- 影响范围:拼图 action 完成 / 执行前 / 失败恢复、拼图结果页试玩前作品更新、拼图表单直生草稿、拼图 form-only 草稿恢复 / 分流 / 结果页渲染、拼图草稿架恢复、抓大鹅表单直生草稿与失败恢复。 +- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、跳一跳 / 敲木鱼生成 action、作品摘要回填 payload 和 pending 草稿 metadata,壳层需要理解描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、跳一跳 / 敲木鱼 payload 与 draft 优先级、Match3D config / draft / anchorPack 优先级和数字解析。 +- 决策:新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,收口 `buildPuzzleFormPayloadFromWork`、`buildPuzzleFormPayloadFromSession`、`buildPuzzleFormPayloadFromAction`、`buildPuzzleCompileActionFromFormPayload`、`buildPuzzleWorkUpdatePayloadFromDraft`、`buildJumpHopDraftActionPayload`、`buildWoodenFishDraftActionPayload`、`buildPendingPuzzleDraftMetadata`、`isPuzzleFormOnlyDraft`、`isEmptyPuzzleFormOnlyDraft`、`buildMatch3DFormPayloadFromSession`、`buildMatch3DFormPayloadFromWork` 与 `buildPendingMatch3DDraftMetadata`;`parseOptionalFiniteNumber` 留在 Module 内部。 +- 影响范围:拼图 action 完成 / 执行前 / 失败恢复、拼图结果页试玩前作品更新、跳一跳 / 敲木鱼生成与重生成 action、拼图表单直生草稿、拼图 form-only 草稿恢复 / 分流 / 结果页渲染、拼图草稿架恢复、抓大鹅表单直生草稿与失败恢复。 - 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/README.md b/docs/README.md index afb54240..dcbfb1f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,11 +55,11 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口生成页进度 tick 的 stage 到生成状态映射、终态判定和视觉小说轻量生成特例收口到 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,壳层只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,规则见 [【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md)。 -平台壳的拼图 runtime 恢复 work、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 +平台壳的拼图 runtime 恢复 work、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复 session、敲木鱼生成中作品摘要和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并、抓大鹅生成资产旁路进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 -平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、pending metadata 与拼图 form-only 草稿判定收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 +平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、跳一跳 / 敲木鱼生成 action、pending metadata 与拼图 form-only 草稿判定收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready,规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md)。 diff --git a/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md index 34a7d312..cc6572cd 100644 --- a/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md @@ -2,7 +2,7 @@ ## 背景 -`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图和抓大鹅草稿恢复所需的表单 payload、拼图编译 action payload、拼图作品更新 payload、作品摘要回填 payload 和 pending 草稿 metadata。壳层因此需要理解拼图描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、Match3D config / draft / anchorPack 优先级,以及 pending 作品架标题摘要如何从 payload 派生。后续还残留拼图 form-only 草稿判定,影响 action 分流、草稿恢复阶段和结果页渲染。 +`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图和抓大鹅草稿恢复所需的表单 payload、拼图编译 action payload、拼图作品更新 payload、跳一跳 / 敲木鱼生成 action payload、作品摘要回填 payload 和 pending 草稿 metadata。壳层因此需要理解拼图描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、Match3D config / draft / anchorPack 优先级、跳一跳 / 敲木鱼 payload 与 session draft 优先级,以及 pending 作品架标题摘要如何从 payload 派生。后续还残留拼图 form-only 草稿判定,影响 action 分流、草稿恢复阶段和结果页渲染。 这些逻辑都是 DTO 变换;不读取 React state,不请求网络,也不写 URL。壳层只应决定何时恢复、何时提交 action、何时写入生成状态。 @@ -15,6 +15,8 @@ - `buildPuzzleFormPayloadFromAction(payload)`:从拼图 action 还原表单 payload,仅接受 `compile_puzzle_draft` 与 `save_puzzle_form_draft`。 - `buildPuzzleCompileActionFromFormPayload(payload)`:从表单 payload 构造拼图编译 action。 - `buildPuzzleWorkUpdatePayloadFromDraft(draft)`:从拼图结果 draft 构造 `updatePuzzleWork(...)` 所需 payload。 +- `buildJumpHopDraftActionPayload(actionType, { payload, draft })`:从跳一跳表单 payload / session draft 构造生成或重生成 action。 +- `buildWoodenFishDraftActionPayload(actionType, { payload, draft })`:从敲木鱼表单 payload / session draft 构造生成或重生成 action。 - `buildPendingPuzzleDraftMetadata(payload)`:从拼图 payload 派生 pending 作品架 metadata。 - `isPuzzleFormOnlyDraft(session)` 与 `isEmptyPuzzleFormOnlyDraft(session)`:判断拼图 session 是否仍只是表单草稿,以及表单草稿是否没有任何可提交内容。 - `buildMatch3DFormPayloadFromSession(session)` 与 `buildMatch3DFormPayloadFromWork(item)`:从抓大鹅 session / work 恢复表单 payload。 @@ -29,6 +31,7 @@ - 拼图编译 action 的 `promptText` 来自 `pictureDescription || seedText`;`workDescription` 缺省回退到图片描述;`candidateCount` 固定为 `1`。 - 拼图 action 还原只接受 `compile_puzzle_draft` 与 `save_puzzle_form_draft`;其它 action 返回 `null`。 - 拼图作品更新 payload 必须直接映射 `workTitle`、`workDescription`、`levelName`、`summary`、`themeTags`、`coverImageSrc`、`coverAssetId`,`levels` 缺失时回退空数组。 +- 跳一跳和敲木鱼生成 action payload 的字段优先级固定为表单 payload 优先,其次 session draft;重生成 action 只传 session draft 字段。 - 拼图 form-only 草稿只在 `session.stage === 'collecting_anchors'` 且存在 `draft.formDraft` 时成立。 - 空 form-only 草稿必须同时缺少 `seedText`、`formDraft.workTitle`、`formDraft.workDescription` 与 `formDraft.pictureDescription`。 - 抓大鹅 session payload 优先读取 `config`,其次 `draft`,最后 `anchorPack`;`anchorPack.clearCount` 与 `anchorPack.difficulty` 只接受有限数字字符串或数字。 @@ -37,8 +40,8 @@ ## Depth / Leverage / Locality -- **Depth**:壳层以一组表意函数取得 payload / metadata;字段优先级、结果页 draft 更新字段、默认空资产和数字解析藏入 Module Implementation。 -- **Leverage**:后续调整拼图或抓大鹅草稿恢复表单、拼图作品更新字段时,先改 Module 与单测,再保持壳层 API / state 副作用不变。 +- **Depth**:壳层以一组表意函数取得 payload / metadata;字段优先级、结果页 draft 更新字段、跳一跳 / 敲木鱼 action 字段、默认空资产和数字解析藏入 Module Implementation。 +- **Leverage**:后续调整拼图或抓大鹅草稿恢复表单、拼图作品更新字段、跳一跳 / 敲木鱼生成 action 字段时,先改 Module 与单测,再保持壳层 API / state 副作用不变。 - **Locality**:表单恢复、作品更新与 action payload 规则集中到一个纯测试面,避免在大型平台壳的生成、重试和恢复流程里重复散落 DTO 拼装。 ## 验收 diff --git a/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md index 77b680c1..1578d439 100644 --- a/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md @@ -2,7 +2,7 @@ ## 背景 -`PlatformEntryFlowShellImpl.tsx` 顶部曾保留拼图 runtime 恢复、方洞挑战 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 work detail 恢复和敲木鱼 pending session 多段纯 DTO 映射。它们没有 React state、网络请求、路由、弹窗或计时副作用,却住在大型平台壳内;新增或修正生成中草稿恢复时,需要在壳层里理解 sessionId 优先级、拼图稳定 ID、方洞 profile 默认值、视觉小说 work/session fallback、pending draft 默认值和木鱼 fallback 规则。 +`PlatformEntryFlowShellImpl.tsx` 顶部曾保留拼图 runtime 恢复、方洞挑战 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 work detail 恢复、敲木鱼生成中作品摘要和敲木鱼 pending session 多段纯 DTO 映射。它们没有 React state、网络请求、路由、弹窗或计时副作用,却住在大型平台壳内;新增或修正生成中草稿恢复时,需要在壳层里理解 sessionId 优先级、拼图稳定 ID、方洞 profile 默认值、视觉小说 work/session fallback、pending draft 默认值和木鱼 fallback 规则。 这些规则属于平台壳 session / work 恢复映射,应成为可测试的 **Module**。壳层只负责调用网络、写 React state、写 URL 和切换 stage。 @@ -15,6 +15,7 @@ - `buildVisualNovelSessionFromWorkDetail(work)`:从视觉小说 work detail 恢复 `VisualNovelAgentSessionSnapshot`,供草稿作品架回到结果页继续编辑。 - `buildJumpHopPendingSession(item)`:从跳一跳作品架 summary 构造生成中 pending session。 - `buildWoodenFishSessionFromWorkDetail(work, fallbackItem?)`:从敲木鱼 work detail 恢复 session,并按 summary / fallback / profileId 决定 sessionId。 +- `buildWoodenFishGeneratingWorkSummary(session, payload?)`:从敲木鱼生成 session 和可选表单 payload 构造作品架生成中摘要。 - `buildWoodenFishPendingSession(item)`:从敲木鱼作品架 summary 构造生成中 pending session。 `PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:调用这些映射后继续负责 `set*Session`、`set*Work`、`set*Run`、`createMiniGameDraftGenerationState(...)`、`writeCreationUrlState(...)`、`enterCreateTab()` 和 `setSelectionStage(...)`。 @@ -29,13 +30,14 @@ - 视觉小说恢复 session 的 `sessionId` 优先归一化后的 `sourceSessionId`,为空时回退 `workId`;`status='ready'`,`messages=[]`,`pendingAction=null`,`sourceMode` 来自 draft,`updatedAt` 来自 summary。 - 跳一跳 pending sessionId 优先 `sourceSessionId`,缺失时用 `profileId`;素材、路径和 prompt 维持空值兜底。 - 敲木鱼 detail sessionId 优先级固定为 `work.summary.sourceSessionId > fallbackItem.sourceSessionId > profileId`。 +- 敲木鱼生成中摘要的 `workId/profileId/sourceSessionId` 都来自 sessionId;标题、描述和标签优先表单 payload,其次 session draft,最后回退 `敲木鱼` / 空描述 / `['敲木鱼']`。 - 敲木鱼 pending session 保持 `floatingWords=['功德 +1']`、素材 / 音效 / 背景为空的旧默认。 ## Depth / Leverage / Locality -- **Depth**:壳层以少量函数取得恢复用 DTO;ID 优先级、方洞 profile 默认值、视觉小说 session fallback 和 pending draft 字段藏入 Module Implementation。 +- **Depth**:壳层以少量函数取得恢复用 DTO;ID 优先级、方洞 profile 默认值、视觉小说 session fallback、敲木鱼生成中摘要和 pending draft 字段藏入 Module Implementation。 - **Leverage**:后续新增生成中作品恢复或修改 sessionId 规则时,先改 Module 与单测,再保持壳层 Adapter 副作用不变。 -- **Locality**:拼图、方洞、视觉小说、跳一跳和敲木鱼的恢复映射集中在一个纯测试面,避免在大型壳层顶部继续堆积 DTO 构造。 +- **Locality**:拼图、方洞、视觉小说、跳一跳和敲木鱼的恢复 / 生成中映射集中在一个纯测试面,避免在大型壳层顶部继续堆积 DTO 构造。 ## 验收 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index a793c15e..4a3cf00c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -525,6 +525,7 @@ import { resolveFinishedMiniGameDraftGenerationState, } from './platformMiniGameDraftGenerationStateModel'; import { + buildJumpHopDraftActionPayload, buildMatch3DFormPayloadFromSession, buildMatch3DFormPayloadFromWork, buildPendingMatch3DDraftMetadata, @@ -534,6 +535,7 @@ import { buildPuzzleFormPayloadFromSession, buildPuzzleFormPayloadFromWork, buildPuzzleWorkUpdatePayloadFromDraft, + buildWoodenFishDraftActionPayload, isEmptyPuzzleFormOnlyDraft, isPuzzleFormOnlyDraft, } from './platformMiniGameDraftPayloadModel'; @@ -542,6 +544,7 @@ import { buildPuzzleRuntimeWorkFromSession, buildSquareHoleProfileFromSession, buildVisualNovelSessionFromWorkDetail, + buildWoodenFishGeneratingWorkSummary, buildWoodenFishPendingSession, buildWoodenFishSessionFromWorkDetail, } from './platformMiniGameSessionMappingModel'; @@ -6828,25 +6831,10 @@ export function PlatformEntryFlowShellImpl({ try { const response = await jumpHopClient.executeAction( created.session.sessionId, - { - actionType: 'compile-draft', - workTitle: payload?.workTitle ?? created.session.draft?.workTitle, - workDescription: - payload?.workDescription ?? - created.session.draft?.workDescription, - themeTags: payload?.themeTags ?? created.session.draft?.themeTags, - difficulty: - payload?.difficulty ?? created.session.draft?.difficulty, - stylePreset: - payload?.stylePreset ?? created.session.draft?.stylePreset, - characterPrompt: - payload?.characterPrompt ?? - created.session.draft?.characterPrompt, - tilePrompt: - payload?.tilePrompt ?? created.session.draft?.tilePrompt, - endMoodPrompt: - payload?.endMoodPrompt ?? created.session.draft?.endMoodPrompt, - }, + buildJumpHopDraftActionPayload('compile-draft', { + payload, + draft: created.session.draft, + }), ); const readyState = createReadyJumpHopGenerationState(generationState); setJumpHopSession(response.session); @@ -6958,17 +6946,9 @@ export function PlatformEntryFlowShellImpl({ try { const response = await jumpHopClient.executeAction( jumpHopSession.sessionId, - { - actionType, - workTitle: jumpHopSession.draft?.workTitle, - workDescription: jumpHopSession.draft?.workDescription, - themeTags: jumpHopSession.draft?.themeTags, - difficulty: jumpHopSession.draft?.difficulty, - stylePreset: jumpHopSession.draft?.stylePreset, - characterPrompt: jumpHopSession.draft?.characterPrompt, - tilePrompt: jumpHopSession.draft?.tilePrompt, - endMoodPrompt: jumpHopSession.draft?.endMoodPrompt, - }, + buildJumpHopDraftActionPayload(actionType, { + draft: jumpHopSession.draft, + }), ); setJumpHopSession(response.session); setJumpHopWork(response.work ?? jumpHopWork); @@ -7190,30 +7170,8 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('wooden-fish-generating'); markDraftGenerating('wooden-fish', [created.session.sessionId]); markPendingDraftGenerating('wooden-fish', created.session.sessionId); - const createdAt = created.session.updatedAt ?? created.session.createdAt; setWoodenFishWorks((current) => [ - { - runtimeKind: 'wooden-fish', - workId: created.session.sessionId, - profileId: created.session.sessionId, - ownerUserId: created.session.ownerUserId, - sourceSessionId: created.session.sessionId, - workTitle: - payload?.workTitle ?? created.session.draft?.workTitle ?? '敲木鱼', - workDescription: - payload?.workDescription ?? - created.session.draft?.workDescription ?? - '', - themeTags: payload?.themeTags ?? - created.session.draft?.themeTags ?? ['敲木鱼'], - coverImageSrc: created.session.draft?.coverImageSrc ?? null, - publicationStatus: 'draft', - playCount: 0, - updatedAt: createdAt, - publishedAt: null, - publishReady: false, - generationStatus: 'generating', - }, + buildWoodenFishGeneratingWorkSummary(created.session, payload), ...current.filter( (item) => item.sourceSessionId !== created.session.sessionId, ), @@ -7222,24 +7180,10 @@ export function PlatformEntryFlowShellImpl({ try { const response = await woodenFishClient.executeAction( created.session.sessionId, - { - actionType: 'compile-draft', - workTitle: payload?.workTitle ?? created.session.draft?.workTitle, - workDescription: - payload?.workDescription ?? - created.session.draft?.workDescription, - themeTags: payload?.themeTags ?? created.session.draft?.themeTags, - hitObjectPrompt: - payload?.hitObjectPrompt ?? - created.session.draft?.hitObjectPrompt, - hitObjectReferenceImageSrc: - payload?.hitObjectReferenceImageSrc ?? - created.session.draft?.hitObjectReferenceImageSrc, - hitSoundAsset: - payload?.hitSoundAsset ?? created.session.draft?.hitSoundAsset, - floatingWords: - payload?.floatingWords ?? created.session.draft?.floatingWords, - }, + buildWoodenFishDraftActionPayload('compile-draft', { + payload, + draft: created.session.draft, + }), ); setWoodenFishSession(response.session); setWoodenFishWork(response.work ?? null); @@ -7375,17 +7319,9 @@ export function PlatformEntryFlowShellImpl({ try { const response = await woodenFishClient.executeAction( woodenFishSession.sessionId, - { - actionType, - workTitle: woodenFishSession.draft?.workTitle, - workDescription: woodenFishSession.draft?.workDescription, - themeTags: woodenFishSession.draft?.themeTags, - hitObjectPrompt: woodenFishSession.draft?.hitObjectPrompt, - hitObjectReferenceImageSrc: - woodenFishSession.draft?.hitObjectReferenceImageSrc, - hitSoundAsset: woodenFishSession.draft?.hitSoundAsset, - floatingWords: woodenFishSession.draft?.floatingWords, - }, + buildWoodenFishDraftActionPayload(actionType, { + draft: woodenFishSession.draft, + }), ); setWoodenFishSession(response.session); setWoodenFishWork(response.work ?? woodenFishWork); diff --git a/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts b/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts index 084fb40f..3573db00 100644 --- a/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts +++ b/src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'vitest'; +import type { + JumpHopSessionSnapshotResponse, + JumpHopWorkspaceCreateRequest, +} from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DAgentSessionSnapshot, Match3DAnchorPackResponse, @@ -15,7 +19,12 @@ import type { PuzzleAgentSessionSnapshot, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { + WoodenFishSessionSnapshotResponse, + WoodenFishWorkspaceCreateRequest, +} from '../../../packages/shared/src/contracts/woodenFish'; import { + buildJumpHopDraftActionPayload, buildMatch3DFormPayloadFromSession, buildMatch3DFormPayloadFromWork, buildPendingMatch3DDraftMetadata, @@ -25,6 +34,7 @@ import { buildPuzzleFormPayloadFromSession, buildPuzzleFormPayloadFromWork, buildPuzzleWorkUpdatePayloadFromDraft, + buildWoodenFishDraftActionPayload, isEmptyPuzzleFormOnlyDraft, isPuzzleFormOnlyDraft, } from './platformMiniGameDraftPayloadModel'; @@ -196,6 +206,91 @@ function buildMatch3DWork( }; } +function buildJumpHopDraft( + overrides: Partial> = {}, +): NonNullable { + return { + templateId: 'jump-hop', + templateName: '跳一跳', + profileId: 'jump-hop-profile-1', + workTitle: '草稿跳一跳', + workDescription: '从草稿恢复。', + themeTags: ['草稿'], + difficulty: 'standard', + stylePreset: 'paper-toy', + characterPrompt: '草稿角色', + tilePrompt: '草稿平台', + endMoodPrompt: '草稿终点', + characterAsset: null, + tileAtlasAsset: null, + tileAssets: [], + path: null, + coverComposite: null, + generationStatus: 'draft', + ...overrides, + }; +} + +function buildJumpHopPayload( + overrides: Partial = {}, +): JumpHopWorkspaceCreateRequest { + return { + templateId: 'jump-hop', + workTitle: '表单跳一跳', + workDescription: '从表单提交。', + themeTags: ['表单'], + difficulty: 'advanced', + stylePreset: 'neon-glass', + characterPrompt: '表单角色', + tilePrompt: '表单平台', + endMoodPrompt: '表单终点', + ...overrides, + }; +} + +function buildWoodenFishDraft( + overrides: Partial< + NonNullable + > = {}, +): NonNullable { + return { + templateId: 'wooden-fish', + templateName: '敲木鱼', + profileId: 'wooden-fish-profile-1', + workTitle: '草稿木鱼', + workDescription: '从草稿恢复。', + themeTags: ['草稿'], + hitObjectPrompt: '草稿敲击物', + hitObjectReferenceImageSrc: '/draft-hit-ref.png', + hitSoundPrompt: null, + floatingWords: ['草稿 +1'], + hitObjectAsset: null, + backgroundAsset: null, + backButtonAsset: null, + hitSoundAsset: null, + coverImageSrc: null, + generationStatus: 'draft', + ...overrides, + }; +} + +function buildWoodenFishPayload( + overrides: Partial = {}, +): WoodenFishWorkspaceCreateRequest { + return { + templateId: 'wooden-fish', + workTitle: '表单木鱼', + workDescription: '从表单提交。', + themeTags: ['表单'], + hitObjectPrompt: '表单敲击物', + hitObjectReferenceImageSrc: '/form-hit-ref.png', + hitSoundPrompt: null, + hitSoundAsset: null, + floatingWords: ['表单 +1'], + ...overrides, + }; +} + describe('platformMiniGameDraftPayloadModel', () => { test('builds puzzle form payload from work with fallback description priority', () => { expect( @@ -242,6 +337,64 @@ describe('platformMiniGameDraftPayloadModel', () => { ).toEqual([]); }); + test('builds jump hop draft action payload from payload or draft', () => { + expect( + buildJumpHopDraftActionPayload('compile-draft', { + payload: buildJumpHopPayload(), + draft: buildJumpHopDraft(), + }), + ).toEqual({ + actionType: 'compile-draft', + workTitle: '表单跳一跳', + workDescription: '从表单提交。', + themeTags: ['表单'], + difficulty: 'advanced', + stylePreset: 'neon-glass', + characterPrompt: '表单角色', + tilePrompt: '表单平台', + endMoodPrompt: '表单终点', + }); + + expect( + buildJumpHopDraftActionPayload('regenerate-tiles', { + draft: buildJumpHopDraft(), + }), + ).toMatchObject({ + actionType: 'regenerate-tiles', + workTitle: '草稿跳一跳', + tilePrompt: '草稿平台', + }); + }); + + test('builds wooden fish draft action payload from payload or draft', () => { + expect( + buildWoodenFishDraftActionPayload('compile-draft', { + payload: buildWoodenFishPayload(), + draft: buildWoodenFishDraft(), + }), + ).toEqual({ + actionType: 'compile-draft', + workTitle: '表单木鱼', + workDescription: '从表单提交。', + themeTags: ['表单'], + hitObjectPrompt: '表单敲击物', + hitObjectReferenceImageSrc: '/form-hit-ref.png', + hitSoundAsset: null, + floatingWords: ['表单 +1'], + }); + + expect( + buildWoodenFishDraftActionPayload('regenerate-hit-object', { + draft: buildWoodenFishDraft(), + }), + ).toMatchObject({ + actionType: 'regenerate-hit-object', + workTitle: '草稿木鱼', + hitObjectPrompt: '草稿敲击物', + floatingWords: ['草稿 +1'], + }); + }); + test('builds puzzle form payload from session form draft and fallbacks', () => { expect(buildPuzzleFormPayloadFromSession(buildPuzzleSession())).toEqual({ seedText: '表单画面', diff --git a/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts b/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts index bc9f3f60..19c9c413 100644 --- a/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts +++ b/src/components/platform-entry/platformMiniGameDraftPayloadModel.ts @@ -1,3 +1,8 @@ +import type { + JumpHopActionRequest, + JumpHopSessionSnapshotResponse, + JumpHopWorkspaceCreateRequest, +} from '../../../packages/shared/src/contracts/jumpHop'; import type { CreateMatch3DSessionRequest, Match3DAgentSessionSnapshot, @@ -13,6 +18,11 @@ import type { PuzzleAgentSessionSnapshot, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { + WoodenFishActionRequest, + WoodenFishSessionSnapshotResponse, + WoodenFishWorkspaceCreateRequest, +} from '../../../packages/shared/src/contracts/woodenFish'; export type PuzzleWorkUpdatePayload = { workTitle?: string; @@ -65,6 +75,49 @@ export function buildPuzzleWorkUpdatePayloadFromDraft( }; } +export function buildJumpHopDraftActionPayload( + actionType: 'compile-draft' | 'regenerate-character' | 'regenerate-tiles', + input: { + payload?: JumpHopWorkspaceCreateRequest | null; + draft?: JumpHopSessionSnapshotResponse['draft'] | null; + }, +): JumpHopActionRequest { + const { payload, draft } = input; + return { + actionType, + workTitle: payload?.workTitle ?? draft?.workTitle, + workDescription: payload?.workDescription ?? draft?.workDescription, + themeTags: payload?.themeTags ?? draft?.themeTags, + difficulty: payload?.difficulty ?? draft?.difficulty, + stylePreset: payload?.stylePreset ?? draft?.stylePreset, + characterPrompt: payload?.characterPrompt ?? draft?.characterPrompt, + tilePrompt: payload?.tilePrompt ?? draft?.tilePrompt, + endMoodPrompt: payload?.endMoodPrompt ?? draft?.endMoodPrompt, + }; +} + +export function buildWoodenFishDraftActionPayload( + actionType: 'compile-draft' | 'regenerate-hit-object', + input: { + payload?: WoodenFishWorkspaceCreateRequest | null; + draft?: WoodenFishSessionSnapshotResponse['draft'] | null; + }, +): WoodenFishActionRequest { + const { payload, draft } = input; + return { + actionType, + workTitle: payload?.workTitle ?? draft?.workTitle, + workDescription: payload?.workDescription ?? draft?.workDescription, + themeTags: payload?.themeTags ?? draft?.themeTags, + hitObjectPrompt: payload?.hitObjectPrompt ?? draft?.hitObjectPrompt, + hitObjectReferenceImageSrc: + payload?.hitObjectReferenceImageSrc ?? + draft?.hitObjectReferenceImageSrc, + hitSoundAsset: payload?.hitSoundAsset ?? draft?.hitSoundAsset, + floatingWords: payload?.floatingWords ?? draft?.floatingWords, + }; +} + function parseOptionalFiniteNumber(value: string | number | null | undefined) { if (typeof value === 'number') { return Number.isFinite(value) ? value : undefined; diff --git a/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts b/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts index 6e557059..7c1674d2 100644 --- a/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts +++ b/src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts @@ -19,7 +19,9 @@ import type { import type { WoodenFishAudioAsset, WoodenFishImageAsset, + WoodenFishSessionSnapshotResponse, WoodenFishWorkProfileResponse, + WoodenFishWorkspaceCreateRequest, WoodenFishWorkSummaryResponse, } from '../../../packages/shared/src/contracts/woodenFish'; import { @@ -27,6 +29,7 @@ import { buildPuzzleRuntimeWorkFromSession, buildSquareHoleProfileFromSession, buildVisualNovelSessionFromWorkDetail, + buildWoodenFishGeneratingWorkSummary, buildWoodenFishPendingSession, buildWoodenFishSessionFromWorkDetail, } from './platformMiniGameSessionMappingModel'; @@ -423,6 +426,37 @@ function buildWoodenFishWorkProfile( }; } +function buildWoodenFishSession( + overrides: Partial = {}, +): WoodenFishSessionSnapshotResponse { + const summary = buildWoodenFishSummary(); + return { + sessionId: 'wooden-fish-session-1', + ownerUserId: 'user-1', + status: 'generating', + draft: buildWoodenFishWorkProfile({ summary }).draft, + createdAt: '2026-06-01T11:59:00.000Z', + updatedAt: '2026-06-01T12:00:00.000Z', + ...overrides, + }; +} + +function buildWoodenFishCreatePayload( + overrides: Partial = {}, +): WoodenFishWorkspaceCreateRequest { + return { + templateId: 'wooden-fish', + workTitle: '表单星灯木鱼', + workDescription: '表单里敲亮星灯。', + themeTags: ['表单星灯'], + hitObjectPrompt: '星灯', + hitObjectReferenceImageSrc: null, + hitSoundPrompt: null, + floatingWords: ['功德 +1'], + ...overrides, + }; +} + describe('platformMiniGameSessionMappingModel', () => { test('builds a draft puzzle runtime work from a session', () => { expect( @@ -607,6 +641,47 @@ describe('platformMiniGameSessionMappingModel', () => { }); }); + test('builds wooden fish generating work summary from session and payload', () => { + expect( + buildWoodenFishGeneratingWorkSummary( + buildWoodenFishSession(), + buildWoodenFishCreatePayload(), + ), + ).toEqual({ + runtimeKind: 'wooden-fish', + workId: 'wooden-fish-session-1', + profileId: 'wooden-fish-session-1', + ownerUserId: 'user-1', + sourceSessionId: 'wooden-fish-session-1', + workTitle: '表单星灯木鱼', + workDescription: '表单里敲亮星灯。', + themeTags: ['表单星灯'], + coverImageSrc: '/wooden-fish-cover.png', + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-01T12:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + }); + + expect( + buildWoodenFishGeneratingWorkSummary( + buildWoodenFishSession({ + draft: null, + createdAt: '2026-06-01T11:59:00.000Z', + }), + null, + ), + ).toMatchObject({ + workTitle: '敲木鱼', + workDescription: '', + themeTags: ['敲木鱼'], + coverImageSrc: null, + updatedAt: '2026-06-01T12:00:00.000Z', + }); + }); + test('builds wooden fish recovered session with summary, fallback and profile id priority', () => { expect( buildWoodenFishSessionFromWorkDetail( diff --git a/src/components/platform-entry/platformMiniGameSessionMappingModel.ts b/src/components/platform-entry/platformMiniGameSessionMappingModel.ts index a59e9a6a..f240bf09 100644 --- a/src/components/platform-entry/platformMiniGameSessionMappingModel.ts +++ b/src/components/platform-entry/platformMiniGameSessionMappingModel.ts @@ -10,6 +10,7 @@ import type { import type { WoodenFishSessionSnapshotResponse, WoodenFishWorkProfileResponse, + WoodenFishWorkspaceCreateRequest, WoodenFishWorkSummaryResponse, } from '../../../packages/shared/src/contracts/woodenFish'; import { normalizeCreationUrlValue } from './platformCreationUrlStateModel'; @@ -159,6 +160,31 @@ export function buildWoodenFishSessionFromWorkDetail( }; } +export function buildWoodenFishGeneratingWorkSummary( + session: WoodenFishSessionSnapshotResponse, + payload?: WoodenFishWorkspaceCreateRequest | null, +): WoodenFishWorkSummaryResponse { + const updatedAt = session.updatedAt ?? session.createdAt; + return { + runtimeKind: 'wooden-fish', + workId: session.sessionId, + profileId: session.sessionId, + ownerUserId: session.ownerUserId, + sourceSessionId: session.sessionId, + workTitle: payload?.workTitle ?? session.draft?.workTitle ?? '敲木鱼', + workDescription: + payload?.workDescription ?? session.draft?.workDescription ?? '', + themeTags: payload?.themeTags ?? session.draft?.themeTags ?? ['敲木鱼'], + coverImageSrc: session.draft?.coverImageSrc ?? null, + publicationStatus: 'draft', + playCount: 0, + updatedAt, + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + }; +} + export function buildWoodenFishPendingSession( item: WoodenFishWorkSummaryResponse, ): WoodenFishSessionSnapshotResponse { From 217cc881e698e0c672b96205f06885e56ace7651 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 05:51:09 +0800 Subject: [PATCH 63/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E6=89=93=E5=BC=80=E7=8A=B6=E6=80=81=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 +- ...DraftGenerationShelfModel收口计划-2026-06-03.md | 4 +- .../PlatformEntryFlowShellImpl.tsx | 289 +++++++---------- .../platformDraftGenerationShelfModel.test.ts | 172 ++++++++++ .../platformDraftGenerationShelfModel.ts | 294 ++++++++++++++++++ 6 files changed, 589 insertions(+), 180 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 05e98325..27e7f6a8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-04 Draft Generation Shelf 草稿打开 intent 收口 + +- 背景:`openPuzzleDraft` / `openMatch3DDraft` 在平台壳内重复判断已发布作品、缺 session、ready 未读、失败 notice、active / background 生成中、持久化 generating 和普通草稿恢复,导致壳层继续理解拼图稳定 ID、抓大鹅 notice key 与生成状态优先级。 +- 决策:扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,以 `resolvePuzzleDraftOpenIntent(...)` 与 `resolveMatch3DDraftOpenIntent(...)` 返回纯打开计划和 notice keys;壳层只按 intent 执行网络读取、生成态 rebase、试玩启动、错误写入、路由 / stage 和 notice seen 副作用。 +- 影响范围:创作中心作品架打开拼图 / 抓大鹅草稿、公开码搜索强制打开抓大鹅草稿、生成完成后 ready 未读试玩、失败草稿恢复和后续 pending / persisted generating 判定。 +- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 + ## 2026-06-04 Bark Battle Work Cache 草稿状态收口 - 背景:`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 字段映射,导致结果页试玩、作品架启动、草稿恢复和公开详情启动都要理解同一份资产字段清单。 diff --git a/docs/README.md b/docs/README.md index dcbfb1f5..612614c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,7 +77,7 @@ Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复 RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 -平台入口创作生成通知、pending 作品架占位、作品详情更新回填、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +平台入口创作生成通知、pending 作品架占位、作品详情更新回填、失败覆盖、拼图 / 抓大鹅草稿打开优先级、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md index 942da2b3..12270520 100644 --- a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md @@ -15,9 +15,10 @@ - `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。 - `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。 - `mergePuzzleWorkSummary(current, updated)` 与 `mergeBigFishWorkSummary(current, updated)`:统一作品详情更新后回填作品架和当前详情的身份匹配规则。 +- `resolvePuzzleDraftOpenIntent(...)` 与 `resolveMatch3DDraftOpenIntent(...)`:统一拼图 / 抓大鹅草稿打开时的已发布详情、缺 session、ready 未读试玩、失败生成页、active / background 生成页、持久化 generating 恢复和普通草稿恢复优先级。 - `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)`、`isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**。 -`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、启动生成、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总和作品架 runtime state 规则。 +`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、读取生成 session、启动 ready 草稿试玩、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总、作品架 runtime state 和拼图 / 抓大鹅草稿打开优先级。 ## 约定 @@ -26,6 +27,7 @@ - 拼图作品详情更新只以 `profileId` 匹配回填;大鱼吃小鱼作品详情更新只以 `sourceSessionId` 匹配回填。 - 失败 notice 优先级高于持久化 generating,且可通过 pending metadata 提供更具体 summary;否则回退玩法默认失败摘要。 - 已有封面的拼图草稿即使局部关卡仍在后台生成,也不得被整卡遮罩为不可打开的生成中状态。 +- 拼图 / 抓大鹅草稿打开 intent 只返回纯计划与 notice keys,不创建失败生成态、不请求详情、不写 stage;这些仍由壳层 Adapter 执行。 - 本 **Module** 不做网络请求、路由切换、弹窗副作用或 React state 写入,只保留纯 **Implementation**,以提高 **Depth**、**Leverage** 与 **Locality**。 ## 验证 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 4a3cf00c..f47ac8de 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -349,8 +349,6 @@ import { buildCreationWorkShelfItems, type CreationWorkShelfItem, isPersistedBarkBattleDraftGenerating, - isPersistedPuzzleDraftGenerating, - resolvePuzzleWorkCoverImageSrc, } from '../custom-world-home/creationWorkShelf'; import { buildPlatformRecommendFeedEntries, @@ -437,7 +435,6 @@ import { import { buildCreationWorkShelfRuntimeState, buildDraftCompletionDialogSource, - buildDraftFailedShelfSummary, buildPendingBarkBattleWorks, buildPendingBigFishWorks, buildPendingJumpHopWorks, @@ -451,19 +448,17 @@ import { createPendingDraftShelfState, type DraftGenerationNoticeMap, type DraftGenerationNoticeStatus, - getDraftGenerationNotice, getGenerationNoticeShelfKeys, hasDraftGenerationNoticeStatus, hasUnreadDraftGenerationUpdates, - hasUnreadReadyDraftGenerationNotice, - isPersistedDraftFailed, - isPersistedDraftGenerating, mergeBigFishWorkSummary, mergePuzzleWorkSummary, normalizeDraftNoticeId, type PendingDraftShelfKind, type PendingDraftShelfMap, type PendingDraftShelfMetadata, + resolveMatch3DDraftOpenIntent, + resolvePuzzleDraftOpenIntent, } from './platformDraftGenerationShelfModel'; import { canExposePublicWork, @@ -2012,26 +2007,11 @@ export function PlatformEntryFlowShellImpl({ activePuzzleGenerationSessionIdRef.current === sessionId ); }, []); - const isDraftNoticeGenerating = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => - hasDraftGenerationNoticeStatus( - draftGenerationNotices, - kind, - ids, - 'generating', - ), - [draftGenerationNotices], - ); const isDraftNoticeFailed = useCallback( (kind: CreationWorkShelfKind, ids: Array) => hasDraftGenerationNoticeStatus(draftGenerationNotices, kind, ids, 'failed'), [draftGenerationNotices], ); - const isDraftNoticeReadyUnread = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => - hasUnreadReadyDraftGenerationNotice(draftGenerationNotices, kind, ids), - [draftGenerationNotices], - ); const ensureEnoughDraftGenerationPointsFromServer = useCallback( async (pointsCost: number) => { try { @@ -10221,83 +10201,67 @@ export function PlatformEntryFlowShellImpl({ const openPuzzleDraft = useCallback( async (item: PuzzleWorkSummary) => { - const noticeKeys = collectDraftNoticeKeys('puzzle', [ - item.workId, - item.profileId, - item.sourceSessionId, - buildPuzzleResultWorkId(item.sourceSessionId), - buildPuzzleResultProfileId(item.sourceSessionId), - ]); - const failedNotice = getDraftGenerationNotice( - draftGenerationNotices, - noticeKeys, - ); - const isPersistedFailed = isPersistedDraftFailed(item.generationStatus); - const hasGeneratingNotice = isDraftNoticeGenerating('puzzle', [ - item.workId, - item.profileId, - item.sourceSessionId, - buildPuzzleResultWorkId(item.sourceSessionId), - buildPuzzleResultProfileId(item.sourceSessionId), - ]); - const hasFailedNotice = isDraftNoticeFailed('puzzle', [ - item.workId, - item.profileId, - item.sourceSessionId, - buildPuzzleResultWorkId(item.sourceSessionId), - buildPuzzleResultProfileId(item.sourceSessionId), - ]); - const noticeErrorMessage = - failedNotice?.status === 'failed' - ? (failedNotice.message ?? buildDraftFailedShelfSummary('puzzle')) - : buildDraftFailedShelfSummary('puzzle'); - const isMarkedGenerating = - !hasFailedNotice && - ((hasGeneratingNotice && !resolvePuzzleWorkCoverImageSrc(item)) || - isPersistedPuzzleDraftGenerating(item)); + const sourceSessionId = item.sourceSessionId?.trim() ?? ''; + const backgroundTask = sourceSessionId + ? getPuzzleBackgroundCompileTask(sourceSessionId) + : null; + const activeGenerationState = + backgroundTask?.generationState ?? puzzleGenerationViewState; + const openIntent = resolvePuzzleDraftOpenIntent({ + item, + notices: draftGenerationNotices, + generation: { + activeSessionId: puzzleSession?.sessionId, + hasActiveGenerationFailure: + activeGenerationState?.phase === 'failed', + hasActiveGenerationRunning: isMiniGameDraftGenerating( + activeGenerationState ?? null, + ), + hasBackgroundGenerationFailure: + backgroundTask?.generationState.phase === 'failed', + hasBackgroundGenerationRunning: isMiniGameDraftGenerating( + backgroundTask?.generationState ?? null, + ), + }, + }); + const { noticeKeys } = openIntent; setPuzzleOperation(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); setSelectedPuzzleDetail(null); - if (!item.sourceSessionId?.trim()) { - if (item.publicationStatus === 'published') { - await openPuzzleDetail(item.profileId, { tab: 'create' }); - return; - } - setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。'); + if (openIntent.type === 'open-published-detail') { + await openPuzzleDetail(item.profileId, { tab: 'create' }); return; } - const backgroundTask = getPuzzleBackgroundCompileTask( - item.sourceSessionId, - ); - const activeGenerationState = - backgroundTask?.generationState ?? puzzleGenerationViewState; - const failedGenerationState = - backgroundTask?.generationState.phase === 'failed' - ? backgroundTask.generationState - : item.sourceSessionId === puzzleSession?.sessionId && - activeGenerationState?.phase === 'failed' - ? activeGenerationState - : hasFailedNotice || isPersistedFailed - ? createFailedMiniGameDraftGenerationStateForRestoredDraft( + if (openIntent.type === 'missing-session') { + setPuzzleError(openIntent.errorMessage); + return; + } + + if (openIntent.type === 'failed-generation') { + const failedGenerationState = + openIntent.source === 'background' + ? backgroundTask?.generationState + : openIntent.source === 'active' + ? activeGenerationState + : createFailedMiniGameDraftGenerationStateForRestoredDraft( 'puzzle', item.updatedAt, - noticeErrorMessage, + openIntent.errorMessage, { puzzleAiRedraw: true }, - ) - : null; - - if ((hasFailedNotice || isPersistedFailed) && failedGenerationState) { + ); + if (!failedGenerationState) { + return; + } let failedSession = backgroundTask?.session ?? null; let failedPayload = backgroundTask?.payload ?? null; - const failedError = - backgroundTask?.error ?? failedNotice?.message ?? noticeErrorMessage; + const failedError = backgroundTask?.error ?? openIntent.errorMessage; if (!failedSession) { try { const { session: latestSession } = await getPuzzleAgentSession( - item.sourceSessionId, + sourceSessionId, ); failedSession = latestSession; failedPayload = buildPuzzleFormPayloadFromSession(latestSession); @@ -10330,16 +10294,13 @@ export function PlatformEntryFlowShellImpl({ } enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; - activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + activePuzzleGenerationSessionIdRef.current = sourceSessionId; setPuzzleGenerationState(failedGenerationState); setSelectionStage('puzzle-generating'); return; } - if ( - item.sourceSessionId === puzzleSession?.sessionId && - isMiniGameDraftGenerating(activeGenerationState) - ) { + if (openIntent.type === 'active-generation') { if (!activeGenerationState) { return; } @@ -10347,7 +10308,7 @@ export function PlatformEntryFlowShellImpl({ rebaseMiniGameDraftGenerationStateForDisplay(activeGenerationState); enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; - activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + activePuzzleGenerationSessionIdRef.current = sourceSessionId; setPuzzleGenerationState(rebasedGenerationState); if (backgroundTask) { setPuzzleBackgroundCompileTasks((current) => ({ @@ -10362,10 +10323,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if ( - backgroundTask && - isMiniGameDraftGenerating(backgroundTask.generationState) - ) { + if (openIntent.type === 'background-generation' && backgroundTask) { const rebasedTask = rebaseMiniGameDraftBackgroundCompileTaskForDisplay(backgroundTask); puzzleFlow.setSession(rebasedTask.session); @@ -10380,15 +10338,15 @@ export function PlatformEntryFlowShellImpl({ } enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; - activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + activePuzzleGenerationSessionIdRef.current = sourceSessionId; setSelectionStage('puzzle-generating'); return; } - if (isMarkedGenerating) { + if (openIntent.type === 'restore-generating') { try { const { session: latestSession } = await getPuzzleAgentSession( - item.sourceSessionId, + sourceSessionId, ); const payload = buildPuzzleFormPayloadFromSession(latestSession); const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs( @@ -10424,7 +10382,7 @@ export function PlatformEntryFlowShellImpl({ })); enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; - activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + activePuzzleGenerationSessionIdRef.current = sourceSessionId; setSelectionStage('puzzle-generating'); return; } catch (error) { @@ -10439,7 +10397,7 @@ export function PlatformEntryFlowShellImpl({ markDraftNoticeSeen(noticeKeys); const restoredSession = await puzzleFlow.restoreDraft( - item.sourceSessionId, + sourceSessionId, ); if (!restoredSession) { await refreshPuzzleShelf().catch(() => undefined); @@ -10459,8 +10417,6 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab, draftGenerationNotices, getPuzzleBackgroundCompileTask, - isDraftNoticeFailed, - isDraftNoticeGenerating, markDraftNoticeSeen, openPuzzleDetail, puzzleFlow, @@ -10478,78 +10434,52 @@ export function PlatformEntryFlowShellImpl({ item: Match3DWorkSummary, options: { forceDraft?: boolean } = {}, ) => { - const noticeKeys = collectDraftNoticeKeys('match3d', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]); - const hasUnreadReadyNotice = isDraftNoticeReadyUnread('match3d', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]); + const sourceSessionId = item.sourceSessionId?.trim() ?? ''; + const backgroundTask = sourceSessionId + ? getMatch3DBackgroundCompileTask(sourceSessionId) + : null; + const activeGenerationState = + backgroundTask?.generationState ?? match3dGenerationViewState; + const openIntent = resolveMatch3DDraftOpenIntent({ + item, + notices: draftGenerationNotices, + forceDraft: options.forceDraft, + generation: { + activeSessionId: match3dSession?.sessionId, + hasActiveGenerationFailure: + activeGenerationState?.phase === 'failed', + hasActiveGenerationRunning: isMiniGameDraftGenerating( + activeGenerationState ?? null, + ), + hasBackgroundGenerationFailure: + backgroundTask?.generationState.phase === 'failed', + hasBackgroundGenerationRunning: isMiniGameDraftGenerating( + backgroundTask?.generationState ?? null, + ), + }, + }); + const { noticeKeys } = openIntent; setMatch3DRun(null); setMatch3DError(null); setMatch3DProfile(null); setMatch3DRuntimeProfile(null); - if (item.publicationStatus === 'published' && !options.forceDraft) { + if (openIntent.type === 'open-published-detail') { markDraftNoticeSeen(noticeKeys); openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(item)); return; } - if (!item.sourceSessionId?.trim()) { + if (openIntent.type === 'missing-session') { markDraftNoticeSeen(noticeKeys); - setMatch3DError('这份抓大鹅草稿缺少会话信息,请重新开始创作。'); + setMatch3DError(openIntent.errorMessage); return; } - const failedNotice = getDraftGenerationNotice( - draftGenerationNotices, - noticeKeys, - ); - const hasFailedNotice = isDraftNoticeFailed('match3d', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]); - const noticeErrorMessage = - failedNotice?.status === 'failed' - ? (failedNotice.message ?? buildDraftFailedShelfSummary('match3d')) - : buildDraftFailedShelfSummary('match3d'); - const isMarkedGenerating = - !hasFailedNotice && - (isDraftNoticeGenerating('match3d', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]) || - isPersistedDraftGenerating(item.generationStatus)); - - const backgroundTask = getMatch3DBackgroundCompileTask( - item.sourceSessionId, - ); - const activeGenerationState = - backgroundTask?.generationState ?? match3dGenerationViewState; - const failedGenerationState = - backgroundTask?.generationState.phase === 'failed' - ? backgroundTask.generationState - : item.sourceSessionId === match3dSession?.sessionId && - activeGenerationState?.phase === 'failed' - ? activeGenerationState - : hasFailedNotice - ? createFailedMiniGameDraftGenerationStateForRestoredDraft( - 'match3d', - item.updatedAt, - noticeErrorMessage, - ) - : null; - - if (hasUnreadReadyNotice) { + if (openIntent.type === 'ready-unread') { try { const { session: latestSession } = - await match3dCreationClient.getSession(item.sourceSessionId); + await match3dCreationClient.getSession(sourceSessionId); setMatch3DSession(latestSession); setMatch3DFormDraftPayload(null); const profileId = latestSession.draft?.profileId ?? item.profileId; @@ -10577,15 +10507,27 @@ export function PlatformEntryFlowShellImpl({ } } - if (failedGenerationState) { + if (openIntent.type === 'failed-generation') { + const failedGenerationState = + openIntent.source === 'background' + ? backgroundTask?.generationState + : openIntent.source === 'active' + ? activeGenerationState + : createFailedMiniGameDraftGenerationStateForRestoredDraft( + 'match3d', + item.updatedAt, + openIntent.errorMessage, + ); + if (!failedGenerationState) { + return; + } let failedSession = backgroundTask?.session ?? null; let failedPayload = backgroundTask?.payload ?? null; - const failedError = - backgroundTask?.error ?? failedNotice?.message ?? noticeErrorMessage; + const failedError = backgroundTask?.error ?? openIntent.errorMessage; if (!failedSession) { try { const { session: latestSession } = - await match3dCreationClient.getSession(item.sourceSessionId); + await match3dCreationClient.getSession(sourceSessionId); failedSession = latestSession; failedPayload = buildMatch3DFormPayloadFromSession(latestSession); } catch { @@ -10617,16 +10559,13 @@ export function PlatformEntryFlowShellImpl({ } enterCreateTab(); selectionStageRef.current = 'match3d-generating'; - activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + activeMatch3DGenerationSessionIdRef.current = sourceSessionId; setMatch3DGenerationState(failedGenerationState); setSelectionStage('match3d-generating'); return; } - if ( - item.sourceSessionId === match3dSession?.sessionId && - isMiniGameDraftGenerating(activeGenerationState) - ) { + if (openIntent.type === 'active-generation') { if (!activeGenerationState) { return; } @@ -10634,7 +10573,7 @@ export function PlatformEntryFlowShellImpl({ rebaseMiniGameDraftGenerationStateForDisplay(activeGenerationState); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; - activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + activeMatch3DGenerationSessionIdRef.current = sourceSessionId; setMatch3DGenerationState(rebasedGenerationState); if (backgroundTask) { setMatch3DBackgroundCompileTasks((current) => ({ @@ -10649,10 +10588,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if ( - backgroundTask && - isMiniGameDraftGenerating(backgroundTask.generationState) - ) { + if (openIntent.type === 'background-generation' && backgroundTask) { const rebasedTask = rebaseMiniGameDraftBackgroundCompileTaskForDisplay(backgroundTask); setMatch3DSession(rebasedTask.session); @@ -10667,15 +10603,15 @@ export function PlatformEntryFlowShellImpl({ } enterCreateTab(); selectionStageRef.current = 'match3d-generating'; - activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + activeMatch3DGenerationSessionIdRef.current = sourceSessionId; setSelectionStage('match3d-generating'); return; } - if (isMarkedGenerating) { + if (openIntent.type === 'restore-generating') { try { const { session: latestSession } = - await match3dCreationClient.getSession(item.sourceSessionId); + await match3dCreationClient.getSession(sourceSessionId); setMatch3DSession(latestSession); setMatch3DFormDraftPayload(null); setMatch3DProfile(null); @@ -10690,7 +10626,7 @@ export function PlatformEntryFlowShellImpl({ setMatch3DGenerationState(generationState); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; - activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + activeMatch3DGenerationSessionIdRef.current = sourceSessionId; setSelectionStage('match3d-generating'); return; } catch (error) { @@ -10705,7 +10641,7 @@ export function PlatformEntryFlowShellImpl({ markDraftNoticeSeen(noticeKeys); const restoredSession = await match3dFlow.restoreDraft( - item.sourceSessionId, + sourceSessionId, ); if (!restoredSession) { await refreshMatch3DShelf().catch(() => undefined); @@ -10729,9 +10665,6 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab, draftGenerationNotices, getMatch3DBackgroundCompileTask, - isDraftNoticeFailed, - isDraftNoticeGenerating, - isDraftNoticeReadyUnread, markDraftNoticeSeen, match3dFlow, match3dGenerationViewState, diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts index 5863cb11..f01e1195 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf'; import { @@ -15,9 +16,140 @@ import { hasUnreadDraftGenerationUpdates, mergeBigFishWorkSummary, mergePuzzleWorkSummary, + resolveMatch3DDraftOpenIntent, + resolvePuzzleDraftOpenIntent, } from './platformDraftGenerationShelfModel'; describe('platformDraftGenerationShelfModel', () => { + test('resolvePuzzleDraftOpenIntent sends published puzzle without session to detail', () => { + expect( + resolvePuzzleDraftOpenIntent({ + item: buildPuzzleWork({ + sourceSessionId: null, + publicationStatus: 'published', + }), + notices: {}, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'open-published-detail', + }); + }); + + test('resolvePuzzleDraftOpenIntent restores failed puzzle generation with notice copy', () => { + expect( + resolvePuzzleDraftOpenIntent({ + item: buildPuzzleWork(), + notices: { + 'puzzle:puzzle-session-base': { + status: 'failed', + seen: false, + message: '首图生成失败。', + }, + }, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'failed-generation', + source: 'restored', + errorMessage: '首图生成失败。', + }); + }); + + test('resolvePuzzleDraftOpenIntent prefers active generation before restoring draft', () => { + expect( + resolvePuzzleDraftOpenIntent({ + item: buildPuzzleWork(), + notices: {}, + generation: emptyGenerationFacts({ + activeSessionId: 'puzzle-session-base', + hasActiveGenerationRunning: true, + }), + }), + ).toMatchObject({ + type: 'active-generation', + }); + }); + + test('resolvePuzzleDraftOpenIntent does not lock a puzzle draft that already has a cover', () => { + expect( + resolvePuzzleDraftOpenIntent({ + item: buildPuzzleWork({ + coverImageSrc: '/media/puzzle-cover.png', + }), + notices: { + 'puzzle:puzzle-session-base': { + status: 'generating', + seen: false, + }, + }, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'restore-draft', + }); + }); + + test('resolveMatch3DDraftOpenIntent opens published work detail unless forced into draft', () => { + const item = buildMatch3DWork({ + publicationStatus: 'published', + }); + + expect( + resolveMatch3DDraftOpenIntent({ + item, + notices: {}, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'open-published-detail', + }); + + expect( + resolveMatch3DDraftOpenIntent({ + item, + notices: {}, + forceDraft: true, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'restore-draft', + }); + }); + + test('resolveMatch3DDraftOpenIntent starts ready unread draft before failure fallback', () => { + expect( + resolveMatch3DDraftOpenIntent({ + item: buildMatch3DWork(), + notices: { + 'match3d:match3d-session-base': { + status: 'ready', + seen: false, + }, + }, + generation: emptyGenerationFacts({ + hasBackgroundGenerationFailure: true, + }), + }), + ).toMatchObject({ + type: 'ready-unread', + }); + }); + + test('resolveMatch3DDraftOpenIntent restores persisted generating draft', () => { + expect( + resolveMatch3DDraftOpenIntent({ + item: buildMatch3DWork({ + generationStatus: 'generating', + }), + notices: {}, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'restore-generating', + }); + }); + test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => { const pending = buildPendingPuzzleWorks( { @@ -199,6 +331,19 @@ describe('platformDraftGenerationShelfModel', () => { }); }); +function emptyGenerationFacts( + overrides: Partial[0]['generation']> = {}, +): Parameters[0]['generation'] { + return { + activeSessionId: null, + hasActiveGenerationFailure: false, + hasActiveGenerationRunning: false, + hasBackgroundGenerationFailure: false, + hasBackgroundGenerationRunning: false, + ...overrides, + }; +} + function buildPuzzleWork( overrides: Partial = {}, ): PuzzleWorkSummary { @@ -227,6 +372,33 @@ function buildPuzzleWork( }; } +function buildMatch3DWork( + overrides: Partial = {}, +): Match3DWorkSummary { + return { + workId: 'match3d-work-base', + profileId: 'match3d-profile-base', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session-base', + gameName: '潮雾抓大鹅', + themeText: '潮雾港口', + summary: '潮雾港口抓大鹅。', + tags: [], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 0, + difficulty: 1, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-03T08:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'ready', + generatedItemAssets: [], + ...overrides, + }; +} + function buildBigFishWork( overrides: Partial = {}, ): BigFishWorkSummary { diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.ts index dbd0863c..62094b63 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.ts @@ -12,6 +12,7 @@ import { type CreationWorkShelfItem, type CreationWorkShelfKind, type CreationWorkShelfRuntimeState, + isPersistedPuzzleDraftGenerating, resolvePuzzleWorkCoverImageSrc, } from '../custom-world-home/creationWorkShelf'; import { @@ -67,6 +68,86 @@ export type PlatformDraftGenerationVisibleShelfSources = { babyObjectMatchItems: readonly BabyObjectMatchDraft[]; }; +type DraftOpenGenerationFacts = { + activeSessionId?: string | null; + hasActiveGenerationFailure: boolean; + hasActiveGenerationRunning: boolean; + hasBackgroundGenerationFailure: boolean; + hasBackgroundGenerationRunning: boolean; +}; + +type FailedDraftGenerationSource = 'background' | 'active' | 'restored'; + +export type PuzzleDraftOpenIntent = + | { + type: 'open-published-detail'; + noticeKeys: string[]; + } + | { + type: 'missing-session'; + noticeKeys: string[]; + errorMessage: string; + } + | { + type: 'failed-generation'; + noticeKeys: string[]; + errorMessage: string; + source: FailedDraftGenerationSource; + } + | { + type: 'active-generation'; + noticeKeys: string[]; + } + | { + type: 'background-generation'; + noticeKeys: string[]; + } + | { + type: 'restore-generating'; + noticeKeys: string[]; + } + | { + type: 'restore-draft'; + noticeKeys: string[]; + }; + +export type Match3DDraftOpenIntent = + | { + type: 'open-published-detail'; + noticeKeys: string[]; + } + | { + type: 'missing-session'; + noticeKeys: string[]; + errorMessage: string; + } + | { + type: 'ready-unread'; + noticeKeys: string[]; + } + | { + type: 'failed-generation'; + noticeKeys: string[]; + errorMessage: string; + source: FailedDraftGenerationSource; + } + | { + type: 'active-generation'; + noticeKeys: string[]; + } + | { + type: 'background-generation'; + noticeKeys: string[]; + } + | { + type: 'restore-generating'; + noticeKeys: string[]; + } + | { + type: 'restore-draft'; + noticeKeys: string[]; + }; + export function buildDraftNoticeKey( kind: CreationWorkShelfKind, id: string, @@ -334,6 +415,219 @@ export function hasUnreadReadyDraftGenerationNotice( }); } +export function buildPuzzleDraftOpenNoticeKeys(item: PuzzleWorkSummary) { + return collectDraftNoticeKeys('puzzle', [ + item.workId, + item.profileId, + item.sourceSessionId, + buildPuzzleResultWorkId(item.sourceSessionId), + buildPuzzleResultProfileId(item.sourceSessionId), + ]); +} + +export function buildMatch3DDraftOpenNoticeKeys(item: Match3DWorkSummary) { + return collectDraftNoticeKeys('match3d', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]); +} + +export function resolvePuzzleDraftOpenIntent(params: { + item: PuzzleWorkSummary; + notices: DraftGenerationNoticeMap; + generation: DraftOpenGenerationFacts; +}): PuzzleDraftOpenIntent { + const { item, notices, generation } = params; + const noticeKeys = buildPuzzleDraftOpenNoticeKeys(item); + const sourceSessionId = normalizeDraftNoticeId(item.sourceSessionId); + + if (!sourceSessionId) { + if (item.publicationStatus === 'published') { + return { type: 'open-published-detail', noticeKeys }; + } + + return { + type: 'missing-session', + noticeKeys, + errorMessage: '这份拼图草稿缺少会话信息,请重新开始创作。', + }; + } + + const failedNotice = getDraftGenerationNotice(notices, noticeKeys); + const hasFailedNotice = hasDraftGenerationNoticeStatus( + notices, + 'puzzle', + [ + item.workId, + item.profileId, + item.sourceSessionId, + buildPuzzleResultWorkId(item.sourceSessionId), + buildPuzzleResultProfileId(item.sourceSessionId), + ], + 'failed', + ); + const hasGeneratingNotice = hasDraftGenerationNoticeStatus( + notices, + 'puzzle', + [ + item.workId, + item.profileId, + item.sourceSessionId, + buildPuzzleResultWorkId(item.sourceSessionId), + buildPuzzleResultProfileId(item.sourceSessionId), + ], + 'generating', + ); + const noticeErrorMessage = + failedNotice?.status === 'failed' + ? (failedNotice.message ?? buildDraftFailedShelfSummary('puzzle')) + : buildDraftFailedShelfSummary('puzzle'); + const isCurrentSession = + sourceSessionId === normalizeDraftNoticeId(generation.activeSessionId); + + if (generation.hasBackgroundGenerationFailure) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'background', + }; + } + + if (isCurrentSession && generation.hasActiveGenerationFailure) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'active', + }; + } + + if (hasFailedNotice || isPersistedDraftFailed(item.generationStatus)) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'restored', + }; + } + + if (isCurrentSession && generation.hasActiveGenerationRunning) { + return { type: 'active-generation', noticeKeys }; + } + + if (generation.hasBackgroundGenerationRunning) { + return { type: 'background-generation', noticeKeys }; + } + + const isMarkedGenerating = + !hasFailedNotice && + ((hasGeneratingNotice && !resolvePuzzleWorkCoverImageSrc(item)) || + isPersistedPuzzleDraftGenerating(item)); + if (isMarkedGenerating) { + return { type: 'restore-generating', noticeKeys }; + } + + return { type: 'restore-draft', noticeKeys }; +} + +export function resolveMatch3DDraftOpenIntent(params: { + item: Match3DWorkSummary; + notices: DraftGenerationNoticeMap; + forceDraft?: boolean; + generation: DraftOpenGenerationFacts; +}): Match3DDraftOpenIntent { + const { item, notices, forceDraft = false, generation } = params; + const noticeKeys = buildMatch3DDraftOpenNoticeKeys(item); + + if (item.publicationStatus === 'published' && !forceDraft) { + return { type: 'open-published-detail', noticeKeys }; + } + + const sourceSessionId = normalizeDraftNoticeId(item.sourceSessionId); + if (!sourceSessionId) { + return { + type: 'missing-session', + noticeKeys, + errorMessage: '这份抓大鹅草稿缺少会话信息,请重新开始创作。', + }; + } + + if ( + hasUnreadReadyDraftGenerationNotice(notices, 'match3d', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]) + ) { + return { type: 'ready-unread', noticeKeys }; + } + + const failedNotice = getDraftGenerationNotice(notices, noticeKeys); + const hasFailedNotice = hasDraftGenerationNoticeStatus( + notices, + 'match3d', + [item.workId, item.profileId, item.sourceSessionId], + 'failed', + ); + const noticeErrorMessage = + failedNotice?.status === 'failed' + ? (failedNotice.message ?? buildDraftFailedShelfSummary('match3d')) + : buildDraftFailedShelfSummary('match3d'); + const isCurrentSession = + sourceSessionId === normalizeDraftNoticeId(generation.activeSessionId); + + if (generation.hasBackgroundGenerationFailure) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'background', + }; + } + + if (isCurrentSession && generation.hasActiveGenerationFailure) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'active', + }; + } + + if (hasFailedNotice) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'restored', + }; + } + + if (isCurrentSession && generation.hasActiveGenerationRunning) { + return { type: 'active-generation', noticeKeys }; + } + + if (generation.hasBackgroundGenerationRunning) { + return { type: 'background-generation', noticeKeys }; + } + + if ( + hasDraftGenerationNoticeStatus( + notices, + 'match3d', + [item.workId, item.profileId, item.sourceSessionId], + 'generating', + ) || + isPersistedDraftGenerating(item.generationStatus) + ) { + return { type: 'restore-generating', noticeKeys }; + } + + return { type: 'restore-draft', noticeKeys }; +} + export function buildCreationWorkShelfRuntimeState(params: { item: CreationWorkShelfItem; notices: DraftGenerationNoticeMap; From e570d50e9fd7cbb0ad0cd3dfedae5219271798b5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 06:04:55 +0800 Subject: [PATCH 64/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E7=A0=81=E6=90=9C=E7=B4=A2=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 +- ...tformPublicCodeSearchModel收口计划-2026-06-04.md | 13 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- .../PlatformEntryFlowShellImpl.tsx | 174 +++----- .../platformPublicCodeSearchModel.test.ts | 413 ++++++++++++++++++ .../platformPublicCodeSearchModel.ts | 230 ++++++++++ src/services/publicWorkCode.test.ts | 20 + src/services/publicWorkCode.ts | 10 + 9 files changed, 749 insertions(+), 123 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 27e7f6a8..427a7842 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-04 Platform Public Code Search matcher / DTO 收口 + +- 背景:`resolvePlatformPublicCodeSearchPlan(...)` 已收口公开搜索顺序,但 `PlatformEntryFlowShellImpl.tsx` 仍内联 RPG by-code DTO 构造,以及拼图、大鱼吃小鱼、跳一跳、敲木鱼、宝贝识物、抓大鹅、方洞挑战、视觉小说和汪汪声浪的 `isSame*PublicWorkCode` 匹配、公开可见性过滤与详情卡映射。 +- 决策:扩展 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,以 `mapRpgPublicCodeSearchDetailToGalleryCard(...)` 和各 `resolve*PublicCodeSearchMatch(...)` 收口 per-play 公开码匹配与 DTO 映射;壳层只保留 gallery 刷新、详情打开、Bark Battle runtime 特例、用户查询和错误归航副作用。`M3D-*` 旧抓大鹅前缀在 `isSameMatch3DPublicWorkCode(...)` 中继续匹配。 +- 影响范围:平台首页搜索框、初始 `publicWorkCode` 恢复、各玩法公开作品号命中、RPG 公开作品 by-code 详情映射、Bark Battle runtime 内搜索启动。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts src/services/publicWorkCode.test.ts`、针对搜索 Module / 壳层 / publicWorkCode 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-04 Draft Generation Shelf 草稿打开 intent 收口 - 背景:`openPuzzleDraft` / `openMatch3DDraft` 在平台壳内重复判断已发布作品、缺 session、ready 未读、失败 notice、active / background 生成中、持久化 generating 和普通草稿恢复,导致壳层继续理解拼图稳定 ID、抓大鹅 notice key 与生成状态优先级。 diff --git a/docs/README.md b/docs/README.md index 612614c2..b51c6193 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,7 +49,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 创作入口点击的占位、隐藏模板拦截、未知入口 no-op 与工作台启动目标收口到 `src/components/platform-entry/platformCreationLaunchModel.ts`,壳层只执行启动前准备、错误提示和受保护动作,规则见 [【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md)。 -平台入口公开码搜索的用户 ID、陶泥号、RPG 作品号、各玩法作品号前缀和失败回退顺序收口到 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,壳层只按计划执行网络读取、详情打开和错误归航副作用,规则见 [【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md)。 +平台入口公开码搜索的用户 ID、陶泥号、RPG 作品号、各玩法作品号前缀、per-play 公开码匹配、详情卡 DTO 映射和失败回退顺序收口到 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,壳层只按计划执行网络读取、详情打开和错误归航副作用,规则见 [【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md)。 个人“玩过作品”面板的玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback 收口到 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,壳层只执行面板关闭、gallery 读取、详情打开和错误提示副作用,规则见 [【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md)。 diff --git a/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md index 07c9ed2d..602a185a 100644 --- a/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md @@ -12,26 +12,29 @@ - `resolvePlatformPublicCodeSearchPlan(keyword)`:输入用户搜索词,输出 `{ normalizedKeyword, steps }`;空输入返回 `null`。 - `PlatformPublicCodeSearchStep`:枚举壳层可执行的查找步骤,包括 `user-id`、`public-user-code`、`rpg-work`、各玩法公开作品步骤与 `bark-battle-work`。 +- `mapRpgPublicCodeSearchDetailToGalleryCard(entry)`:把 RPG by-code 详情响应映射为公开作品卡,收口 `playCount` / `remixCount` / `likeCount` 的 `0` 兜底。 +- `resolve*PublicCodeSearchMatch(entries, keyword)`:统一各玩法公开作品列表的公开码匹配、公开可见性过滤和详情卡 DTO 映射;拼图、大鱼吃小鱼、跳一跳、敲木鱼、宝贝识物、抓大鹅、方洞挑战、视觉小说和汪汪声浪都走此接口。 -`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `getPublicAuthUserByCode`、各玩法 gallery 刷新 / 详情打开、Bark Battle runtime 特例和 missing work 归航副作用,只按 `steps` 顺序执行,前一步失败才尝试下一步。 +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `getPublicAuthUserByCode`、各玩法 gallery 刷新 / 详情打开、Bark Battle runtime 特例和 missing work 归航副作用,只按 `steps` 顺序执行,前一步失败才尝试下一步;壳层不再重复维护 per-play `isSame*PublicWorkCode` 匹配和 DTO 映射。 ## Interface 约束 - 空白搜索词返回 `null`,壳层不得进入搜索 loading。 - `user_` / `user-` 开头的内部用户 ID 只执行 `user-id`,不回退作品号。 -- `PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀只进入对应玩法公开作品查找;`M3D-*` 继续归入 `M3` / 抓大鹅。 +- `PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀只进入对应玩法公开作品查找;`M3D-*` 继续归入并匹配 `M3` / 抓大鹅。 - `CW` 与 `1-8` 位纯数字先查 RPG 公开作品,再回退陶泥号。 - 普通关键词与 `SY` 陶泥号保持既有顺序:先查陶泥号,再查 RPG 公开作品,再查汪汪声浪作品,最后再以陶泥号兜底。 ## Depth / Leverage / Locality -- **Depth**:壳层只消费短小的 `steps` Interface,搜索前缀、优先级和回退顺序藏入 Module Implementation。 -- **Leverage**:新增公开作品前缀时,先扩展 Module 的 step union、前缀表和单测,再在壳层 Adapter 绑定对应执行函数。 -- **Locality**:搜索计划规则集中在一个纯 Module;UI、网络、详情打开与 runtime 启动副作用继续留在壳层,避免把副作用 setter 变成浅 Interface。 +- **Depth**:壳层只消费短小的 `steps` 与 match result Interface,搜索前缀、优先级、回退顺序、per-play 匹配和 DTO 映射藏入 Module Implementation。 +- **Leverage**:新增公开作品前缀时,先扩展 Module 的 step union、前缀表、matcher 和单测,再在壳层 Adapter 绑定对应网络读取与打开动作。 +- **Locality**:搜索计划与作品命中规则集中在一个纯 Module;UI、网络、详情打开与 runtime 启动副作用继续留在壳层,避免把副作用 setter 变成浅 Interface。 ## 验收 - `npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts` +- `npm run test -- src/services/publicWorkCode.test.ts` - `npx eslint src/components/platform-entry/platformPublicCodeSearchModel.ts src/components/platform-entry/platformPublicCodeSearchModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` - `npm run typecheck` - `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index a7d03114..1394d891 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -73,7 +73,7 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。 -平台公开搜索的分流顺序统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID;`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀分别直达对应玩法公开作品;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链。 +平台公开搜索的分流顺序、per-play 公开码匹配、公开可见性过滤和详情卡 DTO 映射统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID;`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀分别直达对应玩法公开作品;`M3D-*` 作为抓大鹅旧前缀继续匹配;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链、`isSame*PublicWorkCode` 或 DTO 映射。 个人“玩过作品”面板点击作品时,玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼缺 gallery 命中时的 fallback work 统一由 `platformPlayedWorkOpenModel.ts` 判定。平台壳只负责关闭面板、调用对应公开详情打开函数、刷新大鱼 gallery、优先使用真实 gallery 命中项和写入错误提示;不要在壳层重新维护 `worldType` / `worldKey` 分支链。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index f47ac8de..21bb578c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -226,15 +226,7 @@ import { buildSquareHolePublicWorkCode, buildVisualNovelPublicWorkCode, buildWoodenFishPublicWorkCode, - isSameBabyObjectMatchPublicWorkCode, - isSameBarkBattlePublicWorkCode, - isSameBigFishPublicWorkCode, - isSameJumpHopPublicWorkCode, - isSameMatch3DPublicWorkCode, isSamePuzzlePublicWorkCode, - isSameSquareHolePublicWorkCode, - isSameVisualNovelPublicWorkCode, - isSameWoodenFishPublicWorkCode, } from '../../services/publicWorkCode'; import { createPuzzleAgentSession, @@ -356,8 +348,6 @@ import { } from '../rpg-entry/rpgEntryPublicGalleryViewModel'; import { isEdutainmentGalleryEntry, - mapBabyObjectMatchDraftToPlatformGalleryCard, - mapBarkBattleWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, resolvePlatformPublicWorkCode, @@ -550,8 +540,18 @@ import { resolveProfileWalletBalance, } from './platformProfileWalletDeltaModel'; import { + mapRpgPublicCodeSearchDetailToGalleryCard, type PlatformPublicCodeSearchStep, + resolveBabyObjectMatchPublicCodeSearchMatch, + resolveBarkBattlePublicCodeSearchMatch, + resolveBigFishPublicCodeSearchMatch, + resolveJumpHopPublicCodeSearchMatch, + resolveMatch3DPublicCodeSearchMatch, resolvePlatformPublicCodeSearchPlan, + resolvePuzzlePublicCodeSearchMatch, + resolveSquareHolePublicCodeSearchMatch, + resolveVisualNovelPublicCodeSearchMatch, + resolveWoodenFishPublicCodeSearchMatch, } from './platformPublicCodeSearchModel'; import { buildPlatformPublicGalleryFeeds, @@ -12281,26 +12281,7 @@ export function PlatformEntryFlowShellImpl({ const tryOpenGalleryEntry = async () => { const entry = await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword); - const card = { - ownerUserId: entry.ownerUserId, - profileId: entry.profileId, - publicWorkCode: entry.publicWorkCode, - authorPublicUserCode: entry.authorPublicUserCode, - visibility: 'published', - publishedAt: entry.publishedAt, - updatedAt: entry.updatedAt, - authorDisplayName: entry.authorDisplayName, - worldName: entry.worldName, - subtitle: entry.subtitle, - summaryText: entry.summaryText, - coverImageSrc: entry.coverImageSrc, - themeMode: entry.themeMode, - playableNpcCount: entry.playableNpcCount, - landmarkCount: entry.landmarkCount, - playCount: entry.playCount ?? 0, - remixCount: entry.remixCount ?? 0, - likeCount: entry.likeCount ?? 0, - } satisfies CustomWorldGalleryCard; + const card = mapRpgPublicCodeSearchDetailToGalleryCard(entry); if (!canExposePublicWork(card)) { throw new Error(EDUTAINMENT_HIDDEN_MESSAGE); } @@ -12313,18 +12294,16 @@ export function PlatformEntryFlowShellImpl({ puzzleGalleryEntries.length > 0 ? puzzleGalleryEntries : await refreshPuzzleGallery(); - const matchedEntry = entries - .map(mapPuzzleWorkToPublicWorkDetail) - .filter(canExposePublicWork) - .find((entry) => - isSamePuzzlePublicWorkCode(normalizedKeyword, entry.profileId), - ); + const matchedEntry = resolvePuzzlePublicCodeSearchMatch( + entries, + normalizedKeyword, + ); if (!matchedEntry) { throw new Error('未找到拼图作品。'); } - await openPuzzlePublicWorkDetail(matchedEntry.profileId, { + await openPuzzlePublicWorkDetail(matchedEntry.detail.profileId, { tab: platformBootstrap.platformTab, }); }; @@ -12333,170 +12312,133 @@ export function PlatformEntryFlowShellImpl({ bigFishGalleryEntries.length > 0 ? bigFishGalleryEntries : await refreshBigFishGallery(); - const matchedEntry = entries.find((entry) => { - const detailEntry = mapBigFishWorkToPublicWorkDetail(entry); - return ( - canExposePublicWork(detailEntry) && - isSameBigFishPublicWorkCode( - normalizedKeyword, - entry.sourceSessionId, - ) - ); - }); + const matchedEntry = resolveBigFishPublicCodeSearchMatch( + entries, + normalizedKeyword, + ); if (!matchedEntry) { throw new Error('未找到大鱼吃小鱼作品。'); } - openPublicWorkDetail(mapBigFishWorkToPublicWorkDetail(matchedEntry)); + openPublicWorkDetail(matchedEntry.detail); }; const tryOpenJumpHopGalleryEntry = async () => { const entries = jumpHopGalleryEntries.length > 0 ? jumpHopGalleryEntries : await refreshJumpHopGallery(); - const matchedEntry = entries.find((entry) => { - const detailEntry = mapJumpHopWorkToPublicWorkDetail(entry); - return ( - canExposePublicWork(detailEntry) && - isSameJumpHopPublicWorkCode(normalizedKeyword, entry.profileId) - ); - }); + const matchedEntry = resolveJumpHopPublicCodeSearchMatch( + entries, + normalizedKeyword, + ); if (!matchedEntry) { throw new Error('未找到跳一跳作品。'); } - openPublicWorkDetail(mapJumpHopWorkToPublicWorkDetail(matchedEntry)); + openPublicWorkDetail(matchedEntry.detail); }; const tryOpenWoodenFishGalleryEntry = async () => { const entries = woodenFishGalleryEntries.length > 0 ? woodenFishGalleryEntries : await refreshWoodenFishGallery(); - const matchedEntry = entries.find((entry) => { - const detailEntry = mapWoodenFishWorkToPublicWorkDetail(entry); - return ( - canExposePublicWork(detailEntry) && - isSameWoodenFishPublicWorkCode(normalizedKeyword, entry.profileId) - ); - }); + const matchedEntry = resolveWoodenFishPublicCodeSearchMatch( + entries, + normalizedKeyword, + ); if (!matchedEntry) { throw new Error('未找到敲木鱼作品。'); } - openPublicWorkDetail(mapWoodenFishWorkToPublicWorkDetail(matchedEntry)); + openPublicWorkDetail(matchedEntry.detail); }; const tryOpenMatch3DGalleryEntry = async () => { const entries = match3dGalleryEntries.length > 0 ? match3dGalleryEntries : await refreshMatch3DGallery(); - const matchedEntry = entries.find((entry) => { - const detailEntry = mapMatch3DWorkToPublicWorkDetail(entry); - return ( - canExposePublicWork(detailEntry) && - isSameMatch3DPublicWorkCode(normalizedKeyword, entry.profileId) - ); - }); + const matchedEntry = resolveMatch3DPublicCodeSearchMatch( + entries, + normalizedKeyword, + ); if (!matchedEntry) { throw new Error('未找到抓大鹅作品。'); } - openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(matchedEntry)); + openPublicWorkDetail(matchedEntry.detail); }; const tryOpenSquareHoleGalleryEntry = async () => { const entries = squareHoleGalleryEntries.length > 0 ? squareHoleGalleryEntries : await refreshSquareHoleGallery(); - const matchedEntry = entries.find((entry) => { - const detailEntry = mapSquareHoleWorkToPublicWorkDetail(entry); - return ( - canExposePublicWork(detailEntry) && - isSameSquareHolePublicWorkCode(normalizedKeyword, entry.profileId) - ); - }); + const matchedEntry = resolveSquareHolePublicCodeSearchMatch( + entries, + normalizedKeyword, + ); if (!matchedEntry) { throw new Error('未找到方洞挑战作品。'); } - openPublicWorkDetail(mapSquareHoleWorkToPublicWorkDetail(matchedEntry)); + openPublicWorkDetail(matchedEntry.detail); }; const tryOpenVisualNovelGalleryEntry = async () => { const entries = visualNovelGalleryEntries.length > 0 ? visualNovelGalleryEntries : await refreshVisualNovelGallery(); - const matchedEntry = entries.find((entry) => { - const detailEntry = mapVisualNovelWorkToPublicWorkDetail(entry); - return ( - canExposePublicWork(detailEntry) && - isSameVisualNovelPublicWorkCode(normalizedKeyword, entry.profileId) - ); - }); + const matchedEntry = resolveVisualNovelPublicCodeSearchMatch( + entries, + normalizedKeyword, + ); if (!matchedEntry) { throw new Error('未找到视觉小说作品。'); } - openPublicWorkDetail( - mapVisualNovelWorkToPublicWorkDetail(matchedEntry), - ); + openPublicWorkDetail(matchedEntry.detail); }; const tryOpenBabyObjectMatchGalleryEntry = async () => { const entries = (await listLocalBabyObjectMatchDrafts()).filter( (draft) => draft.publicationStatus === 'published', ); - const matchedDraft = entries.find((draft) => { - const detailEntry = - mapBabyObjectMatchDraftToPlatformGalleryCard(draft); - return ( - canExposePublicWork(detailEntry) && - isSameBabyObjectMatchPublicWorkCode( - normalizedKeyword, - draft.profileId, - ) - ); - }); + const matchedDraft = resolveBabyObjectMatchPublicCodeSearchMatch( + entries, + normalizedKeyword, + ); if (!matchedDraft) { throw new Error('未找到宝贝识物作品。'); } - const detailEntry = - mapBabyObjectMatchDraftToPlatformGalleryCard(matchedDraft); - setBabyObjectMatchDraft(matchedDraft); - openPublicWorkDetail(detailEntry); + setBabyObjectMatchDraft(matchedDraft.item); + openPublicWorkDetail(matchedDraft.detail); }; const tryOpenBarkBattleGalleryEntry = async () => { const entries = barkBattleGalleryEntries.length > 0 ? barkBattleGalleryEntries : await refreshBarkBattleGallery(); - const matchedEntry = entries.find((entry) => { - const detailEntry = mapBarkBattleWorkToPlatformGalleryCard(entry); - return ( - canExposePublicWork(detailEntry) && - isSameBarkBattlePublicWorkCode(normalizedKeyword, entry.workId) - ); - }); + const matchedEntry = resolveBarkBattlePublicCodeSearchMatch( + entries, + normalizedKeyword, + ); if (!matchedEntry) { throw new Error('未找到汪汪声浪作品。'); } if (selectionStage === 'bark-battle-runtime') { - startBarkBattleRunFromWork(matchedEntry, 'platform'); + startBarkBattleRunFromWork(matchedEntry.item, 'platform'); return; } - openPublicWorkDetail( - mapBarkBattleWorkToPlatformGalleryCard(matchedEntry), - ); + openPublicWorkDetail(matchedEntry.detail); }; const runSearchStep = async (step: PlatformPublicCodeSearchStep) => { diff --git a/src/components/platform-entry/platformPublicCodeSearchModel.test.ts b/src/components/platform-entry/platformPublicCodeSearchModel.test.ts index c4bc04b1..5130d391 100644 --- a/src/components/platform-entry/platformPublicCodeSearchModel.test.ts +++ b/src/components/platform-entry/platformPublicCodeSearchModel.test.ts @@ -1,8 +1,39 @@ import { describe, expect, test } from 'vitest'; +import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { + buildBarkBattlePublicWorkCode, + buildBigFishPublicWorkCode, + buildJumpHopPublicWorkCode, + buildMatch3DPublicWorkCode, + buildPuzzlePublicWorkCode, + buildSquareHolePublicWorkCode, + buildVisualNovelPublicWorkCode, + buildWoodenFishPublicWorkCode, +} from '../../services/publicWorkCode'; +import type { CustomWorldProfile } from '../../types'; +import { + mapRpgPublicCodeSearchDetailToGalleryCard, type PlatformPublicCodeSearchStep, + resolveBabyObjectMatchPublicCodeSearchMatch, + resolveBarkBattlePublicCodeSearchMatch, + resolveBigFishPublicCodeSearchMatch, + resolveJumpHopPublicCodeSearchMatch, + resolveMatch3DPublicCodeSearchMatch, resolvePlatformPublicCodeSearchPlan, + resolvePuzzlePublicCodeSearchMatch, + resolveSquareHolePublicCodeSearchMatch, + resolveVisualNovelPublicCodeSearchMatch, + resolveWoodenFishPublicCodeSearchMatch, } from './platformPublicCodeSearchModel'; function expectSearchSteps( @@ -66,4 +97,386 @@ describe('platformPublicCodeSearchModel', () => { expectSearchSteps('SY-00000001', legacyFallbackSteps); expectSearchSteps('月井守望', legacyFallbackSteps); }); + + test('maps RPG detail responses to gallery cards with count defaults', () => { + expect( + mapRpgPublicCodeSearchDetailToGalleryCard( + buildRpgDetailEntry({ + playCount: undefined, + remixCount: undefined, + likeCount: undefined, + }), + ), + ).toMatchObject({ + profileId: 'rpg-profile-1', + visibility: 'published', + worldName: '潮雾世界', + playCount: 0, + remixCount: 0, + likeCount: 0, + }); + }); + + test('resolves public code matches for every play-specific gallery type', () => { + const puzzle = buildPuzzleWork({ profileId: 'puzzle-profile-12345678' }); + const bigFish = buildBigFishWork({ + sourceSessionId: 'big-fish-session-12345678', + }); + const jumpHop = buildJumpHopCard({ profileId: 'jump-hop-profile-12345678' }); + const woodenFish = buildWoodenFishCard({ + profileId: 'wooden-fish-profile-12345678', + }); + const babyObjectMatch = buildBabyObjectMatchDraft({ + profileId: 'baby-object-profile-12345678', + }); + const match3d = buildMatch3DWork({ profileId: 'match3d-profile-12345678' }); + const squareHole = buildSquareHoleWork({ + profileId: 'square-hole-profile-12345678', + }); + const visualNovel = buildVisualNovelWork({ + profileId: 'visual-novel-profile-12345678', + }); + const barkBattle = buildBarkBattleWork({ + workId: 'bark-battle-work-12345678', + }); + + expect( + resolvePuzzlePublicCodeSearchMatch( + [puzzle], + buildPuzzlePublicWorkCode(puzzle.profileId), + )?.detail, + ).toMatchObject({ sourceType: 'puzzle' }); + expect( + resolveBigFishPublicCodeSearchMatch( + [bigFish], + buildBigFishPublicWorkCode(bigFish.sourceSessionId), + )?.detail, + ).toMatchObject({ sourceType: 'big-fish' }); + expect( + resolveJumpHopPublicCodeSearchMatch( + [jumpHop], + buildJumpHopPublicWorkCode(jumpHop.profileId), + )?.detail, + ).toMatchObject({ sourceType: 'jump-hop' }); + expect( + resolveWoodenFishPublicCodeSearchMatch( + [woodenFish], + buildWoodenFishPublicWorkCode(woodenFish.profileId), + )?.detail, + ).toMatchObject({ sourceType: 'wooden-fish' }); + expect( + resolveBabyObjectMatchPublicCodeSearchMatch( + [babyObjectMatch], + `BO-${babyObjectMatch.profileId.slice(-8)}`, + )?.detail, + ).toMatchObject({ sourceType: 'edutainment' }); + expect( + resolveMatch3DPublicCodeSearchMatch( + [match3d], + buildMatch3DPublicWorkCode(match3d.profileId), + )?.detail, + ).toMatchObject({ sourceType: 'match3d' }); + expect( + resolveSquareHolePublicCodeSearchMatch( + [squareHole], + buildSquareHolePublicWorkCode(squareHole.profileId), + )?.detail, + ).toMatchObject({ sourceType: 'square-hole' }); + expect( + resolveVisualNovelPublicCodeSearchMatch( + [visualNovel], + buildVisualNovelPublicWorkCode(visualNovel.profileId), + )?.detail, + ).toMatchObject({ sourceType: 'visual-novel' }); + expect( + resolveBarkBattlePublicCodeSearchMatch( + [barkBattle], + buildBarkBattlePublicWorkCode(barkBattle.workId), + )?.detail, + ).toMatchObject({ sourceType: 'bark-battle' }); + }); + + test('public code search matchers skip entries hidden by visibility policy', () => { + const hiddenPuzzle = buildPuzzleWork({ + profileId: 'hidden-profile-12345678', + }); + + expect( + resolvePuzzlePublicCodeSearchMatch( + [hiddenPuzzle], + buildPuzzlePublicWorkCode(hiddenPuzzle.profileId), + () => false, + ), + ).toBeNull(); + }); }); + +function buildRpgDetailEntry( + overrides: Partial> = {}, +): CustomWorldLibraryEntry { + return { + ownerUserId: 'rpg-owner-1', + profileId: 'rpg-profile-1', + publicWorkCode: 'CW-00000001', + authorPublicUserCode: 'SY-00000001', + profile: {} as CustomWorldProfile, + visibility: 'published', + publishedAt: '2026-06-04T00:00:00.000Z', + updatedAt: '2026-06-04T00:00:00.000Z', + authorDisplayName: '测试作者', + worldName: '潮雾世界', + subtitle: '潮雾港', + summaryText: '潮雾世界说明。', + coverImageSrc: null, + themeMode: 'tide', + playableNpcCount: 1, + landmarkCount: 1, + playCount: 1, + remixCount: 1, + likeCount: 1, + ...overrides, + }; +} + +function buildPuzzleWork( + overrides: Partial = {}, +): PuzzleWorkSummary { + return { + workId: 'puzzle-work-1', + profileId: 'puzzle-profile-1', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-1', + authorDisplayName: '测试作者', + workTitle: '潮雾拼图', + workDescription: '潮雾拼图说明。', + levelName: '潮雾拼图', + summary: '潮雾拼图说明。', + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: '2026-06-04T00:00:00.000Z', + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: true, + levels: [], + ...overrides, + }; +} + +function buildBigFishWork( + overrides: Partial = {}, +): BigFishWorkSummary { + return { + workId: 'big-fish-work-1', + sourceSessionId: 'big-fish-session-1', + ownerUserId: 'user-1', + authorDisplayName: '测试作者', + title: '潮雾大鱼', + subtitle: '潮雾港', + summary: '潮雾大鱼说明。', + coverImageSrc: null, + status: 'published', + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: '2026-06-04T00:00:00.000Z', + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: true, + levelCount: 1, + levelMainImageReadyCount: 1, + levelMotionReadyCount: 1, + backgroundReady: true, + ...overrides, + }; +} + +function buildJumpHopCard( + overrides: Partial = {}, +): JumpHopGalleryCardResponse { + const profileId = overrides.profileId ?? 'jump-hop-profile-1'; + return { + publicWorkCode: buildJumpHopPublicWorkCode(profileId), + workId: 'jump-hop-work-1', + profileId, + ownerUserId: 'user-1', + authorDisplayName: '测试作者', + workTitle: '潮雾跳一跳', + workDescription: '潮雾跳一跳说明。', + coverImageSrc: null, + themeTags: [], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + publicationStatus: 'published', + playCount: 0, + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: '2026-06-04T00:00:00.000Z', + generationStatus: 'ready', + ...overrides, + }; +} + +function buildWoodenFishCard( + overrides: Partial = {}, +): WoodenFishGalleryCardResponse { + const profileId = overrides.profileId ?? 'wooden-fish-profile-1'; + return { + publicWorkCode: buildWoodenFishPublicWorkCode(profileId), + workId: 'wooden-fish-work-1', + profileId, + ownerUserId: 'user-1', + authorDisplayName: '测试作者', + workTitle: '潮雾木鱼', + workDescription: '潮雾木鱼说明。', + coverImageSrc: null, + themeTags: ['敲木鱼'], + publicationStatus: 'published', + playCount: 0, + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: '2026-06-04T00:00:00.000Z', + generationStatus: 'ready', + ...overrides, + }; +} + +function buildBabyObjectMatchDraft( + overrides: Partial = {}, +): BabyObjectMatchDraft { + return { + draftId: 'baby-draft-1', + profileId: 'baby-object-profile-1', + templateId: 'baby-object-match', + templateName: '宝贝识物', + workTitle: '潮雾识物', + workDescription: '潮雾识物说明。', + itemNames: ['苹果', '香蕉'], + itemAssets: [ + buildBabyObjectMatchItemAsset('item-a', '苹果'), + buildBabyObjectMatchItemAsset('item-b', '香蕉'), + ], + visualPackage: null, + themeTags: ['寓教于乐'], + publicationStatus: 'published', + createdAt: '2026-06-04T00:00:00.000Z', + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: '2026-06-04T00:00:00.000Z', + ...overrides, + }; +} + +function buildBabyObjectMatchItemAsset(itemId: string, itemName: string) { + return { + itemId, + itemName, + imageSrc: `/media/${itemId}.png`, + assetObjectId: null, + generationProvider: 'placeholder' as const, + prompt: itemName, + }; +} + +function buildMatch3DWork( + overrides: Partial = {}, +): Match3DWorkSummary { + return { + workId: 'match3d-work-1', + profileId: 'match3d-profile-1', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session-1', + gameName: '潮雾抓大鹅', + themeText: '潮雾港', + summary: '潮雾抓大鹅说明。', + tags: [], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 0, + difficulty: 1, + publicationStatus: 'published', + playCount: 0, + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: '2026-06-04T00:00:00.000Z', + publishReady: true, + generationStatus: 'ready', + generatedItemAssets: [], + ...overrides, + }; +} + +function buildSquareHoleWork( + overrides: Partial = {}, +): SquareHoleWorkSummary { + return { + workId: 'square-hole-work-1', + profileId: 'square-hole-profile-1', + ownerUserId: 'user-1', + sourceSessionId: 'square-hole-session-1', + gameName: '潮雾方洞', + themeText: '潮雾港', + twistRule: '避开雾门', + summary: '潮雾方洞说明。', + tags: [], + coverImageSrc: null, + backgroundPrompt: '潮雾港', + backgroundImageSrc: null, + shapeOptions: [], + holeOptions: [], + shapeCount: 1, + difficulty: 1, + publicationStatus: 'published', + playCount: 0, + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: '2026-06-04T00:00:00.000Z', + publishReady: true, + ...overrides, + }; +} + +function buildVisualNovelWork( + overrides: Partial = {}, +): VisualNovelWorkSummary { + return { + runtimeKind: 'visual-novel', + profileId: 'visual-novel-profile-1', + ownerUserId: 'user-1', + title: '潮雾视觉小说', + description: '潮雾视觉小说说明。', + coverImageSrc: null, + tags: [], + publishStatus: 'published', + publishReady: true, + playCount: 0, + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: '2026-06-04T00:00:00.000Z', + ...overrides, + }; +} + +function buildBarkBattleWork( + overrides: Partial = {}, +): BarkBattleWorkSummary { + return { + workId: 'bark-battle-work-1', + draftId: 'bark-battle-draft-1', + ownerUserId: 'user-1', + authorDisplayName: '测试作者', + title: '潮雾声浪', + summary: '潮雾声浪说明。', + themeDescription: '潮雾港', + playerImageDescription: '小狗', + opponentImageDescription: '对手', + onomatopoeia: ['汪'], + playerCharacterImageSrc: null, + opponentCharacterImageSrc: null, + uiBackgroundImageSrc: null, + difficultyPreset: 'normal', + status: 'published', + generationStatus: 'ready', + publishReady: true, + playCount: 0, + updatedAt: '2026-06-04T00:00:00.000Z', + publishedAt: '2026-06-04T00:00:00.000Z', + ...overrides, + }; +} diff --git a/src/components/platform-entry/platformPublicCodeSearchModel.ts b/src/components/platform-entry/platformPublicCodeSearchModel.ts index 616d69aa..1f1b93f0 100644 --- a/src/components/platform-entry/platformPublicCodeSearchModel.ts +++ b/src/components/platform-entry/platformPublicCodeSearchModel.ts @@ -1,3 +1,42 @@ +import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, +} from '../../../packages/shared/src/contracts/runtime'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish'; +import { + isSameBabyObjectMatchPublicWorkCode, + isSameBarkBattlePublicWorkCode, + isSameBigFishPublicWorkCode, + isSameJumpHopPublicWorkCode, + isSameMatch3DPublicWorkCode, + isSamePuzzlePublicWorkCode, + isSameSquareHolePublicWorkCode, + isSameVisualNovelPublicWorkCode, + isSameWoodenFishPublicWorkCode, +} from '../../services/publicWorkCode'; +import type { CustomWorldProfile } from '../../types'; +import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; +import { mapBabyObjectMatchDraftToPlatformGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; +import { canExposePublicWork } from './platformEdutainmentVisibility'; +import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile'; +import { + mapBarkBattleWorkToPublicWorkDetail, + mapBigFishWorkToPublicWorkDetail, + mapJumpHopWorkToPublicWorkDetail, + mapPuzzleWorkToPublicWorkDetail, + mapSquareHoleWorkToPublicWorkDetail, + mapVisualNovelWorkToPublicWorkDetail, + mapWoodenFishWorkToPublicWorkDetail, +} from './platformPublicWorkDetailFlow'; + export type PlatformPublicCodeSearchStep = | 'user-id' | 'public-user-code' @@ -17,6 +56,19 @@ export type PlatformPublicCodeSearchPlan = { steps: readonly PlatformPublicCodeSearchStep[]; }; +export type PlatformPublicCodeSearchMatch = { + item: TItem; + detail: PlatformPublicGalleryCard; +}; + +type PlatformPublicCodeSearchMatcherInput = { + keyword: string; + entries: readonly TItem[]; + mapEntry: (item: TItem) => PlatformPublicGalleryCard; + matchesEntry: (keyword: string, item: TItem) => boolean; + canExposeEntry?: (entry: PlatformPublicGalleryCard) => boolean; +}; + const PLATFORM_PUBLIC_USER_ID_PATTERN = /^user[_-][a-z0-9_-]+$/iu; const PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN = /^\d{1,8}$/u; @@ -81,3 +133,181 @@ export function resolvePlatformPublicCodeSearchPlan( ], }; } + +export function mapRpgPublicCodeSearchDetailToGalleryCard( + entry: CustomWorldLibraryEntry, +): CustomWorldGalleryCard { + return { + ownerUserId: entry.ownerUserId, + profileId: entry.profileId, + publicWorkCode: entry.publicWorkCode, + authorPublicUserCode: entry.authorPublicUserCode, + visibility: 'published', + publishedAt: entry.publishedAt, + updatedAt: entry.updatedAt, + authorDisplayName: entry.authorDisplayName, + worldName: entry.worldName, + subtitle: entry.subtitle, + summaryText: entry.summaryText, + coverImageSrc: entry.coverImageSrc, + themeMode: entry.themeMode, + playableNpcCount: entry.playableNpcCount, + landmarkCount: entry.landmarkCount, + playCount: entry.playCount ?? 0, + remixCount: entry.remixCount ?? 0, + likeCount: entry.likeCount ?? 0, + }; +} + +export function resolvePuzzlePublicCodeSearchMatch( + entries: readonly PuzzleWorkSummary[], + keyword: string, + canExposeEntry = canExposePublicWork, +) { + return resolveMappedPublicCodeSearchMatch({ + keyword, + entries, + mapEntry: mapPuzzleWorkToPublicWorkDetail, + matchesEntry: (searchKeyword, item) => + isSamePuzzlePublicWorkCode(searchKeyword, item.profileId), + canExposeEntry, + }); +} + +export function resolveBigFishPublicCodeSearchMatch( + entries: readonly BigFishWorkSummary[], + keyword: string, + canExposeEntry = canExposePublicWork, +) { + return resolveMappedPublicCodeSearchMatch({ + keyword, + entries, + mapEntry: mapBigFishWorkToPublicWorkDetail, + matchesEntry: (searchKeyword, item) => + isSameBigFishPublicWorkCode(searchKeyword, item.sourceSessionId), + canExposeEntry, + }); +} + +export function resolveJumpHopPublicCodeSearchMatch( + entries: readonly JumpHopGalleryCardResponse[], + keyword: string, + canExposeEntry = canExposePublicWork, +) { + return resolveMappedPublicCodeSearchMatch({ + keyword, + entries, + mapEntry: mapJumpHopWorkToPublicWorkDetail, + matchesEntry: (searchKeyword, item) => + isSameJumpHopPublicWorkCode(searchKeyword, item.profileId), + canExposeEntry, + }); +} + +export function resolveWoodenFishPublicCodeSearchMatch( + entries: readonly WoodenFishGalleryCardResponse[], + keyword: string, + canExposeEntry = canExposePublicWork, +) { + return resolveMappedPublicCodeSearchMatch({ + keyword, + entries, + mapEntry: mapWoodenFishWorkToPublicWorkDetail, + matchesEntry: (searchKeyword, item) => + isSameWoodenFishPublicWorkCode(searchKeyword, item.profileId), + canExposeEntry, + }); +} + +export function resolveBabyObjectMatchPublicCodeSearchMatch( + entries: readonly BabyObjectMatchDraft[], + keyword: string, + canExposeEntry = canExposePublicWork, +) { + return resolveMappedPublicCodeSearchMatch({ + keyword, + entries, + mapEntry: mapBabyObjectMatchDraftToPlatformGalleryCard, + matchesEntry: (searchKeyword, item) => + isSameBabyObjectMatchPublicWorkCode(searchKeyword, item.profileId), + canExposeEntry, + }); +} + +export function resolveMatch3DPublicCodeSearchMatch( + entries: readonly Match3DWorkSummary[], + keyword: string, + canExposeEntry = canExposePublicWork, +) { + return resolveMappedPublicCodeSearchMatch({ + keyword, + entries, + mapEntry: mapMatch3DWorkToPublicWorkDetail, + matchesEntry: (searchKeyword, item) => + isSameMatch3DPublicWorkCode(searchKeyword, item.profileId), + canExposeEntry, + }); +} + +export function resolveSquareHolePublicCodeSearchMatch( + entries: readonly SquareHoleWorkSummary[], + keyword: string, + canExposeEntry = canExposePublicWork, +) { + return resolveMappedPublicCodeSearchMatch({ + keyword, + entries, + mapEntry: mapSquareHoleWorkToPublicWorkDetail, + matchesEntry: (searchKeyword, item) => + isSameSquareHolePublicWorkCode(searchKeyword, item.profileId), + canExposeEntry, + }); +} + +export function resolveVisualNovelPublicCodeSearchMatch( + entries: readonly VisualNovelWorkSummary[], + keyword: string, + canExposeEntry = canExposePublicWork, +) { + return resolveMappedPublicCodeSearchMatch({ + keyword, + entries, + mapEntry: mapVisualNovelWorkToPublicWorkDetail, + matchesEntry: (searchKeyword, item) => + isSameVisualNovelPublicWorkCode(searchKeyword, item.profileId), + canExposeEntry, + }); +} + +export function resolveBarkBattlePublicCodeSearchMatch( + entries: readonly BarkBattleWorkSummary[], + keyword: string, + canExposeEntry = canExposePublicWork, +) { + return resolveMappedPublicCodeSearchMatch({ + keyword, + entries, + mapEntry: mapBarkBattleWorkToPublicWorkDetail, + matchesEntry: (searchKeyword, item) => + isSameBarkBattlePublicWorkCode(searchKeyword, item.workId), + canExposeEntry, + }); +} + +function resolveMappedPublicCodeSearchMatch({ + keyword, + entries, + mapEntry, + matchesEntry, + canExposeEntry = canExposePublicWork, +}: PlatformPublicCodeSearchMatcherInput): + | PlatformPublicCodeSearchMatch + | null { + for (const item of entries) { + const detail = mapEntry(item); + if (canExposeEntry(detail) && matchesEntry(keyword, item)) { + return { item, detail }; + } + } + return null; +} diff --git a/src/services/publicWorkCode.test.ts b/src/services/publicWorkCode.test.ts index c90d1c48..5b724b17 100644 --- a/src/services/publicWorkCode.test.ts +++ b/src/services/publicWorkCode.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it } from 'vitest'; import { buildCustomWorldPublicWorkCode, buildJumpHopPublicWorkCode, + buildMatch3DPublicWorkCode, buildWoodenFishPublicWorkCode, isSameCustomWorldPublicWorkCode, isSameJumpHopPublicWorkCode, + isSameMatch3DPublicWorkCode, isSameWoodenFishPublicWorkCode, } from './publicWorkCode'; @@ -34,6 +36,24 @@ describe('publicWorkCode', () => { ); }); + it('matches current and legacy match3d public work prefixes', () => { + expect(buildMatch3DPublicWorkCode('match3d-profile-12345678')).toBe( + 'M3-12345678', + ); + expect( + isSameMatch3DPublicWorkCode( + 'M3-12345678', + 'match3d-profile-12345678', + ), + ).toBe(true); + expect( + isSameMatch3DPublicWorkCode( + 'M3D-12345678', + 'match3d-profile-12345678', + ), + ).toBe(true); + }); + it('builds and matches custom world public work codes from profile ids', () => { expect(buildCustomWorldPublicWorkCode('world-public-1')).toBe('CW-00000001'); expect(isSameCustomWorldPublicWorkCode('cw-00000001', 'world-public-1')).toBe( diff --git a/src/services/publicWorkCode.ts b/src/services/publicWorkCode.ts index 2d33ae06..4cc1782d 100644 --- a/src/services/publicWorkCode.ts +++ b/src/services/publicWorkCode.ts @@ -29,6 +29,14 @@ export function buildMatch3DPublicWorkCode(profileId: string) { return `M3-${suffix}`; } +function buildLegacyMatch3DPublicWorkCode(profileId: string) { + const normalized = normalizePublicCodeText(profileId); + const fallback = normalized || '00000000'; + const suffix = fallback.slice(-8).padStart(8, '0'); + + return `M3D-${suffix}`; +} + export function buildSquareHolePublicWorkCode(profileId: string) { const normalized = normalizePublicCodeText(profileId); const fallback = normalized || '00000000'; @@ -134,6 +142,8 @@ export function isSameMatch3DPublicWorkCode(keyword: string, profileId: string) return ( normalizedKeyword === normalizePublicCodeText(buildMatch3DPublicWorkCode(profileId)) || + normalizedKeyword === + normalizePublicCodeText(buildLegacyMatch3DPublicWorkCode(profileId)) || normalizedKeyword === normalizePublicCodeText(profileId) ); } From c93b8fb5705ea7e995571005d1d833397de1d209 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 06:12:26 +0800 Subject: [PATCH 65/67] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=89=A9?= =?UTF-8?q?=E4=BD=99=E8=8D=89=E7=A8=BF=E6=89=93=E5=BC=80=20intent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 +- ...DraftGenerationShelfModel收口计划-2026-06-03.md | 6 +- .../PlatformEntryFlowShellImpl.tsx | 79 ++++---- .../platformDraftGenerationShelfModel.test.ts | 179 +++++++++++++++++ .../platformDraftGenerationShelfModel.ts | 189 ++++++++++++++++++ 6 files changed, 422 insertions(+), 41 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 427a7842..0f6852e8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-04 Draft Generation Shelf 剩余草稿打开 intent 收口 + +- 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。 +- 决策:继续扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,新增 `resolveSquareHoleDraftOpenIntent(...)`、`resolveBigFishDraftOpenIntent(...)` 与 `resolveVisualNovelDraftOpenIntent(...)`;平台壳只按 intent 执行 notice seen、详情打开、恢复 session、读取 work detail、清生成态和切 stage 副作用。 +- 影响范围:创作中心作品架打开方洞挑战 / 大鱼吃小鱼 / 视觉小说草稿、创作 URL 恢复时强制打开草稿、生成中回到生成页和视觉小说结果页恢复。 +- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 + ## 2026-06-04 Platform Public Code Search matcher / DTO 收口 - 背景:`resolvePlatformPublicCodeSearchPlan(...)` 已收口公开搜索顺序,但 `PlatformEntryFlowShellImpl.tsx` 仍内联 RPG by-code DTO 构造,以及拼图、大鱼吃小鱼、跳一跳、敲木鱼、宝贝识物、抓大鹅、方洞挑战、视觉小说和汪汪声浪的 `isSame*PublicWorkCode` 匹配、公开可见性过滤与详情卡映射。 diff --git a/docs/README.md b/docs/README.md index b51c6193..00c2f843 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,7 +77,7 @@ Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复 RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 -平台入口创作生成通知、pending 作品架占位、作品详情更新回填、失败覆盖、拼图 / 抓大鹅草稿打开优先级、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +平台入口创作生成通知、pending 作品架占位、作品详情更新回填、失败覆盖、跨玩法草稿打开优先级、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md index 12270520..da131994 100644 --- a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md @@ -15,10 +15,10 @@ - `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。 - `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。 - `mergePuzzleWorkSummary(current, updated)` 与 `mergeBigFishWorkSummary(current, updated)`:统一作品详情更新后回填作品架和当前详情的身份匹配规则。 -- `resolvePuzzleDraftOpenIntent(...)` 与 `resolveMatch3DDraftOpenIntent(...)`:统一拼图 / 抓大鹅草稿打开时的已发布详情、缺 session、ready 未读试玩、失败生成页、active / background 生成页、持久化 generating 恢复和普通草稿恢复优先级。 +- `resolvePuzzleDraftOpenIntent(...)`、`resolveMatch3DDraftOpenIntent(...)`、`resolveSquareHoleDraftOpenIntent(...)`、`resolveBigFishDraftOpenIntent(...)` 与 `resolveVisualNovelDraftOpenIntent(...)`:统一拼图、抓大鹅、方洞挑战、大鱼吃小鱼和视觉小说草稿打开时的已发布详情、缺 session、ready 未读试玩、失败 / active / background 生成页、当前结果页、持久化 generating 恢复和普通草稿恢复优先级。 - `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)`、`isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**。 -`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、读取生成 session、启动 ready 草稿试玩、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总、作品架 runtime state 和拼图 / 抓大鹅草稿打开优先级。 +`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、读取生成 session、启动 ready 草稿试玩、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总、作品架 runtime state 和上述玩法草稿打开优先级。 ## 约定 @@ -27,7 +27,7 @@ - 拼图作品详情更新只以 `profileId` 匹配回填;大鱼吃小鱼作品详情更新只以 `sourceSessionId` 匹配回填。 - 失败 notice 优先级高于持久化 generating,且可通过 pending metadata 提供更具体 summary;否则回退玩法默认失败摘要。 - 已有封面的拼图草稿即使局部关卡仍在后台生成,也不得被整卡遮罩为不可打开的生成中状态。 -- 拼图 / 抓大鹅草稿打开 intent 只返回纯计划与 notice keys,不创建失败生成态、不请求详情、不写 stage;这些仍由壳层 Adapter 执行。 +- 草稿打开 intent 只返回纯计划、notice keys 与必要稳定 ID,不创建失败生成态、不请求详情、不写 stage;这些仍由壳层 Adapter 执行。 - 本 **Module** 不做网络请求、路由切换、弹窗副作用或 React state 写入,只保留纯 **Implementation**,以提高 **Depth**、**Leverage** 与 **Locality**。 ## 验证 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 21bb578c..a76165f2 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -447,8 +447,11 @@ import { type PendingDraftShelfKind, type PendingDraftShelfMap, type PendingDraftShelfMetadata, + resolveBigFishDraftOpenIntent, resolveMatch3DDraftOpenIntent, resolvePuzzleDraftOpenIntent, + resolveSquareHoleDraftOpenIntent, + resolveVisualNovelDraftOpenIntent, } from './platformDraftGenerationShelfModel'; import { canExposePublicWork, @@ -10685,42 +10688,42 @@ export function PlatformEntryFlowShellImpl({ item: SquareHoleWorkSummary, options: { forceDraft?: boolean } = {}, ) => { + const openIntent = resolveSquareHoleDraftOpenIntent({ + item, + forceDraft: options.forceDraft, + activeSessionId: squareHoleSession?.sessionId, + hasActiveGenerationRunning: isMiniGameDraftGenerating( + squareHoleGenerationState, + ), + isGenerationReady: isMiniGameDraftReady(squareHoleGenerationState), + }); setSquareHoleRun(null); setSquareHoleError(null); setSquareHoleProfile(null); - markDraftNoticeSeen( - collectDraftNoticeKeys('square-hole', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ); + markDraftNoticeSeen(openIntent.noticeKeys); - if (item.publicationStatus === 'published' && !options.forceDraft) { + if (openIntent.type === 'open-published-detail') { openPublicWorkDetail(mapSquareHoleWorkToPublicWorkDetail(item)); return; } - if (!item.sourceSessionId?.trim()) { - setSquareHoleError('这份方洞挑战草稿缺少会话信息,请重新开始创作。'); + if (openIntent.type === 'missing-session') { + setSquareHoleError(openIntent.errorMessage); return; } - if ( - item.sourceSessionId === squareHoleSession?.sessionId && - isMiniGameDraftGenerating(squareHoleGenerationState) - ) { + if (openIntent.type === 'active-generation') { enterCreateTab(); selectionStageRef.current = 'square-hole-generating'; setSelectionStage('square-hole-generating'); return; } - if (!isMiniGameDraftReady(squareHoleGenerationState)) { + if (openIntent.shouldClearGenerationState) { setSquareHoleGenerationState(null); } const restoredSession = await squareHoleFlow.restoreDraft( - item.sourceSessionId, + openIntent.sourceSessionId, ); if (!restoredSession) { await refreshSquareHoleShelf().catch(() => undefined); @@ -10756,21 +10759,23 @@ export function PlatformEntryFlowShellImpl({ const openBigFishDraft = useCallback( async (item: BigFishWorkSummary) => { + const openIntent = resolveBigFishDraftOpenIntent({ + item, + activeSessionId: bigFishSession?.sessionId, + hasActiveGenerationRunning: isMiniGameDraftGenerating( + bigFishGenerationState, + ), + }); setBigFishRun(null); - markDraftNoticeSeen( - collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]), - ); - if ( - item.sourceSessionId === bigFishSession?.sessionId && - isMiniGameDraftGenerating(bigFishGenerationState) - ) { + markDraftNoticeSeen(openIntent.noticeKeys); + if (openIntent.type === 'active-generation') { enterCreateTab(); selectionStageRef.current = 'big-fish-generating'; setSelectionStage('big-fish-generating'); return; } const restoredSession = await bigFishFlow.restoreDraft( - item.sourceSessionId, + openIntent.sourceSessionId, ); if (!restoredSession) { await refreshBigFishShelf().catch(() => undefined); @@ -10823,27 +10828,27 @@ export function PlatformEntryFlowShellImpl({ item: VisualNovelWorkSummary, options: { forceDraft?: boolean } = {}, ) => { - if (item.publishStatus === 'published' && !options.forceDraft) { + const openIntent = resolveVisualNovelDraftOpenIntent({ + item, + forceDraft: options.forceDraft, + activeSessionId: visualNovelSession?.sessionId, + hasActiveGenerationRunning: visualNovelGenerationPhase === 'generating', + hasActiveSessionDraft: Boolean(visualNovelSession?.draft), + }); + + if (openIntent.type === 'open-published-detail') { openPublicWorkDetail(mapVisualNovelWorkToPublicWorkDetail(item)); return; } - markDraftNoticeSeen( - collectDraftNoticeKeys('visual-novel', [item.profileId]), - ); - if ( - item.profileId === visualNovelSession?.sessionId && - visualNovelGenerationPhase === 'generating' - ) { + markDraftNoticeSeen(openIntent.noticeKeys); + if (openIntent.type === 'active-generation') { enterCreateTab(); selectionStageRef.current = 'visual-novel-generating'; setSelectionStage('visual-novel-generating'); return; } - if ( - item.profileId === visualNovelSession?.sessionId && - visualNovelSession.draft - ) { + if (openIntent.type === 'current-result') { enterCreateTab(); setSelectionStage('visual-novel-result'); return; @@ -10856,7 +10861,7 @@ export function PlatformEntryFlowShellImpl({ setIsVisualNovelBusy(true); try { - const { work } = await getVisualNovelWorkDetail(item.profileId); + const { work } = await getVisualNovelWorkDetail(openIntent.profileId); setVisualNovelWork(work); setVisualNovelSession(buildVisualNovelSessionFromWorkDetail(work)); enterCreateTab(); diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts index f01e1195..156d2196 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts @@ -3,6 +3,8 @@ import { describe, expect, test } from 'vitest'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf'; import { buildCreationWorkShelfRuntimeState, @@ -16,8 +18,11 @@ import { hasUnreadDraftGenerationUpdates, mergeBigFishWorkSummary, mergePuzzleWorkSummary, + resolveBigFishDraftOpenIntent, resolveMatch3DDraftOpenIntent, resolvePuzzleDraftOpenIntent, + resolveSquareHoleDraftOpenIntent, + resolveVisualNovelDraftOpenIntent, } from './platformDraftGenerationShelfModel'; describe('platformDraftGenerationShelfModel', () => { @@ -150,6 +155,131 @@ describe('platformDraftGenerationShelfModel', () => { }); }); + test('resolveBigFishDraftOpenIntent reopens active generating session before restoring draft', () => { + expect( + resolveBigFishDraftOpenIntent({ + item: buildBigFishWork(), + activeSessionId: 'big-fish-session-base', + hasActiveGenerationRunning: true, + }), + ).toMatchObject({ + type: 'active-generation', + sourceSessionId: 'big-fish-session-base', + }); + + expect( + resolveBigFishDraftOpenIntent({ + item: buildBigFishWork(), + activeSessionId: 'other-session', + hasActiveGenerationRunning: true, + }), + ).toMatchObject({ + type: 'restore-draft', + sourceSessionId: 'big-fish-session-base', + }); + }); + + test('resolveSquareHoleDraftOpenIntent handles published, missing, active and restore states', () => { + expect( + resolveSquareHoleDraftOpenIntent({ + item: buildSquareHoleWork({ publicationStatus: 'published' }), + activeSessionId: null, + hasActiveGenerationRunning: false, + isGenerationReady: false, + }), + ).toMatchObject({ + type: 'open-published-detail', + }); + + expect( + resolveSquareHoleDraftOpenIntent({ + item: buildSquareHoleWork({ sourceSessionId: null }), + forceDraft: true, + activeSessionId: null, + hasActiveGenerationRunning: false, + isGenerationReady: false, + }), + ).toMatchObject({ + type: 'missing-session', + }); + + expect( + resolveSquareHoleDraftOpenIntent({ + item: buildSquareHoleWork(), + activeSessionId: 'square-hole-session-base', + hasActiveGenerationRunning: true, + isGenerationReady: false, + }), + ).toMatchObject({ + type: 'active-generation', + sourceSessionId: 'square-hole-session-base', + }); + + expect( + resolveSquareHoleDraftOpenIntent({ + item: buildSquareHoleWork(), + activeSessionId: 'other-session', + hasActiveGenerationRunning: false, + isGenerationReady: false, + }), + ).toMatchObject({ + type: 'restore-draft', + shouldClearGenerationState: true, + }); + }); + + test('resolveVisualNovelDraftOpenIntent handles published, active, current result and load detail states', () => { + expect( + resolveVisualNovelDraftOpenIntent({ + item: buildVisualNovelWork({ publishStatus: 'published' }), + activeSessionId: null, + hasActiveGenerationRunning: false, + hasActiveSessionDraft: false, + }), + ).toMatchObject({ + type: 'open-published-detail', + }); + + expect( + resolveVisualNovelDraftOpenIntent({ + item: buildVisualNovelWork(), + forceDraft: true, + activeSessionId: 'visual-novel-profile-base', + hasActiveGenerationRunning: true, + hasActiveSessionDraft: false, + }), + ).toMatchObject({ + type: 'active-generation', + profileId: 'visual-novel-profile-base', + }); + + expect( + resolveVisualNovelDraftOpenIntent({ + item: buildVisualNovelWork(), + forceDraft: true, + activeSessionId: 'visual-novel-profile-base', + hasActiveGenerationRunning: false, + hasActiveSessionDraft: true, + }), + ).toMatchObject({ + type: 'current-result', + profileId: 'visual-novel-profile-base', + }); + + expect( + resolveVisualNovelDraftOpenIntent({ + item: buildVisualNovelWork(), + forceDraft: true, + activeSessionId: 'other-profile', + hasActiveGenerationRunning: false, + hasActiveSessionDraft: false, + }), + ).toMatchObject({ + type: 'load-detail', + profileId: 'visual-novel-profile-base', + }); + }); + test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => { const pending = buildPendingPuzzleWorks( { @@ -425,3 +555,52 @@ function buildBigFishWork( ...overrides, }; } + +function buildSquareHoleWork( + overrides: Partial = {}, +): SquareHoleWorkSummary { + return { + workId: 'square-hole-work-base', + profileId: 'square-hole-profile-base', + ownerUserId: 'user-1', + sourceSessionId: 'square-hole-session-base', + gameName: '潮雾方洞', + themeText: '潮雾港口', + twistRule: '避开雾门', + summary: '潮雾港口方洞挑战。', + tags: [], + coverImageSrc: null, + backgroundPrompt: '潮雾港口', + backgroundImageSrc: null, + shapeOptions: [], + holeOptions: [], + shapeCount: 1, + difficulty: 1, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-03T08:00:00.000Z', + publishedAt: null, + publishReady: false, + ...overrides, + }; +} + +function buildVisualNovelWork( + overrides: Partial = {}, +): VisualNovelWorkSummary { + return { + runtimeKind: 'visual-novel', + profileId: 'visual-novel-profile-base', + ownerUserId: 'user-1', + title: '潮雾视觉小说', + description: '潮雾港口视觉小说。', + coverImageSrc: null, + tags: [], + publishStatus: 'draft', + publishReady: false, + playCount: 0, + updatedAt: '2026-06-03T08:00:00.000Z', + publishedAt: null, + ...overrides, + }; +} diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.ts index 62094b63..dc5b5220 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.ts @@ -148,6 +148,61 @@ export type Match3DDraftOpenIntent = noticeKeys: string[]; }; +export type BigFishDraftOpenIntent = + | { + type: 'active-generation'; + noticeKeys: string[]; + sourceSessionId: string; + } + | { + type: 'restore-draft'; + noticeKeys: string[]; + sourceSessionId: string; + }; + +export type SquareHoleDraftOpenIntent = + | { + type: 'open-published-detail'; + noticeKeys: string[]; + } + | { + type: 'missing-session'; + noticeKeys: string[]; + errorMessage: string; + } + | { + type: 'active-generation'; + noticeKeys: string[]; + sourceSessionId: string; + } + | { + type: 'restore-draft'; + noticeKeys: string[]; + sourceSessionId: string; + shouldClearGenerationState: boolean; + }; + +export type VisualNovelDraftOpenIntent = + | { + type: 'open-published-detail'; + noticeKeys: string[]; + } + | { + type: 'active-generation'; + noticeKeys: string[]; + profileId: string; + } + | { + type: 'current-result'; + noticeKeys: string[]; + profileId: string; + } + | { + type: 'load-detail'; + noticeKeys: string[]; + profileId: string; + }; + export function buildDraftNoticeKey( kind: CreationWorkShelfKind, id: string, @@ -433,6 +488,29 @@ export function buildMatch3DDraftOpenNoticeKeys(item: Match3DWorkSummary) { ]); } +export function buildBigFishDraftOpenNoticeKeys(item: BigFishWorkSummary) { + return collectDraftNoticeKeys('big-fish', [ + item.workId, + item.sourceSessionId, + ]); +} + +export function buildSquareHoleDraftOpenNoticeKeys( + item: SquareHoleWorkSummary, +) { + return collectDraftNoticeKeys('square-hole', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]); +} + +export function buildVisualNovelDraftOpenNoticeKeys( + item: VisualNovelWorkSummary, +) { + return collectDraftNoticeKeys('visual-novel', [item.profileId]); +} + export function resolvePuzzleDraftOpenIntent(params: { item: PuzzleWorkSummary; notices: DraftGenerationNoticeMap; @@ -628,6 +706,117 @@ export function resolveMatch3DDraftOpenIntent(params: { return { type: 'restore-draft', noticeKeys }; } +export function resolveBigFishDraftOpenIntent(params: { + item: BigFishWorkSummary; + activeSessionId?: string | null; + hasActiveGenerationRunning: boolean; +}): BigFishDraftOpenIntent { + const { item, activeSessionId, hasActiveGenerationRunning } = params; + const noticeKeys = buildBigFishDraftOpenNoticeKeys(item); + if (item.sourceSessionId === activeSessionId && hasActiveGenerationRunning) { + return { + type: 'active-generation', + noticeKeys, + sourceSessionId: item.sourceSessionId, + }; + } + + return { + type: 'restore-draft', + noticeKeys, + sourceSessionId: item.sourceSessionId, + }; +} + +export function resolveSquareHoleDraftOpenIntent(params: { + item: SquareHoleWorkSummary; + forceDraft?: boolean; + activeSessionId?: string | null; + hasActiveGenerationRunning: boolean; + isGenerationReady: boolean; +}): SquareHoleDraftOpenIntent { + const { + item, + forceDraft = false, + activeSessionId, + hasActiveGenerationRunning, + isGenerationReady, + } = params; + const noticeKeys = buildSquareHoleDraftOpenNoticeKeys(item); + + if (item.publicationStatus === 'published' && !forceDraft) { + return { type: 'open-published-detail', noticeKeys }; + } + + const sourceSessionId = normalizeDraftNoticeId(item.sourceSessionId); + if (!sourceSessionId) { + return { + type: 'missing-session', + noticeKeys, + errorMessage: '这份方洞挑战草稿缺少会话信息,请重新开始创作。', + }; + } + + if (sourceSessionId === activeSessionId && hasActiveGenerationRunning) { + return { + type: 'active-generation', + noticeKeys, + sourceSessionId, + }; + } + + return { + type: 'restore-draft', + noticeKeys, + sourceSessionId, + shouldClearGenerationState: !isGenerationReady, + }; +} + +export function resolveVisualNovelDraftOpenIntent(params: { + item: VisualNovelWorkSummary; + forceDraft?: boolean; + activeSessionId?: string | null; + hasActiveGenerationRunning: boolean; + hasActiveSessionDraft: boolean; +}): VisualNovelDraftOpenIntent { + const { + item, + forceDraft = false, + activeSessionId, + hasActiveGenerationRunning, + hasActiveSessionDraft, + } = params; + const noticeKeys = buildVisualNovelDraftOpenNoticeKeys(item); + + if (item.publishStatus === 'published' && !forceDraft) { + return { type: 'open-published-detail', noticeKeys }; + } + + const isCurrentSession = item.profileId === activeSessionId; + if (isCurrentSession && hasActiveGenerationRunning) { + return { + type: 'active-generation', + noticeKeys, + profileId: item.profileId, + }; + } + + if (isCurrentSession && hasActiveSessionDraft) { + return { + type: 'current-result', + noticeKeys, + profileId: item.profileId, + }; + } + + return { + type: 'load-detail', + noticeKeys, + profileId: item.profileId, + }; +} + export function buildCreationWorkShelfRuntimeState(params: { item: CreationWorkShelfItem; notices: DraftGenerationNoticeMap; From bbb9269bab8ccf76c90b742d969d47f24aa745a3 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 06:26:09 +0800 Subject: [PATCH 66/67] =?UTF-8?q?refactor:=20=E8=A1=A5=E9=BD=90=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E4=B8=8ESSE=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 9 +- ...DraftGenerationShelfModel收口计划-2026-06-03.md | 2 +- ...架构】SSE客户端传输层收口约定-2026-06-03.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 64 ++++--- .../platformDraftGenerationShelfModel.test.ts | 167 ++++++++++++++++++ .../platformDraftGenerationShelfModel.ts | 147 +++++++++++++++ src/services/llmClient.test.ts | 51 ++++++ src/services/llmClient.ts | 67 ++++--- 8 files changed, 433 insertions(+), 76 deletions(-) create mode 100644 src/services/llmClient.test.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 0f6852e8..01124161 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -20,7 +20,8 @@ - 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。 - 决策:继续扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,新增 `resolveSquareHoleDraftOpenIntent(...)`、`resolveBigFishDraftOpenIntent(...)` 与 `resolveVisualNovelDraftOpenIntent(...)`;平台壳只按 intent 执行 notice seen、详情打开、恢复 session、读取 work detail、清生成态和切 stage 副作用。 -- 影响范围:创作中心作品架打开方洞挑战 / 大鱼吃小鱼 / 视觉小说草稿、创作 URL 恢复时强制打开草稿、生成中回到生成页和视觉小说结果页恢复。 +- 追加决策:跳一跳与敲木鱼草稿打开也归入同一 Draft Generation Shelf Model,新增 `resolveJumpHopDraftOpenIntent(...)` 与 `resolveWoodenFishDraftOpenIntent(...)`;壳层只按 intent 执行已发布详情、失败生成页恢复、持久化 generating 恢复、读取 detail 和敲木鱼失败 fallback stage 副作用。 +- 影响范围:创作中心作品架打开方洞挑战 / 大鱼吃小鱼 / 视觉小说 / 跳一跳 / 敲木鱼草稿、创作 URL 恢复时强制打开草稿、生成中回到生成页和视觉小说结果页恢复。 - 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 @@ -114,9 +115,9 @@ ## 2026-06-03 前端 SSE 客户端传输层统一收口 - 背景:创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态等多个前端 client 曾各自手写 SSE 边界扫描、`TextDecoder` 解码、JSON 解析和流结束 flush,导致 CRLF / LF、UTF-8 尾部、多行 `data:` 和提前停止释放 reader 的处理容易漂移。 -- 决策:前端 SSE 传输层统一使用 `src/services/sseStream.ts`;`readSseStream` 负责事件边界、解码 flush、多行 data 和提前停止取消 reader,`readSseJsonStream` 负责 JSON object 事件解析与异常 JSON 静默跳过。业务 client 只保留领域事件归一化、结果聚合和中文错误文案,后续不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环。 -- 影响范围:`src/services/sseStream.ts`、`src/services/aiService.ts`、`src/services/creation-agent/creationAgentSse.ts`、`src/services/creative-agent/creativeAgentSse.ts`、`src/services/visual-novel-runtime/visualNovelRuntimeSse.ts`、`src/services/rpg-entry/rpgProfileClient.ts`、前端 SSE 相关测试与架构文档。 -- 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/creation-agent/creationAgentSse.test.ts src/services/creative-agent/creativeAgentSse.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/ai.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 `npx eslint ... --max-warnings 0` 通过。 +- 决策:前端 SSE 传输层统一使用 `src/services/sseStream.ts`;`readSseStream` 负责事件边界、解码 flush、多行 data 和提前停止取消 reader,`readSseJsonStream` 负责 JSON object 事件解析与异常 JSON 静默跳过。业务 client 只保留领域事件归一化、结果聚合和中文错误文案,OpenAI 兼容文本流通过 `readSseStream` 处理 `[DONE]` 哨兵,后续不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环。 +- 影响范围:`src/services/sseStream.ts`、`src/services/aiService.ts`、`src/services/llmClient.ts`、`src/services/creation-agent/creationAgentSse.ts`、`src/services/creative-agent/creativeAgentSse.ts`、`src/services/visual-novel-runtime/visualNovelRuntimeSse.ts`、`src/services/rpg-entry/rpgProfileClient.ts`、前端 SSE 相关测试与架构文档。 +- 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/llmClient.test.ts src/services/creation-agent/creationAgentSse.test.ts src/services/creative-agent/creativeAgentSse.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/ai.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 `npx eslint ... --max-warnings 0` 通过。 - 关联文档:`docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md`。 ## 2026-06-03 平台入口公开作品流身份规则收口 diff --git a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md index da131994..2c508c5f 100644 --- a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md @@ -15,7 +15,7 @@ - `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。 - `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。 - `mergePuzzleWorkSummary(current, updated)` 与 `mergeBigFishWorkSummary(current, updated)`:统一作品详情更新后回填作品架和当前详情的身份匹配规则。 -- `resolvePuzzleDraftOpenIntent(...)`、`resolveMatch3DDraftOpenIntent(...)`、`resolveSquareHoleDraftOpenIntent(...)`、`resolveBigFishDraftOpenIntent(...)` 与 `resolveVisualNovelDraftOpenIntent(...)`:统一拼图、抓大鹅、方洞挑战、大鱼吃小鱼和视觉小说草稿打开时的已发布详情、缺 session、ready 未读试玩、失败 / active / background 生成页、当前结果页、持久化 generating 恢复和普通草稿恢复优先级。 +- `resolvePuzzleDraftOpenIntent(...)`、`resolveMatch3DDraftOpenIntent(...)`、`resolveSquareHoleDraftOpenIntent(...)`、`resolveBigFishDraftOpenIntent(...)`、`resolveVisualNovelDraftOpenIntent(...)`、`resolveJumpHopDraftOpenIntent(...)` 与 `resolveWoodenFishDraftOpenIntent(...)`:统一拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说、跳一跳和敲木鱼草稿打开时的已发布详情、缺 session、ready 未读试玩、失败 / active / background 生成页、当前结果页、持久化 generating 恢复、失败 fallback stage 和普通草稿恢复优先级。 - `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)`、`isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**。 `PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、读取生成 session、启动 ready 草稿试玩、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总、作品架 runtime state 和上述玩法草稿打开优先级。 diff --git a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md index 0948019a..de90be6b 100644 --- a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md +++ b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md @@ -24,11 +24,13 @@ - `src/services/creative-agent/creativeAgentSse.ts` - `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts` - `src/services/rpg-entry/rpgProfileClient.ts` +- `src/services/llmClient.ts` 后续新增 SSE client 时不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing,应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。 ## 验收 - `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。 +- `src/services/llmClient.test.ts` 覆盖 OpenAI 兼容文本流、异常 JSON 跳过和 `[DONE]` 后提前停止。 - 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。 - `npm run typecheck` 不产生新的类型错误。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index a76165f2..46b7a8d3 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -439,7 +439,6 @@ import { type DraftGenerationNoticeMap, type DraftGenerationNoticeStatus, getGenerationNoticeShelfKeys, - hasDraftGenerationNoticeStatus, hasUnreadDraftGenerationUpdates, mergeBigFishWorkSummary, mergePuzzleWorkSummary, @@ -448,10 +447,12 @@ import { type PendingDraftShelfMap, type PendingDraftShelfMetadata, resolveBigFishDraftOpenIntent, + resolveJumpHopDraftOpenIntent, resolveMatch3DDraftOpenIntent, resolvePuzzleDraftOpenIntent, resolveSquareHoleDraftOpenIntent, resolveVisualNovelDraftOpenIntent, + resolveWoodenFishDraftOpenIntent, } from './platformDraftGenerationShelfModel'; import { canExposePublicWork, @@ -2010,11 +2011,6 @@ export function PlatformEntryFlowShellImpl({ activePuzzleGenerationSessionIdRef.current === sessionId ); }, []); - const isDraftNoticeFailed = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => - hasDraftGenerationNoticeStatus(draftGenerationNotices, kind, ids, 'failed'), - [draftGenerationNotices], - ); const ensureEnoughDraftGenerationPointsFromServer = useCallback( async (pointsCost: number) => { try { @@ -9920,12 +9916,17 @@ export function PlatformEntryFlowShellImpl({ const openJumpHopDraft = useCallback( async (item: JumpHopWorkSummaryResponse) => { - const noticeIds = [item.workId, item.profileId, item.sourceSessionId]; - const hasFailedNotice = isDraftNoticeFailed('jump-hop', noticeIds); - const sessionId = normalizeCreationUrlValue(item.sourceSessionId); - markDraftNoticeSeen(collectDraftNoticeKeys('jump-hop', noticeIds)); + const openIntent = resolveJumpHopDraftOpenIntent({ + item, + notices: draftGenerationNotices, + generation: { + activeSessionId: jumpHopSession?.sessionId, + hasActiveGenerationFailure: jumpHopGenerationState?.phase === 'failed', + }, + }); + markDraftNoticeSeen(openIntent.noticeKeys); - if (item.publicationStatus === 'published') { + if (openIntent.type === 'open-published-detail') { void openJumpHopPublicWorkDetail(item.profileId); return; } @@ -9933,18 +9934,14 @@ export function PlatformEntryFlowShellImpl({ setJumpHopError(null); setPublicWorkDetailError(null); setIsJumpHopBusy(true); - if ( - hasFailedNotice && - sessionId === jumpHopSession?.sessionId && - jumpHopGenerationState?.phase === 'failed' - ) { + if (openIntent.type === 'active-failed-generation') { enterCreateTab(); setSelectionStage('jump-hop-generating'); setIsJumpHopBusy(false); return; } - if (item.generationStatus === 'generating' && !hasFailedNotice) { + if (openIntent.type === 'restore-generating') { const pendingSession = buildJumpHopPendingSession(item); setJumpHopSession(pendingSession); setJumpHopRun(null); @@ -9981,8 +9978,8 @@ export function PlatformEntryFlowShellImpl({ } }, [ + draftGenerationNotices, enterCreateTab, - isDraftNoticeFailed, jumpHopGenerationState?.phase, jumpHopSession?.sessionId, markDraftNoticeSeen, @@ -10016,13 +10013,18 @@ export function PlatformEntryFlowShellImpl({ const openWoodenFishDraft = useCallback( async (item: WoodenFishWorkSummaryResponse) => { - const noticeIds = [item.workId, item.profileId, item.sourceSessionId]; - const hasFailedNotice = isDraftNoticeFailed('wooden-fish', noticeIds); - const sessionId = - normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId; - markDraftNoticeSeen(collectDraftNoticeKeys('wooden-fish', noticeIds)); + const openIntent = resolveWoodenFishDraftOpenIntent({ + item, + notices: draftGenerationNotices, + generation: { + activeSessionId: woodenFishSession?.sessionId, + hasActiveGenerationFailure: + woodenFishGenerationState?.phase === 'failed', + }, + }); + markDraftNoticeSeen(openIntent.noticeKeys); - if (item.publicationStatus === 'published') { + if (openIntent.type === 'open-published-detail') { void openWoodenFishPublicWorkDetail(item.profileId); return; } @@ -10030,18 +10032,14 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishError(null); setPublicWorkDetailError(null); setIsWoodenFishBusy(true); - if ( - hasFailedNotice && - sessionId === woodenFishSession?.sessionId && - woodenFishGenerationState?.phase === 'failed' - ) { + if (openIntent.type === 'active-failed-generation') { enterCreateTab(); setSelectionStage('wooden-fish-generating'); setIsWoodenFishBusy(false); return; } - if (item.generationStatus === 'generating' && !hasFailedNotice) { + if (openIntent.type === 'restore-generating') { const pendingSession = buildWoodenFishPendingSession(item); setWoodenFishSession(pendingSession); setWoodenFishRun(null); @@ -10083,16 +10081,14 @@ export function PlatformEntryFlowShellImpl({ resolveRpgCreationErrorMessage(error, '读取敲木鱼草稿失败。'), ); enterCreateTab(); - setSelectionStage( - hasFailedNotice ? 'wooden-fish-workspace' : 'wooden-fish-generating', - ); + setSelectionStage(openIntent.failureFallbackStage); } finally { setIsWoodenFishBusy(false); } }, [ + draftGenerationNotices, enterCreateTab, - isDraftNoticeFailed, markDraftNoticeSeen, openWoodenFishPublicWorkDetail, woodenFishGenerationState?.phase, diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts index 156d2196..7242e7b1 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from 'vitest'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf'; import { buildCreationWorkShelfRuntimeState, @@ -19,10 +21,12 @@ import { mergeBigFishWorkSummary, mergePuzzleWorkSummary, resolveBigFishDraftOpenIntent, + resolveJumpHopDraftOpenIntent, resolveMatch3DDraftOpenIntent, resolvePuzzleDraftOpenIntent, resolveSquareHoleDraftOpenIntent, resolveVisualNovelDraftOpenIntent, + resolveWoodenFishDraftOpenIntent, } from './platformDraftGenerationShelfModel'; describe('platformDraftGenerationShelfModel', () => { @@ -280,6 +284,121 @@ describe('platformDraftGenerationShelfModel', () => { }); }); + test('resolveJumpHopDraftOpenIntent handles published, failed current generation, generating and detail states', () => { + expect( + resolveJumpHopDraftOpenIntent({ + item: buildJumpHopWork({ publicationStatus: 'published' }), + notices: {}, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'open-published-detail', + }); + + expect( + resolveJumpHopDraftOpenIntent({ + item: buildJumpHopWork(), + notices: { + 'jump-hop:jump-hop-session-base': { + status: 'failed', + seen: false, + }, + }, + generation: emptyGenerationFacts({ + activeSessionId: 'jump-hop-session-base', + hasActiveGenerationFailure: true, + }), + }), + ).toMatchObject({ + type: 'active-failed-generation', + }); + + expect( + resolveJumpHopDraftOpenIntent({ + item: buildJumpHopWork({ generationStatus: 'generating' }), + notices: {}, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'restore-generating', + }); + + expect( + resolveJumpHopDraftOpenIntent({ + item: buildJumpHopWork({ generationStatus: 'generating' }), + notices: { + 'jump-hop:jump-hop-session-base': { + status: 'failed', + seen: false, + }, + }, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'load-detail', + }); + }); + + test('resolveWoodenFishDraftOpenIntent uses profile fallback and failure fallback stage', () => { + expect( + resolveWoodenFishDraftOpenIntent({ + item: buildWoodenFishWork({ + sourceSessionId: null, + generationStatus: 'generating', + }), + notices: { + 'wooden-fish:wooden-fish-profile-base': { + status: 'failed', + seen: false, + }, + }, + generation: emptyGenerationFacts({ + activeSessionId: 'wooden-fish-profile-base', + hasActiveGenerationFailure: true, + }), + }), + ).toMatchObject({ + type: 'active-failed-generation', + }); + + expect( + resolveWoodenFishDraftOpenIntent({ + item: buildWoodenFishWork({ generationStatus: 'generating' }), + notices: {}, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'restore-generating', + }); + + expect( + resolveWoodenFishDraftOpenIntent({ + item: buildWoodenFishWork(), + notices: { + 'wooden-fish:wooden-fish-session-base': { + status: 'failed', + seen: false, + }, + }, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'load-detail', + failureFallbackStage: 'wooden-fish-workspace', + }); + + expect( + resolveWoodenFishDraftOpenIntent({ + item: buildWoodenFishWork(), + notices: {}, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'load-detail', + failureFallbackStage: 'wooden-fish-generating', + }); + }); + test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => { const pending = buildPendingPuzzleWorks( { @@ -604,3 +723,51 @@ function buildVisualNovelWork( ...overrides, }; } + +function buildJumpHopWork( + overrides: Partial = {}, +): JumpHopWorkSummaryResponse { + return { + runtimeKind: 'jump-hop', + workId: 'jump-hop-work-base', + profileId: 'jump-hop-profile-base', + ownerUserId: 'user-1', + sourceSessionId: 'jump-hop-session-base', + workTitle: '潮雾跳一跳', + workDescription: '潮雾港口跳一跳。', + themeTags: [], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-03T08:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'ready', + ...overrides, + }; +} + +function buildWoodenFishWork( + overrides: Partial = {}, +): WoodenFishWorkSummaryResponse { + return { + runtimeKind: 'wooden-fish', + workId: 'wooden-fish-work-base', + profileId: 'wooden-fish-profile-base', + ownerUserId: 'user-1', + sourceSessionId: 'wooden-fish-session-base', + workTitle: '潮雾敲木鱼', + workDescription: '潮雾港口敲木鱼。', + themeTags: ['敲木鱼'], + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-03T08:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'ready', + ...overrides, + }; +} diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.ts index dc5b5220..de16f098 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.ts @@ -203,6 +203,43 @@ export type VisualNovelDraftOpenIntent = profileId: string; }; +export type JumpHopDraftOpenIntent = + | { + type: 'open-published-detail'; + noticeKeys: string[]; + } + | { + type: 'active-failed-generation'; + noticeKeys: string[]; + } + | { + type: 'restore-generating'; + noticeKeys: string[]; + } + | { + type: 'load-detail'; + noticeKeys: string[]; + }; + +export type WoodenFishDraftOpenIntent = + | { + type: 'open-published-detail'; + noticeKeys: string[]; + } + | { + type: 'active-failed-generation'; + noticeKeys: string[]; + } + | { + type: 'restore-generating'; + noticeKeys: string[]; + } + | { + type: 'load-detail'; + noticeKeys: string[]; + failureFallbackStage: 'wooden-fish-workspace' | 'wooden-fish-generating'; + }; + export function buildDraftNoticeKey( kind: CreationWorkShelfKind, id: string, @@ -505,6 +542,26 @@ export function buildSquareHoleDraftOpenNoticeKeys( ]); } +export function buildJumpHopDraftOpenNoticeKeys( + item: JumpHopWorkSummaryResponse, +) { + return collectDraftNoticeKeys('jump-hop', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]); +} + +export function buildWoodenFishDraftOpenNoticeKeys( + item: WoodenFishWorkSummaryResponse, +) { + return collectDraftNoticeKeys('wooden-fish', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]); +} + export function buildVisualNovelDraftOpenNoticeKeys( item: VisualNovelWorkSummary, ) { @@ -817,6 +874,96 @@ export function resolveVisualNovelDraftOpenIntent(params: { }; } +export function resolveJumpHopDraftOpenIntent(params: { + item: JumpHopWorkSummaryResponse; + notices: DraftGenerationNoticeMap; + generation: Pick< + DraftOpenGenerationFacts, + 'activeSessionId' | 'hasActiveGenerationFailure' + >; +}): JumpHopDraftOpenIntent { + const { item, notices, generation } = params; + const noticeKeys = buildJumpHopDraftOpenNoticeKeys(item); + + if (item.publicationStatus === 'published') { + return { type: 'open-published-detail', noticeKeys }; + } + + const noticeIds = [item.workId, item.profileId, item.sourceSessionId]; + const sourceSessionId = normalizeDraftNoticeId(item.sourceSessionId); + const hasFailedNotice = hasDraftGenerationNoticeStatus( + notices, + 'jump-hop', + noticeIds, + 'failed', + ); + const isCurrentSession = + sourceSessionId === normalizeDraftNoticeId(generation.activeSessionId); + + if ( + hasFailedNotice && + isCurrentSession && + generation.hasActiveGenerationFailure + ) { + return { type: 'active-failed-generation', noticeKeys }; + } + + if (isPersistedDraftGenerating(item.generationStatus) && !hasFailedNotice) { + return { type: 'restore-generating', noticeKeys }; + } + + return { type: 'load-detail', noticeKeys }; +} + +export function resolveWoodenFishDraftOpenIntent(params: { + item: WoodenFishWorkSummaryResponse; + notices: DraftGenerationNoticeMap; + generation: Pick< + DraftOpenGenerationFacts, + 'activeSessionId' | 'hasActiveGenerationFailure' + >; +}): WoodenFishDraftOpenIntent { + const { item, notices, generation } = params; + const noticeKeys = buildWoodenFishDraftOpenNoticeKeys(item); + + if (item.publicationStatus === 'published') { + return { type: 'open-published-detail', noticeKeys }; + } + + const noticeIds = [item.workId, item.profileId, item.sourceSessionId]; + const sourceSessionId = + normalizeDraftNoticeId(item.sourceSessionId) ?? + normalizeDraftNoticeId(item.profileId); + const hasFailedNotice = hasDraftGenerationNoticeStatus( + notices, + 'wooden-fish', + noticeIds, + 'failed', + ); + const isCurrentSession = + sourceSessionId === normalizeDraftNoticeId(generation.activeSessionId); + + if ( + hasFailedNotice && + isCurrentSession && + generation.hasActiveGenerationFailure + ) { + return { type: 'active-failed-generation', noticeKeys }; + } + + if (isPersistedDraftGenerating(item.generationStatus) && !hasFailedNotice) { + return { type: 'restore-generating', noticeKeys }; + } + + return { + type: 'load-detail', + noticeKeys, + failureFallbackStage: hasFailedNotice + ? 'wooden-fish-workspace' + : 'wooden-fish-generating', + }; +} + export function buildCreationWorkShelfRuntimeState(params: { item: CreationWorkShelfItem; notices: DraftGenerationNoticeMap; diff --git a/src/services/llmClient.test.ts b/src/services/llmClient.test.ts new file mode 100644 index 00000000..a46df049 --- /dev/null +++ b/src/services/llmClient.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { streamPlainTextCompletion } from './llmClient'; + +function createSseResponse(body: string) { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + }, + }); +} + +describe('llmClient streamPlainTextCompletion', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('reads OpenAI compatible SSE through the shared stream reader', async () => { + const onUpdate = vi.fn(); + const fetchMock = vi.fn().mockResolvedValue( + createSseResponse( + [ + 'data: {"choices":[{"delta":{"content":"溪上"}}]}\r\n\r\n', + 'data: not-json\r\n\r\n', + 'data: {"choices":[{"delta":{"content":"春风"}}]}\r\n\r\n', + 'data: [DONE]\r\n\r\n', + 'data: {"choices":[{"delta":{"content":"不应读取"}}]}\r\n\r\n', + ].join(''), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await streamPlainTextCompletion('system', 'user', { + onUpdate, + }); + + expect(result).toBe('溪上春风'); + expect(onUpdate).toHaveBeenNthCalledWith(1, '溪上'); + expect(onUpdate).toHaveBeenNthCalledWith(2, '溪上春风'); + expect(onUpdate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/services/llmClient.ts b/src/services/llmClient.ts index 04a972fe..5182cc35 100644 --- a/src/services/llmClient.ts +++ b/src/services/llmClient.ts @@ -1,5 +1,6 @@ import type {TextStreamOptions} from './aiTypes'; import { fetchWithApiAuth } from './apiClient'; +import { parseSseJsonObject, readSseStream } from './sseStream'; const ENV: Partial = import.meta.env ?? {}; @@ -44,6 +45,26 @@ function resolveHeaders(headers?: HeadersInit) { return nextHeaders; } +function readLlmStreamDeltaContent(parsed: Record) { + const choices = parsed.choices; + if (!Array.isArray(choices)) { + return null; + } + + const [firstChoice] = choices; + if (typeof firstChoice !== 'object' || firstChoice === null) { + return null; + } + + const delta = (firstChoice as {delta?: unknown}).delta; + if (typeof delta !== 'object' || delta === null) { + return null; + } + + const content = (delta as {content?: unknown}).content; + return typeof content === 'string' && content.length > 0 ? content : null; +} + const NODE_ENV = getNodeEnv(); const IS_SERVER_RUNTIME = typeof window === 'undefined'; const SERVER_API_KEY = @@ -291,48 +312,20 @@ export async function streamPlainTextCompletion( return fallbackText; } - const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; let accumulatedText = ''; - for (;;) { - const {done, value} = await reader.read(); - if (done) { - break; + await readSseStream(response, ({ data }) => { + if (data === '[DONE]') { + return false; } - buffer += decoder.decode(value, {stream: true}); - - while (buffer.includes('\n\n')) { - const boundary = buffer.indexOf('\n\n'); - const eventBlock = buffer.slice(0, boundary); - buffer = buffer.slice(boundary + 2); - - for (const rawLine of eventBlock.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (!line.startsWith('data:')) { - continue; - } - - const data = line.slice(5).trim(); - if (!data || data === '[DONE]') { - continue; - } - - try { - const parsed = JSON.parse(data); - const delta = parsed?.choices?.[0]?.delta?.content; - if (typeof delta === 'string' && delta.length > 0) { - accumulatedText += delta; - options.onUpdate?.(accumulatedText); - } - } catch { - // Ignore malformed SSE frames and continue consuming the stream. - } - } + const parsed = parseSseJsonObject(data); + const delta = parsed ? readLlmStreamDeltaContent(parsed) : null; + if (delta) { + accumulatedText += delta; + options.onUpdate?.(accumulatedText); } - } + }); return accumulatedText.trim(); } catch (error) { From b60382a752ce1f6a5b4b260e982c5311ba65d9c2 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 17:43:56 +0800 Subject: [PATCH 67/67] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E7=86=94=E6=96=AD=E4=B8=8E=E8=B7=B3=E4=B8=80=E8=B7=B3?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + server-rs/crates/api-server/src/app.rs | 30 ++++- .../api-server/src/creation_entry_config.rs | 120 ++++++++++-------- .../platformDraftGenerationShelfModel.test.ts | 18 +++ .../platformDraftGenerationShelfModel.ts | 3 +- 5 files changed, 116 insertions(+), 57 deletions(-) diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 1394d891..6e3c3458 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -34,6 +34,8 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。 +入口配置中的 `open=false` 表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。 + `PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 `platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recommended` / `热门推荐`,并把历史 `recent` / `最近创作` 归一到推荐分类。`最近创作` 不属于模板分类页签,只能由 7 天内的真实草稿 / 作品架后端数据决定是否展示;展示内容仍然从后端入口配置的模板卡中筛选,不读取或渲染作品标题、作品摘要、草稿阶段文案。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index a9fbf3d5..6bc88ace 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -740,7 +740,8 @@ mod tests { let response = app .oneshot( Request::builder() - .uri("/api/runtime/puzzle/works") + .method("POST") + .uri("/api/runtime/puzzle/agent/sessions") .body(Body::empty()) .expect("request should build"), ) @@ -756,6 +757,31 @@ mod tests { assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle"); } + #[tokio::test] + async fn disabled_creation_entry_does_not_block_published_runtime_routes() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state.set_test_creation_entry_route_enabled("puzzle", false); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/puzzle/runs") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_ne!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = read_json_response(response).await; + assert_ne!( + body["error"]["details"]["reason"], + "creation_entry_disabled" + ); + } + #[tokio::test] async fn disabled_visual_novel_creation_route_returns_service_unavailable() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -789,7 +815,7 @@ mod tests { } #[tokio::test] - async fn disabled_rpg_route_returns_service_unavailable() { + async fn disabled_rpg_creation_route_returns_service_unavailable() { let state = AppState::new(AppConfig::default()).expect("state should build"); state.set_test_creation_entry_route_enabled("rpg", false); let app = build_router(state); diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 8707b709..70b4d70d 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -34,7 +34,7 @@ pub async fn get_creation_entry_config_handler( Ok(json_success_body(Some(&request_context), config)) } -/// 中文注释:api-server 路由熔断只拦创作/运行态 API 请求,不改变前端入口展示规则。 +/// 中文注释:api-server 路由熔断只拦新建创作入口,不限制已有作品读取、发布作品游玩或公开广场浏览。 pub async fn require_creation_entry_route_enabled( State(state): State, request: Request, @@ -72,54 +72,56 @@ pub async fn require_creation_entry_route_enabled( pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { let normalized = path.trim_end_matches('/'); - if normalized.starts_with("/api/runtime/puzzle") { + if normalized == "/api/runtime/puzzle/agent/sessions" + || normalized == "/api/runtime/puzzle/onboarding/generate" + { return Some("puzzle"); } - if normalized.starts_with("/api/runtime/match3d") { - return Some("match3d"); + if normalized.starts_with("/api/runtime/puzzle/gallery/") + && normalized.ends_with("/remix") + { + return Some("puzzle"); } - if normalized.starts_with("/api/runtime/bark-battle") { - return Some("bark-battle"); - } - if normalized.starts_with("/api/creation/bark-battle") { - return Some("bark-battle"); - } - if normalized.starts_with("/api/runtime/wooden-fish") { - return Some("wooden-fish"); - } - if normalized.starts_with("/api/creation/wooden-fish") { - return Some("wooden-fish"); - } - if normalized.starts_with("/api/runtime/square-hole") { - return Some("square-hole"); - } - if normalized.starts_with("/api/runtime/jump-hop") { - return Some("jump-hop"); - } - if normalized.starts_with("/api/creation/jump-hop") { - return Some("jump-hop"); - } - if normalized.starts_with("/api/runtime/big-fish") { + if normalized == "/api/runtime/big-fish/agent/sessions" { return Some("big-fish"); } - if normalized.starts_with("/api/runtime/custom-world") - || normalized.starts_with("/api/runtime/custom-world-library") - || normalized.starts_with("/api/runtime/custom-world-gallery") - || normalized.starts_with("/api/runtime/chat") - || normalized.starts_with("/api/story") + if normalized.starts_with("/api/runtime/big-fish/gallery/") + && normalized.ends_with("/remix") + { + return Some("big-fish"); + } + if normalized == "/api/runtime/custom-world/agent/sessions" + || normalized == "/api/runtime/custom-world/profile" { return Some("rpg"); } - if normalized.starts_with("/api/runtime/visual-novel") { + if normalized.starts_with("/api/runtime/custom-world-gallery/") + && normalized.ends_with("/remix") + { + return Some("rpg"); + } + if normalized == "/api/creation/match3d/sessions" { + return Some("match3d"); + } + if normalized == "/api/creation/square-hole/sessions" { + return Some("square-hole"); + } + if normalized == "/api/creation/bark-battle/drafts" { + return Some("bark-battle"); + } + if normalized == "/api/creation/wooden-fish/sessions" { + return Some("wooden-fish"); + } + if normalized == "/api/creation/jump-hop/sessions" { + return Some("jump-hop"); + } + if normalized == "/api/creation/visual-novel/sessions" { return Some("visual-novel"); } - if normalized.starts_with("/api/creation/visual-novel") { - return Some("visual-novel"); - } - if normalized.starts_with("/api/creation/edutainment/baby-object-match") { + if normalized == "/api/creation/edutainment/baby-object-match/assets" { return Some("baby-object-match"); } - if normalized.starts_with("/api/creation/edutainment/baby-love-drawing") { + if normalized == "/api/creation/edutainment/baby-love-drawing/magic" { return Some("baby-love-drawing"); } None @@ -171,58 +173,68 @@ mod tests { use super::*; #[test] - fn resolves_runtime_paths_to_creation_type_ids() { + fn resolves_new_creation_paths_to_creation_type_ids() { assert_eq!( - resolve_creation_entry_route_id("/api/runtime/puzzle/works"), + resolve_creation_entry_route_id("/api/runtime/puzzle/agent/sessions"), Some("puzzle"), ); assert_eq!( - resolve_creation_entry_route_id("/api/runtime/match3d/runs/run-1"), + resolve_creation_entry_route_id("/api/runtime/puzzle/gallery/profile-1/remix"), + Some("puzzle"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/match3d/sessions"), Some("match3d"), ); assert_eq!( - resolve_creation_entry_route_id("/api/runtime/square-hole/runs/run-1"), + resolve_creation_entry_route_id("/api/creation/square-hole/sessions"), Some("square-hole"), ); - assert_eq!( - resolve_creation_entry_route_id("/api/runtime/visual-novel/works"), - Some("visual-novel"), - ); assert_eq!( resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"), Some("visual-novel"), ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/big-fish/agent/sessions"), + Some("big-fish"), + ); assert_eq!( resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"), Some("rpg"), ); assert_eq!( - resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"), + resolve_creation_entry_route_id( + "/api/runtime/custom-world-gallery/user-1/profile-1/remix" + ), Some("rpg"), ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"), + None, + ); assert_eq!( resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"), - Some("rpg"), + None, ); assert_eq!( resolve_creation_entry_route_id("/api/story/sessions/runtime"), - Some("rpg"), + None, ); assert_eq!( resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"), - Some("rpg"), - ); - assert_eq!( - resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"), - Some("bark-battle"), + None, ); assert_eq!( resolve_creation_entry_route_id("/api/creation/bark-battle/drafts"), Some("bark-battle"), ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"), + None, + ); assert_eq!( resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"), - Some("wooden-fish"), + None, ); assert_eq!( resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"), diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts index 7242e7b1..0a2f4949 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts @@ -337,6 +337,24 @@ describe('platformDraftGenerationShelfModel', () => { ).toMatchObject({ type: 'load-detail', }); + + expect( + resolveJumpHopDraftOpenIntent({ + item: buildJumpHopWork({ sourceSessionId: null }), + notices: { + 'jump-hop:jump-hop-work-base': { + status: 'failed', + seen: false, + }, + }, + generation: emptyGenerationFacts({ + activeSessionId: null, + hasActiveGenerationFailure: true, + }), + }), + ).toMatchObject({ + type: 'load-detail', + }); }); test('resolveWoodenFishDraftOpenIntent uses profile fallback and failure fallback stage', () => { diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.ts index de16f098..da69f6aa 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.ts @@ -897,8 +897,9 @@ export function resolveJumpHopDraftOpenIntent(params: { noticeIds, 'failed', ); + const activeSessionId = normalizeDraftNoticeId(generation.activeSessionId); const isCurrentSession = - sourceSessionId === normalizeDraftNoticeId(generation.activeSessionId); + sourceSessionId !== null && sourceSessionId === activeSessionId; if ( hasFailedNotice &&