Player/CharacterVoice.cs

Static class that manages player character voice lines and remote playback. It defines situations with chance, cooldown and priority, enforces a global gap and per-situation cooldowns, chooses and plays local cues, forwards resolved cues to other clients, and plays positioned remote voice lines. Includes console commands for diagnostics and live tuning.

NetworkingFile Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// The crew's voice lines. 526 of them, four characters, nineteen situations.
///
/// ⛔ ONE LINE AT A TIME, GLOBALLY. Your character has one mouth; two cues playing together is not
/// twice the character, it is unintelligible. A kill quip and a "no ammo" line landing in the same
/// frame is not a corner case — it is what happens the moment a magazine runs dry mid-horde.
///
/// ⛔ AND THE LOUDER EVENT WINS. Without priority, a 6%-chance kill quip occupies the mouth and the
/// player goes down in silence a tenth of a second later. Powerups, downs and revives outrank
/// chatter and cut it off; chatter never interrupts anything.
///
/// ⚠️ PER-SITUATION CHANCE, NOT ONE GLOBAL RATE. A Max Ammo is rare and should ALWAYS be remarked
/// on; a zombie kill happens hundreds of times a round and must not be. One rate makes the rare
/// events feel broken or the common ones unbearable.
///
/// ⚠️ 2D, NOT POSITIONAL — it is your own character speaking. Same reasoning as Brutus's helmet
/// break: you do not hear yourself from three feet away.
///
/// ⚠️ A STATIC CLASS, NOT A `Component`. Nothing here has per-instance state or needs a tick, and
/// deriving from `Component` made `Enabled` hide `Component.Enabled` — two properties one letter
/// apart, where setting the wrong one silently does nothing.
/// </summary>
public static class CharacterVoice
{
	/// <summary>How likely a situation is to be remarked on, and how long before it may repeat.</summary>
	public readonly record struct Line( string Type, float Chance, float Cooldown, int Priority );

	public const int Chatter = 0;
	public const int Event = 1;
	public const int Urgent = 2;

	/// <summary>
	/// The nineteen situations.
	///
	/// ⚠️ THE NUMBERS ARE CHOSEN, NOT PORTED. The TFA VOX pack's Lua is compressed and unreadable,
	/// so its real rates could not be recovered. These respect how often each situation actually
	/// occurs; `nz_voice_set` retunes any of them live.
	/// </summary>
	public static readonly List<Line> Lines = new()
	{
		// Rare, deliberate moments — always worth a line.
		new( "nuke", 1.00f, 0f, Event ),
		new( "instakill", 1.00f, 0f, Event ),
		new( "doublepoints", 1.00f, 0f, Event ),
		new( "maxammo", 1.00f, 0f, Event ),
		new( "firesale", 1.00f, 0f, Event ),
		new( "carpenter", 1.00f, 0f, Event ),
		new( "poweron", 1.00f, 0f, Event ),
		new( "upgrading", 1.00f, 0f, Event ),
		new( "perkdrink", 1.00f, 0f, Event ),

		// Life and death outranks everything.
		new( "downed", 1.00f, 0f, Urgent ),
		new( "revived", 1.00f, 0f, Urgent ),
		new( "reviving", 1.00f, 4f, Urgent ),

		// Constant occurrences — rationed hard.
		new( "kill", 0.06f, 7f, Chatter ),
		new( "pain", 0.20f, 6f, Chatter ),
		new( "surrounded", 0.50f, 20f, Chatter ),
		new( "noammo", 0.30f, 8f, Chatter ),
		new( "nomoney", 0.35f, 5f, Chatter ),
		new( "pickup", 0.35f, 5f, Chatter ),
		new( "doground", 0.15f, 10f, Chatter ),
	};

	static int IndexOfType( string type )
	{
		for ( var i = 0; i < Lines.Count; i++ )
			if ( Lines[i].Type.Equals( type, StringComparison.OrdinalIgnoreCase ) ) return i;

		return -1;
	}

