Player/PlayerCharacter.cs

Defines playable characters, their model paths, and utilities to query and apply a players chosen character and avatar. Handles loading hand/body models, deciding which character sets are allowed per map, applying avatar clothing or character bodies to player renderers, and console commands for debugging and toggles.

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

namespace NZombies;

/// <summary>One playable character: who they are, and what the player sees of them.</summary>
public sealed record NZCharacter( string Id, string Name, string Arms, string Body );

/// <summary>
/// The four playable characters, and which one this player is.
///
/// ⛔ THE ARMS ARE THE WHOLE VISIBLE CHARACTER RIGHT NOW, and that is not a shortcut. nZombies is
/// first person; the body is a Citizen and stays one. The Primis playermodels are **ValveBiped, 75
/// bones** while s&box's player is the **Citizen** skeleton — different rigs, so a Primis body would
/// not animate at all without a full retarget. The c_arms share `ValveBiped.Bip01_*` naming with the
/// `c_arms_cstrike` hands this project already bone-merges onto every weapon, so they drop straight
/// in. One half of the port is nearly free and the other is days of work; this is the free half.
///
/// ⚠️ NO VOICE LINES YET. 526 of them are extracted and sitting in `Staging/primis`. `Id` is
/// deliberately the same string as their sound folder so wiring them later is a path join.
/// </summary>
public static class PlayerCharacters
{
	public const string ArmsRoot = "models/player/primis";

	/// <summary>
	/// The four.
	///
	/// ⛔ RENAMED AGAIN — `All` → `Roster` → `Cast` → `Primis` — BECAUSE HOTLOAD KEEPS WINNING. It copies static
	/// values forward BY NAME, so editing what is INSIDE this array changes nothing in a running
	/// session: the previously-loaded array survives under the same name. Switching the bodies from
	/// `_rigged` back to the static meshes appeared to do nothing at all, while `PreviewYaw` — a
	/// `const`, compiled inline — flipped immediately. The result was a character that had visibly
	/// rotated and changed in no other way.
	///
	/// ⚠️ THE RULE IS THE SAME EVERY TIME: a new field name has no old value to inherit. Renaming the
	/// TYPE was not enough before, and neither is editing the contents now.
	///
	/// ⛔ ORIGINALLY RENAMED FROM `All` BECAUSE HOTLOAD KEPT THE OLD ONE ALIVE. s&box copies static values
	/// forward BY NAME into the recompiled assembly, so when `NZCharacter` gained a `Body` field the
	/// previously-loaded 3-field array carried straight over it — records that resolved by id,
	/// reported the right name, and had `Body` null. The lobby therefore found the character, asked
	/// for its body, got nothing, and fell back to the Citizen.
	///
	/// ⚠️ A NEW FIELD NAME HAS NO OLD VALUE TO INHERIT. `WalkerFootsteps` carries the same warning
	/// for the same reason; restarting play does not help, because that does not rebuild the
	/// assembly.
	/// </summary>
	public static readonly NZCharacter[] Primis =
	{
		new( "dempsey",   "Tank Dempsey",     $"{ArmsRoot}/dempsey_arms.vmdl",   $"{ArmsRoot}/dempsey_rigged.vmdl" ),
		new( "nikolai",   "Nikolai Belinski", $"{ArmsRoot}/nikolai_arms.vmdl",   $"{ArmsRoot}/nikolai_rigged.vmdl" ),
		new( "richtofen", "Edward Richtofen", $"{ArmsRoot}/richtofen_arms.vmdl", $"{ArmsRoot}/richtofen_rigged.vmdl" ),
		new( "takeo",     "Takeo Masaki",     $"{ArmsRoot}/takeo_arms.vmdl",     $"{ArmsRoot}/takeo_rigged.vmdl" ),
	};

	public const string UltimisRoot = "models/player/ultimis";
	public const string VictisRoot = "models/player/victis";

