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 {
abstract tankList(): Promise<TankList>;
abstract tank(id: string): Promise<Tank>;
abstract tanks(): Promise<Tanks>;
abstract popularTanks(): Promise<PopularTanks>;
private _tanksCache: Tanks | undefined;
protected abstract _tanks(): Promise<Tanks>;
async tanks() {
if (this._tanksCache === undefined) {
this._tanksCache = await this._tanks();
}
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[]>;
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) {
const supported = locales.supported.find(
@@ -36,137 +43,26 @@ export abstract class AbstractAPI {
return supported;
}
private _stringsCache: Record<string, Strings> = {};
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>>;
@Cache<[string, string, boolean]>((...args) => args.join("-"))
async groupedGameStrings(locale: string, group: string, prefix: boolean) {
this.assertLocale(locale);
if (this._groupedGameStringsCache[locale] === undefined) {
this._groupedGameStringsCache[locale] = {};
}
const gameStrings = await this._groupedGameStrings(locale, group);
if (this._groupedGameStringsCache[locale][group] === undefined) {
this._groupedGameStringsCache[locale][group] =
await this._groupedGameStrings(locale, group);
}
if (!prefix) return this._groupedGameStringsCache[locale][group];
if (!prefix) return gameStrings;
const strings: Record<string, string> = {};
for (const key in this._groupedGameStringsCache[locale][group]) {
strings[`${group}__${key}`] =
this._groupedGameStringsCache[locale][group][key];
for (const key in gameStrings) {
strings[`${group}__${key}`] = gameStrings[key];
}
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[]>(
discriminator: (...args: Arguments) => string = () => "",
discriminator: (...args: Arguments) => string = (...args) => args.join("-"),
) {
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 { PopularTanks } from "../../protos/popular_tanks";
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 {
protected _avatars() {
return fetchPB("/api/avatars/all.pb", Avatars);
@UnimplementedOnClient
tankList() {
return rejected;
}
protected _gameStrings(): Promise<Record<string, string>> {
throw new Error("Cannot fetch full game strings on client");
@Cache()
tank(id: string) {
return fetchPB(`/api/tanks/${id}.pb`, Tank);
}
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
tanks() {
return rejected;
}
protected async _strings(locale: string) {
const response = await fetch(`/api/strings/${locale}.json`);
const json = (await response.json()) as Strings;
return json;
}
protected _popularTanks() {
@Cache()
popularTanks() {
return fetchPB("/api/tanks/popular.pb", PopularTanks);
}
protected _tank(id: string) {
return fetchPB(`/api/tanks/${id}.pb`, Tank);
@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;
}
@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();
}
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()
async tankList() {
const group = this.metadata.group("TankEntity");
@@ -157,18 +59,7 @@ export class ServerAPI extends AbstractAPI {
return { list };
}
protected 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<[string]>((id) => id)
@Cache()
async tank(id: string) {
const tankList = await this.tankList();
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;
}
protected async _avatars() {
const avatars: Avatar[] = [];
@Cache()
async tanks() {
const tankList = await this.tankList();
const data: Tanks = { tanks: {} };
for (const item of this.metadata.group("ProfileAvatarEntity")) {
avatars.push(await this.avatar(item.name));
for (const { id } of tankList.list) {
data.tanks[id] = await this.tank(id);
}
return { avatars };
return data;
}
protected async _avatarList() {
const avatars = await this.avatars();
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() {
@Cache()
async popularTanks() {
if (!existsSync("../../temp/popular.json")) {
const { tanks } = await this.tanks();
const tanksArray = Object.values(tanks);
@@ -332,4 +173,174 @@ export class ServerAPI extends AbstractAPI {
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_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) {
const locale = useLocale();
const strings = useAwait(
() => api.groupedGameStrings(locale, group, true),
`game-strings-${locale}-${group}`
() => api._groupedGameStrings(locale, group, true),
`game-strings-${locale}-${group}`,
);
return strings;
}
@@ -20,10 +20,10 @@ export async function GET({
}: {
params: { locale: string; group: string };
}) {
const strings = await api.groupedGameStrings(
const strings = await api._groupedGameStrings(
params.locale,
params.group,
false
false,
);
return Response.json(strings);
}