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