	/// <summary>
	/// The Ultimis four — the same characters as <see cref="Primis"/> in their WaW-era outfits.
	///
	/// ⚠️ SEPARATE IDS, NOT A SKIN. `CharacterId` is a plain string on the player and the sound
	/// folders are named after it, so "dempsey" is already taken by the Primis model; sharing an id
	/// would make the two indistinguishable everywhere the id is the key.
	/// </summary>
	public static readonly NZCharacter[] Ultimis =
	{
		new( "ult_dempsey",   "Tank Dempsey (Ultimis)",     $"{UltimisRoot}/ult_dempsey_arms.vmdl",   $"{UltimisRoot}/ult_dempsey_rigged.vmdl" ),
		new( "ult_nikolai",   "Nikolai Belinski (Ultimis)", $"{UltimisRoot}/ult_nikolai_arms.vmdl",   $"{UltimisRoot}/ult_nikolai_rigged.vmdl" ),
		new( "ult_richtofen", "Edward Richtofen (Ultimis)", $"{UltimisRoot}/ult_richtofen_arms.vmdl", $"{UltimisRoot}/ult_richtofen_rigged.vmdl" ),
		new( "ult_takeo",     "Takeo Masaki (Ultimis)",     $"{UltimisRoot}/ult_takeo_arms.vmdl",     $"{UltimisRoot}/ult_takeo_rigged.vmdl" ),
	};

	/// <summary>The Victis four, from Black Ops II.</summary>
	public static readonly NZCharacter[] Victis =
	{
		new( "marlton",    "Marlton Johnson",   $"{VictisRoot}/marlton_arms.vmdl",    $"{VictisRoot}/marlton_rigged.vmdl" ),
		new( "misty",      "Misty",             $"{VictisRoot}/misty_arms.vmdl",      $"{VictisRoot}/misty_rigged.vmdl" ),
		new( "russman",    "Russman",           $"{VictisRoot}/russman_arms.vmdl",    $"{VictisRoot}/russman_rigged.vmdl" ),
		new( "stuhlinger", "Samuel Stuhlinger", $"{VictisRoot}/stuhlinger_arms.vmdl", $"{VictisRoot}/stuhlinger_rigged.vmdl" ),
	};

	/// <summary>
	/// Every playable character, in roster order. THE list — `Find` and the diagnostics read this.
	///
	/// ⛔ A NEW FIELD NAME, AND THAT IS THE WHOLE REASON IT IS NOT JUST MORE ROWS IN `Primis`.
	/// Hotload copies static values forward BY NAME, so editing what is INSIDE an existing array
	/// changes nothing in a running session — this file has been renamed `All` → `Roster` → `Cast`
	/// → `Primis` for exactly that reason, three times. Adding twelve characters by appending to
	/// `Primis` would have shown four.
	/// </summary>
	public static readonly NZCharacter[] Everyone =
		Primis.Concat( Ultimis ).Concat( Victis ).ToArray();

	/// <summary>
	/// The roster grouped for the lobby picker, in roster order.
	///
	/// ⚠️ TWELVE FLAT ROWS IS A WALL. The picker was written for four names and reads as one
	/// decision; three named groups of four keeps that, where one list of twelve would not.
	///
	/// ⛔ A NEW FIELD NAME, for the same reason as <see cref="Everyone"/> — hotload copies statics
	/// forward BY NAME, so a group list has to be a name that never existed rather than extra rows
	/// in an array a running session already holds.
	/// </summary>
	public static readonly (string Name, NZCharacter[] Members)[] Sets =
	{
		( "Primis", Primis ),
		( "Ultimis", Ultimis ),
		( "Victis", Victis ),
	};

	/// <summary>
	/// The Easter egg line of the map being played — "primis", "immunis", or "" for a Survival map — from the manifest
	/// (`MapLibrary.Original.Line`, the field the map browser's tabs read).
	/// ⚠️ READ ON EVERY CALL, NEVER KEPT: the map changes under a running session, and a remembered line is the last map's.
	/// </summary>
	public static string MapLine( string mapName = null )
	{
		var key = NZMap.KeyFor( mapName ?? NZMap.CurrentRaw ?? "" );
		return MapLibrary.Originals.FirstOrDefault( o => NZMap.KeyFor( o.MapName ) == key )?.Line ?? "";
	}

	/// <summary>
	/// The groups a player may choose from on the map being played (or `mapName`), in roster order.
	///
	/// ⛔ ON A PRIMIS MAP, THE PRIMIS FOUR ONLY (2026-10-05). The user: *"On the primis maps only the 4 [...] characters are
	/// available"*; asked which four, they chose the Primis group (the BO3 outfits). Every other map offers every group.
	/// ⚠️ A METHOD, NOT A STATIC LIST: hotload carries a static's old value forward by name (see `Primis`), and the answer
	/// changes with the map anyway. The group is found by its NAME, which no hotload changes.
	/// </summary>
	public static (string Name, NZCharacter[] Members)[] SetsHere( string mapName = null )
		=> MapLine( mapName ) == "primis" ? Sets.Where( s => s.Name == "Primis" ).ToArray() : Sets;

