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 = {}; 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; 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 = {}; 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 = {}; const parsed = parse(text) as Record; 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 = {}; 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(); 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 }; } }