From f5df061b4219ff659c69dae6420472976b066610 Mon Sep 17 00:00:00 2001 From: Katalina Okano Date: Wed, 23 Nov 2022 15:12:09 -0500 Subject: [PATCH] refactor, add vehicles and classes add gitignore for node_modules --- Cargo.lock | 122 ++++++++++++++- README.md | 6 +- docker-compose.live.yaml | 52 ++++++- docker-compose.yaml | 4 +- hack/codegen/.gitignore | 1 + hack/codegen/codegen.js | 87 +++++++++++ hack/codegen/package-lock.json | 144 +++++++++++++++++ hack/codegen/package.json | 15 ++ services/api/Cargo.toml | 5 +- services/api/src/classes.rs | 106 +++++++++++++ services/api/src/main.rs | 160 +++++++++---------- services/api/src/population.rs | 55 +++++++ services/api/src/redispool.rs | 5 + services/api/src/vehicles.rs | 214 ++++++++++++++++++++++++++ services/tasks/src/main.rs | 16 +- services/websocket/Cargo.toml | 5 +- services/websocket/event_examples.md | 38 +++++ services/websocket/src/main.rs | 187 ++++++++++++++++++++-- services/websocket/src/translators.rs | 99 ++++++++++++ 19 files changed, 1213 insertions(+), 108 deletions(-) create mode 100644 hack/codegen/.gitignore create mode 100644 hack/codegen/codegen.js create mode 100644 hack/codegen/package-lock.json create mode 100644 hack/codegen/package.json create mode 100644 services/api/src/classes.rs create mode 100644 services/api/src/population.rs create mode 100644 services/api/src/redispool.rs create mode 100644 services/api/src/vehicles.rs create mode 100644 services/websocket/event_examples.md create mode 100644 services/websocket/src/translators.rs diff --git a/Cargo.lock b/Cargo.lock index cd48e44..d77b369 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,9 +41,8 @@ dependencies = [ name = "api" version = "0.1.0" dependencies = [ - "once_cell", - "redis", "rocket", + "rocket_db_pools", "serde", "serde_json", ] @@ -174,7 +173,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" dependencies = [ "bytes", + "futures-core", "memchr", + "pin-project-lite", + "tokio", + "tokio-util", ] [[package]] @@ -240,6 +243,38 @@ dependencies = [ "cipher", ] +[[package]] +name = "deadpool" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421fe0f90f2ab22016f32a9881be5134fdd71c65298917084b0c7477cbc3856e" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "retain_mut", + "tokio", +] + +[[package]] +name = "deadpool-redis" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a62ebf187bc30bfc1a14bed4073912b988551d111208fe800b27c32df282481" +dependencies = [ + "deadpool", + "redis 0.21.6", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa37046cc0f6c3cc6090fbdbf73ef0b8ef4cfcc37f6befc0020f63e8cf121e1" +dependencies = [ + "tokio", +] + [[package]] name = "devise" version = "0.3.1" @@ -971,6 +1006,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + [[package]] name = "rand" version = "0.8.5" @@ -1001,6 +1047,25 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redis" +version = "0.21.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571c252c68d09a2ad3e49edd14e9ee48932f3e0f27b06b4ea4c9b2a706d31103" +dependencies = [ + "async-trait", + "bytes", + "combine", + "futures-util", + "itoa", + "percent-encoding", + "pin-project-lite", + "ryu", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redis" version = "0.22.1" @@ -1010,11 +1075,21 @@ dependencies = [ "combine", "itoa", "percent-encoding", + "r2d2", "ryu", "sha1_smol", "url", ] +[[package]] +name = "redis_ts" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c56cb76c7c8a0f2624cd8d2fec425080637dc0e43b850eee1fd6116577c3be" +dependencies = [ + "redis 0.22.1", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -1077,6 +1152,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "retain_mut" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0" + [[package]] name = "rocket" version = "0.5.0-rc.2" @@ -1132,6 +1213,29 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "rocket_db_pools" +version = "0.1.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc154f4f4985a136e2d59c336474a56da02103993f5e637e3a5424971ee4eff" +dependencies = [ + "deadpool", + "deadpool-redis", + "rocket", + "rocket_db_pools_codegen", + "version_check", +] + +[[package]] +name = "rocket_db_pools_codegen" +version = "0.1.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa8f9b37bb1d4827aa5cca400d74e91d30f4352713cb65d6e7427bafe21336c" +dependencies = [ + "devise", + "quote", +] + [[package]] name = "rocket_http" version = "0.5.0-rc.2" @@ -1181,6 +1285,15 @@ dependencies = [ "windows-sys 0.36.1", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977a7519bff143a44f842fd07e80ad1329295bd71686457f18e496736f4bf9bf" +dependencies = [ + "parking_lot", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -1364,7 +1477,7 @@ name = "tasks" version = "0.1.0" dependencies = [ "once_cell", - "redis", + "redis 0.22.1", ] [[package]] @@ -1754,7 +1867,8 @@ dependencies = [ "futures", "futures-util", "once_cell", - "redis", + "redis 0.22.1", + "redis_ts", "serde", "serde_json", "tokio", diff --git a/README.md b/README.md index 7ba14ff..a3e205c 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,10 @@ This API only supports GET, and supports CORS. ## Architecture - Websocket processors - - One per PC, PS4US, PS4EU - - Connects to [wss://push.nanite-systems.net](https://nanite-systems.net), one process per "environment" + - A pair per PC, PS4US, PS4EU + - Connects to [wss://push.nanite-systems.net](https://nanite-systems.net) and Census Websocket + - Primary will connect to NS. + - Backup will connect to Census. It will wait for 60 seconds before deciding the primary is dead, and then start processing events. - API - Serves https://saerro.harasse.rs - Redis diff --git a/docker-compose.live.yaml b/docker-compose.live.yaml index c08916b..a9c994d 100644 --- a/docker-compose.live.yaml +++ b/docker-compose.live.yaml @@ -14,9 +14,10 @@ services: image: ghcr.io/genudine/saerro/api:latest pull_policy: always ports: - - 8000:8000 + - 80:8000 links: - redis + restart: always environment: - REDIS_ADDR=redis://redis:6379 - ROCKET_ADDRESS=0.0.0.0 @@ -24,30 +25,79 @@ services: ws_pc: image: ghcr.io/genudine/saerro/websocket:latest pull_policy: always + restart: always environment: REDIS_ADDR: redis://redis:6379 WS_ADDR: wss://push.nanite-systems.net/streaming?environment=ps2&service-id=s:saegd WORLDS: 1,10,13,17,19,40 + PAIR: pc + ROLE: primary + links: + - redis + + ws_pc_backup: + image: ghcr.io/genudine/saerro/websocket:latest + pull_policy: always + restart: always + environment: + REDIS_ADDR: redis://redis:6379 + WS_ADDR: wss://push.planetside2.com/streaming?environment=ps2&service-id=s:saegd + WORLDS: 1,10,13,17,19,40 + PAIR: pc + ROLE: backup links: - redis ws_ps4us: image: ghcr.io/genudine/saerro/websocket:latest pull_policy: always + restart: always environment: REDIS_ADDR: redis://redis:6379 WS_ADDR: wss://push.nanite-systems.net/streaming?environment=ps2ps4us&service-id=s:saegd WORLDS: 1000 + PAIR: ps4us + ROLE: primary links: - redis + ws_ps4us_backup: + image: ghcr.io/genudine/saerro/websocket:latest + pull_policy: always + restart: always + environment: + REDIS_ADDR: redis://redis:6379 + WS_ADDR: wss://push.planetside2.com/streaming?environment=ps2ps4us&service-id=s:saegd + WORLDS: 1000 + PAIR: ps4us + ROLE: backup + links: + - redis + + ws_ps4eu: image: ghcr.io/genudine/saerro/websocket:latest pull_policy: always + restart: always environment: REDIS_ADDR: redis://redis:6379 WS_ADDR: wss://push.nanite-systems.net/streaming?environment=ps2ps4eu&service-id=s:saegd WORLDS: 2000 + PAIR: ps4eu + ROLE: primary + links: + - redis + + ws_ps4eu_backup: + image: ghcr.io/genudine/saerro/websocket:latest + pull_policy: always + restart: always + environment: + REDIS_ADDR: redis://redis:6379 + WS_ADDR: wss://push.planetside2.com/streaming?environment=ps2ps4eu&service-id=s:saegd + WORLDS: 2000 + PAIR: ps4eu + ROLE: backup links: - redis diff --git a/docker-compose.yaml b/docker-compose.yaml index da086b0..8f44334 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,9 +2,9 @@ version: "3" services: redis: - image: redis:alpine + image: redislabs/redistimeseries:1.8.3 command: redis-server --save "" --appendonly no container_name: redis restart: always ports: - - "6379:6379" \ No newline at end of file + - "6379:6379" diff --git a/hack/codegen/.gitignore b/hack/codegen/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/hack/codegen/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/hack/codegen/codegen.js b/hack/codegen/codegen.js new file mode 100644 index 0000000..8ddb652 --- /dev/null +++ b/hack/codegen/codegen.js @@ -0,0 +1,87 @@ +import fetch from "node-fetch"; + +const vehicles_hashmap = async () => { + const req = await fetch("https://census.lithafalcon.cc/get/ps2/vehicle"); + const resp = await req.json(); + + const relevantVehicles = [ + "flash", + "sunderer", + "lightning", + "scythe", + "vanguard", + "prowler", + "reaver", + "mosquito", + "galaxy", + "valkyrie", + "liberator", + "ant", + "harasser", + "dervish", + "chimera", + "javelin", + "corsair", + ]; + + const matcher = new RegExp(`\\b${relevantVehicles.join("|")}\\b`, "i"); + + return resp.vehicle_list + .reduce((acc, vehicle) => { + if (vehicle.name?.en) { + let result = vehicle.name.en.match(matcher); + if (result) { + acc.push(`("${vehicle.vehicle_id}", "${result[0].toLowerCase()}")`); + } + } + + return acc; + }, []) + .filter((v) => !!v); +}; + +const class_hashmap = async () => { + const req = await fetch("https://census.lithafalcon.cc/get/ps2/loadout"); + const resp = await req.json(); + + return resp.loadout_list.map( + (loadout) => + `("${loadout.loadout_id}", "${loadout.code_name + .toLowerCase() + .replace(/\btr|nc|vs|nso\b/, "") + .trim() + .replace("defector", "max") + .replace(/ /g, "_")}")` + ); +}; + +console.log(`// GENERATED CODE -- Do not edit. Run \`node hack/codegen/codegen.js > services/websocket/src/translators.rs\` to regenerate. + +use once_cell::sync::Lazy; +use std::collections::HashMap; + +static VEHICLE_TO_NAME: Lazy> = Lazy::new(|| { + HashMap::from([ + ${(await vehicles_hashmap()).join(",\n ")}, + ]) +}); + +pub fn vehicle_to_name(vehicle_id: &str) -> String { + match VEHICLE_TO_NAME.get(&vehicle_id) { + Some(name) => name.to_string(), + None => "unknown".to_string(), + } +} + +static LOADOUT_TO_CLASS: Lazy> = Lazy::new(|| { + HashMap::from([ + ${(await class_hashmap()).join(",\n ")}, + ]) +}); + +pub fn loadout_to_class(loadout_id: &str) -> String { + match LOADOUT_TO_CLASS.get(&loadout_id) { + Some(name) => name.to_string(), + None => "unknown".to_string(), + } +}`); diff --git a/hack/codegen/package-lock.json b/hack/codegen/package-lock.json new file mode 100644 index 0000000..0e86274 --- /dev/null +++ b/hack/codegen/package-lock.json @@ -0,0 +1,144 @@ +{ + "name": "saerro-codegen", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "saerro-codegen", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "node-fetch": "^3.3.0" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz", + "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.0.tgz", + "integrity": "sha512-BKwRP/O0UvoMKp7GNdwPlObhYGB5DQqwhEDQlNKuoqwVYSxkSZCSbHjnFFmUEtwSKRPU4kNK8PbDYYitwaE3QA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "engines": { + "node": ">= 8" + } + } + }, + "dependencies": { + "data-uri-to-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz", + "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==" + }, + "fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "requires": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + } + }, + "formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "requires": { + "fetch-blob": "^3.1.2" + } + }, + "node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==" + }, + "node-fetch": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.0.tgz", + "integrity": "sha512-BKwRP/O0UvoMKp7GNdwPlObhYGB5DQqwhEDQlNKuoqwVYSxkSZCSbHjnFFmUEtwSKRPU4kNK8PbDYYitwaE3QA==", + "requires": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + } + }, + "web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" + } + } +} diff --git a/hack/codegen/package.json b/hack/codegen/package.json new file mode 100644 index 0000000..b624ec0 --- /dev/null +++ b/hack/codegen/package.json @@ -0,0 +1,15 @@ +{ + "name": "saerro-codegen", + "version": "1.0.0", + "description": "", + "main": "codegen.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "node-fetch": "^3.3.0" + } +} diff --git a/services/api/Cargo.toml b/services/api/Cargo.toml index 0ba9aa1..09b498c 100644 --- a/services/api/Cargo.toml +++ b/services/api/Cargo.toml @@ -7,7 +7,6 @@ edition = "2021" [dependencies] rocket = { version = "0.5.0-rc.2", features = ["json"] } +rocket_db_pools = { version = "0.1.0-rc.2", features = [ "deadpool_redis" ] } serde_json = "1.0.88" -serde = "1.0.147" -redis = { version = "0.22.1", default_features = false, features = [] } -once_cell = "1.16.0" \ No newline at end of file +serde = "1.0.147" \ No newline at end of file diff --git a/services/api/src/classes.rs b/services/api/src/classes.rs new file mode 100644 index 0000000..f8494d2 --- /dev/null +++ b/services/api/src/classes.rs @@ -0,0 +1,106 @@ +use core::time; +use rocket_db_pools::deadpool_redis::redis::{pipe, AsyncCommands}; +use rocket_db_pools::Connection; +use serde::{Deserialize, Serialize}; +use std::ops::Sub; +use std::time::SystemTime; + +use crate::redispool::RedisPool; + +#[derive(Serialize, Deserialize, Debug)] +struct ClassCounts { + world_id: String, + classes: Classes, +} + +#[derive(Serialize, Deserialize, Debug)] +struct Classes { + light_assault: u32, + engineer: u32, + combat_medic: u32, + heavy_assault: u32, + infiltrator: u32, + max: u32, +} + +#[get("/w//classes")] +pub async fn get_classes(world_id: String, mut con: Connection) -> serde_json::Value { + let cache_key = format!("cache:classes:{}", world_id); + + match con.get::(cache_key.clone()).await { + Ok(cached) => { + return serde_json::from_str(&cached).unwrap(); + } + Err(_) => {} + } + + let filter_timestamp = SystemTime::now() + .sub(time::Duration::from_secs(60 * 15)) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // I hate this but it's fast??? + // The type only allows 12 at a time. + let (light_assault, engineer, combat_medic, heavy_assault, infiltrator, max): ( + u32, + u32, + u32, + u32, + u32, + u32, + ) = pipe() + .zcount( + format!("c:{}/{}", world_id, "light_assault"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("c:{}/{}", world_id, "engineer"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("c:{}/{}", world_id, "combat_medic"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("c:{}/{}", world_id, "heavy_assault"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("c:{}/{}", world_id, "infiltrator"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("c:{}/{}", world_id, "max"), + filter_timestamp, + "+inf", + ) + .query_async(&mut *con) + .await + .unwrap(); + + let response = ClassCounts { + world_id, + classes: Classes { + light_assault, + engineer, + combat_medic, + heavy_assault, + infiltrator, + max, + }, + }; + + let out = json!(response); + + con.set_ex::(cache_key, out.to_string(), 5) + .await + .unwrap(); + + out +} diff --git a/services/api/src/main.rs b/services/api/src/main.rs index f32319c..d43d509 100644 --- a/services/api/src/main.rs +++ b/services/api/src/main.rs @@ -1,10 +1,16 @@ +pub mod classes; pub mod cors; +pub mod population; +pub mod redispool; +pub mod vehicles; + +use redispool::RedisPool; +use rocket::fairing::AdHoc; +use rocket::{error, Build, Rocket}; +use rocket_db_pools::deadpool_redis::redis::{cmd, pipe}; +use rocket_db_pools::{Connection, Database}; -use core::time; -use once_cell::sync::Lazy; -use rocket::{Build, Rocket}; use serde::{Deserialize, Serialize}; -use std::{ops::Sub, time::SystemTime}; #[macro_use] extern crate rocket; @@ -16,31 +22,14 @@ struct IncomingHeaders { host: String, } -#[derive(Serialize, Deserialize, Debug)] -struct Factions { - tr: u32, - nc: u32, - vs: u32, - ns: u32, +fn hello_world(host: String, world_id: &str) -> serde_json::Value { + json!({ + "population": format!("https://{}/w/{}", host, world_id), + "vehicles": format!("https://{}/w/{}/vehicles", host, world_id), + "classes": format!("https://{}/w/{}/classes", host, world_id), + }) } -#[derive(Serialize, Deserialize, Debug)] -struct WorldPopulation { - world_id: u32, - total: u32, - factions: Factions, -} - -#[derive(Serialize, Deserialize, Debug)] -struct MultipleWorldPopulation { - worlds: Vec, -} - -pub static REDIS_CLIENT: Lazy = Lazy::new(|| { - redis::Client::open(std::env::var("REDIS_ADDR").unwrap_or("redis://localhost:6379".to_string())) - .unwrap() -}); - fn hello(host: String) -> serde_json::Value { json!({ "@": "Saerro Listening Post - PlanetSide 2 Live Population API", @@ -48,73 +37,76 @@ fn hello(host: String) -> serde_json::Value { "@Disclaimer": "Genudine Dynamics is not responsible for any damages caused by this software. Use at your own risk.", "@Support": "#api-dev in https://discord.com/servers/planetside-2-community-251073753759481856", "Worlds": { - "Connery": format!("https://{}/w/1", host), - "Miller": format!("https://{}/w/10", host), - "Cobalt": format!("https://{}/w/13", host), - "Emerald": format!("https://{}/w/17", host), - "Jaeger": format!("https://{}/w/19", host), - "SolTech": format!("https://{}/w/40", host), - "Genudine": format!("https://{}/w/1000", host), - "Ceres": format!("https://{}/w/2000", host), + "Connery": hello_world(host.clone(), "1"), + "Miller": hello_world(host.clone(), "10"), + "Cobalt": hello_world(host.clone(), "13"), + "Emerald": hello_world(host.clone(), "17"), + "Jaeger": hello_world(host.clone(), "19"), + "SolTech": hello_world(host.clone(), "40"), + "Genudine": hello_world(host.clone(), "1000"), + "Ceres": hello_world(host.clone(), "2000"), }, - "All Worlds": format!("https://{}/m/?ids=1,10,13,17,19,40,1000,2000", host), + // "All World Population": format!("https://{}/m/?ids=1,10,13,17,19,40,1000,2000", host), }) } -async fn get_world_pop(world_id: String) -> WorldPopulation { - let mut con = REDIS_CLIENT.get_connection().unwrap(); - - let filter_timestamp = SystemTime::now() - .sub(time::Duration::from_secs(60 * 15)) - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs(); - - let (vs, nc, tr, ns): (u32, u32, u32, u32) = redis::pipe() - .zcount(format!("wp:{}/{}", world_id, 1), filter_timestamp, "+inf") - .zcount(format!("wp:{}/{}", world_id, 2), filter_timestamp, "+inf") - .zcount(format!("wp:{}/{}", world_id, 3), filter_timestamp, "+inf") - .zcount(format!("wp:{}/{}", world_id, 4), filter_timestamp, "+inf") - .query(&mut con) - .unwrap(); - - let total = tr + vs + nc; - - let response = WorldPopulation { - world_id: world_id.parse().unwrap(), - total, - factions: Factions { tr, nc, vs, ns }, - }; - - response -} - -#[get("/w/")] -async fn world_pop(world_id: String) -> serde_json::Value { - let response = get_world_pop(world_id).await; - - json!(response) -} - -#[get("/m?")] -async fn multiple_world_pop(ids: String) -> serde_json::Value { - let mut response = MultipleWorldPopulation { worlds: vec![] }; - - for id in ids.split(",") { - response.worlds.push(get_world_pop(id.to_string()).await); - } - - json!(response) -} - #[get("/")] -async fn index() -> serde_json::Value { +fn index() -> serde_json::Value { hello("saerro.harasse.rs".to_string()) } +#[get("/health")] +async fn health(mut con: Connection) -> serde_json::Value { + let (ping, pc, ps4us, ps4eu): (String, bool, bool, bool) = pipe() + .cmd("PING") + .get("heartbeat:pc") + .get("heartbeat:ps4us") + .get("heartbeat:ps4eu") + .query_async(&mut *con) + .await + .unwrap_or_default(); + + json!({ + "status": "ok", + "redis": ping == "PONG", + "pc": if pc { "primary" } else { "backup/down" }, + "ps4us": if ps4us { "primary" } else { "backup/down" }, + "ps4eu": if ps4eu { "primary" } else { "backup/down" }, + }) +} + #[launch] fn rocket() -> Rocket { + let figment = rocket::Config::figment().merge(( + "databases.redis.url", + format!( + "redis://{}:{}", + std::env::var("REDIS_HOST").unwrap_or("localhost".to_string()), + std::env::var("REDIS_PORT").unwrap_or("6379".to_string()), + ), + )); + rocket::build() + .configure(figment) .attach(cors::CORS) - .mount("/", routes![index, world_pop, multiple_world_pop]) + .attach(RedisPool::init()) + .attach(AdHoc::on_ignite("Redis Check", |rocket| async move { + if let Some(pool) = RedisPool::fetch(&rocket) { + let mut con = pool.get().await.unwrap(); + let _: () = cmd("PING").query_async(&mut con).await.unwrap(); + } else { + error!("Redis connection failed"); + } + rocket + })) + .mount( + "/", + routes![ + index, + health, + population::get_world_pop, + vehicles::get_vehicles, + classes::get_classes, + ], + ) } diff --git a/services/api/src/population.rs b/services/api/src/population.rs new file mode 100644 index 0000000..1289b2a --- /dev/null +++ b/services/api/src/population.rs @@ -0,0 +1,55 @@ +use core::time; +use std::{ops::Sub, time::SystemTime}; + +use rocket_db_pools::{deadpool_redis::redis::pipe, Connection}; +use serde::{Deserialize, Serialize}; + +use crate::redispool::RedisPool; + +#[derive(Serialize, Deserialize, Debug)] +pub struct Factions { + tr: u32, + nc: u32, + vs: u32, + ns: u32, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct WorldPopulation { + world_id: u32, + total: u32, + factions: Factions, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct MultipleWorldPopulation { + worlds: Vec, +} + +#[get("/w/")] +pub async fn get_world_pop(world_id: String, mut con: Connection) -> serde_json::Value { + let filter_timestamp = SystemTime::now() + .sub(time::Duration::from_secs(60 * 15)) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let (vs, nc, tr, ns): (u32, u32, u32, u32) = pipe() + .zcount(format!("wp:{}/{}", world_id, 1), filter_timestamp, "+inf") + .zcount(format!("wp:{}/{}", world_id, 2), filter_timestamp, "+inf") + .zcount(format!("wp:{}/{}", world_id, 3), filter_timestamp, "+inf") + .zcount(format!("wp:{}/{}", world_id, 4), filter_timestamp, "+inf") + .query_async(&mut *con) + .await + .unwrap(); + + let total = tr + vs + nc; + + let response = WorldPopulation { + world_id: world_id.parse().unwrap(), + total, + factions: Factions { tr, nc, vs, ns }, + }; + + json!(response) +} diff --git a/services/api/src/redispool.rs b/services/api/src/redispool.rs new file mode 100644 index 0000000..65aa6b7 --- /dev/null +++ b/services/api/src/redispool.rs @@ -0,0 +1,5 @@ +use rocket_db_pools::{deadpool_redis, Database}; + +#[derive(Database)] +#[database("redis")] +pub struct RedisPool(deadpool_redis::Pool); diff --git a/services/api/src/vehicles.rs b/services/api/src/vehicles.rs new file mode 100644 index 0000000..607505b --- /dev/null +++ b/services/api/src/vehicles.rs @@ -0,0 +1,214 @@ +use core::time; +use rocket_db_pools::deadpool_redis::redis::{pipe, AsyncCommands}; +use rocket_db_pools::Connection; +use serde::{Deserialize, Serialize}; +use std::ops::Sub; +use std::time::SystemTime; + +use crate::redispool::RedisPool; + +#[derive(Serialize, Deserialize, Debug)] +struct VehiclesCounts { + world_id: String, + total: u32, + vehicles: Vehicles, +} + +#[derive(Serialize, Deserialize, Debug)] +struct Vehicles { + flash: u32, + sunderer: u32, + lightning: u32, + scythe: u32, + vanguard: u32, + prowler: u32, + reaver: u32, + mosquito: u32, + galaxy: u32, + valkyrie: u32, + liberator: u32, + ant: u32, + harasser: u32, + dervish: u32, + chimera: u32, + javelin: u32, + corsair: u32, +} + +#[get("/w//vehicles")] +pub async fn get_vehicles(world_id: String, mut con: Connection) -> serde_json::Value { + let cache_key = format!("cache:vehicles:{}", world_id); + + match con.get::(cache_key.clone()).await { + Ok(cached) => { + return serde_json::from_str(&cached).unwrap(); + } + Err(_) => {} + } + + let filter_timestamp = SystemTime::now() + .sub(time::Duration::from_secs(60 * 15)) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // I hate this but it's fast??? + // The type only allows 12 at a time. + let ( + flash, + sunderer, + lightning, + scythe, + vanguard, + prowler, + reaver, + mosquito, + galaxy, + valkyrie, + liberator, + ant, + ): (u32, u32, u32, u32, u32, u32, u32, u32, u32, u32, u32, u32) = pipe() + .zcount( + format!("v:{}/{}", world_id, "flash"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "sunderer"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "lightning"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "scythe"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "vanguard"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "prowler"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "reaver"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "mosquito"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "galaxy"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "valkyrie"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "liberator"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "ant"), + filter_timestamp, + "+inf", + ) + .query_async(&mut *con) + .await + .unwrap(); + + let (harasser, dervish, chimera, javelin, corsair): (u32, u32, u32, u32, u32) = pipe() + .zcount( + format!("v:{}/{}", world_id, "harasser"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "dervish"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "chimera"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "javelin"), + filter_timestamp, + "+inf", + ) + .zcount( + format!("v:{}/{}", world_id, "corsair"), + filter_timestamp, + "+inf", + ) + .query_async(&mut *con) + .await + .unwrap(); + + let total = flash + + sunderer + + lightning + + scythe + + vanguard + + prowler + + reaver + + mosquito + + galaxy + + valkyrie + + liberator + + ant + + harasser + + dervish + + chimera + + javelin + + corsair; + + let response = VehiclesCounts { + world_id, + total, + vehicles: Vehicles { + flash, + sunderer, + lightning, + scythe, + vanguard, + prowler, + reaver, + mosquito, + galaxy, + valkyrie, + liberator, + ant, + harasser, + dervish, + chimera, + javelin, + corsair, + }, + }; + + let out = json!(response); + + con.set_ex::(cache_key, out.to_string(), 5) + .await + .unwrap(); + + out +} diff --git a/services/tasks/src/main.rs b/services/tasks/src/main.rs index a19e056..0006e3c 100644 --- a/services/tasks/src/main.rs +++ b/services/tasks/src/main.rs @@ -21,7 +21,21 @@ fn cmd_prune() { let keys: Vec = con.keys("wp:*").unwrap(); for key in keys { - println!("-> Pruning {}", key); + println!("-> Pruning world pop {}", key); + let removed_items: u64 = con.zrembyscore(key, 0, prune_after).unwrap(); + println!("==> Removed {} items", removed_items); + } + + let keys: Vec = con.keys("v:*").unwrap(); + for key in keys { + println!("-> Pruning vehicle {}", key); + let removed_items: u64 = con.zrembyscore(key, 0, prune_after).unwrap(); + println!("==> Removed {} items", removed_items); + } + + let keys: Vec = con.keys("c:*").unwrap(); + for key in keys { + println!("-> Pruning class {}", key); let removed_items: u64 = con.zrembyscore(key, 0, prune_after).unwrap(); println!("==> Removed {} items", removed_items); } diff --git a/services/websocket/Cargo.toml b/services/websocket/Cargo.toml index ad9766c..f49af35 100644 --- a/services/websocket/Cargo.toml +++ b/services/websocket/Cargo.toml @@ -6,7 +6,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -redis = "0.22.1" +redis = { version = "0.22.1", default_features = false, features = ["r2d2"] } +redis_ts = "0.4.2" once_cell = "1.16.0" tokio-tungstenite = { version = "0.17.2", features=["native-tls"] } serde_json = "1.0.88" @@ -14,4 +15,4 @@ serde = { version = "1.0.147", features = ["derive"] } tokio = { version = "1.22.0" } url = "2.3.1" futures-util = "0.3.25" -futures = "0.3.25" \ No newline at end of file +futures = "0.3.25" diff --git a/services/websocket/event_examples.md b/services/websocket/event_examples.md new file mode 100644 index 0000000..3b15a52 --- /dev/null +++ b/services/websocket/event_examples.md @@ -0,0 +1,38 @@ +```json +{ + "attacker_character_id": "5429241327001550385", + "attacker_loadout_id": "29", + "attacker_team_id": "2", + "attacker_vehicle_id": "0", + "attacker_weapon_id": "6005191", + "character_id": "5428257385129506001", + "event_name": "VehicleDestroy", + "facility_id": "0", + "faction_id": "1", + "team_id": "1", + "timestamp": "1669213115", + "vehicle_id": "2", + "world_id": "1", + "zone_id": "6" +} +``` + +```json +{ + "attacker_character_id": "5428257385129506001", + "attacker_fire_mode_id": "80387", + "attacker_loadout_id": "15", + "attacker_team_id": "1", + "attacker_vehicle_id": "0", + "attacker_weapon_id": "6003665", + "character_id": "5429201282572095697", + "character_loadout_id": "6", + "event_name": "Death", + "is_critical": "0", + "is_headshot": "0", + "team_id": "2", + "timestamp": "1669213113", + "world_id": "1", + "zone_id": "6" +} +``` diff --git a/services/websocket/src/main.rs b/services/websocket/src/main.rs index 5cf1c31..35e8bd0 100644 --- a/services/websocket/src/main.rs +++ b/services/websocket/src/main.rs @@ -7,11 +7,17 @@ use serde_json::json; use std::{env, time::SystemTime}; use tokio_tungstenite::{connect_async, tungstenite::Message}; +mod translators; + pub static REDIS_CLIENT: Lazy = Lazy::new(|| { redis::Client::open(std::env::var("REDIS_ADDR").unwrap_or("redis://localhost:6379".to_string())) .unwrap() }); +static PAIR: Lazy = Lazy::new(|| env::var("PAIR").unwrap_or_default()); +static ROLE: Lazy = Lazy::new(|| env::var("ROLE").unwrap_or("primary".to_string())); +static WS_ADDR: Lazy = Lazy::new(|| env::var("WS_ADDR").unwrap_or_default()); + async fn send_init(tx: futures::channel::mpsc::UnboundedSender) { let worlds_raw = env::var("WORLDS").unwrap_or_default(); if worlds_raw == "" { @@ -36,22 +42,166 @@ async fn send_init(tx: futures::channel::mpsc::UnboundedSender) { println!("Sent setup message"); } -fn process_event(event: &Event) { +struct PopEvent { + world_id: String, + team_id: String, + character_id: String, + timestamp: u64, +} + +struct VehicleEvent { + world_id: String, + vehicle_id: String, + character_id: String, + timestamp: u64, +} + +struct ClassEvent { + world_id: String, + character_id: String, + loadout_id: String, + timestamp: u64, +} + +async fn track_pop(pop_event: PopEvent) { let mut con = REDIS_CLIENT.get_connection().unwrap(); + let PopEvent { + world_id, + team_id, + character_id, + timestamp, + } = pop_event; + + let key = format!("wp:{}/{}", world_id, team_id); + let _: () = con.zadd(key, character_id, timestamp).unwrap(); +} + +async fn track_vehicle(vehicle_event: VehicleEvent) { + let mut con = REDIS_CLIENT.get_connection().unwrap(); + + let VehicleEvent { + world_id, + vehicle_id, + timestamp, + character_id, + } = vehicle_event; + + let vehicle_name = translators::vehicle_to_name(vehicle_id.as_str()); + + if vehicle_name == "unknown" { + return; + } + + let key = format!("v:{}/{}", world_id, vehicle_name); + let _: () = con.zadd(key, character_id, timestamp).unwrap(); +} + +async fn track_class(class_event: ClassEvent) { + let mut con = REDIS_CLIENT.get_connection().unwrap(); + + let ClassEvent { + world_id, + character_id, + loadout_id, + timestamp, + } = class_event; + + let class_name = translators::loadout_to_class(loadout_id.as_str()); + + if class_name == "unknown" { + return; + } + + let key = format!("c:{}/{}", world_id, class_name); + let _: () = con.zadd(key, character_id, timestamp).unwrap(); +} + +fn should_process_event() -> bool { + let mut con = REDIS_CLIENT.get_connection().unwrap(); + let role: String = ROLE.parse().unwrap(); + let heartbeat_key = format!("heartbeat:{}", PAIR.to_string()); + + if role == "primary" { + let _: () = con.set_ex(heartbeat_key, "1", 60).unwrap(); + return false; + } + + match con.get(heartbeat_key) { + Ok(1) => true, + _ => false, + } +} + +fn process_event(event: &Event) { + if should_process_event() { + return; + } + let timestamp = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs(); - let key: String = format!("wp:{}/{}", event.world_id, event.team_id); - con.zadd::(key, event.character_id.clone(), timestamp) - .unwrap(); + // General population tracking + track_pop(PopEvent { + world_id: event.world_id.clone(), + team_id: event.team_id.clone(), + character_id: event.character_id.clone(), + timestamp, + }) + .now_or_never(); - if event.attacker_character_id != "" { - let key = format!("wp:{}/{}", event.world_id, event.attacker_team_id); - con.zadd::(key, event.attacker_character_id.clone(), timestamp) - .unwrap(); + if event.event_name == "VehicleDestroy" { + track_vehicle(VehicleEvent { + world_id: event.world_id.clone(), + vehicle_id: event.vehicle_id.clone(), + character_id: event.character_id.clone(), + timestamp, + }) + .now_or_never(); + } + + if event.event_name == "Death" { + track_class(ClassEvent { + world_id: event.world_id.clone(), + character_id: event.character_id.clone(), + loadout_id: event.loadout_id.clone(), + timestamp, + }) + .now_or_never(); + } + + if event.attacker_character_id != "" + && (event.attacker_team_id != "" || event.attacker_team_id != "0") + { + track_pop(PopEvent { + world_id: event.world_id.clone(), + team_id: event.attacker_team_id.clone(), + character_id: event.attacker_character_id.clone(), + timestamp, + }) + .now_or_never(); + + if event.event_name == "VehicleDestroy" { + track_vehicle(VehicleEvent { + world_id: event.world_id.clone(), + vehicle_id: event.attacker_vehicle_id.clone(), + character_id: event.attacker_character_id.clone(), + timestamp, + }) + .now_or_never(); + } + + if event.event_name == "Death" { + track_class(ClassEvent { + world_id: event.world_id.clone(), + character_id: event.attacker_character_id.clone(), + loadout_id: event.attacker_loadout_id.clone(), + timestamp, + }) + .now_or_never(); + } } } @@ -63,6 +213,18 @@ struct Event { attacker_character_id: String, attacker_team_id: String, team_id: String, + + // Class Tracking + #[serde(default)] + attacker_loadout_id: String, + #[serde(default)] + loadout_id: String, + + // Vehicle Tracking + #[serde(default)] + vehicle_id: String, + #[serde(default)] + attacker_vehicle_id: String, } #[derive(Deserialize, Debug, Clone)] @@ -72,12 +234,13 @@ struct Payload { #[tokio::main] async fn main() { - let addr = env::var("WS_ADDR").unwrap_or_default(); + let addr: String = WS_ADDR.to_string(); if addr == "" { println!("WS_ADDR not set"); return; } let url = url::Url::parse(&addr).unwrap(); + let (tx, rx) = futures::channel::mpsc::unbounded(); let (ws_stream, _) = connect_async(url).await.expect("Failed to connect"); let (write, read) = ws_stream.split(); @@ -85,6 +248,8 @@ async fn main() { let fused_writer = rx.map(Ok).forward(write).fuse(); let fused_reader = read .for_each(|msg| async move { + // println!("Processing event: {:?}", msg); + let body = &msg.unwrap().to_string(); let data: Payload = serde_json::from_str(body).unwrap_or(Payload { payload: Event { @@ -94,6 +259,10 @@ async fn main() { attacker_character_id: "".to_string(), attacker_team_id: "".to_string(), team_id: "".to_string(), + attacker_loadout_id: "".to_string(), + loadout_id: "".to_string(), + vehicle_id: "".to_string(), + attacker_vehicle_id: "".to_string(), }, }); diff --git a/services/websocket/src/translators.rs b/services/websocket/src/translators.rs new file mode 100644 index 0000000..037c08d --- /dev/null +++ b/services/websocket/src/translators.rs @@ -0,0 +1,99 @@ +// GENERATED CODE -- Do not edit. Run `node hack/codegen/codegen.js > services/websocket/src/translators.rs` to regenerate. + +use once_cell::sync::Lazy; +use std::collections::HashMap; + +static VEHICLE_TO_NAME: Lazy> = Lazy::new(|| { + HashMap::from([ + ("1", "flash"), + ("2", "sunderer"), + ("3", "lightning"), + ("5", "vanguard"), + ("6", "prowler"), + ("7", "scythe"), + ("8", "reaver"), + ("9", "mosquito"), + ("10", "liberator"), + ("11", "galaxy"), + ("12", "harasser"), + ("14", "valkyrie"), + ("15", "ant"), + ("100", "ant"), + ("101", "ant"), + ("102", "ant"), + ("150", "ant"), + ("151", "ant"), + ("160", "ant"), + ("161", "ant"), + ("162", "ant"), + ("1001", "flash"), + ("1002", "sunderer"), + ("1005", "vanguard"), + ("1007", "scythe"), + ("1008", "reaver"), + ("1009", "mosquito"), + ("1010", "liberator"), + ("1011", "galaxy"), + ("1105", "vanguard"), + ("2006", "ant"), + ("2009", "ant"), + ("2010", "flash"), + ("2033", "javelin"), + ("2122", "mosquito"), + ("2123", "reaver"), + ("2124", "scythe"), + ("2125", "javelin"), + ("2129", "javelin"), + ("2130", "sunderer"), + ("2131", "galaxy"), + ("2132", "valkyrie"), + ("2134", "vanguard"), + ("2135", "prowler"), + ("2136", "dervish"), + ("2137", "chimera"), + ("2142", "corsair"), + ]) +}); + +pub fn vehicle_to_name(vehicle_id: &str) -> String { + match VEHICLE_TO_NAME.get(&vehicle_id) { + Some(name) => name.to_string(), + None => "unknown".to_string(), + } +} + +static LOADOUT_TO_CLASS: Lazy> = Lazy::new(|| { + HashMap::from([ + ("1", "infiltrator"), + ("3", "light_assault"), + ("4", "combat_medic"), + ("5", "engineer"), + ("6", "heavy_assault"), + ("7", "max"), + ("8", "infiltrator"), + ("10", "light_assault"), + ("11", "combat_medic"), + ("12", "engineer"), + ("13", "heavy_assault"), + ("14", "max"), + ("15", "infiltrator"), + ("17", "light_assault"), + ("18", "combat_medic"), + ("19", "engineer"), + ("20", "heavy_assault"), + ("21", "max"), + ("28", "infiltrator"), + ("29", "light_assault"), + ("30", "combat_medic"), + ("31", "engineer"), + ("32", "heavy_assault"), + ("45", "max"), + ]) +}); + +pub fn loadout_to_class(loadout_id: &str) -> String { + match LOADOUT_TO_CLASS.get(&loadout_id) { + Some(name) => name.to_string(), + None => "unknown".to_string(), + } +}