Diagnostics/PublishCheck.cs

Editor and published-build diagnostics for packaging loose data. Provides console commands to check that required loose files (JSON, images, fonts, particles) are present in the published package or that editor-authored data matches what would be shipped.

File AccessHttp Calls
using Sandbox;
using System;
using System.Linq;
using System.Text.Json;

namespace NZombies;

/// <summary>
/// `nz_publish_check` — does this build actually CARRY the loose data it reads?
///
/// ⛔ THIS EXISTS BECAUSE THE EDITOR CANNOT REPRODUCE THE FAILURE. `FileSystem.Mounted` in the
/// editor reads loose files straight off the project folder, so every manifest and config
/// resolves and everything looks fine. A PUBLISHED package carries only what s&box was told to
/// bundle — the `Resources` field in `nzombies_sbox.sbproj` — and a plain `.json` is not a
/// compiled asset. Get that field wrong and three systems come up empty in the published game
/// while working perfectly on the machine that built it.
///
/// ⛔ AND ALL THREE FAIL QUIETLY. On 2026-09-02 a published copy logged one line
/// (`maps/manifest.json unreadable`), showed an empty map browser, and said NOTHING about the
/// weapon library also being empty — `WeaponLibrary` skips a null manifest without a word. The
/// symptom read as "the map browser is broken" when the cause was packaging.
///
/// Run it in the PUBLISHED game, not the editor. In the editor it will nearly always pass.
/// </summary>
public static class PublishCheck
{
	/// <summary>What the `Resources` field has to cover, and why. Kept beside the checks so the
	/// two cannot drift.</summary>
	static readonly (string Glob, string Why)[] Expected =
	{
		( "maps/*.json", "the shipped map list — MapLibrary" ),
		( "weapons/*.json", "the weapon list and the stats panel — WeaponLibrary, WeaponClassStats" ),

		// ⚠️ ONE FOLDER DEEPER, like everything the 2026-09-27 audit found (`Tools/publish_audit.py`): the glob above stops at
		// `weapons/`, and the Prisma's parts list and the Kitbash guns' icons each sit in a folder of their own.
		( "weapons/prisma/*.json", "the Prisma's parts list — SckPartsRig" ),
		( "weapons/*/*.png", "the Kitbash guns' icons, named by their prefabs" ),
		( "configs/*/*.json", "the original maps' spawns, buys and walls — MapConfig" ),

		// ⛔ NOT `prefabs/weapons/*.prefab` (2026-10-03). It shipped every gun's text a second
		// time, 1,070 files, for the stat bars and Chimera, and those read the COMPILED prefab
		// now (`PrefabText`). The package's file list has to stay under 8 MiB.

		// ⛔ LOOSE IMAGES ARE READ BY PATH AND SHIP LIKE ANY OTHER LOOSE FILE — THEY DO NOT
		// RIDE THE ASSET GRAPH. A stylesheet's `url("ui/blood/blood_damage.png")` and a particle
		// prefab's texture path are both resolved at RUNTIME, so nothing at compile time knows
		// the file is needed. On 2026-09-09 that cost the muzzle flash, the tracers, the damage
		// overlay, the ammo-type icons and the lobby background in one published build, all
		// present in Assets/ and none of them in the package.
		( "ui/*.png", "the default lobby background" ),
		( "ui/*/*.png", "damage overlay, ammo-type icons, perk icons, per-map lobby backgrounds" ),

		// ⚠️ DEPTH THREE IS NOT AN EDGE CASE. `ui/nz/cherry/shock_00.png` and every icon
		// under `materials/nz/` sit one level deeper than the rest, and a glob set that stopped
		// at two would have shipped MOST of the images — which is worse than shipping none,
		// because a half-working HUD reads as a bug in the HUD.
		( "ui/*/*/*.png", "Cherry's shock animation" ),
		( "materials/nz/*/*.png", "powerup icons and map thumbnails" ),
		( "materials/swb/*/*.png", "scope overlays" ),
		( "materials/clues/*.png", "images a mapper puts on an easter-egg clue" ),
		( "materials/clues/*/*.png", "the Easter egg keypad's glyphs and the Color Rings' ammo-mod symbols" ),
		( "materials/models/weapons/spectra/*.png", "the Prisma's reload glow — PrismaReloadGlow loads them by path" ),
		( "particles/*/*.png", "particle sprites" ),
		( "particles/*/*/*.png", "muzzle flash frames" ),
		( "particles/*/*/*.vtex_c", "the SWB muzzle flash and tracer flare textures" ),

		// ⚠️ A FONT IS A LOOSE FILE TOO, and its licence has to travel with it (SIL OFL)
		( "fonts/*.ttf", "Cinzel — the carved HUD's round numbers" ),
		( "fonts/*.txt", "Cinzel's licence" ),
	};