	/// <summary>May this character be chosen on the map being played? No character at all (your own avatar) always may.</summary>
	public static bool AllowedHere( NZCharacter c, string mapName = null )
		=> c is null || SetsHere( mapName ).Any( s => s.Members.Any( m => m.Id.Equals( c.Id, StringComparison.OrdinalIgnoreCase ) ) );

	/// <summary>Is "Your own avatar" offered in the picker here? Not on a Primis map, where the four are the whole list.</summary>
	public static bool AvatarChoosableHere => MapLine() != "primis";

	/// <summary>
	/// What a pick becomes on the map being played: itself where it is allowed; else the same person from a group that is
	/// (an Ultimis pick on a Primis map: `ult_takeo` → `takeo`); else no character (a Victis pick there).
	/// </summary>
	public static NZCharacter AllowedVersionOf( NZCharacter c, string mapName = null )
	{
		if ( AllowedHere( c, mapName ) ) return c;

		var person = c.Id.StartsWith( "ult_", StringComparison.OrdinalIgnoreCase ) ? c.Id["ult_".Length..] : c.Id;
		return SetsHere( mapName ).SelectMany( s => s.Members )
			.FirstOrDefault( m => m.Id.Equals( person, StringComparison.OrdinalIgnoreCase ) );
	}

	/// <summary>`nz_cast [map]` — which characters a map offers, and what each pick becomes there (no argument: this map).</summary>
	[ConCmd( "nz_cast" )]
	public static void CastCmd( string map = "" )
	{
		var name = string.IsNullOrWhiteSpace( map ) ? null : map;
		var line = MapLine( name );
		Log.Info( $"[nz-char] cast of '{name ?? NZMap.CurrentRaw}': line '{(line == "" ? "survival" : line)}' · offers "
			+ string.Join( ", ", SetsHere( name ).Select( s => s.Name ) )
			+ (MapLine( name ) == "primis" ? " · no own-avatar row" : "") );

		foreach ( var c in Everyone )
		{
			var to = AllowedVersionOf( c, name );
			Log.Info( $"[nz-char]   {c.Id,-14} {(to == c ? "allowed" : $"→ {to?.Id ?? "own avatar"}")}" );
		}
	}

	/// <summary>
	/// Yaw to add so the model faces the camera in a preview.
	///
	/// ⛔ ZERO NOW, AND THAT IS A REAL VALUE RATHER THAN A DISABLED ONE. The facing is baked into the
	/// exported model by `Tools/retarget_to_human.py` (`EXPORT_YAW`), so a retargeted body faces the
	/// same way the Citizen does and needs no correction anywhere — preview, lobby or gameplay.
	///
	/// ⚠️ IT WAS +90 WHILE `Body` POINTED AT THE ValveBiped STATIC MESHES, which are authored facing
	/// a different axis. Fixing facing per-viewer meant every new place that drew a body had to know
	/// about it; fixing it in the asset means none of them do.
	///
	/// ⚠️ KEPT RATHER THAN DELETED because a future character may arrive from a pipeline that does
	/// not bake it. If one does, this becomes a field on the record instead of a constant.
	/// </summary>
	public const float PreviewYaw = 0f;

	/// <summary>
	/// What the player wore before any character was applied, captured once.
	///
	/// ⛔ RESTORED RATHER THAN REPLACED WITH A CONSTANT. A hardcoded default imposes a body the
	/// scene may never have used — and this project has BOTH in play already: the lobby preview
	/// stages `citizen_human_male` while a constant here said `citizen`. Clearing a character should
	/// put back what was there, not assert what ought to be.
	///
	/// ⚠️ CAPTURED ON THE FIRST APPLY, WHICH IS THE ONLY MOMENT IT IS STILL TRUE. Read it later
	/// and it is whatever character was last worn.
	/// </summary>
	static Model _originalBody;

