Files
blitzkit/packages/website-ue/src/core/api/server.ts
T
2026-04-17 23:35:56 -05:00

342 lines
9.8 KiB
TypeScript

import type { MetadataAccessor } from "@blitzkit/closed";
import { assertSecret, sluggify } from "@blitzkit/core";
import type { Strings } from "@blitzkit/i18n";
import locales from "@blitzkit/i18n/locales.json";
import type { Avatar, DeepPartial } from "@protos/blitzkit/avatar";
import type { Background } from "@protos/blitzkit/background";
import type { PopularTanks } from "@protos/blitzkit/popular_tanks";
import type { Tank } from "@protos/blitzkit/tank";
import type { TankListEntry } from "@protos/blitzkit/tank_list";
import type { Tanks } from "@protos/blitzkit/tanks";
import type { RemoteStorageComponent } from "@protos/game/proto/legacy/blitz_static_remote_storage_component";
import { existsSync } from "fs";
import { mkdir, readFile, writeFile } from "fs/promises";
import { google } from "googleapis";
import { merge } from "lodash-es";
import { parse } from "yaml";
import { AbstractAPI, Cache } from "./abstract";
if (typeof window !== "undefined") {
throw new Error("ServerAPI is being evaluated in the browser");
}
const globbedStrings = import.meta.glob("../../../../i18n/strings/*.json", {
import: "default",
});
const tankSlugPattern = /^(\/\w+)?\/tanks\/([a-z0-9-]+)\/$/;
interface LocalizationConfig {
namespaces: string[];
}
export class ServerAPI extends AbstractAPI {
constructor(private metadata: MetadataAccessor) {
super();
}
@Cache()
async tankList() {
const group = this.metadata.group("TankEntity");
const list: TankListEntry[] = [];
const strings = await this.groupedGameStrings("en", "TankEntity", true);
for (const item of group) {
const tankCatalog = item.TankCatalog();
if (!tankCatalog.name) continue;
const name = strings[tankCatalog.name.value];
if (!name) continue;
const id = item.name;
const slug = sluggify(name);
list.push({ id, slug });
}
return { list };
}
@Cache()
async tank(id: string) {
const tankList = await this.tankList();
const tankListEntry = tankList.list.find((tank) => tank.id === id);
if (!tankListEntry) {
throw new Error(`Tank with id ${id} not found`);
}
const { slug } = tankListEntry;
const item = this.metadata.item(`TankEntity.${id}`);
const tank = item.TankCatalog();
const compensation = item.Compensation();
return { id, slug, tank, compensation } satisfies Tank;
}
@Cache()
async tanks() {
const tankList = await this.tankList();
const data: Tanks = { tanks: {} };
for (const { id } of tankList.list) {
data.tanks[id] = await this.tank(id);
}
return data;
}
@Cache()
async popularTanks() {
if (!existsSync("../../temp/popular.json")) {
const { tanks } = await this.tanks();
const tanksArray = Object.values(tanks);
const auth = await google.auth.getClient({
keyFile: assertSecret(import.meta.env.GOOGLE_APPLICATION_CREDENTIALS),
scopes: ["https://www.googleapis.com/auth/analytics.readonly"],
});
const analytics = google.analyticsdata({ version: "v1beta", auth });
const report = await analytics.properties.runReport({
property: `properties/${assertSecret(
import.meta.env.PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID,
)}`,
requestBody: {
dateRanges: [{ startDate: "7daysAgo", endDate: "today" }],
dimensions: [{ name: "pagePath" }],
metrics: [{ name: "screenPageViews" }],
orderBys: [{ metric: { metricName: "screenPageViews" }, desc: true }],
dimensionFilter: {
filter: {
fieldName: "pagePath",
stringFilter: {
matchType: "BEGINS_WITH",
value: "/tanks/",
},
},
},
},
});
const views: Record<string, number> = {};
if (!report.data.rows) {
throw new Error("No rows in report");
}
for (const row of report.data.rows) {
if (
!row.dimensionValues ||
!row.dimensionValues[0].value ||
!row.metricValues ||
!row.metricValues[0].value
) {
continue;
}
const matches = row.dimensionValues[0].value.match(tankSlugPattern);
if (!matches) continue;
const slug = matches[2];
const tank = tanksArray.find((tank) => tank.slug === slug);
if (!tank) continue;
if (tank.id in views) {
views[tank.id] += Number(row.metricValues[0].value);
} else {
views[tank.id] = Number(row.metricValues[0].value);
}
}
for (const id in tanks) {
if (!(id in views)) {
views[id] = 0;
}
}
const popularTanks = {
tanks: Object.entries(views)
.sort(([, a], [, b]) => b - a)
.map(([id, views]) => ({ id, views })),
} satisfies PopularTanks;
await mkdir("../../temp", { recursive: true });
await writeFile("../../temp/popular.json", JSON.stringify(popularTanks));
return popularTanks;
}
const data = await readFile("../../temp/popular.json", "utf-8");
return JSON.parse(data) as PopularTanks;
}
@Cache()
async strings(locale: string) {
const localized = (await globbedStrings[
`../../../../i18n/strings/${locale}.json`
]()) as DeepPartial<Strings>;
const defaults = (await globbedStrings[
`../../../../i18n/strings/${locales.default}.json`
]()) as Strings;
const strings = merge({}, defaults, localized);
return strings;
}
// @Cache()
async gameStrings(locale: string) {
const group = this.metadata.group("ClientConfigsEntity");
if (group.length !== 1) {
throw new RangeError(
`Don't know how to handle ${group.length} ClientConfigsEntities`,
);
}
const clientConfig = this.metadata.item(group[0].id);
const localizationResources = clientConfig.LocalizationResources();
if (localizationResources.remote_storages.length !== 1) {
throw new RangeError(
`Don't know how to handle ${group.length} remote_storages`,
);
}
const remoteStorageName = localizationResources.remote_storages[0];
let remoteStorage: RemoteStorageComponent | undefined = undefined;
for (const remoteUrl of remoteStorageName.remote_urls) {
const item = this.metadata.item(remoteUrl);
const candidate = item.RemoteStorage();
if (
remoteStorage === undefined ||
candidate.download_speed_weight > remoteStorage.download_speed_weight
) {
remoteStorage = candidate;
}
}
if (remoteStorage === undefined) {
throw new Error("No suitable production remote storage found");
}
const strings: Record<string, string> = {};
const configPath = `${remoteStorage.url}${remoteStorage.relative_path}/config.yaml`;
try {
const config = await fetch(configPath)
.then((response) => response.text())
.then((text) => parse(text) as LocalizationConfig);
for (const namespace of config.namespaces) {
const url = `${remoteStorage.url}${remoteStorage.relative_path}/${namespace}/${locale}.yaml`;
const response = await fetch(url);
const text = await response.text();
const namespaceStrings: Record<string, string> = {};
const parsed = parse(text) as Record<string, string>;
for (const key in parsed) {
strings[key] = parsed[key].replaceAll('\\"', '"');
}
Object.assign(strings, namespaceStrings);
}
} catch (error) {
console.warn(
`Failed to fetch config from ${configPath}. Returning empty strings. This will throw an error in production. Error:`,
error,
);
}
return strings;
}
@Cache()
protected async _groupedGameStrings(locale: string, group: string) {
const strings = await this.gameStrings(locale);
const filtered: Record<string, string> = {};
for (const key in strings) {
if (key.startsWith(`${group}__`)) {
const trimmed = key.substring(group.length + 2);
filtered[trimmed] = strings[key];
}
}
return filtered;
}
@Cache()
async gameStringGroups() {
const defaultGameStrings = await this.gameStrings(locales.default);
const groups = new Set<string>();
for (const key in defaultGameStrings) {
const [group] = key.split("__");
groups.add(group);
}
return Array.from(groups.values());
}
@Cache()
async avatar(id: string) {
const avatar = this.metadata.item(`ProfileAvatarEntity.${id}`);
const name = avatar.name;
const stuff_ui = avatar.StuffUI("UIComponent");
const profile_avatar = avatar.ProfileAvatar();
const sellable = avatar.components.sellableComponent
? avatar.Sellable()
: undefined;
return { name, stuff_ui, profile_avatar, sellable };
}
@Cache()
async avatars() {
const avatars: Avatar[] = [];
for (const item of this.metadata.group("ProfileAvatarEntity")) {
avatars.push(await this.avatar(item.name));
}
return { avatars };
}
@Cache()
async avatarList() {
const avatars = await this.avatars();
const list = avatars.avatars.map((avatar) => avatar.name);
return { list };
}
@Cache()
async background(id: string) {
const background = this.metadata.item(`ProfileBackgroundEntity.${id}`);
const name = background.name;
const stuff_ui = background.StuffUI("UIComponent");
const profile_background = background.ProfileBackground();
const sellable = background.components.sellableComponent
? background.Sellable()
: undefined;
return { name, stuff_ui, profile_background, sellable };
}
@Cache()
async backgrounds() {
const backgrounds: Background[] = [];
for (const item of this.metadata.group("ProfileBackgroundEntity")) {
backgrounds.push(await this.background(item.name));
}
return { backgrounds };
}
}