Buyables/WallBuyManager.cs

Manager component for wall-buy entities. It finds/creates the singleton, tracks all WallBuy instances, handles aiming/range tests, spawning visual representations (chalk outline, weapon model or marker), rebuilding from config, placement, and per-frame reveal/outline breathing logic.

File AccessNetworking
using System.Collections.Generic;
using System.Linq;
using System;
using Sandbox;

namespace NZombies;

/// <summary>
/// Finds and operates wallbuys. Deliberately the same shape as DebrisManager
/// and PowerManager — singleton, `Aimed()`, `Reach` — so the use key, the
/// prompt and the console all talk to every buyable the same way.
///
/// ⚠️ THE SHAPE IS THE POINT. A third interaction that invented its own
/// aiming/range/prompt convention would mean the prompt and the key could
/// disagree for wallbuys only, which is exactly the class of bug the shared
/// order in TickUse/UsePrompt exists to prevent.
/// </summary>
public sealed class WallBuyManager : Component
{
	public static WallBuyManager Instance { get; private set; }

	/// <summary>How far the aiming ray is cast.</summary>
	[Property] public float BuyRange { get; set; } = 200f;

	/// <summary>How close you must actually stand. Matches the debris reach.</summary>
	[Property] public float Reach { get; set; } = 80f;

	/// <summary>
	/// Find the manager, creating it if the scene has none.
	///
	/// ⚠️ Created at runtime rather than saved into the scene — the same as every
	/// other manager here, and for the same reason: a .scene is never rewritten
	/// from a script. Without this the whole feature silently no-ops, because
	/// every entry point starts with `Instance?.`
	/// </summary>
	public static WallBuyManager Ensure()
	{
		if ( Instance.IsValid() ) return Instance;

		var scene = Game.ActiveScene;
		if ( scene is null ) return null;

		var found = scene.GetAllComponents<WallBuyManager>().FirstOrDefault( m => m.IsValid() );
		if ( found.IsValid() ) return Instance = found;

		var go = scene.CreateObject();
		go.Name = "WallBuy Manager";
		go.Flags |= GameObjectFlags.NotSaved;
		Log.Info( "[wallbuy] created manager (none in scene)" );
		return Instance = go.Components.Create<WallBuyManager>();
	}

	protected override void OnAwake() => Instance = this;
	protected override void OnDestroy() { if ( Instance == this ) Instance = null; }

	public List<WallBuy> All => Scene?
		.GetAllComponents<WallBuy>()
		.Where( w => w.IsValid() )
		.ToList() ?? new();

	/// <summary>
	/// The wallbuy the player is looking at, or null.
	///
	/// ⛔ IGNORE THE PLAYER'S OWN HIERARCHY. The camera sits at eye height INSIDE
	/// the player's collider, so an un-ignored trace stops at zero distance on
	/// the body it started in and never reaches anything — the same trap
	/// DebrisManager documents.
	/// </summary>
	public WallBuy Aimed( NZPlayer player )
	{
		if ( !player.IsValid() ) return null;

		var cam = Scene.Camera;
		if ( !cam.IsValid() ) return null;

		var from = cam.WorldPosition;
		var dir = cam.WorldRotation.Forward;

		// ⚠️ Nearest wins — two wallbuys on facing walls would otherwise pick by
		// list order rather than by what is actually under the crosshair.
		WallBuy best = null;
		var bestDist = float.MaxValue;
		foreach ( var b in All )
		{
			if ( !Hits( b, from, dir, out var dist ) ) continue;
			if ( dist > BuyRange || dist >= bestDist ) continue;
			if ( (from + dir * dist).Distance( player.WorldPosition ) > Reach ) continue;
			best = b;
			bestDist = dist;
		}
		return best;
	}

	static readonly Dictionary<string, Vector3> _centreCache = new();

	/// <summary>
	/// Drop cached visual centres so the next rebuild recomputes them.
	///
	/// ⛔ WITHOUT THIS, TUNING LOOKS LIKE IT DOES NOTHING. The cache is static and
	/// survives both a wallbuy rebuild and a code hotload, so changing how the
	/// centre is computed had no visible effect and no log line — indistinguishable
	/// from the change not working.
	/// </summary>
	public static void ClearCentreCache() => _centreCache.Clear();

	/// <summary>
	/// Where a weapon LOOKS centred, rather than the middle of its bounding box.
	///
	/// ⛔ Bounds.Center IS THE WRONG ANCHOR FOR A GUN. An AABB is decided by the
	/// two extremes, so a long thin barrel drags the centre far forward of where
	/// the weapon's mass actually is — a revolver centres near its cylinder, not
	/// halfway down the barrel. The drawing then sits visibly off.
	///
	/// ⚠️ The MEAN VERTEX POSITION is a good proxy for "densest part": detail
	/// clusters on the receiver, cylinder and grip, while a barrel is a few rings
	/// of geometry spanning a long distance. So averaging vertices weights the
	/// result toward the bulk without needing real mass properties.
	///
	/// ⚠️ Cached per model — this walks every vertex, and a wallbuy rebuild would
	/// otherwise redo it for each placement.
	/// </summary>
	static Vector3 VisualCentre( Model model )
	{
		if ( _centreCache.TryGetValue( model.Name, out var hit ) ) return hit;

		var centre = model.Bounds.Center;
		try
		{
			var verts = model.GetVertices();
			if ( verts is not null && verts.Length > 0 )
			{
				var sum = Vector3.Zero;
				foreach ( var v in verts ) sum += v.Position;
				centre = sum / verts.Length;
			}
		}
		catch ( System.Exception e )
		{
			// ⚠️ Falls back to the bounding centre rather than failing — a wallbuy
			// slightly off is better than no wallbuy.
			Log.Info( $"[wallbuy] no vertex data for {model.Name} ({e.Message}) — using bounds" );
		}

		// ⚠️ Report BOTH so "I see no difference" is answerable: either the vertex
		// centroid really is near the bounds centre for this mesh, or GetVertices
		// returned nothing and we silently fell back.
		Log.Info( $"[wallbuy/centre] {System.IO.Path.GetFileName( model.Name )}  " +
			$"bounds {model.Bounds.Center}  vertices {centre}  " +
			$"delta {(centre - model.Bounds.Center).Length:0.##}" );

		_centreCache[model.Name] = centre;
		return centre;
	}

