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