using System.Text.Json; namespace CsWeb.Services; // ---- weapons.json DTOs (produced by deploy-web/extract-weapons.sh from the installed game's files) ---- public sealed class WeaponRow { public string Id { get; init; } = ""; public string Name { get; init; } = ""; public string Cat { get; init; } = ""; public int Dmg { get; init; } public double ArmorRatio { get; init; } public double RangeMod { get; init; } public double HsMult { get; init; } public double Range { get; init; } public double Cycle { get; init; } public int Clip { get; init; } public int Pellets { get; init; } public string Icon { get; init; } = ""; } public sealed class WeaponsDoc { public int V { get; init; } public string GeneratedAt { get; init; } = ""; public string GameVersion { get; init; } = ""; public List Weapons { get; init; } = []; } // File-backed twin of Fleet's socket caches: weapons.json + icons re-read at TTL pace with last-good retention, // so an extractor run mid-request (or a briefly missing file during its swap) never blanks the table. public sealed class Weapons(IConfiguration cfg, IWebHostEnvironment env, ILogger log) { private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(60); private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; // Relative dir = dev convenience (resolved against the project root); prod config uses the absolute path. private readonly string _dir = Path.IsPathRooted(cfg["Outnumbered:WeaponsDir"] ?? "") ? cfg["Outnumbered:WeaponsDir"]! : Path.Combine(env.ContentRootPath, cfg["Outnumbered:WeaponsDir"] ?? "weapons-data"); private readonly object _lock = new(); private WeaponsDoc? _doc; private Dictionary _icons = []; private DateTimeOffset _lastAttempt = DateTimeOffset.MinValue; public WeaponsDoc? Doc { get { Refresh(); lock (_lock) return _doc; } } public string? IconSvg(string icon) { Refresh(); lock (_lock) return _icons.GetValueOrDefault(icon); } private void Refresh() { lock (_lock) { if (DateTimeOffset.UtcNow - _lastAttempt < Ttl) return; _lastAttempt = DateTimeOffset.UtcNow; // stamped before the read: a missing dir is retried at TTL pace } try { var doc = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(_dir, "weapons.json")), JsonOpts); if (doc is null || doc.Weapons.Count == 0) return; var icons = new Dictionary(); foreach (var icon in doc.Weapons.Select(w => w.Icon)) { var p = Path.Combine(_dir, "icons", icon + ".svg"); if (File.Exists(p)) icons[icon] = File.ReadAllText(p); } lock (_lock) { _doc = doc; _icons = icons; } } catch (Exception ex) { log.LogWarning(ex, "weapons data read failed from {Dir} (keeping last-good)", _dir); } } } // Vanilla CS2 bullet math. Per-weapon numbers come from weapons.json; the four constants below live in compiled // engine code (no data file carries them) and are validated against anchor damage numbers (AK-47 helmet HS = 111, // USP-S = 70, Glock = 56, AWP armored legs = 86) — wwwroot/js/weapons.js mirrors this class function-for-function, // with THE SAME operation order so the load-time parity check compares bit-identical doubles. public static class WeaponMath { // Hitgroup order used everywhere (SSR cells, parity blob, JS): head, chest, stomach, legs. public static readonly string[] Hitgroups = ["head", "chest", "stomach", "legs"]; // Raw (unfloored) per-pellet damage. Armor gates: legs never take armor reduction; "armored" means // kevlar + helmet together (the head is armored only via helmet — one toggle covers the full buy). public static double Pellet(WeaponRow w, double dist, string hg, bool armored) { if (dist > w.Range) return 0; // beyond the weapon's own max bullet range nothing connects at all double d = w.Dmg * Math.Pow(w.RangeMod, dist / 500.0); d *= hg switch { "head" => w.HsMult, "stomach" => 1.25, "legs" => 0.75, _ => 1.0 }; if (armored && hg != "legs") d *= w.ArmorRatio * 0.5; return d; } // Damage is floored per pellet at application (each pellet is its own trace); a "shot" assumes every pellet // lands in the same hitgroup at the same distance — the standard convention for shotgun table numbers. public static int Shot(WeaponRow w, double dist, string hg, bool armored) => (int)Math.Floor(Pellet(w, dist, hg, armored)) * w.Pellets; public static int Stk(int shot) => shot <= 0 ? 0 : (int)Math.Ceiling(100.0 / shot); // 0 = can't kill (out of range) public static double Ttk(WeaponRow w, int stk) => stk <= 0 ? 0 : (stk - 1) * w.Cycle; // first shot at t=0 }