	/// <summary>
	/// Exact files, checked by name rather than by glob.
	///
	/// ⚠️ ONE PER FAILURE THAT ACTUALLY HAPPENED. A glob that looks right is not evidence;
	/// this list is every file a published build has been SEEN to be missing, so a single run
	/// answers "did the pattern work" for each of them by name.
	/// </summary>
	static readonly (string Path, string What)[] Files =
	{
		( "weapons/placement.json", "hand-aligned weapon and ADS positions" ),
		( "weapons/tuning.json", "weapon stat overrides" ),
		( "ui/lobby_bg.png", "the DEFAULT lobby background" ),
		( "ui/maps/ttt_canyon_labs_d_bg.png", "Canyon Labs' own lobby background" ),
		( "materials/nz/maps/ttt_canyon_labs_d.png", "the map browser's thumbnail" ),
		( "materials/nz/powerups/dp.png", "the powerup icons" ),
		( "ui/perks/dtap.png", "the perk HUD icons" ),
		( "ui/nz/cherry/shock_00.png", "Cherry's shock animation" ),
		( "materials/swb/scopes/bo1_wa2000.png", "the scope overlays" ),
		( "ui/blood/blood_damage.png", "the player damage overlay" ),
		( "ui/blood/blood_highlights.png", "the damage overlay's highlights" ),
		( "ui/aat/deadwire.png", "the ammo-type icons" ),
		( "particles/arc9/muzzle/flash_0.png", "the muzzle flash" ),
		( "particles/swb/muzzle/flash.vtex_c", "the muzzle smoke texture" ),
		( "particles/swb/muzzle/flare.vtex_c", "the tracer flare texture" ),

		// the 2026-09-27 audit: one file from each folder it found no glob reaching
		( "materials/clues/glyphs/glyph_1.png", "the Easter egg keypad's glyphs" ),
		( "materials/clues/symbols/deadwire.png", "the Color Rings' ammo-mod symbols" ),
		( "materials/clues/basalt_hex_tiles.png", "basalt's hex clue picture" ),
		( "weapons/prisma/prisma_parts.json", "the Prisma's parts list" ),
		( "weapons/cifosi_ac33/cifosi_ac33_icon.png", "the Kitbash guns' icons" ),
		( "materials/models/weapons/spectra/spectra_bone_baset_r25.png", "the Prisma's reload glow" ),
		( "fonts/Cinzel-Bold.ttf", "the carved HUD's round font" ),
	};

	[ConCmd( "nz_publish_check" )]
	public static void Run()
	{
		Log.Info( "[nz-publish] ── loose data this build needs ──────────────────────" );

		var ok = true;

		ok &= Manifest( "maps/manifest.json", "shipped maps" );
		ok &= Manifest( "weapons/manifest.json", "weapons" );
		ok &= Configs();
		ok &= Named();
		PrefabSource();

		// ⚠️ NOT INTO `ok`: its fix is a config, not the `Resources` field the failure message below names
		MapGamemodes();

		Log.Info( "[nz-publish] ─────────────────────────────────────────────────────" );

		if ( ok )
		{
			Log.Info( "[nz-publish] all present. If something is still empty in game, it is not "
				+ "packaging — look at the reader." );
			return;
		}

		// ⚠️ THE FIX IS NAMED, NOT DESCRIBED. Somebody reading this in a published build is not
		// in front of the project and cannot go looking for which field means what.
		Log.Warning( "[nz-publish] SOMETHING IS MISSING FROM THE PACKAGE. Loose .json is not a "
			+ "compiled asset, so it only ships if `Resources` in nzombies_sbox.sbproj lists it "
			+ "(Project Settings > Assets writes the same field). It should read, one glob per "
			+ "line, relative to Assets/:" );

		foreach ( var (glob, why) in Expected )
			Log.Warning( $"[nz-publish]     {glob,-20} — {why}" );

		Log.Warning( "[nz-publish] Set it, RE-PUBLISH, and run this again in the published copy. "
			+ "Editing the field alone changes nothing that is already uploaded." );
	}

