Buyables/WeaponLibrary.cs

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.

File AccessNetworking
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 ) )}" );
		}
	}
}