	/// <summary>
	/// The local player, whether or not they are currently in the map.
	///
	/// ⛔ `Scene.GetAllComponents<NZPlayer>()` DOES NOT SEE A DISABLED PLAYER, and in the lobby the
	/// player object IS disabled — `PlayerPresence` switches it off rather than destroying it. That
	/// is why picking a character from the lobby answered "no player" while the player was plainly
	/// sitting there in the menu.
	///
	/// ⚠️ `PlayerPresence.Find()` already solves this and caches the object. Anything asking "who
	/// is the local player" from a menu must go through it.
	/// </summary>
	public static NZPlayer Local()
		=> PlayerPresence.Find()?.Components
			.Get<NZPlayer>( FindMode.EverythingInSelfAndDescendants );

	public static NZCharacter Find( string id )
		=> string.IsNullOrWhiteSpace( id )
			? null
			: Everyone.FirstOrDefault( c => c.Id.Equals( id, StringComparison.OrdinalIgnoreCase ) );

	/// <summary>
	/// The arms model this player should be holding, or null to leave the weapon's own choice alone.
	///
	/// ⛔ NULL MEANS "DO NOT TOUCH", NOT "USE THE DEFAULT". Every weapon already ships a
	/// `ViewModelHands`, and some may legitimately want their own — gloves, a suit. Returning a
	/// fallback here would silently overwrite those with generic hands for a player who never picked
	/// a character.
	///
	/// ⚠️ IT RETURNS A LOADED `Model`, NOT A PATH, so a missing or broken asset is caught here once
	/// rather than at three separate call sites.
	/// </summary>
	public static Model HandsFor( NZPlayer player )
	{
		var c = Find( player.IsValid() ? player.CharacterId : null );
		if ( c is null ) return null;

		var m = Model.Load( c.Arms );

		// ⚠️ `IsError` TOO, NOT JUST NULL. A compiled-but-broken vmdl loads as the ERROR MODEL, which
		// renders happily as a checkerboard — the same trap `ZombieAI.EnsureBody` documents.
		return m is null || m.IsError ? null : m;
	}

	/// <summary>The Human body s&box avatars are made on. A female avatar's body comes from its own skin item.</summary>
	public const string HumanBody = "models/citizen_human/citizen_human_male.vmdl";

	/// <summary>
	/// The s&box avatar of whoever owns this body: their saved outfit and appearance. Null when it cannot be read here yet (a
	/// teammate's connection this machine has not heard of).
	///
	/// ⚠️ MY OWN BODY READS THE LOCAL USER, solo or not — solo has no other avatar to read.
	/// </summary>
	public static ClothingContainer AvatarOf( NZPlayer player )
	{
		if ( !player.IsValid() ) return null;
		if ( !Networking.IsActive || PlayerPresence.Mine( player.GameObject ) ) return ClothingContainer.CreateFromLocalUser();

		return AvatarOf( OwnerConnection( player.GameObject ) );
	}

	/// <summary>One connection's s&box avatar: the local user's own, or what the engine knows of a teammate's.</summary>
	public static ClothingContainer AvatarOf( Connection connection )
	{
		if ( connection is null ) return null;
		if ( connection == Connection.Local ) return ClothingContainer.CreateFromLocalUser();

		return ClothingContainer.CreateFromConnection( connection );
	}

	/// <summary>
	/// An outfit as something two logs can compare: how many items, and a print of its whole content that is the same on every
	/// machine (FNV-1a over `Serialize`, never `GetHashCode`, which .NET seeds per process).
	///
	/// ⛔ A PRINT THAT MATCHES YOUR OWN, ON SOMEBODY ELSE'S BODY, IS THE BUG OF 2026-10-05: *"from my point of view everyone is
	/// using my human model, and for other players everyone is using their model"*. The published lobby dressed every slot with
	/// `CreateFromLocalUser`. It is fixed here and in the lobby, and the line this goes into says so on every machine.
	/// </summary>
	public static string DescribeOutfit( ClothingContainer outfit, bool isMine )
	{
		if ( outfit is null ) return "no outfit";

		var print = OutfitPrint( outfit );
		var items = outfit.Clothing?.Count ?? 0;

		// ⚠️ ONLY WITH SOMETHING ON: two fresh avatars really are alike, and an empty outfit says nothing about whose it is
		var clash = !isMine && items > 0 && print == OutfitPrint( ClothingContainer.CreateFromLocalUser() );

		return $"{items} item(s) · print {print}" + (clash ? "  ⚠ THE SAME AS YOURS — another player is in your outfit" : "");
	}