	/// <summary>
	/// `nz_ship_data` — is what the EDITOR saved the same as what PUBLISHING would ship?
	///
	/// ⛔ A DIFFERENT QUESTION FROM `nz_publish_check`, AND THE OPPOSITE PLACE TO ASK IT. That
	/// command asks "did the package carry the files", and only a published build can answer.
	/// This one asks "are the files in Assets still the ones I authored", and only the EDITOR can
	/// answer — a published copy has an empty `FileSystem.Data` to compare against.
	///
	/// ⛔ THE GAME SAVES WHERE PUBLISHING DOES NOT READ. `MapConfig.Save`, `WeaponPlacement` and
	/// the tuning overrides all write through `FileSystem.Data`, a per-user folder. Publishing
	/// packages `Assets/`. Nothing moves between the two on its own, so an edit made in the editor
	/// is live here and absent from every published copy — and `MapConfig.Load` falls back to the
	/// stale shipped file without raising anything.
	///
	/// ⚠️ THE COUNTS MATCH WHEN THE CONTENT DOES NOT. On 2026-09-15 all three live maps had
	/// identical spawn, wallbuy and perk counts in both copies and different bytes, because the
	/// edits were moves rather than additions. Comparing what the menus show is not a check.
	///
	/// ⚠️ `Tools/ship_data.py` IS THE FIX AND THE AUTHORITY. This compares text through the two
	/// filesystems, which is enough to warn; the script hashes bytes and is what actually copies.
	/// </summary>
	[ConCmd( "nz_ship_data" )]
	public static void ShipData()
	{
		if ( !Game.IsEditor )
		{
			Log.Warning( "[nz-ship] only meaningful in the EDITOR — a published build has no "
				+ "FileSystem.Data to compare the shipped copies against." );
			return;
		}

		Log.Info( "[nz-ship] ── editor data vs what would be published ─────────────" );

		var stale = 0;

		// The two loose files that are authored in game and shipped under a different name.
		stale += Compare( "weapon_placement.json", "weapons/placement.json",
			"hand-aligned weapon and ADS positions" ) ? 0 : 1;
		stale += Compare( "weapon_tuning.json", "weapons/tuning.json",
			"weapon stat overrides" ) ? 0 : 1;

		// ⚠️ WALKED FROM DATA, NOT FROM MOUNTED. A config saved for a map that has never had a
		// shipped copy exists only in Data, and walking the shipped side would never see it —
		// which is the case that loses a whole map's setup rather than part of one.
		const string root = "configs";

		if ( FileSystem.Data.DirectoryExists( root ) )
		{
			foreach ( var map in FileSystem.Data.FindDirectory( root ) )
			{
				foreach ( var f in FileSystem.Data.FindFile( $"{root}/{map}", "*.json" ) )
				{
					var path = $"{root}/{map}/{f}";
					if ( !Compare( path, path, "map config" ) ) stale++;
				}
			}
		}

		Log.Info( "[nz-ship] ──────────────────────────────────────────────────────" );

		if ( stale == 0 )
		{
			Log.Info( "[nz-ship] everything you have authored is already in Assets/. Safe to publish." );
			return;
		}

		// ⚠️ THE FIX IS THE COMMAND, SPELLED OUT. Describing it costs a lookup at the exact
		// moment somebody is trying to ship.
		Log.Warning( $"[nz-ship] {stale} file(s) would publish STALE or not at all. Run this in the "
			+ "working folder, then publish:" );
		Log.Warning( "[nz-ship]     py Tools/ship_data.py --apply" );
		Log.Warning( "[nz-ship] Or leave `py Tools/ship_data.py --watch` running while you edit, "
			+ "and Assets/ stays current on its own." );
	}