	// ⚠️ STATIC, AND CORRECT HERE: tuning and playback bookkeeping for the LOCAL player's own voice,
	// not state a player owns in the `SERVER_ROADMAP.md` §4 sense. Split-screen would need it
	// per-player; nothing else would.
	/// <summary>
	/// Shortest gap between ANY two chatter lines, whatever their situation.
	///
	/// ⛔ THE PER-SITUATION COOLDOWNS ARE NOT ENOUGH ON THEIR OWN, AND THAT IS WHAT WENT WRONG.
	/// Every one of them worked — the counters showed cooldown and busy skips both firing — but a
	/// kill, a pickup, a grunt, a "no ammo" and a barricade repair each have their OWN key, so eight
	/// situations can every one be within its limit while the player hears someone talking
	/// constantly. Sixteen lines in a short session, none of them a bug.
	///
	/// ⚠️ EVENTS AND URGENT LINES IGNORE IT. A Max Ammo or going down is rare and load-bearing; making
	/// those wait behind a kill quip is the opposite of the priority system.
	/// </summary>
	/// <remarks>
	/// ⛔ 15s, AND IT NOW APPLIES TO EVERY LINE RATHER THAN TO CHATTER ONLY. The exemption for
	/// events and urgent lines is gone deliberately: it was the remaining way a character could
	/// speak twice in quick succession, which is the thing being removed.
	///
	/// ⚠️ SO A DOWN OR A REVIVE CAN NOW BE SWALLOWED. Go down eight seconds after a kill quip and
	/// the character says nothing — those lines used to bypass this on purpose, being rare and
	/// load-bearing. If that reads wrong in play, `nz_voice_gap` changes it live, and the exemption
	/// is one condition to restore. This is the trade the rule asks for, not an oversight.
	/// </remarks>
	/// <remarks>
	/// ⛔ NULLABLE-BACKED, AND A PLAIN `= 15f` DID NOT WORK. A static's VALUE survives a hotload but
	/// its INITIALISER does not re-run, so raising this from 12 left the running editor still
	/// reporting the old number while the source said 15 — the change appearing to do nothing, on a
	/// setting whose whole job is to be felt. `VoiceRangeScale`, `SoundGate.Policies` and
	/// `AmbientBudget` all carry the same defence, and this is at least the fourth time this trap
	/// has cost time in this project.
	/// </remarks>
	public static float GlobalGap
	{
		get => _globalGap ??= 15f;
		set => _globalGap = value;
	}

	static float? _globalGap;

	static float _nextAnyChatter;

	static readonly Dictionary<string, float> _nextAllowed = new();
	static SoundHandle _current;
	static float _busyUntil;

	/// <summary>Suppress every voice line. `nz_voice_off`.</summary>
	public static bool Enabled { get; set; } = true;

	public static int Played, SkippedChance, SkippedCooldown, SkippedBusy, SkippedNoCharacter;
	public static int SkippedGlobal;
	public static int FailedHandles;
	public static string LastFailed = "-";
	public static string LastSaid = "-";