	/// <summary>The outfit's content as eight hex digits, the same on every machine. See <see cref="DescribeOutfit"/>.</summary>
	static string OutfitPrint( ClothingContainer outfit )
	{
		uint h = 2166136261;
		foreach ( var ch in outfit?.Serialize() ?? "" )
		{
			h ^= ch;
			h *= 16777619;
		}

		return h.ToString( "x8" );
	}

	/// <summary>
	/// Whose this body is, as a connection: the RECORDED owner first (`NZPlayers.OwnerOf`), the engine's only as a fallback —
	/// the order every "whose body" question in this project takes, after the engine's own answer was wrong twice.
	/// </summary>
	static Connection OwnerConnection( GameObject body )
	{
		if ( Guid.TryParse( NZPlayers.OwnerOf( body ), out var id ) && Connection.Find( id ) is { } recorded ) return recorded;

		return body.IsValid() ? body.Network.Owner : null;
	}

	/// <summary>
	/// The body every player's own avatar is shown on: ALWAYS THE HUMAN.
	///
	/// ⛔ NOT THE ONE THE AVATAR WAS BUILT ON (2026-10-05). It asked the avatar's `PrefersHuman`, so an avatar built on the Citizen
	/// stayed a Citizen; the user: *"no no, they are forced to be the human form"*. The Citizen only if the Human will not load.
	/// The avatar's own skin item can still make the Human female.
	/// </summary>
	public static Model AvatarBody()
	{
		var human = Model.Load( HumanBody );
		if ( human is not null && !human.IsError ) return human;

		return _originalBody ?? Model.Load( "models/citizen/citizen.vmdl" );
	}

	/// <summary>
	/// Put this player in their chosen character's body, or in their own s&box avatar (2026-10-05; it was the Citizen).
	///
	/// ⛔ THE BODY IS THE PORTED MESH AND DOES NOT ANIMATE. These are ValveBiped, 75 bones; s&box's
	/// player animation is authored for the human rig, 94 bones, sharing not one name with them. So
	/// the body renders correctly — right proportions, right materials — in its bind pose, and stays
	/// there.
	///
	/// ⚠️ THE BODIES ARE THE RETARGETED ONES. `Tools/retarget_to_human.py` rebinds the ValveBiped
	/// meshes onto the human skeleton so the animgraph drives them, and the four `_rigged.vmdl`
	/// files are what `Primis` points at.
	///
	/// ⛔ THE REBIND HAS TO REPROPORTION THE MESH, AND THAT IS NOT OPTIONAL. Every human animation
	/// carries position channels for all 72 animated bones, so the skeleton snaps to the human's
	/// proportions whatever rest pose the model ships with — keeping the character's own limb lengths
	/// is not available. Residual mean edge-length error is 7.6% concentrated at the joints, down
	/// from 17.5%; the median edge is exact.
	///
	/// ⚠️ WHICH COSTS LITTLE HERE. This is a first-person game — you never look at your own body. It
	/// shows in the lobby preview, where a still pose is what a character-select screen wants anyway,
	/// and in the downed state.
	///
	/// ⚠️ `PlayerController.Renderer` IS THE FIELD TO WRITE. The body is a child object; finding it
	/// by name or by "first SkinnedModelRenderer" would also match a weapon's world model.
	/// </summary>
	public static void ApplyBody( NZPlayer player )
	{
		if ( !player.IsValid() ) return;

		var ctrl = player.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
		if ( !ctrl.IsValid() ) return;

		// ⛔ `ctrl.Renderer` IS A COMPONENT REFERENCE, AND A SILENT `return` ON IT IS A TRAP.
		// It points at the `Body` child's renderer, which is fine on a body this machine built —
		// and is not something this project has ever verified survives a `NetworkSpawn`. If it
		// arrives null on the far machine, this method returned quietly and the body kept whatever
		// model it happened to have, with nothing logged and nothing to find.
		//
		// ⚠️ AND `nz_see` WOULD NOT HAVE CAUGHT IT, because that falls back to searching
		// descendants — so it reports a perfectly healthy renderer that nobody ever wrote a model
		// into. "Drawable: yes" and "wearing the right character" can both be true of a body you
		// cannot see.
		var rend = ctrl.Renderer;

		if ( !rend.IsValid() )
		{
			// ⚠️ THE FALLBACK IS SAFE NOW IN A WAY IT WAS NOT BEFORE. The original note here
			// warned that "the first SkinnedModelRenderer" could match a weapon's world model —
			// true then, and weapons no longer cross the network at all (their prefabs are
			// `NetworkMode.Never`), so on somebody else's body the only skinned renderer is the
			// body. The viewmodel tag is excluded anyway.
			rend = player.Components
				.GetAll<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants )
				.FirstOrDefault( r => r.IsValid() && !r.GameObject.Tags.Has( "viewmodel" ) );

			Log.Warning( $"[nz-char] '{player.GameObject.Name}' has no PlayerController.Renderer — "
				+ $"{(rend.IsValid() ? $"using '{rend.GameObject.Name}' instead" : "AND NO SKINNED RENDERER AT ALL")}."
				+ " A body whose renderer reference did not survive the network would be invisible"
				+ " while reporting perfectly healthy." );
		}

