reorganize some code

This commit is contained in:
tresabhi committed 2026-05-15 18:31:17 -05:00
1 parent 7011d4d20f
commit b298874f0d
24 files changed
+5 -5

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
import type { MetadataAccessor } from "@blitzkit/closed";
import type { Strings } from "@blitzkit/i18n";
import locales from "@blitzkit/i18n/locales.json";
import type { Avatar } from "@protos/blitzkit/avatar";
import type { AvatarList } from "@protos/blitzkit/avatar_list";
import type { Avatars } from "@protos/blitzkit/avatars";
import type { Background } from "@protos/blitzkit/background";
import type { Backgrounds } from "@protos/blitzkit/backgrounds";
import type { PopularTanks } from "@protos/blitzkit/popular_tanks";
import type { Tank } from "@protos/blitzkit/tank";
import type { TankList } from "@protos/blitzkit/tank_list";
import type { Tanks } from "@protos/blitzkit/tanks";
import type { Tiers } from "@protos/blitzkit/tiers";
import type { TankUpgradePricePresetComponent } from "@protos/game/proto/legacy/blitz_static_tank_upgrade_price_preset_component";
export abstract class AbstractAPI {
abstract metadata: MetadataAccessor;
abstract tankList(): Promise<TankList>;
abstract tank(id: string): Promise<Tank>;
abstract tanks(): Promise<Tanks>;
abstract popularTanks(): Promise<PopularTanks>;
abstract tankUpgradePreset(
name: string,
): Promise<TankUpgradePricePresetComponent>;
abstract tiers(): Promise<Tiers>;
abstract strings(locale: string): Promise<Strings>;
abstract gameStrings(locale: string): Promise<Record<string, string>>;
protected abstract _groupedGameStrings(
locale: string,
group: string,
): Promise<Record<string, string>>;
abstract gameStringGroups(): Promise<string[]>;
abstract avatar(id: string): Promise<Avatar>;
abstract avatars(): Promise<Avatars>;
abstract avatarList(): Promise<AvatarList>;
abstract background(id: string): Promise<Background>;
abstract backgrounds(): Promise<Backgrounds>;
abstract currencyIcon(id: string): Promise<string>;
private assertLocale(locale: string) {
const supported = locales.supported.find(
(supported) => supported.locale === locale,
);
if (supported === undefined) {
throw new Error(`Unsupported locale: ${locale}`);
}
return supported;
}
@Cache()
async groupedGameStrings(locale: string, group: string, prefix: boolean) {
this.assertLocale(locale);
const gameStrings = await this._groupedGameStrings(locale, group);
if (!prefix) return gameStrings;
const strings: Record<string, string> = {};
for (const key in gameStrings) {
strings[`${group}__${key}`] = gameStrings[key];
}
return strings;
}
}
export function Cache<Arguments extends unknown[]>(disableInDev = false) {
const cache = new WeakMap<object, Map<string, Promise<unknown>>>();
return function (
_target: unknown,
_propertyKey: string,
descriptor: PropertyDescriptor,
) {
const original = descriptor.value;
descriptor.value = function (...args: Arguments) {
const key = args.join("-");
let thisCache = cache.get(this);
if (!thisCache) {
thisCache = new Map();
cache.set(this, thisCache);
}
const disabled = disableInDev && import.meta.env.DEV;
if (thisCache.has(key) && !disabled) {
return thisCache.get(key);
}
const promise = (async () => {
try {
return await original.apply(this, args);
} catch (error) {
thisCache!.delete(key);
throw error;
}
})();
thisCache.set(key, promise);
return promise;
};
};
}
+111
View File
@@ -0,0 +1,111 @@
import { fetchPB } from "@blitzkit/core";
import type { Strings } from "@blitzkit/i18n";
import { Avatars } from "@protos/blitzkit/avatars";
import { PopularTanks } from "@protos/blitzkit/popular_tanks";
import { TankList } from "@protos/blitzkit/tank_list";
import { Tanks } from "@protos/blitzkit/tanks";
import { Tiers } from "@protos/blitzkit/tiers";
import { TankUpgradePricePresetComponent } from "@protos/game/proto/legacy/blitz_static_tank_upgrade_price_preset_component";
import { AbstractAPI, Cache } from "./abstract";
const rejected = Promise.reject("Not implemented on client");
export class ClientAPI extends AbstractAPI {
get metadata(): never {
throw new Error("MetadataAccessor is inaccessible on the client");
}
@Cache()
tankList() {
return fetchPB("/api/tanks/list.pb", TankList);
}
@Cache()
async tank(id: string) {
const tanks = await this.tanks();
return tanks.tanks[id];
}
@Cache()
tanks() {
return fetchPB("/api/tanks/all.pb", Tanks);
}
@Cache()
popularTanks() {
return fetchPB("/api/tanks/popular.pb", PopularTanks);
}
@Cache()
tankUpgradePreset(name: string) {
return fetchPB(
`/api/tanks/presets/upgrade/${name}.pb`,
TankUpgradePricePresetComponent,
);
}
@Cache()
tiers() {
return fetchPB("/api/tiers.pb", Tiers);
}
@Cache()
async strings(locale: string) {
const response = await fetch(`/api/strings/${locale}.json`);
const json = (await response.json()) as Strings;
return json;
}
@UnimplementedOnClient
gameStrings() {
return rejected;
}
@Cache()
protected async _groupedGameStrings(locale: string, group: string) {
const response = await fetch(`/api/game-strings/${locale}/${group}.json`);
const json = (await response.json()) as Record<string, string>;
return json;
}
@UnimplementedOnClient
gameStringGroups() {
return rejected;
}
@UnimplementedOnClient
avatar() {
return rejected;
}
avatars() {
return fetchPB("/api/avatars/all.pb", Avatars);
}
@UnimplementedOnClient
avatarList() {
return rejected;
}
@UnimplementedOnClient
background() {
return rejected;
}
@UnimplementedOnClient
backgrounds() {
return rejected;
}
}
function UnimplementedOnClient(
_target: unknown,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
descriptor.value = function () {
throw new Error(`${propertyKey} is not implemented on the client`);
};
}
+22
View File
@@ -0,0 +1,22 @@
import { assertSecret } from "@blitzkit/core";
import type { AbstractAPI } from "./abstract";
let dynamicAPI: AbstractAPI;
if (typeof window === "undefined") {
const { ProxyClient } = await import("@blitzkit/closed");
const { ServerAPI } = await import("./server");
const proxyClient = await new ProxyClient(
assertSecret(import.meta.env.WOTB_SERVER),
).handshake(assertSecret(import.meta.env.WOTB_VERSION));
const metadata = await proxyClient.metadata();
dynamicAPI = new ServerAPI(metadata);
} else {
const { ClientAPI } = await import("./client");
dynamicAPI = new ClientAPI();
}
export const api = dynamicAPI;
+23
View File
@@ -0,0 +1,23 @@
import sharp from "sharp";
export async function imageProxy(url: string) {
if (import.meta.env.DEV) {
return Response.redirect(url);
}
let response: Response;
try {
response = await fetch(url);
} catch (error) {
console.warn(`Failed to fetch image proxy ${url}:`, error);
return new Response(null, { status: 404 });
}
const buffer = await response.arrayBuffer();
const image = sharp(buffer, { animated: true });
const webp = await image.webp().toBuffer();
const array = new Uint8Array(webp);
return new Response(array);
}
+3
View File
@@ -0,0 +1,3 @@
export const clientUnmountedResponse = new Response(null, { status: 503 });
export const notFoundResponse = new Response(null, { status: 404 });
+377
View File
@@ -0,0 +1,377 @@
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 { Tiers } from "@protos/blitzkit/tiers";
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(public metadata: MetadataAccessor) {
super();
}
resolveRemoteUrl(remoteUrls: string[]) {
let remoteStorage: RemoteStorageComponent | undefined = undefined;
for (const remoteUrl of remoteUrls) {
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");
}
return remoteStorage;
}
resolveClientConfig() {
const group = this.metadata.group("ClientConfigsEntity");
if (group.length !== 1) {
throw new RangeError(
`Don't know how to handle ${group.length} ClientConfigsEntities`,
);
}
return this.metadata.item(group[0].id);
}
@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 tankUpgradePreset(id: string) {
const item = this.metadata.item(`TankUpgradePricePresetEntity.${id}`);
return item.TankUpgradePricePreset();
}
@Cache()
async tiers() {
const group = this.metadata.group("TankTierEntity");
const tiers: Tiers = { tiers: {} };
for (const item of group) {
tiers.tiers[item.name] = item.TankTier();
}
return tiers;
}
@Cache(true)
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 clientConfig = this.resolveClientConfig();
const localizationResources = clientConfig.LocalizationResources();
const strings: Record<string, string> = {};
for (const remoteStorageSettings of localizationResources.remote_storages) {
const remoteStorage = this.resolveRemoteUrl(
remoteStorageSettings.remote_urls,
);
const configPath = `${remoteStorage.url}${remoteStorage.relative_path}/config.yaml`;
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);
}
}
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 };
}
@Cache()
async currencyIcon(id: string) {
const item = this.metadata.item(`CurrencyEntity.${id}`);
const stuffUI = item.StuffUI("UIComponent");
const config = this.resolveClientConfig();
const metroResources = config.MetroResources();
const remoteStorage = this.resolveRemoteUrl(
metroResources.remote_storage!.remote_urls,
);
const iconPathHack = stuffUI.icon.replace("\n", "");
if (!stuffUI.icon.includes("\n")) {
throw new Error("Icon path doesn't include newline, can't apply hack");
}
return `${remoteStorage.url}${remoteStorage.relative_path}${iconPathHack}`;
}
}