	/// <summary>
	/// Say something, if this character has anything to say about it and the moment allows.
	///
	/// ⚠️ IT TAKES THE SITUATION, NOT A SOUND NAME. The caller says what happened; which of four
	/// voices, which of fifteen takes, and whether to speak at all are decided here. A call site
	/// naming a cue would have to know the character, and every one would need editing to add a
	/// fifth.
	/// </summary>
	public static void Say( string type, NZPlayer player = null )
	{
		if ( !Enabled ) return;

		// ⚠️ VIA `PlayerCharacters.Local`, which sees a DISABLED player. In the lobby the player
		// object is switched off, and the plain scene query returns nothing there.
		player ??= PlayerCharacters.Local();

		// ⛔ SOMEBODY ELSE'S MOUTH IS NOT MINE TO OPEN. Half of this method reads state that only
		// exists on the speaker's own machine — which character they picked, which of their
		// cooldowns are spent, whether they are already mid-sentence — and a proxy answers all
		// three wrong. The visible symptom was the kill quip: `AwardKillPoints` runs on the HOST for
		// every kill in the game, so the host's character chirped for a client's kills and the
		// client's own character never said a word.
		//
		// ⚠️ AN ASK, NOT A COMMAND. The owner still decides whether to speak at all; this only
		// moves the question to the machine that can answer it.
		if ( Networking.IsActive && player.IsValid()
			&& PlayerPresence.Theirs( player.GameObject ) )
		{
			var them = NZPlayers.OwnerOf( player.GameObject );
			if ( !string.IsNullOrEmpty( them ) ) NZNet.VoiceAsk( them, type );

			return;
		}

		var who = PlayerCharacters.Find( player.IsValid() ? player.CharacterId : null );

		// ⚠️ SILENCE, NOT A DEFAULT VOICE. Nobody picked a character, so nobody speaks.
		if ( who is null ) { SkippedNoCharacter++; return; }

		var idx = IndexOfType( type );
		if ( idx < 0 ) return;

		var line = Lines[idx];

		// ⛔ THE BUSY CHECK COMES BEFORE THE DICE. Rolling first would burn the cooldown on a line
		// that was never going to be heard, silently spending a rare event while someone else talks.
		//
		// ⛔ AND PRIORITY NO LONGER OVERRIDES IT. `line.Priority > _currentPriority` used to let an
		// important line through here, and the code below then STOPPED the sentence in progress to
		// make room — a character cut off mid-word. One mouth now means one mouth: whatever is
		// speaking finishes, and the new line is refused rather than granted at its expense.
		//
		// ⚠️ THE HANDLE IS ASKED, NOT A TIMER TRUSTED. `_busyUntil` is a flat 2.5s guess made
		// because the clip length is not known here; `SoundHandle.IsPlaying` is the real answer and
		// costs nothing. The timer stays as the cover for the frame a sound is starting in.
		if ( _current.IsValid() && _current.IsPlaying )
		{
			SkippedBusy++;
			return;
		}

		if ( Time.Now < _busyUntil )
		{
			SkippedBusy++;
			return;
		}

		// ⛔ THE GLOBAL GAP, NOW ON EVERY LINE. Checked before the per-situation cooldown because it
		// is the stricter of the two and the cheaper to answer. See `GlobalGap` for what the loss of
		// the chatter-only exemption costs.
		if ( Time.Now < _nextAnyChatter )
		{
			SkippedGlobal++;
			return;
		}

		var key = $"{who.Id}.{type}";

		if ( _nextAllowed.TryGetValue( key, out var next ) && Time.Now < next )
		{
			SkippedCooldown++;
			return;
		}

		if ( line.Chance < 1f && Game.Random.Float() > line.Chance )
		{
			SkippedChance++;
			return;
		}

		// ⛔ NOTHING IS STOPPED HERE ANY MORE. This used to be `_current.Stop()` — "one mouth",
		// enforced by cutting the sentence already coming out of it. The busy check above now owns
		// that rule and enforces it by refusing the new line instead, so reaching this point means
		// the previous one has genuinely finished.
		var cue = $"vo.primis.{who.Id}.{type}";

		// ⛔ FETCH THE RESOURCE, DO NOT PLAY THE NAME. `Sound.Play( "vo.primis.dempsey.nuke" )`
		// returned a DEAD HANDLE while the asset existed, compiled, and was findable by path — the
		// bare-name lookup does not resolve these. `NZSound` already works this way at its own call
		// site for the same reason. Measured, not guessed: the handle-validity counter below is what
		// separated "the cue is missing" from "the name form is wrong".
		//
		// ⚠️ AND THE PATH IS `sounds/vo/`, NOT `sounds/nz/`. NZSound hardcodes the latter, which is
		// why these could not simply go through it.
		if ( !ResourceLibrary.TryGet<SoundEvent>( $"sounds/vo/{cue}.sound", out var ev ) )
		{
			FailedHandles++;
			LastFailed = cue;
			return;
		}

		_current = Sound.Play( ev );

		if ( !_current.IsValid() ) { FailedHandles++; LastFailed = cue; }

		// ⛔ AND NOW EVERYBODY ELSE HEARS IT. Voice lines have never left the machine that spoke
		// them, so a co-op crew of four was four people talking to themselves — no "I'm down!", no
		// "reviving you", which are the two lines the whole downed system is built around.
		//
		// ⚠️ THE RESOLVED CUE TRAVELS, NOT THE SITUATION. Which character, which take and whether
		// to speak were all just decided here; re-deciding on each receiver would give one player
		// four different voices depending on who was listening.
		if ( Networking.IsActive && player.IsValid() )
		{
			var mine = NZPlayers.OwnerOf( player.GameObject );
			if ( !string.IsNullOrEmpty( mine ) ) NZNet.VoiceLine( mine, cue, line.Priority );
		}

		// ⚠️ A FLAT HOLD, BECAUSE THE CLIP LENGTH IS NOT KNOWN HERE. Most run 1-3 seconds; 2.5 keeps
		// lines off each other without per-clip durations we would have to measure and then keep in
		// step with the assets.
		_busyUntil = Time.Now + 2.5f;

		if ( line.Cooldown > 0f ) _nextAllowed[key] = Time.Now + line.Cooldown;

		// ⚠️ EVERY LINE PUSHES THE GAP OUT, which is now the same rule on the way in and the way
		// out. It used to be stamped by all lines but honoured only by chatter.
		_nextAnyChatter = Time.Now + GlobalGap;

		Played++;
		LastSaid = key;
	}