	/// <summary>Half-extents of a wallbuy's aimable box, in its own space.</summary>
	static readonly Vector3 AimBox = new( 2f, 15f, 7f );

	/// <summary>
	/// Ray vs the wallbuy's box, done in the wallbuy's LOCAL space so the box can
	/// stay axis-aligned — the standard slab test.
	///
	/// ⚠️ Replaces a physics trace so the entity needs no collider and therefore
	/// blocks nothing. Local X is the wall normal, matching every other convention
	/// in this file.
	/// </summary>
	static bool Hits( WallBuy buy, Vector3 from, Vector3 dir, out float dist )
	{
		dist = 0f;
		if ( !buy.IsValid() ) return false;

		var inv = buy.WorldRotation.Inverse;
		var o = inv * (from - buy.WorldPosition);
		var d = inv * dir;

		float near = float.MinValue, far = float.MaxValue;
		for ( var i = 0; i < 3; i++ )
		{
			// ⚠️ A ray parallel to a slab either misses entirely or is unconstrained
			// by it; dividing by ~0 would give infinities that poison the interval.
			if ( MathF.Abs( d[i] ) < 0.0001f )
			{
				if ( MathF.Abs( o[i] ) > AimBox[i] ) return false;
				continue;
			}
			var t1 = (-AimBox[i] - o[i]) / d[i];
			var t2 = (AimBox[i] - o[i]) / d[i];
			if ( t1 > t2 ) (t1, t2) = (t2, t1);
			near = MathF.Max( near, t1 );
			far = MathF.Min( far, t2 );
			if ( near > far ) return false;
		}

		if ( far < 0f ) return false;
		dist = near < 0f ? 0f : near;
		return true;
	}

	/// <summary>Place one. Used by the editor tool and the console command.</summary>
	/// <summary>
	/// Destroy what is standing and build the CONFIG again.
	///
	/// ⚠️ Same shape as every other manager, and it did not exist before — wall
	/// buys were only ever created by clicking, never rebuilt, so loading a config
	/// produced none of them.
	/// </summary>
	public void Rebuild()
	{
		foreach ( var b in All.ToList() )
			b?.GameObject?.Destroy();

		_built.Clear();

		var list = ActiveConfig.Current?.WallBuys;
		if ( list is null || list.Count == 0 ) return;

		for ( var i = 0; i < list.Count; i++ )
		{
			var spot = list[i];
			var buy = Spawn( spot.Position, spot.Angles.ToRotation(),
				spot.WeaponPrefab, spot.Price, spot.Link, spot.Rarity );

			buy.Index = i;
			_built.Add( buy );
		}

		Log.Info( $"[nz] {list.Count} weapon buy(s) built" );
	}

	/// <summary>
	/// Every wall buy the config built, each carrying its index there — so a purchase that arrives by message (`NZNet.WallBought`)
	/// finds its wall. ⚠️ NOT `All`: a rebuild's outgoing walls are still in the scene until the frame ends, under the same indices.
	/// </summary>
	readonly List<WallBuy> _built = new();

	public IReadOnlyList<WallBuy> Built => _built;

	/// <summary>The wall buy at this config index, or null.</summary>
	public WallBuy ByIndex( int index ) => _built.FirstOrDefault( b => b.IsValid() && b.Index == index );

	/// <summary>Place one AND record it in the config, so it survives a save.</summary>
	public WallBuy Place( Vector3 pos, Rotation rot, string weaponPrefab, int price,
		int rarity = 0 )
	{
		// ⛔ THE CONFIG ENTRY IS THE POINT OF THIS METHOD NOW. Creating only the
		// scene object is what lost every wallbuy on reload.
		ActiveConfig.Current.WallBuys.Add( new WallBuySpot
		{
			Position = pos,
			Angles = rot.Angles(),
			WeaponPrefab = weaponPrefab,
			Price = price,
			Rarity = rarity,
		} );

		var buy = Spawn( pos, rot, weaponPrefab, price, DoorLinks.Unlinked, rarity );
		buy.Index = ActiveConfig.Current.WallBuys.Count - 1;
		_built.Add( buy );
		return buy;
	}

	/// <summary>Build the world object only. Rebuild and Place share it.</summary>
	WallBuy Spawn( Vector3 pos, Rotation rot, string weaponPrefab, int price, string link,
		int rarity = 0 )
	{
		var go = Scene.CreateObject();
		go.Name = $"WallBuy ({System.IO.Path.GetFileNameWithoutExtension( weaponPrefab )})";
		go.WorldPosition = pos;
		go.WorldRotation = rot;

		// ⚠️ NotSaved, like every other config-built object. Without it a play
		// session bakes them into the scene file and they come back permanently,
		// on top of the ones the config builds.
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)

		var buy = go.Components.Create<WallBuy>();
		buy.WeaponPrefab = weaponPrefab;
		buy.Price = price;
		buy.Rarity = rarity;

