mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-23 19:49:50 +02:00
infrastructure for inline vfs and clients
This commit is contained in:
1 parent
d81d885767
commit
39745dd475
19 files changed
+133
-64
No files matched your search
@@ -0,0 +1,5 @@
|
||||
import { GameDefinitions } from "../../protos";
|
||||
|
||||
export abstract class BlitzKitAPI {
|
||||
abstract game(): Promise<GameDefinitions>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { fetchPB } from "../../protobuf";
|
||||
import { GameDefinitions } from "../../protos";
|
||||
import { BlitzKitAPI } from "./base";
|
||||
|
||||
export class ClientBlitzKitAPI extends BlitzKitAPI {
|
||||
game() {
|
||||
return fetchPB("/api/definitions/game.pb", GameDefinitions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./base";
|
||||
export * from "./client";
|
||||
export * from "./server";
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
AbstractVFS,
|
||||
AvailableNationsYaml,
|
||||
SquadBattleTypeStylesYaml,
|
||||
} from "@blitzkit/core";
|
||||
import { GameDefinitions } from "../../protos";
|
||||
import { BlitzKitAPI } from "./base";
|
||||
|
||||
export class ServerBlitzKitAPI extends BlitzKitAPI {
|
||||
vfs: AbstractVFS;
|
||||
|
||||
constructor(vfs: AbstractVFS) {
|
||||
super();
|
||||
|
||||
this.vfs = vfs;
|
||||
}
|
||||
|
||||
async game() {
|
||||
const versionFile = await this.vfs.text("Data/version.txt");
|
||||
const version = versionFile.split(" ")[0];
|
||||
|
||||
const availableNations = await this.vfs.yaml<AvailableNationsYaml>(
|
||||
"Data/available_nations.yaml",
|
||||
);
|
||||
const nations = availableNations.available_nations;
|
||||
|
||||
const game: GameDefinitions = {
|
||||
version,
|
||||
nations,
|
||||
gameModes: {},
|
||||
roles: {},
|
||||
};
|
||||
|
||||
const squadBattleTypeStyles =
|
||||
await this.vfs.yaml<SquadBattleTypeStylesYaml>(
|
||||
`Data/UI/Screens3/Lobby/Hangar/Squad/SquadBattleType.yaml`,
|
||||
);
|
||||
|
||||
for (const match of squadBattleTypeStyles.Prototypes[0].components.UIDataLocalBindingsComponent.data[1][2].matchAll(
|
||||
/"(\d+)" -> "(battleType\/([a-zA-Z]+))"/g,
|
||||
)) {
|
||||
const id = Number(match[1]);
|
||||
const name = getString(match[2]);
|
||||
|
||||
gameModeNativeNames[match[3]] = id;
|
||||
game.gameModes[id] = {
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
Object.entries(combatRoles).forEach(([, value]) => {
|
||||
gameDefinitions.roles[value.id] = { provisions: [], consumables: [] };
|
||||
|
||||
value.default_abilities.forEach((ability) => {
|
||||
if (ability in consumableNativeNames) {
|
||||
gameDefinitions.roles[value.id].consumables.push(
|
||||
consumableNativeNames[ability],
|
||||
);
|
||||
} else if (ability in provisionNativeNames) {
|
||||
gameDefinitions.roles[value.id].provisions.push(
|
||||
provisionNativeNames[ability],
|
||||
);
|
||||
} else throw new Error(`Unknown ability ${ability}`);
|
||||
});
|
||||
});
|
||||
|
||||
return game;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { fetchPB } from '../protobuf';
|
||||
import { GameDefinitions } from '../protos';
|
||||
import { asset } from './asset';
|
||||
|
||||
export function fetchGameDefinitions() {
|
||||
return fetchPB(asset('definitions/game.pb'), GameDefinitions);
|
||||
}
|
||||
@@ -7,15 +7,16 @@ export * from "./averageDefinitions";
|
||||
export * from "./coefficient";
|
||||
export * from "./consumableDefinitions";
|
||||
// export * from './context';
|
||||
export * from "./api";
|
||||
export * from "./createDefaultSkills";
|
||||
export * from "./degressiveStat";
|
||||
export * from "./discoveredIdDefinitions";
|
||||
export * from "./emblemURL";
|
||||
export * from "./equipmentDefinitions";
|
||||
export * from "./eventManager";
|
||||
export * from "./gameDefinitions";
|
||||
export * from "./getArchivedLatestSeasonNumber";
|
||||
export * from "./sluggify";
|
||||
export * from "./vfs";
|
||||
// export * from './getArchivedRatingInfo';
|
||||
export * from "./getArchivedRatingLeaderboard";
|
||||
// export * from './getArchivedRatingMidnightLeaderboard';
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readDVPL } from "@blitzkit/core";
|
||||
import { Options, parse as parseCSV } from "csv-parse/sync";
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import { normalize } from "path/posix";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
export abstract class AbstractVFS {
|
||||
textDecoder = new TextDecoder();
|
||||
private xmlParser = new XMLParser();
|
||||
|
||||
abstract init(): Promise<typeof this>;
|
||||
|
||||
abstract dispose(): void;
|
||||
|
||||
abstract has(path: string): Promise<boolean>;
|
||||
|
||||
abstract raw(path: string): Promise<Uint8Array>;
|
||||
|
||||
async resolve(path: string) {
|
||||
const normalized = normalize(path);
|
||||
if (await this.has(normalized)) return normalized;
|
||||
|
||||
const dvplPath = `${normalized}.dvpl`;
|
||||
if (await this.has(dvplPath)) return dvplPath;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async assert(path: string) {
|
||||
const resolved = await this.resolve(path);
|
||||
|
||||
if (resolved === null) throw new Error(`File not found: ${path}`);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async file(file: string) {
|
||||
const resolved = await this.assert(file);
|
||||
const raw = await this.raw(resolved);
|
||||
let buffer = raw;
|
||||
|
||||
if (resolved.endsWith(".dvpl")) {
|
||||
buffer = new Uint8Array(readDVPL(Buffer.from(raw)));
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
abstract dir(path: string): Promise<string[]>;
|
||||
|
||||
async text(path: string) {
|
||||
const file = await this.file(path);
|
||||
return this.textDecoder.decode(file);
|
||||
}
|
||||
|
||||
async yaml<Type>(path: string) {
|
||||
const file = await this.text(path);
|
||||
return parseYaml(file) as Type;
|
||||
}
|
||||
|
||||
async xml<Type>(path: string) {
|
||||
const file = await this.text(path);
|
||||
return this.xmlParser.parse(file) as Type;
|
||||
}
|
||||
|
||||
async _csv(text: string, options: Options = {}) {
|
||||
const parsed = parseCSV(text, options);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async csv(path: string, options: Options = {}) {
|
||||
const text = await this.text(path);
|
||||
return await this._csv(text, options);
|
||||
}
|
||||
|
||||
[Symbol.dispose]() {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./abstract";
|
||||
export * from "./local";
|
||||
export * from "./mixed";
|
||||
export * from "./steam";
|
||||
export * from "./zip";
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { AbstractVFS } from "./abstract";
|
||||
|
||||
export class LocalVFS extends AbstractVFS {
|
||||
constructor(private base: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
async init() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async has(path: string) {
|
||||
const file = Bun.file(`${this.base}/${path}`);
|
||||
return await file.exists();
|
||||
}
|
||||
|
||||
async raw(path: string) {
|
||||
const file = Bun.file(`${this.base}/${path}`);
|
||||
const buffer = await file.arrayBuffer();
|
||||
const array = new Uint8Array(buffer);
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
async dir(path: string) {
|
||||
try {
|
||||
return await readdir(`${this.base}/${path}`);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AbstractVFS } from "./abstract";
|
||||
|
||||
export class MixedVFS extends AbstractVFS {
|
||||
constructor(private vfses: AbstractVFS[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
async init() {
|
||||
for (const vfs of this.vfses) {
|
||||
await vfs.init();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const vfs of this.vfses) {
|
||||
vfs.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async has(path: string) {
|
||||
for (const vfs of this.vfses) {
|
||||
if (await vfs.has(path)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async raw(path: string) {
|
||||
for (const vfs of this.vfses) {
|
||||
if (await vfs.has(path)) return await vfs.raw(path);
|
||||
}
|
||||
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
|
||||
async dir(path: string) {
|
||||
const files = new Set<string>();
|
||||
|
||||
for (const vfs of this.vfses) {
|
||||
for (const file of await vfs.dir(path)) {
|
||||
files.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(files);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import SteamUser, { EConnectionProtocol } from "steam-user";
|
||||
import { AbstractVFS } from "./abstract";
|
||||
|
||||
interface SteamManifestFile {
|
||||
chunks: {
|
||||
sha: string;
|
||||
crc: number;
|
||||
offset: string;
|
||||
cb_original: number;
|
||||
cb_compressed: number;
|
||||
}[];
|
||||
filename: string;
|
||||
size: string;
|
||||
flags: number;
|
||||
sha_filename: string;
|
||||
sha_content: string;
|
||||
linktarget: unknown;
|
||||
}
|
||||
|
||||
interface SteamManifest {
|
||||
files: SteamManifestFile[];
|
||||
depot_id: number;
|
||||
gid_manifest: string;
|
||||
creation_time: number;
|
||||
filenames_encrypted: boolean;
|
||||
cb_disk_original: string;
|
||||
cb_disk_compressed: string;
|
||||
unique_chunks: number;
|
||||
crc_encrypted: number;
|
||||
crc_clear: number;
|
||||
}
|
||||
|
||||
export class SteamVFS extends AbstractVFS {
|
||||
private steam = new SteamUser({ protocol: EConnectionProtocol.TCP });
|
||||
private manifest: Map<string, SteamManifestFile> = new Map();
|
||||
|
||||
constructor(
|
||||
private username: string,
|
||||
private password: string,
|
||||
private app: number,
|
||||
private depot: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.steam.logOn({
|
||||
accountName: this.username,
|
||||
password: this.password,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => this.steam.once("loggedOn", resolve));
|
||||
|
||||
const productInfo = await this.steam.getProductInfo([this.app], []);
|
||||
const manifest: SteamManifest = await new Promise((resolve) => {
|
||||
// @ts-expect-error
|
||||
this.steam.getManifest(
|
||||
this.app,
|
||||
this.depot,
|
||||
// @ts-expect-error
|
||||
productInfo.apps[this.app].appinfo.depots[this.depot].manifests.public
|
||||
.gid,
|
||||
"public",
|
||||
// @ts-expect-error
|
||||
(_, response) => {
|
||||
resolve(response);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
for (const file of manifest.files) {
|
||||
const path = file.filename.replaceAll("\\", "/");
|
||||
this.manifest.set(path, file);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async has(path: string) {
|
||||
return this.manifest.has(path);
|
||||
}
|
||||
|
||||
async raw(path: string) {
|
||||
const fileManifest = this.manifest.get(path)!;
|
||||
const downloaded: { type: "complete"; file: Buffer } =
|
||||
// @ts-expect-error
|
||||
await this.steam.downloadFile(this.app, this.depot, fileManifest);
|
||||
|
||||
return new Uint8Array(downloaded.file);
|
||||
}
|
||||
|
||||
async dir(path: string) {
|
||||
const parentSegments = path.split("/").length;
|
||||
const children = new Set<string>();
|
||||
|
||||
for (const child of this.manifest.keys()) {
|
||||
if (!child.startsWith(path) || child === path) continue;
|
||||
|
||||
const childSegments = child.split("/");
|
||||
const nextSegment = childSegments[parentSegments];
|
||||
|
||||
children.add(nextSegment);
|
||||
}
|
||||
|
||||
return Array.from(children);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.steam.logOff();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { normalize } from "path/posix";
|
||||
import { HTTPRangeReader, unzip, ZipEntry } from "unzipit";
|
||||
import { AbstractVFS } from "./abstract";
|
||||
|
||||
export class ZipVFS extends AbstractVFS {
|
||||
entries = new Map<string, ZipEntry>();
|
||||
|
||||
constructor(
|
||||
private url: string,
|
||||
private root = "app/",
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async init() {
|
||||
const reader = new HTTPRangeReader(this.url);
|
||||
const { entries } = await unzip(reader);
|
||||
|
||||
for (const entry of Object.values(entries)) {
|
||||
const normalized = normalize(entry.name.slice(this.root.length));
|
||||
this.entries.set(normalized, entry);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async has(path: string) {
|
||||
return this.entries.has(path);
|
||||
}
|
||||
|
||||
async raw(path: string) {
|
||||
const arrayBuffer = await this.entries.get(path)!.arrayBuffer();
|
||||
return new Uint8Array(arrayBuffer);
|
||||
}
|
||||
|
||||
async dir(path: string) {
|
||||
const parentSegments = path.split("/").length;
|
||||
const children = new Set<string>();
|
||||
|
||||
for (const child of this.entries.keys()) {
|
||||
if (!child.startsWith(path) || child === path) continue;
|
||||
|
||||
const childSegments = child.split("/");
|
||||
const nextSegment = childSegments[parentSegments];
|
||||
|
||||
children.add(nextSegment);
|
||||
}
|
||||
|
||||
return Array.from(children);
|
||||
}
|
||||
|
||||
dispose() {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface AvailableNationsYaml {
|
||||
available_nations: string[];
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './glossary';
|
||||
export * from './tanksStats';
|
||||
export * from "./availableNationsYaml";
|
||||
export * from "./glossary";
|
||||
export * from "./squadBattleTypeStylesYaml";
|
||||
export * from "./tanksStats";
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface SquadBattleTypeStylesYaml {
|
||||
Prototypes: {
|
||||
components: {
|
||||
UIDataLocalBindingsComponent: {
|
||||
data: [string, string, string][];
|
||||
};
|
||||
};
|
||||
}[];
|
||||
}
|
||||
Reference in new issue
Block a user