From 9e1549151dee35e7959d9d710ccb3f180250f672 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 7 Jun 2026 13:56:17 +0800 Subject: [PATCH] feat: add recommendation feed scoring --- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- .../PlatformEntryFlowShellImpl.tsx | 11 +- .../platformRecommendation.test.ts | 178 ++++++++++++++ .../platform-entry/platformRecommendation.ts | 231 ++++++++++++++++++ ...gEntryFlowShell.agent.interaction.test.tsx | 1 - src/components/rpg-entry/RpgEntryHomeView.tsx | 28 +-- 6 files changed, 424 insertions(+), 27 deletions(-) create mode 100644 src/components/platform-entry/platformRecommendation.test.ts create mode 100644 src/components/platform-entry/platformRecommendation.ts diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 8245bed7..a9da1df2 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -175,7 +175,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。 -推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 ## 敲木鱼 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index ab3167cc..6f206be9 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -438,6 +438,7 @@ import { EDUTAINMENT_HIDDEN_MESSAGE, filterGeneralPublicWorks, } from './platformEdutainmentVisibility'; +import { buildPlatformRecommendedEntries } from './platformRecommendation'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import { @@ -5408,14 +5409,10 @@ export function PlatformEntryFlowShellImpl({ ], ); const recommendRuntimeEntries = useMemo(() => { - const entryMap = new Map(); - filterGeneralPublicWorks([ - ...featuredGalleryEntries, - ...latestGalleryEntries, - ]).forEach((entry) => { - entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); + return buildPlatformRecommendedEntries({ + featuredEntries: filterGeneralPublicWorks(featuredGalleryEntries), + latestEntries: filterGeneralPublicWorks(latestGalleryEntries), }); - return Array.from(entryMap.values()); }, [featuredGalleryEntries, latestGalleryEntries]); const creationHubItems = useMemo( diff --git a/src/components/platform-entry/platformRecommendation.test.ts b/src/components/platform-entry/platformRecommendation.test.ts new file mode 100644 index 00000000..a7b51244 --- /dev/null +++ b/src/components/platform-entry/platformRecommendation.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from 'vitest'; + +import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; +import { buildPlatformRecommendedEntries } from './platformRecommendation'; + +const NOW_MS = Date.parse('2026-06-07T12:00:00.000Z'); + +type PublicCardTestParams = { + id: string; + sourceType?: 'puzzle' | 'match3d' | 'jump-hop'; + subtitle?: string; + summaryText?: string; + coverImageSrc?: string | null; + themeTags?: string[]; + playCount?: number; + remixCount?: number; + likeCount?: number; + recentPlayCount7d?: number; + publishedAt?: string | null; + updatedAt?: string; +}; + +function buildPublicCard( + params: PublicCardTestParams, +): PlatformPublicGalleryCard { + const sourceType = params.sourceType ?? 'puzzle'; + + return { + sourceType, + workId: `${sourceType}-work-${params.id}`, + profileId: `${sourceType}-profile-${params.id}`, + publicWorkCode: `${sourceType.toUpperCase()}-${params.id}`, + ownerUserId: `user-${params.id}`, + authorDisplayName: `${params.id} 作者`, + worldName: `${params.id} 作品`, + subtitle: params.subtitle ?? '公开作品', + summaryText: params.summaryText ?? '公开作品摘要。', + coverImageSrc: params.coverImageSrc ?? `${params.id}.png`, + themeTags: params.themeTags ?? ['推荐'], + playCount: params.playCount ?? 0, + remixCount: params.remixCount ?? 0, + likeCount: params.likeCount ?? 0, + recentPlayCount7d: params.recentPlayCount7d ?? 0, + visibility: 'published', + publishedAt: params.publishedAt ?? '2026-06-01T12:00:00.000Z', + updatedAt: + params.updatedAt ?? params.publishedAt ?? '2026-06-01T12:00:00.000Z', + } satisfies PlatformPublicGalleryCard; +} + +describe('buildPlatformRecommendedEntries', () => { + test('combines heat, freshness and featured boost after de-duplicating works', () => { + const coldEntry = buildPublicCard({ + id: 'cold', + playCount: 1, + publishedAt: '2026-04-01T12:00:00.000Z', + }); + const hotRecentEntry = buildPublicCard({ + id: 'hot', + playCount: 8, + likeCount: 4, + recentPlayCount7d: 16, + publishedAt: '2026-06-06T12:00:00.000Z', + }); + const curatedEntry = buildPublicCard({ + id: 'curated', + playCount: 0, + likeCount: 0, + publishedAt: '2026-05-10T12:00:00.000Z', + }); + + const entries = buildPlatformRecommendedEntries( + { + featuredEntries: [curatedEntry], + latestEntries: [coldEntry, hotRecentEntry, curatedEntry], + }, + { nowMs: NOW_MS }, + ); + + expect(entries.map((entry) => entry.profileId)).toEqual([ + hotRecentEntry.profileId, + curatedEntry.profileId, + coldEntry.profileId, + ]); + }); + + test('interleaves close-score works from different play types', () => { + const firstPuzzle = buildPublicCard({ + id: 'puzzle-a', + sourceType: 'puzzle', + likeCount: 2, + }); + const secondPuzzle = buildPublicCard({ + id: 'puzzle-b', + sourceType: 'puzzle', + likeCount: 2, + }); + const match3d = buildPublicCard({ + id: 'match3d-a', + sourceType: 'match3d', + likeCount: 2, + }); + + const entries = buildPlatformRecommendedEntries( + { + featuredEntries: [], + latestEntries: [firstPuzzle, secondPuzzle, match3d], + }, + { nowMs: NOW_MS }, + ); + + expect(entries.map((entry) => entry.profileId)).toEqual([ + firstPuzzle.profileId, + match3d.profileId, + secondPuzzle.profileId, + ]); + }); + + test('separates same-type candidates while alternatives remain', () => { + const hotPuzzle = buildPublicCard({ + id: 'hot-puzzle', + sourceType: 'puzzle', + recentPlayCount7d: 50, + likeCount: 20, + }); + const warmPuzzle = buildPublicCard({ + id: 'warm-puzzle', + sourceType: 'puzzle', + recentPlayCount7d: 32, + likeCount: 12, + }); + const coldMatch3d = buildPublicCard({ + id: 'cold-match3d', + sourceType: 'match3d', + publishedAt: '2026-04-01T12:00:00.000Z', + }); + + const entries = buildPlatformRecommendedEntries( + { + featuredEntries: [], + latestEntries: [hotPuzzle, warmPuzzle, coldMatch3d], + }, + { nowMs: NOW_MS }, + ); + + expect(entries.map((entry) => entry.profileId)).toEqual([ + hotPuzzle.profileId, + coldMatch3d.profileId, + warmPuzzle.profileId, + ]); + }); + + test('falls back to same-type adjacency when no other type remains', () => { + const firstPuzzle = buildPublicCard({ + id: 'only-puzzle-a', + sourceType: 'puzzle', + recentPlayCount7d: 8, + }); + const secondPuzzle = buildPublicCard({ + id: 'only-puzzle-b', + sourceType: 'puzzle', + recentPlayCount7d: 4, + }); + + const entries = buildPlatformRecommendedEntries( + { + featuredEntries: [], + latestEntries: [firstPuzzle, secondPuzzle], + }, + { nowMs: NOW_MS }, + ); + + expect(entries.map((entry) => entry.profileId)).toEqual([ + firstPuzzle.profileId, + secondPuzzle.profileId, + ]); + }); +}); diff --git a/src/components/platform-entry/platformRecommendation.ts b/src/components/platform-entry/platformRecommendation.ts new file mode 100644 index 00000000..15d45631 --- /dev/null +++ b/src/components/platform-entry/platformRecommendation.ts @@ -0,0 +1,231 @@ +import { + buildPlatformPublicGalleryCardKey, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; + +const MS_PER_DAY = 86_400_000; +const FEATURED_BONUS = 14; +const MAX_FRESHNESS_SCORE = 12; + +export type PlatformRecommendationOptions = { + nowMs?: number; + limit?: number; +}; + +type RecommendationCandidate = { + entry: PlatformPublicGalleryCard; + key: string; + sourceType: string; + firstSeenIndex: number; + isFeatured: boolean; + timestampMs: number; + score: number; +}; + +type PlatformRecommendationMetricKey = + | 'playCount' + | 'remixCount' + | 'likeCount' + | 'recentPlayCount7d'; + +function parseRecommendationTimestamp(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); + if (absoluteTimestamp >= 1_000_000_000_000_000) { + return rawTimestamp / 1000; + } + if (absoluteTimestamp >= 1_000_000_000_000) { + return rawTimestamp; + } + if (absoluteTimestamp >= 1_000_000_000) { + return rawTimestamp * 1000; + } + } + } + + const timestamp = new Date(normalized).getTime(); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function getRecommendationTimestamp(entry: PlatformPublicGalleryCard) { + return parseRecommendationTimestamp(entry.publishedAt ?? entry.updatedAt); +} + +function getRecommendationMetric( + entry: PlatformPublicGalleryCard, + key: PlatformRecommendationMetricKey, +) { + const value = ( + entry as Partial> + )[key]; + return Math.max(0, Math.round(Number(value ?? 0) || 0)); +} + +function getRecommendationSourceType(entry: PlatformPublicGalleryCard) { + if ('sourceType' in entry) { + if ( + entry.sourceType === 'edutainment' && + 'templateId' in entry && + entry.templateId + ) { + return `edutainment:${entry.templateId}`; + } + + return entry.sourceType; + } + + return 'rpg'; +} + +function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) { + return 'themeTags' in entry && Array.isArray(entry.themeTags) + ? entry.themeTags + : []; +} + +function scoreRecommendationCandidate( + candidate: Omit, + nowMs: number, +) { + const entry = candidate.entry; + const ageDays = + candidate.timestampMs > 0 + ? Math.max(0, (nowMs - candidate.timestampMs) / MS_PER_DAY) + : Number.POSITIVE_INFINITY; + const freshnessScore = Number.isFinite(ageDays) + ? MAX_FRESHNESS_SCORE / (1 + ageDays / 7) + : 0; + const coverScore = entry.coverImageSrc ? 1.5 : 0; + const tagScore = Math.min(3, getRecommendationThemeTags(entry).length) * 0.6; + const summaryScore = entry.summaryText.trim() ? 0.8 : 0; + + return ( + (candidate.isFeatured ? FEATURED_BONUS : 0) + + Math.log1p(getRecommendationMetric(entry, 'recentPlayCount7d')) * 8 + + Math.log1p(getRecommendationMetric(entry, 'likeCount')) * 5 + + Math.log1p(getRecommendationMetric(entry, 'remixCount')) * 3 + + Math.log1p(getRecommendationMetric(entry, 'playCount')) * 2 + + freshnessScore + + coverScore + + tagScore + + summaryScore + ); +} + +function compareRecommendationCandidates( + left: RecommendationCandidate, + right: RecommendationCandidate, +) { + const scoreDiff = right.score - left.score; + if (scoreDiff !== 0) { + return scoreDiff; + } + + const timeDiff = right.timestampMs - left.timestampMs; + if (timeDiff !== 0) { + return timeDiff; + } + + if (left.firstSeenIndex !== right.firstSeenIndex) { + return left.firstSeenIndex - right.firstSeenIndex; + } + + return left.key.localeCompare(right.key, 'zh-CN'); +} + +function diversifyAdjacentSourceTypes(candidates: RecommendationCandidate[]) { + const remaining = [...candidates]; + const result: RecommendationCandidate[] = []; + + while (remaining.length > 0) { + const lastSourceType = result[result.length - 1]?.sourceType ?? null; + let nextIndex = 0; + + if (lastSourceType) { + const alternativeIndex = remaining.findIndex( + (candidate) => candidate.sourceType !== lastSourceType, + ); + if (alternativeIndex > 0) { + nextIndex = alternativeIndex; + } + } + + const [nextCandidate] = remaining.splice(nextIndex, 1); + if (nextCandidate) { + result.push(nextCandidate); + } + } + + return result; +} + +export function buildPlatformRecommendedEntries( + params: { + featuredEntries: PlatformPublicGalleryCard[]; + latestEntries: PlatformPublicGalleryCard[]; + }, + options: PlatformRecommendationOptions = {}, +) { + const candidateMap = new Map< + string, + Omit + >(); + let firstSeenIndex = 0; + + const collectEntries = ( + entries: PlatformPublicGalleryCard[], + source: 'featured' | 'latest', + ) => { + entries.forEach((entry) => { + const key = buildPlatformPublicGalleryCardKey(entry); + const timestampMs = getRecommendationTimestamp(entry); + const existing = candidateMap.get(key); + if (existing) { + existing.isFeatured = existing.isFeatured || source === 'featured'; + if (timestampMs >= existing.timestampMs) { + existing.entry = entry; + existing.timestampMs = timestampMs; + } + return; + } + + candidateMap.set(key, { + entry, + key, + sourceType: getRecommendationSourceType(entry), + firstSeenIndex, + isFeatured: source === 'featured', + timestampMs, + }); + firstSeenIndex += 1; + }); + }; + + collectEntries(params.featuredEntries, 'featured'); + collectEntries(params.latestEntries, 'latest'); + + const nowMs = options.nowMs ?? Date.now(); + const rankedCandidates = Array.from(candidateMap.values()) + .map((candidate) => ({ + ...candidate, + score: scoreRecommendationCandidate(candidate, nowMs), + })) + .sort(compareRecommendationCandidates); + const diversifiedCandidates = diversifyAdjacentSourceTypes(rankedCandidates); + const limit = + typeof options.limit === 'number' && options.limit > 0 + ? Math.floor(options.limit) + : diversifiedCandidates.length; + + return diversifiedCandidates + .slice(0, limit) + .map((candidate) => candidate.entry); +} diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index bc3d4560..a5e77bc8 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -11928,7 +11928,6 @@ test('creation hub gives jump hop wooden fish and bark battle cards the shared d profileId: 'jump-hop-profile-delete', ownerUserId: 'user-1', sourceSessionId: 'jump-hop-session-delete', - themeText: '跳台删除草稿', workTitle: '跳台删除草稿', workDescription: '跳一跳草稿也应接入统一删除。', themeText: '跳台', diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index f8eeb791..e5f62d21 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -78,7 +78,6 @@ import type { WechatNativePayment, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; -import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes'; import { refreshStoredAccessToken } from '../../services/apiClient'; import type { AuthUser } from '../../services/authService'; import { @@ -133,6 +132,7 @@ import { isEdutainmentEntryEnabled, } from '../platform-entry/platformEdutainmentVisibility'; import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; +import { buildPlatformRecommendedEntries } from '../platform-entry/platformRecommendation'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; import { @@ -154,7 +154,6 @@ import { isWoodenFishGalleryEntry, type PlatformPublicGalleryCard, type PlatformWorldCardLike, - resolvePlatformPublicWorkCode, resolvePlatformWorkAuthorDisplayName, resolvePlatformWorldCoverImage, resolvePlatformWorldCoverSlides, @@ -5379,15 +5378,16 @@ export function RpgEntryHomeView({ const desktopHeroStripEntries = ( featuredShelf.length > 0 ? featuredShelf : generalLatestEntries ).slice(0, 5); + const recommendedFeedEntries = useMemo( + () => + buildPlatformRecommendedEntries({ + featuredEntries: featuredShelf, + latestEntries: generalLatestEntries, + }), + [featuredShelf, generalLatestEntries], + ); // 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。 - const desktopRecommendEntries = useMemo(() => { - const entryMap = new Map(); - [...featuredShelf, ...generalLatestEntries].forEach((entry) => { - entryMap.set(buildPublicGalleryCardKey(entry), entry); - }); - - return Array.from(entryMap.values()); - }, [featuredShelf, generalLatestEntries]); + const desktopRecommendEntries = recommendedFeedEntries; const desktopTodayEntries = useMemo( () => filterTodayPublishedEntries(generalLatestEntries), [generalLatestEntries], @@ -5395,14 +5395,6 @@ export function RpgEntryHomeView({ const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4); const desktopCategoryGrid = activeCategoryEntries.slice(0, 6); const desktopLibraryPreview = myEntries.slice(0, 2); - const recommendedFeedEntries = useMemo(() => { - const entryMap = new Map(); - [...featuredShelf, ...generalLatestEntries].forEach((entry) => { - entryMap.set(buildPublicGalleryCardKey(entry), entry); - }); - - return Array.from(entryMap.values()); - }, [featuredShelf, generalLatestEntries]); const discoverFeedEntries = useMemo(() => { const entryMap = new Map(); const sourceEntries =