	/// <summary>
	/// One speaker's playback state on THIS machine. Not their cooldowns — only their mouth.
	///
	/// ⛔ SEPARATE FROM THE LOCAL STATICS ABOVE, WHICH IS THE POINT. `_busyUntil` and friends are
	/// the DECISION state: my dice, my cooldowns, my sentence in progress. If a teammate speaking
	/// wrote to those, their line would silence mine and eat my global chatter gap — four players
	/// would share one turn to talk. A remote line needs exactly one rule, "one voice per person",
	/// and that is all this holds.
	/// </summary>
	sealed class RemoteMouth
	{
		public SoundHandle Handle;
		public float BusyUntil;
		public int Priority = -1;
	}

	static readonly Dictionary<string, RemoteMouth> _remote = new();

	/// <summary>
	/// Somebody else said something. Play it where they are standing.
	///
	/// ⚠️ POSITIONED, UNLIKE THE LOCAL LINE. Your own character is inside your head and plays
	/// flat; a teammate shouting "I'm down!" is only useful if it tells you WHERE, and 2D audio for
	/// a voice coming from across the map is worse than silence — it reads as your own character.
	///
	/// ⚠️ THE SAME PRIORITY RULE AS THE LOCAL MOUTH, kept here rather than sent: an urgent line
	/// cuts off a quip, a quip never cuts off an urgent line, one voice per person.
	/// </summary>
	public static void PlayRemote( string ownerId, string cue, int priority )
	{
		if ( !Enabled || string.IsNullOrEmpty( cue ) ) return;

		var body = NZPlayers.BodyOf( ownerId );
		if ( !body.IsValid() ) return;

		if ( !_remote.TryGetValue( ownerId, out var mouth ) )
			_remote[ownerId] = mouth = new RemoteMouth();

		if ( Time.Now < mouth.BusyUntil && priority <= mouth.Priority ) { SkippedBusy++; return; }

		if ( !ResourceLibrary.TryGet<SoundEvent>( $"sounds/vo/{cue}.sound", out var ev ) )
		{
			FailedHandles++;
			LastFailed = cue;
			return;
		}

		if ( mouth.Handle.IsValid() ) mouth.Handle.Stop();

		mouth.Handle = Sound.Play( ev, body.WorldPosition + Vector3.Up * 48f );
		mouth.Priority = priority;
		mouth.BusyUntil = Time.Now + 2.5f;

		if ( !mouth.Handle.IsValid() ) { FailedHandles++; LastFailed = cue; return; }

		Played++;
		LastSaid = $"{cue} (remote)";
	}

