feat: use decorators

This commit is contained in:
TrèsAbhi committed 2026-03-23 18:06:26 -05:00
1 parent 44711a58c7
commit 8e3b5de249
5 files changed
+278 -316

No files matched your search

+21 -125
View File
@@ -13,16 +13,23 @@ import type { Tanks } from "../../protos/tanks";
export abstract class AbstractAPI { export abstract class AbstractAPI {
abstract tankList(): Promise<TankList>; abstract tankList(): Promise<TankList>;
abstract tank(id: string): Promise<Tank>; abstract tank(id: string): Promise<Tank>;
abstract tanks(): Promise<Tanks>;
abstract popularTanks(): Promise<PopularTanks>;
private _tanksCache: Tanks | undefined; abstract strings(locale: string): Promise<Strings>;
protected abstract _tanks(): Promise<Tanks>; abstract gameStrings(locale: string): Promise<Record<string, string>>;
async tanks() { protected abstract _groupedGameStrings(
if (this._tanksCache === undefined) { locale: string,
this._tanksCache = await this._tanks(); group: string,
} ): Promise<Record<string, string>>;
abstract gameStringGroups(): Promise<string[]>;
return this._tanksCache; abstract avatar(id: string): Promise<Avatar>;
} abstract avatars(): Promise<Avatars>;
abstract avatarList(): Promise<AvatarList>;
abstract background(id: string): Promise<Background>;
abstract backgrounds(): Promise<Backgrounds>;
private assertLocale(locale: string) { private assertLocale(locale: string) {
const supported = locales.supported.find( const supported = locales.supported.find(
@@ -36,137 +43,26 @@ export abstract class AbstractAPI {
return supported; return supported;
} }
private _stringsCache: Record<string, Strings> = {}; @Cache<[string, string, boolean]>((...args) => args.join("-"))
protected abstract _strings(locale: string): Promise<Strings>;
async strings(locale: string) {
this.assertLocale(locale);
if (this._stringsCache[locale] === undefined) {
this._stringsCache[locale] = await this._strings(locale);
}
return this._stringsCache[locale];
}
private _gameStringsCache: Record<string, Record<string, string>> = {};
protected abstract _gameStrings(
locale: string,
): Promise<Record<string, string>>;
async gameStrings(locale: string) {
this.assertLocale(locale);
if (this._gameStringsCache[locale] === undefined) {
this._gameStringsCache[locale] = await this._gameStrings(locale);
}
return this._gameStringsCache[locale];
}
private _groupedGameStringsCache: Record<
string,
Record<string, Record<string, string>>
> = {};
protected abstract _groupedGameStrings(
locale: string,
group: string,
): Promise<Record<string, string>>;
async groupedGameStrings(locale: string, group: string, prefix: boolean) { async groupedGameStrings(locale: string, group: string, prefix: boolean) {
this.assertLocale(locale); this.assertLocale(locale);
if (this._groupedGameStringsCache[locale] === undefined) { const gameStrings = await this._groupedGameStrings(locale, group);
this._groupedGameStringsCache[locale] = {};
}
if (this._groupedGameStringsCache[locale][group] === undefined) { if (!prefix) return gameStrings;
this._groupedGameStringsCache[locale][group] =
await this._groupedGameStrings(locale, group);
}
if (!prefix) return this._groupedGameStringsCache[locale][group];
const strings: Record<string, string> = {}; const strings: Record<string, string> = {};
for (const key in this._groupedGameStringsCache[locale][group]) { for (const key in gameStrings) {
strings[`${group}__${key}`] = strings[`${group}__${key}`] = gameStrings[key];
this._groupedGameStringsCache[locale][group][key];
} }
return strings; return strings;
} }
private _avatarCache: Record<string, Avatar> = {};
protected abstract _avatar(id: string): Promise<Avatar>;
async avatar(id: string) {
if (this._avatarCache[id] === undefined) {
this._avatarCache[id] = await this._avatar(id);
}
return this._avatarCache[id];
}
private _avatarsCache: Avatars | undefined;
protected abstract _avatars(): Promise<Avatars>;
async avatars() {
if (this._avatarsCache === undefined) {
this._avatarsCache = await this._avatars();
}
return this._avatarsCache;
}
private _avatarListCache: AvatarList | undefined;
protected abstract _avatarList(): Promise<AvatarList>;
async avatarList() {
if (this._avatarListCache === undefined) {
this._avatarListCache = await this._avatarList();
}
return this._avatarListCache;
}
private _backgroundCache: Record<string, Background> = {};
protected abstract _background(id: string): Promise<Background>;
async background(id: string) {
if (this._backgroundCache[id] === undefined) {
this._backgroundCache[id] = await this._background(id);
}
return this._backgroundCache[id];
}
private _backgroundsCache: Backgrounds | undefined;
protected abstract _backgrounds(): Promise<Backgrounds>;
async backgrounds() {
if (this._backgroundsCache === undefined) {
this._backgroundsCache = await this._backgrounds();
}
return this._backgroundsCache;
}
private _gameStringGroupsCache: string[] | undefined;
protected abstract _gameStringGroups(): Promise<string[]>;
async gameStringGroups() {
if (this._gameStringGroupsCache === undefined) {
this._gameStringGroupsCache = await this._gameStringGroups();
}
return this._gameStringGroupsCache;
}
private _popularTanksCache: PopularTanks | undefined;
protected abstract _popularTanks(): Promise<PopularTanks>;
async popularTanks() {
if (this._popularTanksCache === undefined) {
this._popularTanksCache = await this._popularTanks();
}
return this._popularTanksCache;
}
} }
export function Cache<Arguments extends unknown[]>( export function Cache<Arguments extends unknown[]>(
discriminator: (...args: Arguments) => string = () => "", discriminator: (...args: Arguments) => string = (...args) => args.join("-"),
) { ) {
const cache = new WeakMap<object, Map<string, unknown>>(); const cache = new WeakMap<object, Map<string, unknown>>();
+73 -18
View File
@@ -3,34 +3,89 @@ import type { Strings } from "@blitzkit/i18n";
import { Avatars } from "../../protos/avatars"; import { Avatars } from "../../protos/avatars";
import { PopularTanks } from "../../protos/popular_tanks"; import { PopularTanks } from "../../protos/popular_tanks";
import { Tank } from "../../protos/tank"; import { Tank } from "../../protos/tank";
import { AbstractAPI } from "./abstract"; import { AbstractAPI, Cache } from "./abstract";
const rejected = Promise.reject("Not implemented on client");
export class ClientAPI extends AbstractAPI { export class ClientAPI extends AbstractAPI {
protected _avatars() { @UnimplementedOnClient
return fetchPB("/api/avatars/all.pb", Avatars); tankList() {
return rejected;
} }
protected _gameStrings(): Promise<Record<string, string>> { @Cache()
throw new Error("Cannot fetch full game strings on client"); tank(id: string) {
return fetchPB(`/api/tanks/${id}.pb`, Tank);
} }
protected async _groupedGameStrings(locale: string, group: string) { @UnimplementedOnClient
const response = await fetch(`/api/game-strings/${locale}/${group}.json`); tanks() {
const json = (await response.json()) as Record<string, string>; return rejected;
return json;
} }
protected async _strings(locale: string) { @Cache()
const response = await fetch(`/api/strings/${locale}.json`); popularTanks() {
const json = (await response.json()) as Strings;
return json;
}
protected _popularTanks() {
return fetchPB("/api/tanks/popular.pb", PopularTanks); return fetchPB("/api/tanks/popular.pb", PopularTanks);
} }
protected _tank(id: string) { @Cache()
return fetchPB(`/api/tanks/${id}.pb`, Tank); 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;
}
@UnimplementedOnClient
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`);
};
}
+180 -169
View File
@@ -35,104 +35,6 @@ export class ServerAPI extends AbstractAPI {
super(); super();
} }
protected 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_storage) {
throw new Error("Localization resources not found");
}
let remoteStorage: RemoteStorageComponent | undefined = undefined;
for (const candidateId of localizationResources.remote_storage
.remote_urls) {
const item = this.metadata.item(candidateId);
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) {
Object.assign(
strings,
await fetch(
`${remoteStorage.url}${remoteStorage.relative_path}/${namespace}/${locale}.yaml`,
)
.then((response) => response.text())
.then((text) => {
const strings: Record<string, string> = {};
const parsed = parse(text) as Record<string, string>;
for (const key in parsed) {
strings[key] = parsed[key].replaceAll('\\"', '"');
}
return strings;
}),
);
}
} catch (error) {
console.warn(
`Failed to fetch config from ${configPath}. Returning empty strings. This will throw an error in production. Error:`,
error,
);
}
return strings;
}
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;
}
protected 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() @Cache()
async tankList() { async tankList() {
const group = this.metadata.group("TankEntity"); const group = this.metadata.group("TankEntity");
@@ -157,18 +59,7 @@ export class ServerAPI extends AbstractAPI {
return { list }; return { list };
} }
protected async _tanks() { @Cache()
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<[string]>((id) => id)
async tank(id: string) { async tank(id: string) {
const tankList = await this.tankList(); const tankList = await this.tankList();
const tankListEntry = tankList.list.find((tank) => tank.id === id); const tankListEntry = tankList.list.find((tank) => tank.id === id);
@@ -185,70 +76,20 @@ export class ServerAPI extends AbstractAPI {
return { id, slug, tank, compensation } satisfies Tank; return { id, slug, tank, compensation } satisfies Tank;
} }
protected async _avatars() { @Cache()
const avatars: Avatar[] = []; async tanks() {
const tankList = await this.tankList();
const data: Tanks = { tanks: {} };
for (const item of this.metadata.group("ProfileAvatarEntity")) { for (const { id } of tankList.list) {
avatars.push(await this.avatar(item.name)); data.tanks[id] = await this.tank(id);
} }
return { avatars }; return data;
} }
protected async _avatarList() { @Cache()
const avatars = await this.avatars(); async popularTanks() {
const list = avatars.avatars.map((avatar) => avatar.name);
return { list };
}
protected 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 };
}
protected async _backgrounds() {
const backgrounds: Background[] = [];
for (const item of this.metadata.group("ProfileBackgroundEntity")) {
backgrounds.push(await this.background(item.name));
}
return { backgrounds };
}
protected 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 };
}
protected 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());
}
protected async _popularTanks() {
if (!existsSync("../../temp/popular.json")) { if (!existsSync("../../temp/popular.json")) {
const { tanks } = await this.tanks(); const { tanks } = await this.tanks();
const tanksArray = Object.values(tanks); const tanksArray = Object.values(tanks);
@@ -332,4 +173,174 @@ export class ServerAPI extends AbstractAPI {
const data = await readFile("../../temp/popular.json", "utf-8"); const data = await readFile("../../temp/popular.json", "utf-8");
return JSON.parse(data) as PopularTanks; 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_storage) {
throw new Error("Localization resources not found");
}
let remoteStorage: RemoteStorageComponent | undefined = undefined;
for (const candidateId of localizationResources.remote_storage
.remote_urls) {
const item = this.metadata.item(candidateId);
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) {
Object.assign(
strings,
await fetch(
`${remoteStorage.url}${remoteStorage.relative_path}/${namespace}/${locale}.yaml`,
)
.then((response) => response.text())
.then((text) => {
const strings: Record<string, string> = {};
const parsed = parse(text) as Record<string, string>;
for (const key in parsed) {
strings[key] = parsed[key].replaceAll('\\"', '"');
}
return strings;
}),
);
}
} 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 };
}
} }
@@ -5,8 +5,8 @@ import { useLocale } from "./useLocale";
export function useGameStrings(group: string) { export function useGameStrings(group: string) {
const locale = useLocale(); const locale = useLocale();
const strings = useAwait( const strings = useAwait(
() => api.groupedGameStrings(locale, group, true), () => api._groupedGameStrings(locale, group, true),
`game-strings-${locale}-${group}` `game-strings-${locale}-${group}`,
); );
return strings; return strings;
} }
@@ -20,10 +20,10 @@ export async function GET({
}: { }: {
params: { locale: string; group: string }; params: { locale: string; group: string };
}) { }) {
const strings = await api.groupedGameStrings( const strings = await api._groupedGameStrings(
params.locale, params.locale,
params.group, params.group,
false false,
); );
return Response.json(strings); return Response.json(strings);
} }