minor optimizations and weapon sim

This commit is contained in:
Kamal Tufekcic 2026-07-07 20:23:39 +03:00
commit fc7887ce11
10 changed files with 419 additions and 11 deletions

113
Services/Weapons.cs Normal file
View file

@ -0,0 +1,113 @@
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<WeaponRow> 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<Weapons> 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<string, string> _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<WeaponsDoc>(File.ReadAllText(Path.Combine(_dir, "weapons.json")), JsonOpts);
if (doc is null || doc.Weapons.Count == 0) return;
var icons = new Dictionary<string, string>();
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
}