	/// <summary>One file, Data against Mounted. True when they agree or Data has nothing to say.</summary>
	static bool Compare( string dataPath, string shippedPath, string what )
	{
		if ( !FileSystem.Data.FileExists( dataPath ) )
			return true;                       // never authored here — nothing to be stale

		string mine = null, theirs = null;

		try { mine = FileSystem.Data.ReadAllText( dataPath ); } catch ( Exception ) { }
		try { theirs = FileSystem.Mounted.ReadAllText( shippedPath ); } catch ( Exception ) { }

		if ( string.IsNullOrWhiteSpace( theirs ) )
		{
			Log.Warning( $"[nz-ship] {shippedPath,-38} NOT IN Assets/ — {what} exists only on this "
				+ "machine and will not ship" );
			return false;
		}

		if ( string.Equals( mine?.Trim(), theirs.Trim(), StringComparison.Ordinal ) )
		{
			Log.Info( $"[nz-ship] {shippedPath,-38} ok — {what}" );
			return true;
		}

		Log.Warning( $"[nz-ship] {shippedPath,-38} STALE — {what}; the copy that would publish is "
			+ "older than the one you edited" );
		return false;
	}

	/// <summary>Every file in <see cref="Files"/>, present or not.</summary>
	static bool Named()
	{
		var all = true;

		foreach ( var (path, what) in Files )
		{
			if ( FileSystem.Mounted.FileExists( path ) )
			{
				Log.Info( $"[nz-publish] {path,-38} ok — {what}" );
				continue;
			}

			all = false;
			Log.Warning( $"[nz-publish] {path,-38} MISSING — {what} will not appear" );
		}

		return all;
	}

	/// <summary>Read a manifest, parse it, and say how many entries came out.</summary>
	static bool Manifest( string path, string what )
	{
		string json;

		try { json = FileSystem.Mounted.ReadAllText( path ); }
		catch ( Exception e )
		{
			Log.Warning( $"[nz-publish] {path,-24} THREW — {e.Message}" );
			return false;
		}

		// ⚠️ NULL AND EMPTY ARE THE SAME ANSWER HERE and both mean "not in the package".
		// `ReadAllText` on a missing mounted file returns null rather than throwing, which is
		// why the original failure surfaced as "Value cannot be null (Parameter 'json')" from
		// the JSON parser two frames later instead of as a missing file.
		if ( string.IsNullOrWhiteSpace( json ) )
		{
			Log.Warning( $"[nz-publish] {path,-24} MISSING — not in this build" );
			return false;
		}

		try
		{
			using var doc = JsonDocument.Parse( json );

			var n = doc.RootElement.EnumerateObject().Count( e => !e.Name.StartsWith( '_' ) );

			Log.Info( $"[nz-publish] {path,-24} ok — {n} {what}" );
			return true;
		}
		catch ( Exception e )
		{
			Log.Warning( $"[nz-publish] {path,-24} PRESENT BUT UNPARSEABLE — {e.Message}" );
			return false;
		}
	}

