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