		if ( !rend.IsValid() ) return;

		_originalBody ??= rend.Model;

		var c = Find( player.CharacterId );
		var dresser = player.Components.Get<Dresser>( FindMode.EverythingInSelfAndDescendants );

		if ( c is null )
		{
			// ⛔ THE PLAYER'S OWN s&box AVATAR, NOT THE PREFAB'S CITIZEN (2026-10-05). The user: *"when a player does not choose a
			// character, they become a citizen, but I'd like them to use their own human model from sbox"*. This put back the
			// prefab's `citizen.vmdl` and left the Dresser to it, and an avatar made on the Human body has little that fits a
			// Citizen, so a player who never picked anyone stood there as a near-bare Citizen. The body is now always the Human
			// (`AvatarBody`), dressed in the owner's avatar right here.
			var avatar = AvatarOf( player );
			var body = AvatarBody();
			if ( body is not null && rend.Model != body ) rend.Model = body;

			if ( dresser.IsValid() )
			{
				// ⚠️ MANUAL, AND HANDED THE AVATAR RESOLVED ABOVE, rather than left to read the network owner itself
				// (`OwnerConnection`): solo has no network owner, and here the owner comes the way every "whose body" answer
				// does. Set before it is switched on, so it never dresses from the other source first.
				if ( avatar is not null ) dresser.Source = Dresser.ClothingSource.Manual;
				dresser.Enabled = true;

				// ⚠️ RE-DRESSED EVERY TIME, because the clothing is fitted to the body just set. Appearance first (height, skin,
				// eyes, tints), then the outfit, downloading what this machine lacks: a teammate's workshop clothing is not on
				// your disk until it is worn in front of you.
				if ( avatar is not null )
				{
					dresser.UpdateAppearance( avatar );
					_ = dresser.ApplyAsync( avatar );
				}
			}

			// ⚠️ SAID ONCE PER DRESSING (2026-10-05): whose avatar went on this body, and its print (`DescribeOutfit`), so a
			// game with friends shows on every machine who is wearing what. Not while it can't be read: that retries every tick.
			if ( avatar is not null )
			{
				var isMine = !Networking.IsActive || PlayerPresence.Mine( player.GameObject );
				var whose = isMine ? "your own" : $"{OwnerConnection( player.GameObject )?.DisplayName ?? "an unknown player"}'s";
				Log.Info( $"[nz-avatar] '{player.GameObject.Name}' wears {whose} avatar · {DescribeOutfit( avatar, isMine )}" );
			}

			// ⚠️ NOT MARKED DRESSED UNTIL THE AVATAR COULD BE READ, so `NZPlayers.RefreshBodies` tries again on its next tick: a
			// teammate whose connection this machine has not heard of yet would otherwise stay undressed for good.
			player.BodyLook = avatar is null ? null : "";
			return;
		}

		var m = Model.Load( c.Body );

		if ( m is null || m.IsError )
		{
			Log.Warning( $"[nz-char] body '{c.Body}' is"
				+ $" {(m is null ? "missing" : "the error model")}" );
			return;
		}

		// ⚠️ THE AVATAR'S OUTFIT COMES OFF BEFORE THE CHARACTER GOES ON, AND THE DRESSER GOES QUIET. The wardrobe is fitted to
		// the Citizen and Human skeletons, not to a character's body. Taken off explicitly (`Clear`) rather than trusting that
		// switching the Dresser off removes what it already made. `Clear` keeps the avatar's appearance values (height among
		// them); the engine's full reset is private.
		if ( dresser.IsValid() )
		{
			dresser.Clear();
			dresser.Enabled = false;
		}