		SpawnVisual( buy );
		return buy;
	}

	/// <summary>
	/// Build what the wallbuy looks like: THE CHALK DRAWING, AND NOTHING ELSE.
	///
	/// ⛔ NO 3D WEAPON MODEL. This is not a simplification — it is what nZombies
	/// does. In `entities/entities/wall_buys/shared.lua` the only live
	/// `self:DrawModel()` sits behind `if self:GetNoChalk()`; inside
	/// `DrawLegacyOutline` the same call is commented out. The entity carries the
	/// weapon's model **for bounds, collision and use-range** and never renders
	/// it. A lit gun stuck to a wall is not what a wallbuy looks like.
	///
	/// ⚠️ That makes the missing-chalk fallback LOAD-BEARING. With the model gone,
	/// a weapon with no decal would be an *invisible* wallbuy — unfindable and
	/// unplaceable — so it falls back to the marker box.
	/// </summary>
	public static void SpawnVisual( WallBuy buy )
	{
		foreach ( var old in buy.GameObject.Children
			.Where( c => c.Name is "display" or "visual" or "chalk" or "weapon" ).ToList() )
		{
			// ⛔ UNPARENT AND RENAME BEFORE DESTROYING. Destroy is deferred to the
			// end of the frame, so the object is still a child — and still matches
			// a name lookup — for the rest of this one. `nz_wallbuy_depth` rebuilt
			// the model and then revealed the OUTGOING child, leaving the new one
			// hidden and the wallbuy looking empty. Same trap as nz_give.
			old.Name = "~dead";
			old.Enabled = false;
			old.SetParent( null );
			old.Destroy();
		}

		// ⚠️ Drop the reveal too — the object it pointed at is being destroyed, and
		// a stale reference would leave the next aim thinking it is already shown.
		_revealed = null;

		// ⚠️ Read the prefab only to VALIDATE it. Nothing here renders it, but a
		// wallbuy selling a weapon that does not exist should say so at placement
		// rather than at the moment a player spends 1250 points on it.
		if ( ResourceLibrary.Get<PrefabFile>( buy.WeaponPrefab ) is null )
			Log.Warning( $"[wallbuy] no prefab at {buy.WeaponPrefab} — it will sell nothing" );

		if ( SpawnChalk( buy ) )
			SpawnWeaponModel( buy );
		else
			SpawnMarker( buy );

		// ⛔ WITHOUT A COLLIDER NOTHING CAN AIM AT IT. Scene.Trace is a PHYSICS
		// query and a ModelRenderer is not physics — the ray passed straight
		// through, so there was no prompt and the use key did nothing, which
		// reads as "the entity is broken" rather than "it has no shape".
		//
		// ⚠️ This is now the ONLY thing giving the wallbuy a physical presence,
		// which is exactly the job GMod's unrendered weapon model does.
		//
		// ⚠️ GetOrCreate, NOT Create. SpawnVisual is re-run to re-tune the chalk
		// (nz_wallbuy_chalk), and Create would stack a second collider on every
		// call — the trace would still work, so the leak would go unnoticed.
		// ⛔ NO COLLIDER AT ALL. A wallbuy is a chalk drawing — paint, not geometry —
		// so it must not stop bullets or shove the player. It previously carried a
		// BoxCollider purely so the use-trace could find it, and that shape blocked
		// everything else too.
		//
		// ⚠️ Aimed() now does the ray/box test in code instead (see Hits). With a
		// handful of wallbuys that is cheaper than a physics query anyway, and it
		// cannot collide with anything by construction.
		foreach ( var old in buy.GameObject.Components.GetAll<BoxCollider>().ToList() )
			old.Destroy();
	}

	/// <summary>
	/// The solid weapon model, hidden, revealed while the player aims at the wallbuy.
	///
	/// ⚠️ Placed by the SAME Orient() the chalk uses, just un-flattened, so the gun
	/// materialises into its own outline by construction rather than by tuning.
	/// </summary>
	static void SpawnWeaponModel( WallBuy buy )
	{
		var model = WeaponModel( buy );
		if ( model is null ) return;

		var go = buy.Scene.CreateObject();
		go.Name = "weapon";
		go.SetParent( buy.GameObject );
		go.Enabled = false;                     // revealed by OnUpdate
		go.Components.Create<ModelRenderer>().Model = model;

		// ⚠️ AND WHAT BURNS IT IN, where the map asks for that (`WallBuyBurnIn`, `Gameplay.ChalkBurnIn`); at once otherwise
		go.Components.Create<WallBuyBurnIn>();

		// ⛔ FLATTENED, LIKE THE CHALK. A solid 3D gun on a wall needed a depth
		// offset, a centre anchor and a bounds solve, and every one of those was a
		// source of misalignment. Flattening it into the SAME plane makes it a 2D
		// render of the weapon sitting inside its own outline — and since chalk and
		// model are then the same mesh, same scale, same plane, they line up by
		// construction rather than by tuning.
		Orient( go, model, true );
	}


	/// <summary>
	/// How far the revealed model floats off the wall, in front of the chalk.
	///
	/// ⚠️ SMALL ON PURPOSE. This is the ONLY thing that should separate the model
	/// from its drawing — just enough to stop them z-fighting. Anything larger and
	/// the gun visibly hovers off the wall.
	/// </summary>
	public static float ModelDepth { get; set; } = 0.1f;

	/// <summary>Hold every weapon model visible, for inspection. `nz_wallbuy_reveal`.</summary>
	public static bool ForceReveal { get; set; }

	/// <summary>
	/// Extra rotation applied to the weapon model only, in its own frame.
	///
	/// ⚠️ Exists because the model's authored axes are not knowable from the asset
	/// — how a `c_` viewmodel is oriented varies per pack — and every wrong guess
	/// costs a compile plus a screenshot. Dial it with `nz_wallbuy_model` and bake
	/// the winner in as the default here.
	/// </summary>
	// ⚠️ Roll 180 is the MEASURED default, not a guess: without it the child came
	// out at roll −179° — the gun upside down in its own outline, magazine up.
	public static Rotation ModelTweak { get; set; } = Rotation.FromRoll( 180f );

	/// <summary>
	/// Per-pack PRE-flatten rotation, keyed by a substring of the model's path.
	///
	/// ⛔ THIS TURNS THE MESH *BEFORE* IT IS FLATTENED — the distinction is the whole
	/// point. The chalk is the flattened SILHOUETTE of the mesh (SpawnChalk): the
	/// flatten squashes the model's +Y, so the drawing traces its X–Z face. ModelTweak
	/// rotates the wafer AFTER the flatten — it can spin or flip the outline but never
	/// change WHICH face it traces. A pack authored on other axes (SIMER's) presents the
	/// wrong face and comes out end-on ("forwards"); only turning the mesh first, so a
	/// different face is the one flattened, fixes it. Orient() folds this in and
	/// redirects the flatten axis to match.
	///
	/// ⚠️ STATIC — SURVIVES HOTLOAD (INSTRUCTIONS §1). Dialing via nz_wallbuy_premodel
	/// mutates it live; a baked default needs a FULL RESTART to re-run this initialiser.
	/// </summary>
	public static Dictionary<string, Rotation> PreTweaks { get; } = new()
	{
		// ⚠️ MEASURED on the STG-44, 2026-09-02, via `nz_wallbuy_premodel 90 0 0 simers`:
		// a 90° yaw swings the barrel out of the +Y (flattened) axis so the side profile
		// faces the wall. Rotation.From is (pitch, yaw, roll), so yaw 90 → From( 0, 90, 0 ).
		["simers"] = Rotation.From( 0f, 90f, 0f ),

		// ⚠️ Same "forwards"/end-on symptom reported on three more packs — given the SAME
		// yaw 90 as SIMER's, since they share the authored-axis convention. VERIFY each in
		// game; if one differs, dial `nz_wallbuy_premodel <y> <p> <r> <tag>` and rebake.
		["_lpg"] = Rotation.From( 0f, 90f, 0f ),     // Golub low-poly guns  (weapons/*_lpg/)
		["uplp"] = Rotation.From( 0f, 90f, 0f ),     // poly-arms pack       (weapons/uplp_*/)
		["destiny"] = Rotation.From( 0f, 90f, 0f ),  // no shared tag — matched via DestinyRoster below

		// ✅ MEASURED AND CONFIRMED IN GAME, 2026-09-14, via `nz_wallbuy_premodel 90 0 0 historical`
		// — the user checked the chalk and confirmed the profile before this was written down. Same
		// yaw 90 as SIMER's, which is evidence the TFA packs share an authored-axis convention
		// rather than a coincidence to be relied on for the NEXT pack.
		//
		// ⚠️ MATCHED BY MANIFEST PACK NAME, NOT BY PATH — see `ManifestPacks`. 76 weapons across
		// `coldwar_`, `dibis_`, `ins2_`, `isonzo_` and more, with no substring in common.
		["historical"] = Rotation.From( 0f, 90f, 0f ),
	};

	/// <summary>
	/// Packs identified by their MANIFEST pack name instead of a path substring or a hand-typed
	/// roster.
	///
	/// ⛔ "TFA Historical" IS 76 WEAPONS WITH NO SHARED PATH FRAGMENT — `coldwar_`, `dibis_`,
	/// `ins2_`, `isonzo_` and more. The substring tag cannot match it and a Destiny-style hand
	/// roster would be 76 names that go stale the moment the pack gains a weapon.
	///
	/// ⚠️ THE MANIFEST ALREADY KNOWS. `WeaponLibrary.Entry` carries `Pack` straight from
	/// `weapons/manifest.json`, so the roster is DERIVED rather than duplicated — add a gun to the
	/// pack and its chalk is oriented with no code change.
	/// </summary>
	static readonly Dictionary<string, string> ManifestPacks = new()
	{
		["historical"] = "TFA Historical",
	};

	static Dictionary<string, string[]> _manifestRosters;

	/// <summary>Drop the derived rosters — after a manifest reload or a live dial.</summary>
	public static void InvalidateRosters() => _manifestRosters = null;

	/// <summary>
	/// The model-name fragments belonging to a manifest-identified pack.
	///
	/// ⚠️ `nz_coldwar_m12.prefab` -> `coldwar_m12`, which IS the fragment the model path carries
	/// (`weapons/coldwar_m12/v_coldwar_m12.vmdl`). Sliced with plain string ops rather than
	/// `System.IO.Path`, which s&box's whitelist refuses — see INSTRUCTIONS.md, error SB1000.
	/// </summary>
	static string[] ManifestRoster( string tag )
	{
		if ( _manifestRosters is null )
		{
			_manifestRosters = new Dictionary<string, string[]>();

			foreach ( var kv in ManifestPacks )
			{
				var names = new List<string>();

				foreach ( var e in WeaponLibrary.All )
				{
					if ( e.Pack != kv.Value ) continue;

					var n = e.Prefab ?? "";
					int slash = n.LastIndexOf( '/' );
					if ( slash >= 0 ) n = n[(slash + 1)..];
					int dot = n.LastIndexOf( '.' );
					if ( dot >= 0 ) n = n[..dot];
					if ( n.StartsWith( "nz_", System.StringComparison.OrdinalIgnoreCase ) ) n = n[3..];

					if ( n.Length > 2 ) names.Add( n );
				}

				_manifestRosters[kv.Key] = names.ToArray();
			}
		}

		return _manifestRosters.TryGetValue( tag, out var r ) ? r : System.Array.Empty<string>();
	}

	/// <summary>
	/// Packs whose weapons share NO path substring, so the tag alone cannot match them.
	/// The Destiny pack names every gun individually (ace_of_spades, eyasluna…), so its
	/// roster is listed here and ResolvePre treats a hit as the "destiny" tag. Taken from
	/// the Destiny section of PapNames — the same weapons, folder = prefab id minus `nz_`.
	/// </summary>
	static readonly Dictionary<string, string[]> PackRosters = new()
	{
		["destiny"] = new[]
		{
			"farewell", "forerunner", "7th_sidearm", "trespasser", "graviton_lance",
			"gridskipper", "hard_light", "ikelos_smg", "7th_smg", "sweet_sorrow",
			"unforgiven", "bxr_battler", "chroma_rush", "disparity", "khvostov_7g0x",
			"midhas_reckoning", "new_purpose", "outbreak_perfected", "piece_of_mind",
			"revision_zero", "7th_carbine", "smite_of_merain", "the_eremite",
			"touch_of_malice", "vex_mythoclast2", "bryas_love", "doom_of_chelchis",
			"trustee", "commemoration", "eleatic_principle", "forgotten_plague",
			"hammerhead", "nemesis_star", "qullims_terminus", "recurrent_impact",
			"retrofit_escapade", "7th_saw", "shattered_cipher", "thunderlord",
			"tommys_matchbook", "xenophage", "ace_of_spades", "eyasluna", "hawkmoon",
			"ikelos_hc", "kept_confidence", "posterity", "7th_revolver", "sunshot",
			"thorn", "zaoulis_bane", "heritage", "ikelos_shotgun", "matador64",
			"7th_shotgun", "soujourners_tale", "wastelander", "1000_yard_stare",
			"cloudstrike", "darci", "defiance_of_yasmin", "ikelos_sniper", "ice_breaker_2",
			"izanagis", "locus_locutus", "lorentz_driver", "no_land_beyond",
			"polaris_lance", "stormchaser", "succession", "thoughtless", "whisper",
			"zen_meteor",
		},
	};

	/// <summary>
	/// The PRE-flatten rotation for this model — a per-pack override when its path
	/// matches one, otherwise identity (the ARC9 default: no pre-rotation).
	///
	/// ⚠️ ONE resolver, used for BOTH the chalk and the revealed gun (Orient runs for
	/// each), so the two cannot pick different orientations and drift apart.
	/// </summary>
	/// <summary>
	/// How many weapons a pack tag actually matches.
	///
	/// ⛔ THE ONE THING A DIAL CANNOT TELL YOU BY LOOKING. A tag that matches nothing produces a
	/// rotation that is stored, reported and applied to zero chalk — indistinguishable in game
	/// from a rotation that is simply wrong. "TFA Historical" has no shared path fragment, so
	/// this is exactly the pack where a typo would look like a bad angle.
	/// </summary>
	public static int MatchCount( string tag )
	{
		if ( string.IsNullOrWhiteSpace( tag ) ) return 0;

		int n = 0;
		foreach ( var e in WeaponLibrary.All )
		{
			var p = e.Prefab ?? "";
			if ( p.Contains( tag, System.StringComparison.OrdinalIgnoreCase ) ) { n++; continue; }

			if ( PackRosters.TryGetValue( tag, out var roster ) )
				foreach ( var name in roster )
					if ( p.Contains( name, System.StringComparison.OrdinalIgnoreCase ) ) { n++; goto next; }

			foreach ( var name in ManifestRoster( tag ) )
				if ( p.Contains( name, System.StringComparison.OrdinalIgnoreCase ) ) { n++; goto next; }

			next: ;
		}
		return n;
	}

	static Rotation ResolvePre( Model model )
	{
		var path = model?.Name ?? "";
		foreach ( var kv in PreTweaks )
		{
			// A self-identifying tag is a substring of the model path ("simers", "_lpg", "uplp").
			if ( path.Contains( kv.Key, System.StringComparison.OrdinalIgnoreCase ) )
				return kv.Value;
			// A pack with no shared substring (Destiny) matches via its listed roster instead.
			if ( PackRosters.TryGetValue( kv.Key, out var roster ) )
				foreach ( var name in roster )
					if ( path.Contains( name, System.StringComparison.OrdinalIgnoreCase ) )
						return kv.Value;

			// ⚠️ AND A PACK THE MANIFEST NAMES, whose roster is derived rather than typed out.
			foreach ( var name in ManifestRoster( kv.Key ) )
				if ( path.Contains( name, System.StringComparison.OrdinalIgnoreCase ) )
					return kv.Value;
		}
		return Rotation.Identity;
	}

	/// <summary>
	/// Per-axis scale that squashes to a wafer whichever of `dir`'s axes is dominant,
	/// leaving the other two at full `scale`. `dir` is the model axis the pre-rotation
	/// turns into the wall normal (+Y). For a cardinal pre-rotation exactly one
	/// component is ±1; near-cardinal values pick the largest, so the flatten degrades
	/// gracefully rather than squashing two axes at once.
	/// </summary>
	static Vector3 FlattenScale( float scale, Vector3 dir )
	{
		var thin = scale * 0.02f;
		var ax = MathF.Abs( dir.x );
		var ay = MathF.Abs( dir.y );
		var az = MathF.Abs( dir.z );
		if ( ax >= ay && ax >= az ) return new Vector3( thin, scale, scale );
		if ( ay >= ax && ay >= az ) return new Vector3( scale, thin, scale );
		return new Vector3( scale, scale, thin );
	}

	/// <summary>
	/// Nudge the model within the outline — Y along the wall, Z up it.
	///
	/// ⚠️ Separate from the bbox fit, which centres the model's BOUNDS on the
	/// drawing's. That is geometrically right and still reads slightly high,
	/// because the icon's render and our model do not share a pivot.
	/// </summary>
	/// ⛔ ZERO BY DEFAULT. The old -3 was tuned when the chalk STRETCHED every
	/// weapon to one length; with a fixed scale it is stale, and it was the reason
	/// the model sat off its own outline. Chalk and model must share a position —
	/// they are the same mesh, so anything that moves one has to move both.
	public static Vector3 ModelNudge { get; set; } = Vector3.Zero;

	/// <summary>Re-place chalk and model on every wallbuy after a tweak.</summary>
	public static void RefitModels()
	{
		foreach ( var buy in Instance?.All ?? new List<WallBuy>() )
		{
			foreach ( var name in new[] { "chalk", "weapon" } )
			{
				var go = buy.GameObject.Children.FirstOrDefault( c => c.Name == name );
				var r = go.IsValid() ? go.Components.Get<ModelRenderer>() : null;
				if ( r?.Model is null ) continue;

				// ⚠️ Refit in place rather than respawning — respawning goes through the
				// deferred-Destroy path, and the reveal would then point at the outgoing
				// child for the rest of the frame.
				Orient( go, r.Model, name == "chalk" );
			}
		}
	}





	/// <summary>
	/// Show the weapon on whichever wallbuy the player is aiming at.
	///
	/// ⛔ ONE TRACE PER FRAME, NOT ONE PER WALLBUY. Asking each WallBuy whether it
	/// is aimed at would fire a scene trace per entity per frame; a map with
	/// twenty wallbuys would pay twenty traces to answer one question.
	/// </summary>
	protected override void OnUpdate()
	{
		// ⚠️ THE CHALK BREATHES FIRST, above every return below (`TickChalk`)
		TickChalk();

		// ⚠️ `nz_wallbuy_reveal` holds every model on, so the aim tracking must not
		// immediately switch them back off.
		if ( ForceReveal ) return;

		var player = NZPlayer.Local;
		var aimed = player.IsValid() ? Aimed( player ) : null;
		if ( aimed == _revealed ) return;

		SetRevealed( _revealed, false );      // no-ops on a bought wallbuy
		SetRevealed( aimed, true );
		_revealed = aimed;
	}

	// ⚠️ Static because SpawnVisual is, and it has to clear this when it destroys
	// the object being pointed at. Safe here only because the manager is a
	// singleton — see Ensure().
	static WallBuy _revealed;

	/// <summary>
	/// Show or hide a wallbuy's weapon model.
	///
	/// ⚠️ Public because WallBuy.TryBuy reveals on purchase — the model then stays
	/// up permanently, so the reveal is not solely aim-driven any more.
	/// </summary>
	/// <param name="instant">At once, not burning — a joiner catching up on a wall bought before they came (`NZNet.WallBought`).</param>
	public static void SetRevealed( WallBuy buy, bool on, bool instant = false )
	{
		if ( !buy.IsValid() ) return;

		// ⛔ A BOUGHT WALLBUY NEVER HIDES. Aiming away must not undo the purchase
		// reveal, and OnUpdate calls this every time the aim target changes.
		if ( !on && buy.Bought ) return;
		var go = buy.GameObject.Children.FirstOrDefault( c => c.Name == "weapon" );
		if ( !go.IsValid() ) return;

		// ⚠️ BURNING IN AND OUT, where the map burns its chalk (`WallBuyBurnIn`, 2026-09-28) — at once where it does not, as it always was
		var burn = go.Components.Get<WallBuyBurnIn>( FindMode.EverythingInSelf );
		if ( burn.IsValid() ) burn.Show( on, instant );
		else go.Enabled = on;
	}

	/// <summary>
	/// Stand-in for a weapon with no chalk, so the wallbuy is still findable.
	///
	/// ⛔ TWO OBJECTS, SAME AS PowerManager AND DebrisManager. A collider
	/// multiplies by its object's scale, so putting both on one scaled object
	/// squares the collider. The parent stays unscaled and states its collider in
	/// plain world units; only the child is scaled.
	/// </summary>
	static void SpawnMarker( WallBuy buy )
	{
		Log.Info( $"[wallbuy] {buy.WeaponName} has no chalk — placing a marker box" );

		var vis = buy.Scene.CreateObject();
		vis.Name = "visual";
		vis.SetParent( buy.GameObject );
		vis.LocalPosition = Vector3.Zero;
		vis.LocalRotation = Rotation.Identity;

		// ⛔ FLAT TO THE WALL, NOT STICKING OUT OF IT. The object's FORWARD is the
		// surface normal (Rotation.LookAt( normal )), so local X points out of the
		// wall — the THIN axis has to be X. With the long axis there the
		// placeholder stood perpendicular, like a shelf.
		vis.LocalScale = new Vector3( 0.04f, 0.30f, 0.14f );

		var r = vis.Components.Create<ModelRenderer>();
		r.Model = Model.Load( "models/dev/box.vmdl" );
		r.Tint = new Color( 0.31f, 0.79f, 0.66f, 0.75f );
	}

	/// <summary>
	/// The chalk drawing — THE WEAPON'S OWN MODEL, flattened against the wall and
	/// outlined.
	///
	/// ⛔ NOT TRACED FROM A PACK ICON. Icons are optional in a weapon pack and, when
	/// present, need not match that pack's own model — the ARC9 BO1 Galil icon shows
	/// a full-length barrel and skeletal folder while its vmdl has a short barrel and
	/// a solid stock. Every reconciliation knob this file used to carry (fit.json
	/// bboxes, scale factors, nudge offsets) existed only to paper over that gap. An
	/// outline taken from the geometry cannot disagree with the geometry and needs
	/// nothing authored per weapon — which is what lets ANY pack drop into this base.
	///
	/// This is GMod's own fallback path (`UseLegacyOutline` in wall_buys/shared.lua):
	/// it flattens copies of the world model to 1% thickness and outlines them through
	/// the stencil buffer. HighlightOutline is the s&box equivalent.
	/// </summary>
	public static bool SpawnChalk( WallBuy buy )
	{
		foreach ( var old in buy.GameObject.Children.Where( c => c.Name == "chalk" ).ToList() )
		{
			old.Name = "~dead"; old.Enabled = false; old.SetParent( null ); old.Destroy();
		}

		var model = WeaponModel( buy );
		if ( model is null ) return false;

		// ⛔ NOTHING RENDERS WITHOUT A Highlight ON THE CAMERA. HighlightOutline only
		// marks a renderer as a target; the camera component does the drawing.
		EnsureHighlight( buy.Scene );

		var go = buy.Scene.CreateObject();
		go.Name = "chalk";
		go.SetParent( buy.GameObject );

		var r = go.Components.Create<ModelRenderer>();
		r.Model = model;

		// ⚠️ Transparent, so only the OUTLINE reads. GMod does the same with
		// render.SetBlend(0) — its flattened copies are never seen, they only define
		// the silhouette.
		r.Tint = new Color( 1f, 1f, 1f, 0f );

		var o = go.Components.Create<HighlightOutline>();
		// ⛔ TIER 0 KEEPS ChalkColor, IT DOES NOT USE Rarity.ColorFor( 0 ). That returns grey
		// (175,175,175) and this returns white, so routing Common through the rarity palette would
		// silently repaint every wall buy in every existing map. Only a tier the mapper actually
		// chose changes colour.
		// ⚠️ THE MAP'S OWN CHALK, IF IT HAS ONE (`Gameplay.ChalkColour`, 2026-09-28) — basalt's ember
		o.Color = buy.Rarity > 0 ? Rarity.ColorFor( buy.Rarity ) : ChalkBase;
		o.InsideColor = Color.Transparent;
		o.ObscuredColor = Color.Transparent;
		o.InsideObscuredColor = Color.Transparent;
		o.Width = ChalkWidth;

		// ⚠️ THE COMMON TIER BREATHES, each on its own beat (`TickChalk`); a rarity's colour is information and stays still
		if ( buy.Rarity <= 0 ) Chalks.Add( (o, Game.Random.Float( 0f, 6.2832f )) );

		Orient( go, model, true );
		return true;
	}

	/// <summary>Chalk line colour. White, as the original.</summary>
	public static Color ChalkColor { get; set; } = Color.White;

	/// <summary>Chalk line weight.</summary>
	public static float ChalkWidth { get; set; } = 0.4f;

	/// <summary>The common tier's chalk on this map: `Gameplay.ChalkColour` (basalt's ember), else <see cref="ChalkColor"/>, the original's white.</summary>
	public static Color ChalkBase => Color.Parse( ActiveConfig.Current?.Gameplay?.ChalkColour ?? "" ) ?? ChalkColor;

	/// <summary>Every common tier's chalk outline, with its own phase, for the glow. A dead one falls out as it is met.</summary>
	static readonly List<(HighlightOutline Outline, float Phase)> Chalks = new();

	/// <summary>
	/// EMBER CHALK — *"I would love this"* (2026-09-28): the common tier's chalk in the map's colour, breathing like embers by
	/// `Gameplay.ChalkGlow` (0 steady; basalt's 0.35), each wall buy on its own beat so a wall of them never pulses in step.
	///
	/// ⚠️ A COLOUR ON AN OUTLINE AND NOTHING MORE — no light, no material, no draw of its own. The outline is drawn every frame
	/// regardless (`HighlightOutline`); this only changes the number it is drawn with.
	/// </summary>
	static void TickChalk()
	{
		if ( Chalks.Count == 0 ) return;

		var glow = Math.Clamp( ActiveConfig.Current?.Gameplay?.ChalkGlow ?? 0f, 0f, 1f );
		var colour = ChalkBase;
		var now = Time.Now;

		for ( var i = Chalks.Count - 1; i >= 0; i-- )
		{
			var (outline, phase) = Chalks[i];
			if ( !outline.IsValid() ) { Chalks.RemoveAt( i ); continue; }

			// ⚠️ A SLOW BREATH, about 3.4 s, never dimmer than (1 - glow) of full
			var k = glow <= 0f ? 1f : 1f - glow * 0.5f * (1f - MathF.Sin( now * 1.85f + phase ));
			outline.Color = new Color( colour.r * k, colour.g * k, colour.b * k, colour.a );
		}
	}

	/// <summary>
	/// The camera needs a Highlight component or no outline draws anywhere.
	///
	/// ⚠️ Added at runtime like every other manager here — a .scene is never
	/// rewritten from a script.
	/// </summary>
	/// ⚠️ PUBLIC because Pickup needs the same wiring for its loot outlines.
	/// Two copies of "has the camera got a Highlight yet" is exactly the duplicated
	/// lookup INSTRUCTIONS.md §3 warns diverges — one of them gets the fix.
	public static void EnsureHighlight( Scene scene )
	{
		var cam = scene?.Camera;
		if ( !cam.IsValid() ) { Log.Warning( "[wallbuy] no camera — chalk will not draw" ); return; }
		if ( cam.Components.Get<Highlight>() is null )
		{
			cam.Components.Create<Highlight>();
			Log.Info( "[wallbuy] added Highlight to the camera (needed for chalk)" );
		}
	}

	/// <summary>The weapon's model, from its prefab. Null when it cannot be loaded.</summary>
	static Model WeaponModel( WallBuy buy )
	{
		var prefab = ResourceLibrary.Get<PrefabFile>( buy.WeaponPrefab );
		if ( prefab is null ) { Log.Warning( $"[wallbuy] no prefab at {buy.WeaponPrefab}" ); return null; }

		var weapon = prefab.RootObject?["Components"]?.AsArray()
			.FirstOrDefault( c => c?["__type"]?.ToString()?.EndsWith( "Weapon" ) == true );

		// ⛔ THE VIEWMODEL IS THE WORLD MODEL HERE. ARC9 points WorldModelMirror at
		// the same `c_` model it uses in first person.
		var path = weapon?["WorldModel"]?.ToString();
		if ( string.IsNullOrWhiteSpace( path ) ) path = weapon?["ViewModel"]?.ToString();
		if ( string.IsNullOrWhiteSpace( path ) ) return null;

		// ⚠️ ITS DISPLAY MODEL WHEN IT HAS ONE (`WeaponDisplay`, 2026-10-01): an MW viewmodel's bind pose came apart on
		// the wall, *"the chalk for the weapon models its all broken, even though its perfectly good in my hand"*.
		var m = WeaponDisplay.Load( path );
		// ⚠️ Model.Load returns the ERROR model, not null, for a bad path.
		return m is null || m.IsError ? null : m;
	}

	/// <summary>
	/// Lay a weapon model on the wall — same rotation, scale and centring for the
	/// chalk and for the revealed gun, so the two cannot drift apart.
	///
	/// ⚠️ ONE METHOD FOR BOTH ON PURPOSE. The drawing and the gun are the same mesh
	/// now; separate placement code is exactly how they would stop lining up again.
	/// </summary>
	static void Orient( GameObject go, Model model, bool flatten )
	{
		// Orientation is TWO stages that must not be confused:
		//   1. ResolvePre turns the MESH *before* the flatten — it decides WHICH face
		//      becomes the drawing. Identity for ARC9; a per-pack turn for SIMER's.
		//   2. LookAt + ModelTweak orient the finished wafer on the wall (ModelTweak is
		//      the post-flatten roll, the measured ARC9 up/down fix).
		// The pre-rotation is FOLDED into LocalRotation: a cardinal turn commutes with
		// the axis-aligned flatten (S·pre = pre·S′, S′ the permuted scale), so pre stays
		// here and the flatten axis below follows it. For pre = identity this is exactly
		// the old line — model +X (muzzle) → local −Z, +Z (up) → local +Y, +Y (side) →
		// local −X, the weapon lying along the wall in profile.
		var pre = ResolvePre( model );
		go.LocalRotation = Spin * Rotation.LookAt( Vector3.Down, Vector3.Left ) * ModelTweak * pre;

		// ⛔ A FIXED SCALE, NOT STRETCH-TO-FIT. This used to normalise every weapon
		// to the same drawn LENGTH (ChalkScale * 0.5 / size.x), which made a pistol
		// exactly as long as a rifle — so pistols came out gigantic. Weapons differ
		// in size and the chalk should say so.
		//
		// ⚠️ ChalkScale is therefore a MULTIPLIER now, not a target length. 64 is
		// life-size, matching what the old fit produced for a rifle, so rifles look
		// unchanged and only the small weapons shrink.
		var scale = ChalkScale / 64f;

		// ⚠️ FLATTEN THE MODEL AXIS THE PRE-ROTATION TURNS INTO THE WALL NORMAL — not
		// always +Y. The squashed axis must be the one that ends up facing out of the
		// wall. With pre = identity that is the model's own +Y (the ARC9 side); a
		// pre-rotated pack presents a different face, so the flatten has to follow or the
		// drawing comes out an end-on sliver. GMod flattens to 1% for the same reason: a
		// flat cutout outlines cleanly, a 3D gun outlines a shape that changes as you pass.
		var flatAxis = pre.Inverse * new Vector3( 0f, 1f, 0f );
		var flatScale = FlattenScale( scale, flatAxis );
		go.LocalScale = flatten ? flatScale : new Vector3( scale, scale, scale );

		// ⚠️ Cancel the model's own origin — a `c_` model is authored around the
		// grip, not the middle.
		// ⚠️ ModelNudge IS FOR THE SOLID MODEL ONLY. It was being applied to the
		// chalk too, which is why some drawings sat a few units off the point they
		// were placed at — the nudge is a fitting tweak for the revealed weapon,
		// not part of where the wallbuy is.
		// ⚠️ IDENTICAL FOR BOTH except a hair of depth on the wall normal. The chalk
		// and the revealed weapon are the same mesh at the same scale, so any offset
		// applied to one and not the other shows up immediately as the gun sitting
		// off its own outline.
		// ⛔ CENTRE USING THE PER-AXIS SCALE, NOT A UNIFORM ONE. The chalk is
		// FLATTENED — LocalScale.y is scale * 0.02 — but this centring multiplied
		// bounds.Center by the uniform `scale`, over-compensating along the squashed
		// axis by 50x. That is what held the drawing off the wall, and why it varied
		// per weapon: a `c_` model is authored around the GRIP, so how far its
		// centre sits from the origin is arbitrary.
		//
		// ⚠️ Component-wise. Putting the model's CENTRE on the wallbuy origin is the
		// whole intent — the flattened mesh then straddles the wall plane instead of
		// hanging off it.
		// ⛔ ALWAYS CENTRE WITH THE FLATTENED SCALE, FOR BOTH. The chalk is verified
		// correct against the wall; computing the solid model's centre from its own
		// UNIFORM scale gave a different answer along the wall normal, so the gun
		// landed off its own outline. Deriving both from the same vector means they
		// can differ ONLY by ModelDepth, by construction.
		//
		// ⚠️ Flattening squashes the mesh about its own centre, so using the flat
		// scale here does not shrink the solid model — it only decides where the
		// centre sits. Uses the SAME axis-aware flatScale as the squash above, so the
		// centre is cancelled along whichever axis was actually flattened.
		var c = VisualCentre( model );
		var centre = go.LocalRotation * new Vector3(
			-c.x * flatScale.x, -c.y * flatScale.y, -c.z * flatScale.z );

		go.LocalPosition = centre
			+ new Vector3( flatten ? 0f : ModelDepth, 0f, 0f ) + ModelNudge;
	}

	/// <summary>
	/// Chalk size in world units. GMod's `ChalkScale` default is 64.
	/// </summary>
	public static float ChalkScale { get; set; } = 64f;

	/// <summary>
	/// Spin about the wall normal.
	///
	/// ⛔ APPLIED IN THE PARENT FRAME, NOT EACH OBJECT'S OWN. Deriving a per-object
	/// spin axis — the quad presents +Z to the wall normal, the model presents +Y —
	/// is correct on paper and turned out fragile in practice: the quad rotated and
	/// the model did not. Left-multiplying a ROLL rotates both about the wallbuy's
	/// local X, which IS the wall normal by construction (Rotation.LookAt( normal )),
	/// so neither object's internal axes matter.
	///
	/// ⚠️ 270, not 90. The dev plane maps image-right to local Z, so unspun every
	/// weapon hangs VERTICALLY on a wall — but 90 lands it upside down (magazine
	/// up). 270 puts the muzzle left and the magazine down, matching the icon the
	/// chalk was traced from. Measured by screenshot; not derivable from the path.
	/// </summary>
	public static float ChalkSpin { get; set; } = 270f;

	/// <summary>Roll about the wallbuy's local X — the wall normal. Left-multiply it.</summary>
	static Rotation Spin => Rotation.FromRoll( ChalkSpin );
}