	// ── diagnostics ─────────────────────────────────────────────────────────────────────────

	/// <summary>`nz_voice [situation]` — say one now, or list every situation and its tuning.</summary>
	[ConCmd( "nz_voice" )]
	public static void VoiceCmd( string type = "" )
	{
		var player = PlayerCharacters.Local();
		var who = PlayerCharacters.Find( player.IsValid() ? player.CharacterId : null );

		if ( string.IsNullOrWhiteSpace( type ) )
		{
			Log.Info( $"[nz-voice] speaking as: {who?.Name ?? "NOBODY — pick one with nz_character"}"
				+ $" · voice {(Enabled ? "on" : "OFF")} · global gap {GlobalGap:0.#}s" );

			Log.Info( $"[nz-voice] played {Played} · skipped {SkippedChance} chance /"
				+ $" {SkippedCooldown} cooldown / {SkippedBusy} busy /"
				+ $" {SkippedGlobal} global-gap / {SkippedNoCharacter} no-character"
				+ $" · last '{LastSaid}'" );

			if ( FailedHandles > 0 )
				Log.Warning( $"[nz-voice] ⛔ {FailedHandles} cue(s) returned a DEAD HANDLE"
					+ $" — last '{LastFailed}' does not resolve to a sound event" );

			foreach ( var l in Lines.OrderByDescending( l => l.Priority ).ThenBy( l => l.Type ) )
				Log.Info( $"[nz-voice]   {l.Type,-13} {l.Chance * 100f,4:0}%"
					+ $" · {(l.Cooldown > 0f ? $"{l.Cooldown:0}s" : "none"),5} cooldown"
					+ $" · {(l.Priority == Urgent ? "urgent" : l.Priority == Event ? "event" : "chatter")}" );

			return;
		}

		var idx = IndexOfType( type );

		if ( idx < 0 )
		{
			Log.Warning( $"[nz-voice] '{type}' is not a situation. Try: "
				+ string.Join( ", ", Lines.Select( l => l.Type ) ) );
			return;
		}

		// ⛔ BYPASSES CHANCE AND COOLDOWN, AND RESTORES THEM AFTER. A test that rolled the dice is
		// useless for hearing a 6% line, and permanently clearing them would corrupt the very tuning
		// you are testing.
		var before = Played;
		var saved = Lines[idx];

		// ⚠️ THE GLOBAL GAP IS CLEARED TOO, AND IT HAD TO BE. This command exists to make a line play
		// on demand; with the gap now applying to EVERY line rather than to chatter, leaving it set
		// would have `nz_voice <type>` silently refuse for up to fifteen seconds after any line —
		// a test command that reports nothing and does nothing.
		_busyUntil = 0f;
		_nextAnyChatter = 0f;
		_nextAllowed.Remove( $"{who?.Id}.{type}" );

		Lines[idx] = saved with { Chance = 1f, Cooldown = 0f };
		Say( type, player );
		Lines[idx] = saved;

		Log.Info( Played > before
			? $"[nz-voice] {who?.Name} says '{type}'"
			: "[nz-voice] nothing played — is a character selected? (nz_character)" );
	}