	/// <summary>
	/// The shipped map configs — the ones that decide whether a map has spawns at all.
	///
	/// ⚠️ ENUMERATION AND READING ARE TESTED SEPARATELY. `MapConfig.ListFor` walks the directory
	/// (`DirectoryExists` + `FindFile`) while `Load` reads one exact path, and the codebase had
	/// never used enumeration on `Mounted` before shipping this. A build where the file is
	/// present but the directory does not enumerate loads fine by name and shows an empty menu.
	/// </summary>
	static bool Configs()
	{
		const string root = "configs";

		if ( !FileSystem.Mounted.DirectoryExists( root ) )
		{
			Log.Warning( $"[nz-publish] {root + "/",-24} MISSING — no shipped map configs, so every "
				+ "original map loads with no spawns, buys or walls" );
			return false;
		}

		var maps = FileSystem.Mounted.FindDirectory( root ).ToList();

		if ( maps.Count == 0 )
		{
			Log.Warning( $"[nz-publish] {root + "/",-24} PRESENT BUT EMPTY — the directory shipped "
				+ "and its contents did not" );
			return false;
		}

		var files = 0;

		foreach ( var map in maps )
		{
			var names = FileSystem.Mounted.FindFile( $"{root}/{map}", "*.json" ).ToList();
			files += names.Count;

			Log.Info( $"[nz-publish]   {map}: {( names.Count == 0 ? "no configs" : string.Join( ", ", names ) )}" );
		}

		if ( files == 0 )
		{
			Log.Warning( $"[nz-publish] {root + "/",-24} directories shipped, files did not" );
			return false;
		}

		Log.Info( $"[nz-publish] {root + "/*/*.json",-24} ok — {files} config(s) across {maps.Count} map(s)" );
		return true;
	}

	/// <summary>
	/// Does every map in the browser have a gamemode? The published lobby plays only the configs that ship (`Gamemodes`,
	/// 2026-10-05), so a map with none cannot be readied at all.
	///
	/// ⚠️ IN THE EDITOR TOO, AND THAT IS WHERE IT IS WORTH MOST: there it reads Assets, which is what a publish would carry,
	/// so it names the maps to `ship_data.py --apply` before publishing rather than after.
	/// </summary>
	static void MapGamemodes()
	{
		MapLibrary.Reload();

		// ⛔ THE MAPS PLAYERS GET (2026-10-05): a published copy offers only those marked ready (`MapLibrary.Offered`), so
		// those are the ones that must ship a gamemode. Said in the editor too, where it is the list a publish would carry.
		var ready = MapLibrary.Originals.Where( m => m.Ready ).ToList();
		Log.Info( $"[nz-publish] {"maps for players",-24} {ready.Count} of {MapLibrary.Originals.Count} marked ready: "
			+ (ready.Count == 0 ? "NONE — a published copy would offer nothing" : string.Join( ", ", ready.Select( m => m.Name ) )) );

		var missing = ready
			.Select( m => NZMap.KeyFor( m.MapName ) )
			.Where( key => Gamemodes.For( key ).Count == 0 )
			.ToList();

		if ( missing.Count == 0 )
		{
			Log.Info( $"[nz-publish] {"gamemodes",-24} ok — every ready map ships at least one" );
			return;
		}

		Log.Warning( $"[nz-publish] {"gamemodes",-24} {missing.Count} map(s) ship NO gamemode, so the published lobby "
			+ $"cannot start them: {string.Join( ", ", missing )}. A config in Assets/configs/<map>/ is one — "
			+ "ship_data.py --apply copies the editor's" );
	}

	/// <summary>
	/// A weapon prefab's JSON, which two panels read without spawning the gun (`PrefabText`).
	///
	/// ⚠️ REPORTED, NOT FAILED ON. `WeaponClassStats` and `ChimeraPool` both read it through
	/// `PrefabText` and both return null when it is not there — the stats panel and the
	/// chimera donor lookup degrade rather than break. So this is worth KNOWING about a published
	/// build without being a reason to call the package broken.
	/// </summary>
	static void PrefabSource()
	{
		// ⛔ IT ASKS THE MANIFEST, NOT THE FOLDER. `WeaponClassStats` uses the manifest KEY
		// as the path (`prefabs/weapons/nz_357.prefab`), so probing some other directory would
		// test a path nothing reads — the first version of this looked under `weapons/` and
		// reported "none found" about a folder that has never held a prefab.
		string manifest;
		try { manifest = FileSystem.Mounted.ReadAllText( "weapons/manifest.json" ); }
		catch ( Exception ) { manifest = null; }

		if ( string.IsNullOrWhiteSpace( manifest ) )
		{
			Log.Info( "[nz-publish] prefab source          not checked — no weapon manifest to read a path from" );
			return;
		}

		string path = null;

		try
		{
			using var doc = JsonDocument.Parse( manifest );

			path = doc.RootElement.EnumerateObject()
				.Select( e => e.Name )
				.FirstOrDefault( n => !n.StartsWith( '_' ) );
		}
		catch ( Exception ) { }

		if ( path is null )
		{
			Log.Info( "[nz-publish] prefab source          not checked — the manifest named no prefabs" );
			return;
		}

		var text = PrefabText.Read( path );

		if ( string.IsNullOrWhiteSpace( text ) )
		{
			Log.Warning( $"[nz-publish] {"prefab source",-24} MISSING — '{path}' gave no JSON. "
				+ "The weapon stat bars and the chimera donor list will be blank (both fail soft, "
				+ "so nothing else breaks)." );
			return;
		}

		Log.Info( $"[nz-publish] {"prefab source",-24} ok — '{path}' readable" );
	}
}