Utility that reads weapon prefab JSON to compute and cache a weapon's classification tags (class, action, magazine band, one-at-a-time reload flag) and exposes console commands to print or clear cached tags. It parses prefab text, extracts ShootInfo and Weapon components, determines firing mode, RPM, clip size and maps special manual-action prefabs.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace NZombies;
/// <summary>
/// THE FOUR TAGS WEAPON TECH TIERS 4 AND 5 ARE OFFERED BY — class, action, magazine band, reload
/// (Sbox nzombies/Docs/WEAPON_TECH_BY_CLASS.md). A gun's tier-4 and tier-5 offer is the set for its
/// class plus the sets for its action, its magazine band and, for a gun that loads one round at a
/// time, its reload. `nz_tech_tags` prints them for the gun in your hands.
///
/// ⚠️ TIERS 1–3 ARE OFFERED BY THE SAME TAGS SINCE 2026-10-04 (Sbox nzombies/Docs/WEAPON_TECH_TIERS_1_3.md): class, action
/// and magazine band, with no reload set.
///
/// ⛔ READ FROM THE GUN AS AUTHORED — THE PREFAB — NEVER FROM THE LIVE WEAPON. Tier-4 augments change
/// exactly what is tagged (Select Fire and Battle Rifle Conversion the action, Deep Mag and Stick Mag
/// the magazine, Buckshot Belt the pellets), and Chimera once rolled a random fire mode. If the tags
/// followed the live gun, buying one augment would change which others are offered, and with no
/// ladder the order you buy in would matter. The user: tags come from the gun as built.
///
/// ⚠️ THROUGH `PrefabText`, the reader `WeaponClassStats` and `ChimeraPool` already use: the compiled
/// prefab's own JSON, in the editor and in a published game alike.
///
/// ⚠️ CACHED PER PREFAB, AND THAT IS SAFE: an authored prefab does not change during a game. A static,
/// so it survives hotload; `nz_tech_tags_forget` drops it if a prefab is edited mid-session.
/// </summary>
public static class WeaponTags
{
/// <summary>One gun's four tags. `Class` is its manifest category ("Submachine Guns").</summary>
public sealed record Tags( string Class, string Action, string Magazine, bool OneAtATime,
string AuthoredFire, float Rpm, int Clip );
/// <summary>The action values, as the set keys spell them.</summary>
public const string Auto = "auto", Burst = "burst", Semi = "semi", Manual = "manual";
/// <summary>The magazine bands the user chose: 1–8, 9–20, 21–40, 41–60, 61+.</summary>
public static string BandOf( int clip )
=> clip <= 8 ? "1-8" : clip <= 20 ? "9-20" : clip <= 40 ? "21-40" : clip <= 60 ? "41-60" : "61+";
/// <summary>
/// "Manual" = a semi-auto that cycles between shots: at or under this many rounds a minute…
/// </summary>
public const float ManualRpm = 150f;
/// <summary>
/// …OR A BOLT, PUMP, LEVER OR BREAK ACTION THE GAME FIRES FASTER THAN THAT, BY HAND.
///
/// ⛔ THE RPM RULE ALONE MISSED HALF THE BOLT-ACTIONS. The ports speed bolt-action snipers up to
/// 200–400 RPM (Kar98k, AX-50, HDR, SP-R…), so by fire rate they read as semi-autos, though every
/// shot still works a bolt. Listed by prefab stem, hand-made on 2026-10-04 from each gun's real
/// action; a gun missing from here and over 150 RPM reads as semi-auto. Slow semi-autos (a Barrett
/// at 150) stay "manual" through the rule: they play like it.
/// </summary>
public static readonly HashSet<string> ManualByHand = new( StringComparer.OrdinalIgnoreCase )
{
// bolt-action snipers
"nz_bo6_lw3a1frostline", "nz_mw22_fjximperium", "nz_mw23_xrkstalker", "nz_bocw_swissk31", "nz_mw19_kar98k",
"nz_mw22_victusxmr", "nz_mw23_kar98kmwiii", "nz_mw23_mors", "nz_mwc_model189130", "nz_vg_3linerifle",
"nz_vg_type99", "nz_bo3_mosin", "nz_vg_kar98k", "nz_vg_leeenfield", "nz_vg_m1903", "nz_mwc_pgmhecateii",
"nz_bo6_lr762", "nz_mw19_ax50", "nz_mw19_hdr", "nz_mwc_aw50f", "nz_simers_dr_mos", "nz_simers_m_28",
"nz_bocw_pelington703", "nz_mw19_spr", "nz_mwc_sv98", "nz_mw22_mcpr300", "nz_bocw_lw3tundra",
// lever-actions
"nz_mw22_lockwoodmk2", "nz_bo6_essexmodel07", "nz_mw19_mk2carbine", "nz_uplp_marlin",
"nz_codol_m1887", "nz_codol_m18872", "nz_codol_m18873", "nz_codol_m1887cy", "nz_codol_m1887qq",
"nz_codol_m1887zqpk",
// pump-actions
"nz_ksg410_lpg", "nz_vg_combatshotgun", "nz_m500", "nz_bo6_marinesp", "nz_bocw_hauer77", "nz_mw19_model680",
"nz_mwc_lockwood680", "nz_uplp_spas", "nz_mw22_bryson800", "nz_ksg", "nz_mwc_m590a1", "nz_cod4e_w1200",
"nz_ww2_model1897", "nz_uplp_ks23", "nz_inss2_toz_194m", "nz_ks23", "nz_trenchgun", "nz_remington_m870",
"nz_bo3_argus", "nz_bo3_spartan", "nz_spas12", "nz_uplp_m590", "nz_mw2e_spas12", "nz_mw19_r90",
// break-actions (double barrels, over/unders)
"nz_bo3_olympia", "nz_mw2e_ranger", "nz_vg_doublebarrel", "nz_ww2_model21", "nz_mw19_725",
"nz_mw22_lockwood300", "nz_izh43_sawnoff", "nz_olympia", "nz_bo6_olympia", "nz_doublebarrel", "nz_toz",
};
static readonly Dictionary<string, Tags> _cache = new( StringComparer.OrdinalIgnoreCase );
/// <summary>A prefab's tags, or null when it cannot be read (no such prefab, no ShootInfo).</summary>
public static Tags Of( string prefab )
{
if ( string.IsNullOrWhiteSpace( prefab ) ) return null;
if ( _cache.TryGetValue( prefab, out var hit ) ) return hit;
var tags = Read( prefab );
if ( tags is not null ) _cache[prefab] = tags;
return tags;
}
/// <summary>The set keys a prefab belongs to: "class:…", "action:…", "mag:…", and "reload:shell".</summary>
public static IEnumerable<string> SetsOf( string prefab )
{
var t = Of( prefab );
if ( t is null ) yield break;
if ( !string.IsNullOrEmpty( t.Class ) ) yield return "class:" + t.Class;
yield return "action:" + t.Action;
yield return "mag:" + t.Magazine;
if ( t.OneAtATime ) yield return "reload:shell";
}
static Tags Read( string prefab )
{
var text = PrefabText.Read( prefab );
if ( string.IsNullOrEmpty( text ) ) return null;
try
{
using var doc = JsonDocument.Parse( text );
if ( !doc.RootElement.TryGetProperty( "RootObject", out var root ) ) return null;
if ( !root.TryGetProperty( "Components", out var comps ) || comps.ValueKind != JsonValueKind.Array )
return null;
JsonElement? shoot = null, weapon = null;
foreach ( var c in comps.EnumerateArray() )
{
if ( !c.TryGetProperty( "__type", out var type ) ) continue;
var name = type.GetString();
if ( name == "SWB.Base.ShootInfo" && shoot is null ) shoot = c;
else if ( name == "SWB.Base.Weapon" ) weapon = c;
}
if ( shoot is null ) return null;
var fire = FireOf( shoot.Value );
var rpm = Num( shoot.Value, "RPM", 0f );
var clip = (int)Num( shoot.Value, "ClipSize", 0f );
var shells = weapon is not null && Bool( weapon.Value, "ShellReloading" );
var stem = System.IO.Path.GetFileNameWithoutExtension( prefab );
var action = fire switch
{
"auto" => Auto,
"burst" => Burst,
_ => rpm <= ManualRpm || ManualByHand.Contains( stem ) ? Manual : Semi,
};
var cls = WeaponLibrary.Find( prefab )?.Category ?? "";
return new Tags( cls, action, BandOf( clip ), shells, fire, rpm, clip );
}
catch ( Exception e )
{
Log.Warning( $"[nz-tech] could not read the tags of {prefab}: {e.Message}" );
return null;
}
}
/// <summary>`FiringType` as authored: a name ("semi") or the enum's number.</summary>
static string FireOf( JsonElement shoot )
{
if ( !shoot.TryGetProperty( "FiringType", out var f ) ) return "semi";
if ( f.ValueKind == JsonValueKind.String ) return (f.GetString() ?? "semi").ToLowerInvariant();
if ( f.ValueKind == JsonValueKind.Number && f.TryGetInt32( out var i ) )
return ((SWB.Base.FiringType)i).ToString().ToLowerInvariant();
return "semi";
}
static float Num( JsonElement e, string name, float fallback )
{
if ( !e.TryGetProperty( name, out var v ) ) return fallback;
if ( v.ValueKind == JsonValueKind.Number && v.TryGetSingle( out var f ) ) return f;
if ( v.ValueKind == JsonValueKind.String && float.TryParse( v.GetString(),
System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var s ) )
return s;
return fallback;
}
static bool Bool( JsonElement e, string name )
=> e.TryGetProperty( name, out var v ) && v.ValueKind == JsonValueKind.True;
/// <summary>`nz_tech_tags` — the held gun's four tags and its offer in every tier.</summary>
[ConCmd( "nz_tech_tags" )]
public static void Cmd()
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Info( "[nz-tech] no player — press Play first" ); return; }
var wep = Rarity.HeldBy( p );
if ( !wep.IsValid() ) { Log.Info( "[nz-tech] no weapon in hand" ); return; }
var prefab = Rarity.PrefabOf( wep );
var t = Of( prefab );
if ( t is null ) { Log.Info( $"[nz-tech] no tags for {prefab ?? "this weapon"}" ); return; }
Log.Info( $"[nz-tech] {wep.DisplayName} ({prefab}) class {t.Class} action {t.Action}"
+ $" (authored {t.AuthoredFire} at {t.Rpm:0} RPM) magazine {t.Magazine} ({t.Clip})"
+ $" reload {(t.OneAtATime ? "one at a time" : "magazine")}" );
for ( var tier = 1; tier <= WeaponTech.MaxTier; tier++ )
Log.Info( $"[nz-tech] tier {tier}: " + string.Join( ", ",
WeaponTech.OfferFor( prefab, tier ).Select( n => $"{n.Name} [{WeaponTech.SetOf( n.Id )}]" ) ) );
}
/// <summary>`nz_tech_tags_forget` — read every prefab's tags again.</summary>
[ConCmd( "nz_tech_tags_forget" )]
public static void Forget()
{
_cache.Clear();
Log.Info( "[nz-tech] weapon tags will be read again" );
}
}