Files
fristydev/lib/game/game-format.ts
T
fristy 864b9226fc
Build & Push / Lint & Build (pull_request) Failing after 52s
Build & Push / Build & Push Image (pull_request) Skipped
Build & Push / Trigger Deploy (pull_request) Skipped
desloppify: code health overhaul plus abuse hardening (140 green, strict 87.2)
Honest baseline after removing 45 gaming suppressions. Structural: lib/game plus root hooks extraction, CORS origin echo, DEV_ORIGINS layering. Fixes: error-cause binding, discord 5xx split, null-cache guard, co-located tests, rejecting-fetch coverage, naming and typing cleanup, socket controller, cache bound, cascade helpers, latch resets. Hardening: per-IP rate limit on API routes, deterministic-miss UX, retry cooldown plus cap. Suite 140 green, build plus lint clean.
2026-09-11 23:54:59 +03:00

212 lines
6.4 KiB
TypeScript

import type { GamePricePoint, GameProfile, ItadReview } from "@/lib/game/game-profile-types";
import type { Dictionary } from "@/lib/i18n/get-dictionary";
import type { Locale } from "@/lib/i18n/locales";
export type ModalDict = Dictionary["discord"]["gameModal"];
export const STEAM_SCORE_KEYS: Partial<Record<string, keyof ModalDict["reviewScores"]>> = {
"Overwhelmingly Positive": "overwhelminglyPositive",
"Very Positive": "veryPositive",
Positive: "positive",
"Mostly Positive": "mostlyPositive",
Mixed: "mixed",
"Mostly Negative": "mostlyNegative",
Negative: "negative",
"Very Negative": "veryNegative",
"Overwhelmingly Negative": "overwhelminglyNegative",
};
export const LOCALE_TAGS: Record<Locale, string> = {
tr: "tr-TR",
en: "en-US",
de: "de-DE",
};
export const COUNTRIES: Record<Locale, string> = {
tr: "TR",
en: "US",
de: "DE",
};
export function localizeScore(label: string, dict: ModalDict): string {
const key = STEAM_SCORE_KEYS[label];
return key ? dict.reviewScores[key] : label;
}
export function formatRatingPercent(value: number, localeTag: string): string {
try {
return new Intl.NumberFormat(localeTag, {
style: "percent",
maximumFractionDigits: 2,
}).format(value);
} catch {
return `%${(value * 100).toFixed(2)}`;
}
}
export function formatPrice(point: GamePricePoint, localeTag: string): string {
try {
return new Intl.NumberFormat(localeTag, {
style: "currency",
currency: point.currency,
}).format(point.price);
} catch {
return `${point.price} ${point.currency}`;
}
}
export function formatReleaseDate(value: string, localeTag: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const parsed = new Date(`${value}T00:00:00`);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toLocaleDateString(localeTag, {
day: "numeric",
month: "long",
year: "numeric",
});
}
}
return value;
}
export function formatCount(value: number | null | undefined, localeTag: string, unit: string): string {
if (value == null) {
return "—";
}
return `${value.toLocaleString(localeTag)} ${unit}`;
}
export function discountPercent(point: GamePricePoint): number | null {
if (point.regular > point.price && point.regular > 0) {
return Math.round((1 - point.price / point.regular) * 100);
}
return null;
}
export function steamdbRating(positive: number, total: number): number | null {
if (
!Number.isFinite(positive) ||
!Number.isFinite(total) ||
total <= 0 ||
positive < 0 ||
positive > total
) {
return null;
}
const score = positive / total;
return score - (score - 0.5) * 2 ** -Math.log10(total + 1);
}
export function toIsoDate(unixSeconds: number | undefined): string | null {
if (!unixSeconds) {
return null;
}
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
}
export interface GameView {
gameUrl: string;
name: string;
bestPrice: GamePricePoint | null;
historyLow: GamePricePoint | null;
isFree: boolean;
}
export function selectGameView(
profile: GameProfile | null,
fallbackName: string,
fallbackGameUrl: string,
): GameView {
const bestPrice = profile?.itad?.prices ?? null;
const historyLow = profile?.itad?.historyLow ?? null;
return {
gameUrl: profile?.gameUrl ?? fallbackGameUrl,
name: profile?.name || fallbackName,
bestPrice,
historyLow,
isFree: !!bestPrice && bestPrice.price === 0,
};
}
export interface ProfileSummary {
dbRating: number | null;
itadReviews: ItadReview[];
hasDetails: boolean;
hasReviews: boolean;
metascore: ItadReview | null;
opencritic: ItadReview | null;
}
export function describeProfile(profile: GameProfile | null): ProfileSummary {
const dbRating =
profile?.steamReviews?.positive != null && profile?.steamReviews?.total != null
? steamdbRating(profile.steamReviews.positive, profile.steamReviews.total)
: null;
const itadReviews = profile?.itad?.reviews ?? [];
const hasDetails = !!(
profile &&
(profile.developers.length > 0 ||
profile.publishers.length > 0 ||
profile.releaseDate ||
profile.platforms.length > 0 ||
profile.links.length > 0)
);
const metascore = itadReviews.find((r) => r.source === "Metascore") ?? null;
const opencritic = itadReviews.find((r) => r.source === "OpenCritic") ?? null;
const hasReviews = !!(profile?.steamReviews || metascore || opencritic);
return { dbRating, itadReviews, hasDetails, hasReviews, metascore, opencritic };
}
const DEFAULT_ELAPSED_UNITS = { hour: "h", minute: "m", lessThanMinute: "<1m" };
const ELAPSED_UNITS: Partial<
Record<string, { hour: string; minute: string; lessThanMinute: string }>
> = {
tr: { hour: "s", minute: "dk", lessThanMinute: "<1dk" },
de: { hour: " Std.", minute: " Min.", lessThanMinute: "<1 Min." },
en: DEFAULT_ELAPSED_UNITS,
};
export function formatElapsed(start: number, lang: string): string {
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const units = ELAPSED_UNITS[lang] ?? ELAPSED_UNITS.en ?? DEFAULT_ELAPSED_UNITS;
if (hours > 0) {
return `${hours}${units.hour} ${minutes}${units.minute}`;
}
if (minutes > 0) {
return `${minutes}${units.minute}`;
}
return units.lessThanMinute;
}
export function normalizeGameTitle(value: string): string {
return value
.toLowerCase()
.replace(/[®™©]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
export interface GameLocale {
lang: Locale;
country: string;
localeTag: string;
steamLang: string;
}
const STEAM_LANG: Record<Locale, string> = {
tr: "turkish",
en: "english",
de: "german",
};
export function resolveGameLocale(lang: string): GameLocale {
const resolved = lang === "tr" || lang === "en" || lang === "de" ? lang : "en";
return {
lang: resolved,
country: COUNTRIES[resolved],
localeTag: LOCALE_TAGS[resolved],
steamLang: STEAM_LANG[resolved],
};
}