Static WeaponLibrary for NZombies. Loads a weapons manifest JSON and falls back to scanning prefabs on disk, caches entries, exposes Packs/Categories queries, reload command and dump command.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Sandbox;
namespace NZombies;
/// <summary>
/// Every ported weapon, grouped by pack and category, for pickers.
///
/// ⛔ READ FROM A MANIFEST, NOT FROM THE PREFABS. A prefab knows what it shoots
/// but not which game it came from — that lives in the ARC9 Lua's `Category` /
/// `SubCategory`, and is recorded by arc9_port.py at port time. Deriving it from
/// filenames breaks the first time a weapon is renamed.
///
/// ⚠️ 135 weapons is too many for a flat list. Pack → category → weapon is the
/// same shape the original tool used, and the reason is the count.
/// </summary>
public static class WeaponLibrary
{
/// <summary>One gun. `Pap` is its own Pack-a-Punch name — the manifest's optional "pap" key, which the Kitbash
/// Editor's Stats panel writes — or null, when PapNames' table names it (or nothing does).</summary>
public record Entry( string Prefab, string Name, string Pack, string Category, string Pap = null );
static List<Entry> _cache;
/// <summary>
/// Everything in the manifest.
///
/// ⚠️ Falls back to scanning `prefabs/weapons/` when there is no manifest, so
/// a hand-made prefab still shows up — under "Other". Silently omitting it
/// would look like the prefab failed to load.
/// </summary>
public static List<Entry> All
{
get
{
if ( _cache is not null ) return _cache;
_cache = new();
var json = FileSystem.Mounted.ReadAllText( "weapons/manifest.json" );
// ⚠️ IT SAYS SO. This branch used to fall through in silence, so a published
// build with no weapon manifest offered an empty mystery box, an empty wall buy and
// an empty stats panel while logging nothing at all — and the only visible symptom
// was the map browser next door, which does log.
if ( string.IsNullOrWhiteSpace( json ) )
Log.Warning( "[nz-wep] weapons/manifest.json is missing or empty — no weapons will "
+ "be offered anywhere. In a published build this means it was not packaged;"
+ " run nz_publish_check" );
if ( !string.IsNullOrWhiteSpace( json ) )
{
try
{
using var doc = JsonDocument.Parse( json );
foreach ( var p in doc.RootElement.EnumerateObject() )
{
_cache.Add( new Entry(
p.Name,
p.Value.TryGetProperty( "name", out var n ) ? n.GetString() : p.Name,
p.Value.TryGetProperty( "pack", out var k ) ? k.GetString() : "Other",
p.Value.TryGetProperty( "category", out var c ) ? c.GetString() : "Other",
p.Value.TryGetProperty( "pap", out var q ) ? q.GetString() : null ) );
}
}
catch ( System.Exception e )
{
Log.Warning( $"[weaponlib] manifest unreadable: {e.Message}" );
}
}
// anything on disk the manifest does not know about
foreach ( var p in ResourceLibrary.GetAll<PrefabFile>()
.Select( r => r.ResourcePath )
.Where( r => r.StartsWith( "prefabs/weapons/" ) && r.EndsWith( ".prefab" ) ) )
{
if ( _cache.Any( e => e.Prefab == p ) ) continue;
// ⛔ NOT THE PRISMA. It is the Basalt egg's crafted weapon, built at the table and never
// offered by the box, a wall buy, Chimera or the tech roll. Swept in here it was the pack
// "Other" in every picker, and a box on a map that allows all packs could roll it (2026-09-30).
if ( BuildParts.IsWonderWeapon( p ) ) continue;
// ⚠️ ONLY WHAT IS STILL ON DISK. The editor keeps a prefab it has loaded registered after
// the file is moved away, until it restarts, so removed guns stayed in every picker as
// "Other" (108 of them on 2026-09-30). A published build carries only the compiled file.
if ( !FileSystem.Mounted.FileExists( p ) && !FileSystem.Mounted.FileExists( p + "_c" ) ) continue;
_cache.Add( new Entry( p,
System.IO.Path.GetFileNameWithoutExtension( p ).Replace( "nz_", "" ).ToUpper(),
"Other", "Other" ) );
}
return _cache;
}
}
/// <summary>Forget the cached manifest — call after porting a weapon.</summary>
public static void Reload() => _cache = null;
/// <summary>
/// `nz_weapons_reload` — re-read the manifest from disk.
///
/// ⛔ THE MANIFEST IS CACHED FOR THE WHOLE SESSION, so editing it does nothing visible until
/// something clears `_cache`. `Reload()` has always existed but nothing could call it from the
/// console, which made every roster change look like it had silently failed and cost a restart
/// to check. Renaming and re-sorting a pack is a data edit; it should not need one.
/// </summary>
[ConCmd( "nz_weapons_reload" )]
public static void ReloadCmd()
{
Reload();
Log.Info( $"[weaponlib] reloaded — {All.Count} weapon(s) in {Packs.Length} pack(s)" );
foreach ( var pack in Packs )
Log.Info( $"[weaponlib] {pack,-22} {All.Count( e => e.Pack == pack )}"
+ $" [{string.Join( ", ", CategoriesIn( pack ) )}]" );
}
public static string[] Packs =>
All.Select( e => e.Pack ).Distinct().OrderBy( p => p ).ToArray();
public static string[] CategoriesIn( string pack ) =>
All.Where( e => e.Pack == pack )
.Select( e => e.Category ).Distinct().OrderBy( c => c ).ToArray();
public static Entry[] In( string pack, string category ) =>
All.Where( e => e.Pack == pack && (category == "All" || e.Category == category) )
.OrderBy( e => e.Name ).ToArray();
public static Entry Find( string prefab ) =>
All.FirstOrDefault( e => e.Prefab == prefab );
/// <summary>Dump the library. `nz_weapons`.</summary>
[ConCmd( "nz_weapons" )]
public static void Dump()
{
Reload();
Log.Info( $"[weaponlib] {All.Count} weapons in {Packs.Length} pack(s)" );
foreach ( var pack in Packs )
{
Log.Info( $" {pack}" );
foreach ( var cat in CategoriesIn( pack ) )
Log.Info( $" {cat,-20} {string.Join( ", ", In( pack, cat ).Select( e => e.Name ) )}" );
}
}
}