		rend.Model = m;
		player.BodyLook = c.Id;
	}

	/// <summary>
	/// `nz_thirdperson [0/1]` — look at your own body.
	///
	/// ⚠️ IT REPORTS WHAT YOU WILL BE LOOKING AT, because a retargeted body and a static one look
	/// identical standing still. If it says `citizen` you are about to inspect the wrong model.
	/// </summary>
	[ConCmd( "nz_thirdperson" )]
	public static void ThirdPersonCmd( int on = -1 )
	{
		NZPlayer.ThirdPerson = on < 0 ? !NZPlayer.ThirdPerson : on != 0;

		var player = Local();

		// ⛔ WRITE THE CONTROLLER HERE, NOT JUST THE FLAG. `ApplyConfig` is the only thing that pushes
		// it across and it runs on config load — not per tick — so setting the static alone changed
		// nothing until the next config load. The flag exists so `ApplyConfig` does not undo this;
		// it is not what applies it.
		var c = player.IsValid()
			? player.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants )
			: null;

		if ( c.IsValid() ) c.ThirdPerson = NZPlayer.ThirdPerson;
		else Log.Warning( "[nz] no PlayerController — the camera will not move" );
		var who = Find( player.IsValid() ? player.CharacterId : null );

		var body = player.IsValid()
			? player.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants )
				?.Renderer?.Model?.ResourcePath ?? "none"
			: "no player";

		Log.Info( $"[nz] third person {(NZPlayer.ThirdPerson ? "ON" : "off")}"
			+ $" · {who?.Name ?? "no character"} · body '{body}'" );

		if ( NZPlayer.ThirdPerson )
			Log.Info( "[nz]   the viewmodel is hidden while this is on — `nz_thirdperson 0` to return" );
	}

	/// <summary>
	/// Bumped whenever anyone's character changes, so views showing a body know to rebuild.
	///
	/// ⛔ THE LOBBY PREVIEW CHOOSES ITS MODEL WHEN THE BODY IS CREATED and nothing re-reads it, so a
	/// character change has to invalidate the stage rather than expect it to notice. `PickCharacter`
	/// did that by hand, which left the CONSOLE path — `nz_character` — silently stale: the character
	/// changed, hands and voice followed, and the preview kept showing a Citizen.
	///
	/// ⚠️ A COUNTER RATHER THAN AN EVENT, because the reader is a UI tick that is already comparing a
	/// staged player count. One more comparison costs nothing and cannot leak a subscription.
	/// </summary>
	public static int Revision;

	/// <summary>`nz_character [id]` — read or set who you are playing.</summary>
	[ConCmd( "nz_character" )]
	public static void CharacterCmd( string id = "" )
	{
		var player = Local();
		if ( !player.IsValid() ) { Log.Warning( "[nz-char] no player" ); return; }

		if ( string.IsNullOrWhiteSpace( id ) )
		{
			var cur = Find( player.CharacterId );

			Log.Info( $"[nz-char] you are: {cur?.Name ?? "nobody (weapon default hands)"}" );

			foreach ( var c in Everyone )
			{
				var m = Model.Load( c.Arms );

				Log.Info( $"[nz-char]   {c.Id,-10} {c.Name,-20}"
					+ $" arms {(m is null ? "MISSING" : m.IsError ? "ERROR MODEL" : "ok")}"
					+ (c.Id == player.CharacterId ? "   <-- you" : "") );
			}

			Log.Info( "[nz-char] nz_character <id>, or `none` to go back to weapon default" );
			return;
		}

		if ( id.Equals( "none", StringComparison.OrdinalIgnoreCase ) )
		{
			player.CharacterId = null;
			Revision++;
			Log.Info( "[nz-char] cleared - weapons use their own hands again" );
			player.RefreshCharacterHands();
			ApplyBody( player );
			return;
		}

		var pick = Find( id );

		if ( pick is null )
		{
			Log.Warning( $"[nz-char] '{id}' is not a character. Try: "
				+ string.Join( ", ", Everyone.Select( c => c.Id ) ) );
			return;
		}

		player.CharacterId = pick.Id;
		Revision++;
		Log.Info( $"[nz-char] you are now {pick.Name}" );

		player.RefreshCharacterHands();
		ApplyBody( player );
	}
}