	/// <summary>`nz_voice_gap &lt;seconds&gt;` — shortest gap between any two chatter lines.</summary>
	/// <summary>`nz_voice_gap [seconds]` — the minimum silence between ANY two lines from one
	/// character. See <see cref="GlobalGap"/> for why it is no longer chatter-only.</summary>
	[ConCmd( "nz_voice_gap" )]
	public static void GapCmd( float seconds = -1f )
	{
		if ( seconds >= 0f ) GlobalGap = seconds;

		Log.Info( $"[nz-voice] minimum {GlobalGap:0.#}s between ANY two lines, one at a time"
			+ " - nothing is exempt, and nothing interrupts" );
	}

	/// <summary>`nz_voice_set &lt;situation&gt; &lt;chance 0-1&gt; [cooldown]` — retune live.</summary>
	[ConCmd( "nz_voice_set" )]
	public static void SetCmd( string type = "", float chance = -1f, float cooldown = -1f )
	{
		var idx = IndexOfType( type );

		if ( idx < 0 ) { Log.Warning( $"[nz-voice] '{type}' is not a situation" ); return; }

		Lines[idx] = Lines[idx] with
		{
			Chance = chance >= 0f ? MathX.Clamp( chance, 0f, 1f ) : Lines[idx].Chance,
			Cooldown = cooldown >= 0f ? cooldown : Lines[idx].Cooldown,
		};

		Log.Info( $"[nz-voice] {Lines[idx].Type} = {Lines[idx].Chance * 100f:0}%"
			+ $" · {Lines[idx].Cooldown:0}s cooldown" );
	}

	/// <summary>
	/// `nz_sound_test &lt;asset path&gt;` — play any sound event and report exactly what came back.
	///
	/// ⛔ A CONTROL, NOT A FEATURE. A voice cue produced a dead handle from an asset that exists and
	/// compiles, which has two very different explanations: the audio path is broken for everything,
	/// or it is broken for these files specifically. Playing a KNOWN-WORKING event through the same
	/// line separates them, and nothing else does.
	/// </summary>
	[ConCmd( "nz_sound_test" )]
	public static void SoundTest( string path = "sounds/nz/nz.brutus.helmet.sound" )
	{
		// ⛔ THE WHOLE BODY IS GUARDED. Every previous version died with "Exception when calling
		// command", which is the least useful sentence the console can print — it names neither the
		// throw nor the line. A diagnostic must survive the case it exists to diagnose, and report
		// what it found.
		try
		{
			if ( !ResourceLibrary.TryGet<SoundEvent>( path, out var ev ) )
			{
				Log.Warning( $"[nz-sound] no SoundEvent at '{path}'" );
				return;
			}

			int count;
			try { count = ev.Sounds?.Count ?? 0; }
			catch { count = -1; }

			Log.Info( $"[nz-sound] '{path}' · {(count < 0 ? "UNREADABLE" : count.ToString())} sample(s)" );

			// ⚠️ PLAYED SEPARATELY, AFTER REPORTING THE COUNT. `Sound.Play` is what throws on an event
			// whose samples are broken, so doing it first loses the count as well.
			var h = Sound.Play( ev );

			Log.Info( $"[nz-sound]   handle {(h.IsValid() ? "VALID" : "DEAD")}" );
		}
		catch ( System.Exception e )
		{
			Log.Warning( $"[nz-sound] ⛔ threw: {e.GetType().Name}: {e.Message}" );
			Log.Warning( "[nz-sound]   a stale resource is the usual cause — restart play mode so the"
				+ " recompiled .sound and .vsnd files reload" );
		}
	}


	/// <summary>`nz_voice_off` — silence the crew.</summary>
	[ConCmd( "nz_voice_off" )]
	public static void Off() { Enabled = false; Log.Info( "[nz-voice] OFF" ); }

	/// <summary>`nz_voice_on` — restore them.</summary>
	[ConCmd( "nz_voice_on" )]
	public static void On() { Enabled = true; Log.Info( "[nz-voice] on" ); }
}