434 lines
12 KiB
TypeScript
434 lines
12 KiB
TypeScript
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<string, PlatformPublicGalleryCard>();
|
|
|
|
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
|
|
(entry) => {
|
|
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
|
|
},
|
|
);
|
|
|
|
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
|
|
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<string, PlatformPublicGalleryCard>();
|
|
|
|
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<string, PlatformPublicGalleryCard>();
|
|
|
|
[...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<PlatformCategoryKindFilter, 'all'> {
|
|
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;
|
|
}
|