RoundManager.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

public enum SessionMode
{
	Menu,
	Match,
	Sandbox
}

public enum RoundPhase
{
	Waiting,
	Countdown,
	Fighting,
	RoundEnd,

	WinnerDecision,
	ModifierDecision,

	MatchEnd
}

public enum RoundModifier
{
	Standard,
	HighNoon,

	// Phase 8D appends new values so existing serialized enum ordinals remain
	// stable for Standard/HighNoon in older scenes and resources.
	LowGravity,
	HeadshotOnly,

	// Phase 8E1 appends additional modular rules. Keep appending values so
	// serialized ordinals from existing scenes/resources remain stable.
	LargeCaliber,
	DoubleJump,
	HipfireOnly,
	Blitz
}

public enum HighNoonStage
{
	Inactive,
	WaitingForDraw,
	DrawOpen
}

public sealed partial class RoundManager : Component
{
	// ============================================================
	// REFERENCES
	// ============================================================

	[Property]
	public GameObject SpawnA { get; set; }

	[Property]
	public GameObject SpawnB { get; set; }

	[Property]
	public ArenaController Arena { get; set; }

	// ============================================================
	// PRE-MATCH LOBBY / WARMUP
	// ============================================================

	[Property, Group( "Lobby" )]
	public float LobbyWarmupRespawnDelay { get; set; } = 0.50f;

	// ============================================================
	// SANDBOX CONFIG
	// ============================================================

	/// <summary>
	/// Sandbox-only weapon catalog. Competitive rounds and lobby warmup use
	/// OMRRoundLoadoutDirector instead, so event-only weapons can remain
	/// available for testing without leaking into normal match rotation.
	/// </summary>
	[Property, Group( "Sandbox" )]
	public List<GameObject> SandboxWeapons { get; set; } =
		new();

	[Button, Title( "Validate Weapons" ), Description( "Validate the shared OMR weapon prefab catalog." )]
	public void ValidateWeaponCatalog()
	{
		int errors = 0;
		int warnings = 0;

		if (
			SandboxWeapons is null ||
			SandboxWeapons.Count == 0
		)
		{
			Log.Error(
				"WEAPON CATALOG | FAIL | No weapon prefabs are assigned."
			);

			return;
		}

		HashSet<string> ids =
			new(
				StringComparer.OrdinalIgnoreCase
			);

		Dictionary<OMRWeaponArchetype, int> classCounts =
			new();

		int validWeapons = 0;

		foreach (
			GameObject prefab in SandboxWeapons
		)
		{
			if (
				prefab is null ||
				!prefab.IsValid
			)
			{
				errors++;

				Log.Error(
					"WEAPON CATALOG | Missing/invalid prefab reference."
				);

				continue;
			}

			OneMoreRoundWeapon weapon =
				OMRWeaponUtility.GetWeapon(
					prefab
				);

			if ( weapon is null )
			{
				errors++;

				Log.Error(
					$"WEAPON CATALOG | '{prefab.Name}' has no OneMoreRoundWeapon component."
				);

				continue;
			}

			validWeapons++;

			weapon.ValidateWeaponSetupInternal(
				ref errors,
				ref warnings,
				prefab.Name
			);

			string weaponId =
				weapon.WeaponId?.Trim() ??
				string.Empty;

			if (
				!string.IsNullOrWhiteSpace(
					weaponId
				) &&
				!ids.Add(
					weaponId
				)
			)
			{
				errors++;

				Log.Error(
					$"WEAPON CATALOG | Duplicate WeaponId '{weaponId}'."
				);
			}

			if (
				!classCounts.TryGetValue(
					weapon.WeaponArchetype,
					out int count
				)
			)
			{
				count = 0;
			}

			classCounts[weapon.WeaponArchetype] =
				count + 1;
		}

		string classes =
			string.Join(
				", ",
				classCounts
					.OrderBy(
						x => x.Key.ToString()
					)
					.Select(
						x => $"{x.Key}:{x.Value}"
					)
			);

		if ( errors == 0 )
		{
			Log.Info(
				$"WEAPON CATALOG | PASS | Weapons:{validWeapons} | {classes} | Warnings:{warnings}"
			);
		}
		else
		{
			Log.Error(
				$"WEAPON CATALOG | FAIL | Weapons:{validWeapons} | Errors:{errors} | Warnings:{warnings}"
			);
		}
	}

	[Property, Group( "Sandbox" )]
	public float SandboxRespawnDelay { get; set; } = 0.75f;

	[Property, Group( "Sandbox / Spawn Safety" )]
	public bool SandboxSafeRespawns { get; set; } = true;

	[Property, Group( "Sandbox / Spawn Safety" )]
	public float SandboxSpawnDangerDistance { get; set; } = 360f;

	[Property, Group( "Sandbox / Spawn Safety" )]
	public float SandboxVisibleSpawnPenalty { get; set; } = 620f;

	[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox" )]
	public int SandboxWeaponAIndex { get; set; } = 0;

	[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox" )]
	public int SandboxWeaponBIndex { get; set; } = 0;

	// ============================================================
	// SANDBOX BOT HARNESS (BOT 0 -> BOT 6 ADAPTIVE)
	// ============================================================

	[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
	public bool SandboxBotEnabled { get; set; } = false;

	[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
	public OMRBotDifficulty SandboxBotDifficulty { get; set; } =
		OMRBotDifficulty.Normal;

	[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
	public OMRBotBehaviorMode SandboxBotBehavior { get; set; } =
		OMRBotBehaviorMode.TacticalCombat;

	[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
	public string SandboxBotName { get; set; } = "ROOK";

	[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
	public bool SandboxBotDrawNavMesh { get; set; } = false;

	/// <summary>
	/// BOT 6 sandbox-only round-rule selector. This lets the bot and the human
	/// tester exercise modifier-aware movement/combat before match bot integration
	/// exists. High Noon is intentionally excluded from the selector because its
	/// draw/lock flow belongs to the real match state machine.
	/// </summary>
	[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox / Bot" )]
	public RoundModifier SandboxBotTestModifier { get; set; } =
		RoundModifier.Standard;

	/// <summary>
	/// Development-only sandbox option for exercising normal inventory switching,
	/// equip/holster presentation and reload cancellation. Match and lobby loadouts
	/// are unaffected. Keep this disabled for normal OMR play.
	/// </summary>
	[Property, Group( "Sandbox / Inventory Testing" )]
	public bool EnableSandboxMultiWeaponTesting { get; set; } = false;

	/// <summary>
	/// Extra weapon prefabs granted after the normal selected sandbox weapon. The
	/// selected weapon is forced into slot 0; these are placed into slots 1, 2, ...
	/// so Slot1/Slot2/etc. are deterministic during testing.
	/// </summary>
	[Property, Group( "Sandbox / Inventory Testing" )]
	public List<GameObject> SandboxAdditionalWeapons { get; set; } = new();

	[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox" )]
	public int SandboxSpawnALayoutIndex { get; set; } = 0;

	[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox" )]
	public int SandboxCenterLayoutIndex { get; set; } = 0;

	[Property, Sync( SyncFlags.FromHost ), Group( "Sandbox" )]
	public int SandboxSpawnBLayoutIndex { get; set; } = 0;

	// ============================================================
	// MATCH CONFIG
	// ============================================================

	[Property]
	public float CountdownDuration { get; set; } = 3.0f;

	[Property]
	public float RoundEndDuration { get; set; } = 2.0f;

	[Property]
	public float DecisionDuration { get; set; } = 10.0f;

	/// <summary>
	/// Maximum duration of normal combat before health decides
	/// the round.
	/// </summary>
	[Property]
	public float FightDuration { get; set; } = 60.0f;

	/// <summary>
	/// Extra time granted only when both players finish normal
	/// combat at full health.
	///
	/// During this phase any weapon hit is lethal.
	/// </summary>
	[Property]
	public float SuddenDeathDuration { get; set; } = 10.0f;

	/// <summary>
	/// Small tolerance used when comparing synchronized health
	/// values at timeout.
	/// </summary>
	[Property]
	public float HealthTieTolerance { get; set; } = 0.01f;

	// ============================================================
	// HIGH NOON CONFIG
	// ============================================================

	/// <summary>
	/// Random delay after the normal round countdown before DRAW opens.
	/// </summary>
	[Property, Group( "High Noon" ), Range( 0.1f, 3f )]
	public float HighNoonMinDrawDelay { get; set; } = 0.70f;

	[Property, Group( "High Noon" ), Range( 0.1f, 3f )]
	public float HighNoonMaxDrawDelay { get; set; } = 1.60f;

	/// <summary>
	/// Time available to fire after DRAW before the duel becomes a draw.
	/// </summary>
	[Property, Group( "High Noon" ), Range( 1f, 20f )]
	public float HighNoonDrawDuration { get; set; } = 10.0f;

	/// <summary>
	/// Small host adjudication window after the first reported trigger. This lets
	/// both synchronized Time.Now reaction timestamps arrive before choosing a
	/// winner, rather than treating RPC arrival order as reaction speed.
	/// </summary>
	[Property, Group( "High Noon" ), Range( 0.02f, 0.5f )]
	public float HighNoonAdjudicationWindow { get; set; } = 0.15f;

	/// <summary>
	/// Trigger times this close are considered simultaneous and resolve as a draw.
	/// </summary>
	[Property, Group( "High Noon" ), Range( 0f, 0.05f )]
	public float HighNoonTieTolerance { get; set; } = 0.015f;

	/// <summary>
	/// First player to BANK this many points wins the match.
	/// </summary>
	[Property]
	public int TargetScore { get; set; } = 7;

	/// <summary>
	/// Maximum amount of unbanked points a player may hold.
	///
	/// Reaching this value automatically banks the pot.
	/// </summary>
	[Property]
	public int MaxPot { get; set; } = 7;

	/// <summary>
	/// Extra unbanked points awarded when the current pot owner wins after
	/// choosing ONE MORE ROUND.
	///
	/// With InitialPot = 1 and this value at 2, the risk ladder is:
	/// 1 -> 3 -> 5 -> 7.
	/// </summary>
	[Property, Range( 1, 10 )]
	public int OneMoreRoundPotGain { get; set; } = 2;

	// ============================================================
	// SESSION STATE
	// ============================================================

	[Property, Sync( SyncFlags.FromHost )]
	public SessionMode Mode { get; set; } =
		SessionMode.Menu;

	[Property, Sync( SyncFlags.FromHost )]
	public RoundPhase Phase { get; set; } =
		RoundPhase.Waiting;

	// ============================================================
	// ROUND STATE
	// ============================================================

	[Property, Sync( SyncFlags.FromHost )]
	public int RoundNumber { get; set; } = 1;

	[Property, Sync( SyncFlags.FromHost )]
	public int ScoreA { get; set; } = 0;

	[Property, Sync( SyncFlags.FromHost )]
	public int ScoreB { get; set; } = 0;

	[Sync( SyncFlags.FromHost )]
	public float PhaseEndsAt { get; set; }

	[Sync( SyncFlags.FromHost )]
	public float FightMessageEndsAt { get; set; }


	/// <summary>
	/// Absolute time when the current combat phase ends.
	///
	/// During normal combat this is the 60 second round timer.
	/// During sudden death it becomes the 10 second one-shot timer.
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public float FightEndsAt { get; set; }

	/// <summary>
	/// True only during the special full-health tie breaker.
	/// </summary>
	[Property, Sync( SyncFlags.FromHost )]
	public bool IsSuddenDeath { get; set; }

	/// <summary>
	/// Network-visible High Noon sub-state. Time.Now is synchronized, so clients
	/// can present the same DRAW moment from HighNoonDrawAt.
	/// </summary>
	[Property, Sync( SyncFlags.FromHost )]
	public HighNoonStage HighNoonState { get; set; } =
		HighNoonStage.Inactive;

	[Sync( SyncFlags.FromHost )]
	public float HighNoonDrawAt { get; set; }

	[Sync( SyncFlags.FromHost )]
	public float HighNoonFirstTriggerAt { get; set; }

	[Sync( SyncFlags.FromHost )]
	public int HighNoonFirstTriggerTeam { get; set; } = -1;

	[Sync( SyncFlags.FromHost )]
	public int LastRoundWinner { get; set; } = -1;

	// ============================================================
	// ONE MORE ROUND STATE
	// ============================================================

	/// <summary>
	/// Amount currently earned but not safely banked.
	/// </summary>
	[Property, Sync( SyncFlags.FromHost )]
	public int CurrentPot { get; set; } = 0;

	/// <summary>
	/// Team currently holding the unbanked pot.
	///
	/// -1 = nobody
	///  0 = Player A
	///  1 = Player B
	/// </summary>
	[Property, Sync( SyncFlags.FromHost )]
	public int PotOwnerTeam { get; set; } = -1;

	/// <summary>
	/// Absolute time when the current decision expires.
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public float DecisionEndsAt { get; set; }

	// ============================================================
	// MODIFIER STATE
	// ============================================================

	/// <summary>
	/// Modifier being used by the current combat round.
	/// </summary>
	[Property, Sync( SyncFlags.FromHost )]
	public RoundModifier ActiveModifier { get; set; } =
		RoundModifier.Standard;

	/// <summary>
	/// Two possible next-round conditions revealed to both players before the
	/// winner chooses BANK or ONE MORE ROUND. If the winner risks the pot, the
	/// loser chooses which one becomes active.
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public RoundModifier ModifierOptionA { get; set; } =
		RoundModifier.HighNoon;

	[Sync( SyncFlags.FromHost )]
	public RoundModifier ModifierOptionB { get; set; } =
		RoundModifier.Standard;

	/// <summary>
	/// Host-resolved weapon previews for the two revealed OMR conditions.
	/// These strings are synchronized so clients never run their own random
	/// shuffle merely to render UI.
	/// </summary>
	[Sync( SyncFlags.FromHost )]
	public string ModifierOptionAWeaponPreview { get; set; } =
		"RANDOM COMPETITIVE WEAPON";

	[Sync( SyncFlags.FromHost )]
	public string ModifierOptionBWeaponPreview { get; set; } =
		"RANDOM COMPETITIVE WEAPON";

	// ============================================================
	// MATCH END
	// ============================================================

	/// <summary>
	/// -1 = no match winner
	///  0 = Player A
	///  1 = Player B
	/// </summary>
	[Property, Sync( SyncFlags.FromHost )]
	public int MatchWinnerTeam { get; set; } = -1;

	// ============================================================
	// LOCAL INTERNAL STATE
	// ============================================================

	private bool _sandboxPrepared = false;

	// GameObject.Id is used instead of Network.OwnerId because host-controlled
	// bots legitimately share the host connection's owner id.
	private readonly HashSet<Guid> _sandboxInitializedPlayers =
		new();

	private readonly Dictionary<Guid, float> _sandboxRespawnAt =
		new();

	private readonly Dictionary<Guid, int> _sandboxLastSpawnSide =
		new();

	private readonly HashSet<Guid> _lobbyWarmupInitializedPlayers =
		new();

	private readonly Dictionary<Guid, float> _lobbyWarmupRespawnAt =
		new();

	private readonly Dictionary<int, float> _highNoonTriggerTimes =
		new();

	private float _highNoonAdjudicateAt;

	// Host-side copies of temporary round weapon modifiers. Held weapons are
	// owner-simulated, but the host still owns authoritative ammo/reset state.
	// Track exactly which round-rule IDs were applied here so they can be removed
	// without disturbing attachment/perk modifiers from other systems.
	private bool _modifierOffersPrepared;

	private readonly Dictionary<PlayerState, OneMoreRoundWeapon> _hostRoundRuleWeapons =
		new();

	private readonly Dictionary<PlayerState, HashSet<string>> _hostRoundRuleWeaponModifierIds =
		new();

	// ============================================================
	// LIFECYCLE
	// ============================================================

	protected override void OnStart()
	{
		if ( !Networking.IsHost )
			return;

		EnterMenu();
	}

	protected override void OnUpdate()
	{
		if ( !Networking.IsHost )
			return;

		List<PlayerState> players =
			GetPlayers();


		switch ( Mode )
		{
			case SessionMode.Menu:
				UpdateMenu();
				break;

			case SessionMode.Sandbox:
				UpdateSandbox(
					players
				);
				break;

			case SessionMode.Match:
				UpdateMatch(
					players
				);
				break;
		}
	}


	// ============================================================
	// MENU
	// ============================================================

	public void StartMatchMode()
	{
		if ( !Networking.IsHost )
			return;

		Mode =
			SessionMode.Match;

		ResetBotMatchDecisionState();

		if ( !MatchBotEnabled )
			GetBotManager()?.DestroyAllBots();

		Phase =
			RoundPhase.Waiting;

		ScoreA =
			0;

		ScoreB =
			0;

		RoundNumber =
			1;

		LastRoundWinner =
			-1;

		CurrentPot =
			0;

		PotOwnerTeam =
			-1;

		ActiveModifier =
			RoundModifier.Standard;

		Arena?.ResetMatchSequence();

		GetRoundLoadoutDirector()
			?.ResetMatchSequence();

		GetRoundRuleDirector()
			?.ResetMatchSequence();

		_modifierOffersPrepared =
			false;

		ClearRoundRuleRuntime(
			GetPlayers()
		);

		ModifierOptionA =
			RoundModifier.HighNoon;

		ModifierOptionB =
			RoundModifier.Standard;

		ModifierOptionAWeaponPreview =
			"RANDOM COMPETITIVE WEAPON";

		ModifierOptionBWeaponPreview =
			"RANDOM COMPETITIVE WEAPON";

		MatchWinnerTeam =
			-1;

		DecisionEndsAt =
			0f;

		PhaseEndsAt =
			0f;

		FightEndsAt =
			0f;

		FightMessageEndsAt =
			0f;

		IsSuddenDeath =
			false;

		ResetHighNoonRuntimeState(
			GetPlayers(),
			clearOwnerState: true
		);

		SetSuddenDeathMode(
			GetPlayers(),
			false
		);

		_sandboxPrepared =
			false;

		_lobbyWarmupInitializedPlayers.Clear();
		_lobbyWarmupRespawnAt.Clear();

		foreach (
			PlayerState player in
				GetPlayers()
		)
		{
			player.PreMatchState =
				PreMatchPlayerState.Lobby;

			RestoreStartingLoadout(
				player
			);
		}

		foreach (
			PlayerState player in
				GetPlayers()
		)
		{
			player.SetMenuControls(
				false
			);

			player.SetRoundControls(
				false
			);
		}

		EnterWaiting();
		GetBotManager()?.SyncNow();

		Log.Info(
			"SESSION MODE | MATCH"
		);
	}

	public void StartSandboxMode()
	{
		if ( !Networking.IsHost )
			return;

		MatchBotEnabled = false;

		Mode =
			SessionMode.Sandbox;

		Phase =
			RoundPhase.Fighting;

		_sandboxPrepared =
			false;

		_sandboxInitializedPlayers.Clear();
		_sandboxRespawnAt.Clear();
		_sandboxLastSpawnSide.Clear();

		_lobbyWarmupInitializedPlayers.Clear();
		_lobbyWarmupRespawnAt.Clear();

		ClearRoundRuleRuntime(
			GetPlayers()
		);

		foreach (
			PlayerState player in
				GetPlayers()
		)
		{
			player.PreMatchState =
				PreMatchPlayerState.Lobby;
		}

		foreach (
			PlayerState player in
				GetPlayers()
		)
		{
			player.SetMenuControls(
				false
			);

			player.SetRoundControls(
				false
			);
		}

		GetBotManager()?.SyncNow();

		Log.Info(
			"SESSION MODE | SANDBOX"
		);
	}

	// ============================================================
	// PRE-MATCH LOBBY
	// ============================================================

	public void BeginPreMatchLobby()
	{
		if ( !Networking.IsHost )
			return;

		List<PlayerState> players =
			GetPlayers();

		AssignTeams(
			players
		);

		_lobbyWarmupInitializedPlayers.Clear();
		_lobbyWarmupRespawnAt.Clear();

		if ( Arena is not null )
		{
			Arena.PrepareLobby();
		}

		foreach (
			PlayerState player in
				players
		)
		{
			if ( !player.IsReady )
				continue;

			player.PreMatchState =
				PreMatchPlayerState.Lobby;

			player.WarmupWeaponIndex =
				ClampWarmupWeaponIndex(
					player.WarmupWeaponIndex
				);

			PlayerHealth health =
				player.Components
					.Get<PlayerHealth>();

			health?.ResetHealth();

			player.SetRoundControls(
				false
			);

			player.SetMenuControls(
				true
			);
		}

		Log.Info(
			"PRE-MATCH LOBBY | BOTH PLAYERS CONNECTED"
		);
	}

	public void EndPreMatchLobby()
	{
		if ( !Networking.IsHost )
			return;

		_lobbyWarmupInitializedPlayers.Clear();
		_lobbyWarmupRespawnAt.Clear();

		foreach (
			PlayerState player in
				GetPlayers()
		)
		{
			player.PreMatchState =
				PreMatchPlayerState.Lobby;

			player.SetRoundControls(
				false
			);

			player.SetMenuControls(
				true
			);
		}
	}

	public bool ArePreMatchPlayersReady()
	{
		if ( !Networking.IsHost )
			return false;

		PlayerState[] players =
			GetPlayers()
				.Where(
					x =>
						x.IsReady
				)
				.Take( 2 )
				.ToArray();

		if ( players.Length < 2 )
			return false;

		return players.All(
			x =>
				x.PreMatchState ==
					PreMatchPlayerState.Ready
		);
	}

	public void RequestPreMatchReady()
	{
		if ( !IsPreMatchLobbyActive() )
			return;

		if ( Networking.IsHost )
		{
			TogglePreMatchReady(
				GetLocalPlayer()
			);

			return;
		}

		RequestPreMatchReadyHost();
	}

	[Rpc.Host]
	private void RequestPreMatchReadyHost()
	{
		if (
			!Networking.IsHost ||
			!IsPreMatchLobbyActive()
		)
			return;

		TogglePreMatchReady(
			GetPlayerByOwnerId(
				Rpc.CallerId
			)
		);
	}

	private void TogglePreMatchReady(
		PlayerState player
	)
	{
		if (
			player is null ||
			!player.IsReady
		)
			return;

		bool wasReady =
			player.PreMatchState ==
				PreMatchPlayerState.Ready;

		StopPreMatchWarmup(
			player,
			keepLobbyState: true
		);

		player.PreMatchState =
			wasReady
				? PreMatchPlayerState.Lobby
				: PreMatchPlayerState.Ready;

		Log.Info(
			$"PRE-MATCH | {player.Network.OwnerId} | {player.PreMatchState}"
		);
	}

	public void RequestPreMatchWarmup()
	{
		if ( !IsPreMatchLobbyActive() )
			return;

		if ( Networking.IsHost )
		{
			TogglePreMatchWarmup(
				GetLocalPlayer()
			);

			return;
		}

		RequestPreMatchWarmupHost();
	}

	[Rpc.Host]
	private void RequestPreMatchWarmupHost()
	{
		if (
			!Networking.IsHost ||
			!IsPreMatchLobbyActive()
		)
			return;

		TogglePreMatchWarmup(
			GetPlayerByOwnerId(
				Rpc.CallerId
			)
		);
	}

	private void TogglePreMatchWarmup(
		PlayerState player
	)
	{
		if (
			player is null ||
			!player.IsReady
		)
			return;

		if (
			player.PreMatchState ==
				PreMatchPlayerState.Warmup
		)
		{
			StopPreMatchWarmup(
				player,
				keepLobbyState: false
			);

			return;
		}

		player.PreMatchState =
			PreMatchPlayerState.Warmup;

		player.SetMenuControls(
			false
		);

		player.SetRoundControls(
			false
		);

		_lobbyWarmupInitializedPlayers.Remove(
			player.Network.OwnerId
		);

		_lobbyWarmupRespawnAt.Remove(
			player.Network.OwnerId
		);
	}

	private void StopPreMatchWarmup(
		PlayerState player,
		bool keepLobbyState
	)
	{
		if ( player is null )
			return;

		_lobbyWarmupInitializedPlayers.Remove(
			player.Network.OwnerId
		);

		_lobbyWarmupRespawnAt.Remove(
			player.Network.OwnerId
		);

		player.SetRoundControls(
			false
		);

		player.SetMenuControls(
			true
		);

		if ( !keepLobbyState )
		{
			player.PreMatchState =
				PreMatchPlayerState.Lobby;
		}
	}

	public void RequestCyclePreMatchWeapon(
		int direction
	)
	{
		if ( !IsPreMatchLobbyActive() )
			return;

		direction =
			Math.Sign(
				direction
			);

		if ( direction == 0 )
			return;

		if ( Networking.IsHost )
		{
			CyclePreMatchWeapon(
				GetLocalPlayer(),
				direction
			);

			return;
		}

		RequestCyclePreMatchWeaponHost(
			direction
		);
	}

	[Rpc.Host]
	private void RequestCyclePreMatchWeaponHost(
		int direction
	)
	{
		if (
			!Networking.IsHost ||
			!IsPreMatchLobbyActive()
		)
			return;

		CyclePreMatchWeapon(
			GetPlayerByOwnerId(
				Rpc.CallerId
			),
			Math.Sign(
				direction
			)
		);
	}

	private void CyclePreMatchWeapon(
		PlayerState player,
		int direction
	)
	{
		if ( player is null )
			return;

		int weaponCount =
			GetPreMatchWeaponCount();

		if ( weaponCount <= 0 )
			return;

		if (
			player.PreMatchState ==
				PreMatchPlayerState.Ready
		)
		{
			player.PreMatchState =
				PreMatchPlayerState.Lobby;
		}

		player.WarmupWeaponIndex =
			CycleIndex(
				ClampWarmupWeaponIndex(
					player.WarmupWeaponIndex
				),
				direction,
				weaponCount
			);

		if (
			player.PreMatchState ==
				PreMatchPlayerState.Warmup
		)
		{
			_lobbyWarmupInitializedPlayers.Remove(
				player.Network.OwnerId
			);
		}
	}

	public string GetPreMatchWeaponName(
		PlayerState player
	)
	{
		GameObject prefab =
			GetPreMatchWeaponPrefab(
				player
			);

		if ( prefab is null )
			return "NO WEAPON";

		return OMRWeaponUtility
			.GetDisplayName(
				prefab
			)
			.ToUpperInvariant();
	}

	private bool IsPreMatchLobbyActive()
	{
		if ( Mode != SessionMode.Menu )
			return false;

		GameFlowManager flow =
			GameObject.Components
				.Get<GameFlowManager>();

		return
			flow is not null &&
			flow.OnlineState ==
				OnlineFlowState.Lobby;
	}

	/// <summary>
	/// Applies a local pause/menu state to the caller without pausing
	/// the network session itself.
	///
	/// The host handles the authoritative player-control change. When
	/// resuming, controls are restored only if the current game phase
	/// actually allows that player to move and use their weapon.
	/// </summary>
	public void RequestPauseState(
		bool paused
	)
	{
		if (
			Mode != SessionMode.Sandbox &&
			Mode != SessionMode.Match
		)
			return;

		if ( Networking.IsHost )
		{
			PlayerState localPlayer =
				GetLocalPlayer();

			ApplyPauseStateToPlayer(
				localPlayer,
				paused
			);

			return;
		}

		RequestPauseStateHost(
			paused
		);
	}

	[Rpc.Host]
	private void RequestPauseStateHost(
		bool paused
	)
	{
		if (
			!Networking.IsHost ||
			(
				Mode != SessionMode.Sandbox &&
				Mode != SessionMode.Match
			)
		)
			return;

		PlayerState caller =
			GetPlayerByOwnerId(
				Rpc.CallerId
			);

		ApplyPauseStateToPlayer(
			caller,
			paused
		);
	}

	private void ApplyPauseStateToPlayer(
		PlayerState player,
		bool paused
	)
	{
		if ( player is null )
			return;

		if ( paused )
		{
			player.SetMenuControls(
				true
			);

			return;
		}

		player.SetMenuControls(
			false
		);

		player.SetRoundControls(
			ShouldPlayerHaveGameplayControls(
				player
			)
		);
	}

	private bool ShouldPlayerHaveGameplayControls(
		PlayerState player
	)
	{
		if (
			player is null ||
			!player.IsAlive
		)
			return false;

		return Mode switch
		{
			SessionMode.Sandbox =>
				true,

			SessionMode.Match =>
				Phase ==
					RoundPhase.Fighting,

			_ =>
				false
		};
	}

	/// <summary>
	/// Compatibility wrapper retained for any older Sandbox UI path.
	/// </summary>
	public void RequestSandboxControls(
		bool enabled
	)
	{
		if ( Mode != SessionMode.Sandbox )
			return;

		RequestPauseState(
			!enabled
		);
	}

	/// <summary>
	/// Host-only convenience command for the sandbox overlay.
	/// Re-applies the configured arena and loadouts, then respawns both players.
	/// </summary>
	public void ResetSandboxPlayersNow()
	{
		if (
			!Networking.IsHost ||
			Mode != SessionMode.Sandbox
		)
			return;

		_sandboxPrepared =
			false;

		_sandboxInitializedPlayers.Clear();
		_sandboxRespawnAt.Clear();
		_sandboxLastSpawnSide.Clear();
	}

	/// <summary>
	/// Sandbox bot-harness reset. Re-initializes only the bot so navigation,
	/// perception and combat experiments can be repeated without disturbing the
	/// human tester's position/state.
	/// </summary>
	public void ResetSandboxBotNow()
	{
		if (
			!Networking.IsHost ||
			Mode != SessionMode.Sandbox
		)
			return;

		PlayerState bot =
			GetPlayers()
				.FirstOrDefault( x => x is not null && x.IsBot );

		if ( bot?.GameObject is null )
		{
			GetBotManager()?.SyncNow();
			return;
		}

		Guid botId =
			bot.GameObject.Id;

		_sandboxInitializedPlayers.Remove(
			botId
		);

		_sandboxRespawnAt.Remove(
			botId
		);
	}

	// ============================================================
	// SANDBOX SETUP UI
	// ============================================================

	public void SetSandboxBotEnabled(
		bool enabled
	)
	{
		if ( !Networking.IsHost )
			return;

		SandboxBotEnabled = enabled;
		GetBotManager()?.SyncNow();
	}

	public void ToggleSandboxBot()
	{
		SetSandboxBotEnabled(
			!SandboxBotEnabled
		);
	}

	public void CycleSandboxBotDifficulty(
		int direction
	)
	{
		if ( !Networking.IsHost )
			return;

		int count =
			Enum.GetValues<OMRBotDifficulty>().Length;

		SandboxBotDifficulty =
			(OMRBotDifficulty)CycleIndex(
				(int)SandboxBotDifficulty,
				direction,
				count
			);

		GetBotManager()?.SyncNow();
	}

	public string GetSandboxBotDifficultyName() =>
		SandboxBotDifficulty
			.ToString()
			.ToUpperInvariant();

	public void CycleSandboxBotBehavior(
		int direction
	)
	{
		if ( !Networking.IsHost )
			return;

		int count =
			Enum.GetValues<OMRBotBehaviorMode>().Length;

		SandboxBotBehavior =
			(OMRBotBehaviorMode)CycleIndex(
				(int)SandboxBotBehavior,
				direction,
				count
			);

		GetBotManager()?.SyncNow();
	}

	public void ToggleSandboxBotDrawNavMesh()
	{
		if ( !Networking.IsHost )
			return;

		SandboxBotDrawNavMesh =
			!SandboxBotDrawNavMesh;

		GetBotManager()?.SyncNow();
	}

	public void CycleSandboxBotModifier( int direction )
	{
		if ( !Networking.IsHost )
			return;

		RoundModifier[] sandboxModifiers =
		{
			RoundModifier.Standard,
			RoundModifier.LowGravity,
			RoundModifier.HeadshotOnly,
			RoundModifier.LargeCaliber,
			RoundModifier.DoubleJump,
			RoundModifier.HipfireOnly,
			RoundModifier.Blitz
		};

		int current =
			Array.IndexOf(
				sandboxModifiers,
				SandboxBotTestModifier
			);

		if ( current < 0 )
			current = 0;

		SandboxBotTestModifier =
			sandboxModifiers[CycleIndex( current, direction, sandboxModifiers.Length )];

		// Reset both sides so the physical rule state, weapon modifiers and health
		// are deterministic. The bot component itself survives, so its learned
		// opponent model is intentionally preserved across rule A/B tests.
		ResetSandboxPlayersNow();
	}

	public string GetSandboxBotModifierName() =>
		GetModifierDisplayName( SandboxBotTestModifier ).ToUpperInvariant();

	public string GetSandboxBotBehaviorName() =>
		SandboxBotBehavior switch
		{
			OMRBotBehaviorMode.Stationary =>
				"STATIONARY / FOUNDATION",

			OMRBotBehaviorMode.NavigateToOpponent =>
				"NAVIGATE TO PLAYER / BOT 1",

			OMRBotBehaviorMode.PerceptionSearch =>
				"PERCEPTION + SEARCH / BOT 2",

			OMRBotBehaviorMode.Combat =>
				"COMBAT / BOT 3",

			OMRBotBehaviorMode.CombatMovement =>
				"COMBAT + MOVEMENT / BOT 4",

			OMRBotBehaviorMode.TacticalCombat =>
				"ADAPTIVE TACTICAL / BOT 6",

			_ =>
				SandboxBotBehavior.ToString().ToUpperInvariant()
		};

	public void CycleSandboxWeapon(
		int teamId,
		int direction
	)
	{
		if ( !Networking.IsHost )
			return;

		int count =
			SandboxWeapons?.Count ??
			0;

		if ( count <= 0 )
			return;

		if ( teamId == 0 )
		{
			SandboxWeaponAIndex =
				CycleIndex(
					SandboxWeaponAIndex,
					direction,
					count
				);
		}
		else if ( teamId == 1 )
		{
			SandboxWeaponBIndex =
				CycleIndex(
					SandboxWeaponBIndex,
					direction,
					count
				);
		}
	}

	public void CycleSandboxSpawnALayout(
		int direction
	)
	{
		if (
			!Networking.IsHost ||
			Arena is null
		)
			return;

		SandboxSpawnALayoutIndex =
			CycleIndex(
				SandboxSpawnALayoutIndex,
				direction,
				Arena.SpawnALayoutCount
			);
	}

	public void CycleSandboxCenterLayout(
		int direction
	)
	{
		if (
			!Networking.IsHost ||
			Arena is null
		)
			return;

		SandboxCenterLayoutIndex =
			CycleIndex(
				SandboxCenterLayoutIndex,
				direction,
				Arena.CenterLayoutCount
			);
	}

	public void CycleSandboxSpawnBLayout(
		int direction
	)
	{
		if (
			!Networking.IsHost ||
			Arena is null
		)
			return;

		SandboxSpawnBLayoutIndex =
			CycleIndex(
				SandboxSpawnBLayoutIndex,
				direction,
				Arena.SpawnBLayoutCount
			);
	}

	public string GetSandboxWeaponName(
		int teamId
	)
	{
		GameObject prefab =
			GetSandboxWeaponPrefab(
				teamId
			);

		if ( prefab is null )
			return "CURRENT LOADOUT";

		return OMRWeaponUtility
			.GetDisplayName(
				prefab
			)
			.ToUpperInvariant();
	}

	public string GetSandboxSpawnALayoutName()
	{
		return Arena?.GetSpawnALayoutName(
			SandboxSpawnALayoutIndex
		) ?? "NONE";
	}

	public string GetSandboxCenterLayoutName()
	{
		return Arena?.GetCenterLayoutName(
			SandboxCenterLayoutIndex
		) ?? "NONE";
	}

	public string GetSandboxSpawnBLayoutName()
	{
		return Arena?.GetSpawnBLayoutName(
			SandboxSpawnBLayoutIndex
		) ?? "NONE";
	}

	public void ReturnToMenu()
	{
		if ( !Networking.IsHost )
			return;

		EnterMenu();
	}

	private void EnterMenu()
	{
		MatchBotEnabled = false;
		ResetBotMatchDecisionState();

		Mode =
			SessionMode.Menu;

		Phase =
			RoundPhase.Waiting;

		_sandboxPrepared =
			false;

		_sandboxInitializedPlayers.Clear();
		_sandboxRespawnAt.Clear();
		_sandboxLastSpawnSide.Clear();

		GetBotManager()?.DestroyAllBots();

		_lobbyWarmupInitializedPlayers.Clear();
		_lobbyWarmupRespawnAt.Clear();

		LastRoundWinner =
			-1;

		CurrentPot =
			0;

		PotOwnerTeam =
			-1;

		ActiveModifier =
			RoundModifier.Standard;

		_modifierOffersPrepared =
			false;

		MatchWinnerTeam =
			-1;

		DecisionEndsAt =
			0f;

		FightEndsAt =
			0f;

		FightMessageEndsAt =
			0f;

		IsSuddenDeath =
			false;

		SetSuddenDeathMode(
			GetPlayers(),
			false
		);

		ClearRoundRuleRuntime(
			GetPlayers()
		);

		foreach (
			PlayerState player in
				GetPlayers()
		)
		{
			player.SetRoundControls(
				false
			);

			player.SetMenuControls(
				true
			);
		}

		Log.Info(
			"SESSION MODE | MENU"
		);
	}

	private void UpdateMenu()
	{
		if ( !IsPreMatchLobbyActive() )
			return;

		UpdatePreMatchLobby(
			GetPlayers()
		);
	}

	private void UpdatePreMatchLobby(
		List<PlayerState> players
	)
	{
		AssignTeams(
			players
		);

		HashSet<Guid> connectedOwners =
			players
				.Select(
					x =>
						x.Network.OwnerId
				)
				.ToHashSet();

		_lobbyWarmupInitializedPlayers.RemoveWhere(
			ownerId =>
				!connectedOwners.Contains(
					ownerId
				)
		);

		foreach (
			Guid ownerId in
				_lobbyWarmupRespawnAt.Keys
					.Where(
						x =>
							!connectedOwners.Contains(
								x
							)
					)
					.ToList()
		)
		{
			_lobbyWarmupRespawnAt.Remove(
				ownerId
			);
		}

		foreach (
			PlayerState player in
				players
		)
		{
			if (
				!player.IsReady ||
				player.PreMatchState !=
					PreMatchPlayerState.Warmup
			)
				continue;

			Guid ownerId =
				player.Network.OwnerId;

			if (
				!_lobbyWarmupInitializedPlayers.Contains(
					ownerId
				)
			)
			{
				InitializePreMatchWarmupPlayer(
					player
				);

				continue;
			}

			if ( player.IsAlive )
			{
				_lobbyWarmupRespawnAt.Remove(
					ownerId
				);

				continue;
			}

			if (
				!_lobbyWarmupRespawnAt.TryGetValue(
					ownerId,
					out float respawnAt
				)
			)
			{
				_lobbyWarmupRespawnAt[ownerId] =
					Time.Now +
						MathF.Max(
							LobbyWarmupRespawnDelay,
							0f
						);

				continue;
			}

			if ( Time.Now < respawnAt )
				continue;

			ResetPreMatchWarmupPlayer(
				player
			);

			_lobbyWarmupRespawnAt.Remove(
				ownerId
			);
		}
	}

	private void InitializePreMatchWarmupPlayer(
		PlayerState player
	)
	{
		ApplyWeaponLoadout(
			player,
			GetPreMatchWeaponPrefab(
				player
			)
		);

		ResetPreMatchWarmupPlayer(
			player
		);

		_lobbyWarmupInitializedPlayers.Add(
			player.Network.OwnerId
		);

		Log.Info(
			$"PRE-MATCH WARMUP | TEAM {GetTeamName( player.TeamId )} | {GetPreMatchWeaponName( player )}"
		);
	}

	private void ResetPreMatchWarmupPlayer(
		PlayerState player
	)
	{
		GameObject spawn =
			player.TeamId == 1
				? SpawnB
				: SpawnA;

		GameObject lookAtSpawn =
			player.TeamId == 1
				? SpawnA
				: SpawnB;

		ResetPlayer(
			player,
			spawn,
			lookAtSpawn
		);

		player.SetMenuControls(
			false
		);

		player.SetRoundControls(
			true
		);
	}


	// ============================================================
	// SANDBOX / PRACTICE
	// ============================================================

	private void UpdateSandbox(
		List<PlayerState> players
	)
	{
		AssignTeams(
			players
		);

		if ( !_sandboxPrepared )
		{
			if ( Arena is not null )
			{
				Arena.PrepareSandbox(
					SandboxSpawnALayoutIndex,
					SandboxCenterLayoutIndex,
					SandboxSpawnBLayoutIndex
				);
			}

			_sandboxPrepared =
				true;
		}

		HashSet<Guid> connectedPlayerIds =
			players
				.Where( x => x?.GameObject is not null )
				.Select( x => x.GameObject.Id )
				.ToHashSet();

		_sandboxInitializedPlayers.RemoveWhere(
			playerId =>
				!connectedPlayerIds.Contains( playerId )
		);

		foreach (
			Guid playerId in
				_sandboxLastSpawnSide.Keys
					.Where( x => !connectedPlayerIds.Contains( x ) )
					.ToList()
		)
		{
			_sandboxLastSpawnSide.Remove( playerId );
		}

		foreach (
			Guid playerId in
				_sandboxRespawnAt.Keys
					.Where( x => !connectedPlayerIds.Contains( x ) )
					.ToList()
		)
		{
			_sandboxRespawnAt.Remove(
				playerId
			);
		}

		foreach (
			PlayerState player in
				players
		)
		{
			if (
				!player.IsReady ||
				(
					player.TeamId != 0 &&
					player.TeamId != 1
				)
			)
			{
				player.SetRoundControls(
					false
				);

				continue;
			}

			Guid playerId =
				player.GameObject.Id;

			if (
				!_sandboxInitializedPlayers.Contains(
					playerId
				)
			)
			{
				InitializeSandboxPlayer(
					player
				);

				continue;
			}

			if ( player.IsAlive )
			{
				_sandboxRespawnAt.Remove(
					playerId
				);

				continue;
			}

			if (
				!_sandboxRespawnAt.TryGetValue(
					playerId,
					out float respawnAt
				)
			)
			{
				_sandboxRespawnAt[playerId] =
					Time.Now +
					MathF.Max(
						SandboxRespawnDelay,
						0f
					);

				continue;
			}

			if ( Time.Now < respawnAt )
				continue;

			ResetSandboxPlayer(
				player
			);

			_sandboxRespawnAt.Remove(
				playerId
			);
		}

		Phase =
			RoundPhase.Fighting;
	}

	private void InitializeSandboxPlayer(
		PlayerState player
	)
	{
		ApplySandboxLoadout(
			player
		);

		ResetSandboxPlayer(
			player
		);

		_sandboxInitializedPlayers.Add(
			player.GameObject.Id
		);

		Log.Info(
			$"SANDBOX PLAYER READY | {player.DisplayName} | TEAM {GetTeamName( player.TeamId )} | BOT:{player.IsBot} | OBJECT {player.GameObject.Id}"
		);
	}

	private void ResetSandboxPlayer(
		PlayerState player
	)
	{
		GameObject spawn =
			SelectSafeSandboxSpawn(
				player,
				out GameObject lookAtSpawn
			);

		ResetPlayer(
			player,
			spawn,
			lookAtSpawn
		);

		player.SetMenuControls(
			false
		);

		player.SetRoundControls(
			true
		);

		ApplySandboxRuleRuntime( player );
	}

	private GameObject SelectSafeSandboxSpawn(
		PlayerState player,
		out GameObject lookAtSpawn
	)
	{
		GameObject preferred =
			player?.TeamId == 0
				? SpawnA
				: SpawnB;

		GameObject alternate =
			player?.TeamId == 0
				? SpawnB
				: SpawnA;

		lookAtSpawn = alternate;

		if (
			!SandboxSafeRespawns ||
			player?.GameObject is null ||
			SpawnA is null ||
			SpawnB is null
		)
			return preferred ?? alternate;

		List<PlayerState> opponents =
			GetPlayers()
				.Where(
					x =>
						x?.GameObject is not null &&
						x.GameObject.Id != player.GameObject.Id &&
						x.IsAlive
				)
				.ToList();

		if ( opponents.Count == 0 )
			return preferred ?? alternate;

		float scoreA = ScoreSandboxSpawn( player, SpawnA, 0, opponents );
		float scoreB = ScoreSandboxSpawn( player, SpawnB, 1, opponents );
		int chosenSide = scoreB > scoreA ? 1 : 0;
		GameObject chosen = chosenSide == 0 ? SpawnA : SpawnB;

		_sandboxLastSpawnSide[player.GameObject.Id] = chosenSide;

		// Face the opposite arena anchor, not the opponent's live transform. The
		// safe-spawn system may use enemy position/LOS to avoid a spawn camp, but it
		// does not hand the respawning player free tracking information.
		lookAtSpawn = chosenSide == 0 ? SpawnB : SpawnA;
		return chosen;
	}

	private float ScoreSandboxSpawn(
		PlayerState respawningPlayer,
		GameObject spawn,
		int side,
		List<PlayerState> opponents
	)
	{
		if ( spawn is null )
			return float.MinValue;

		float score = 0f;
		float dangerDistance =
			MathF.Max( 120f, SandboxSpawnDangerDistance );

		foreach ( PlayerState opponent in opponents )
		{
			float distance =
				( opponent.WorldPosition - spawn.WorldPosition )
					.WithZ( 0f )
					.Length;

			// Distance is useful, but direct line of sight is the stronger signal.
			score += MathF.Min( distance, 1200f ) * 0.34f;

			if ( distance < dangerDistance )
			{
				float proximity =
					1f - MathX.Clamp( distance / dangerDistance, 0f, 1f );
				score -= 720f * proximity;
			}

			if ( IsSandboxSpawnVisibleToOpponent( spawn, opponent, respawningPlayer ) )
				score -= MathF.Max( 100f, SandboxVisibleSpawnPenalty );
			else
				score += 185f;
		}

		if (
			respawningPlayer?.GameObject is not null &&
			_sandboxLastSpawnSide.TryGetValue(
				respawningPlayer.GameObject.Id,
				out int previousSide
			) &&
			previousSide == side
		)
		{
			// A modest anti-repeat term prevents a spawn camper from learning that
			// every death returns the victim to the same anchor.
			score -= 130f;
		}

		// Preserve the conventional team side when safety is otherwise equivalent.
		if ( respawningPlayer?.TeamId == side )
			score += 18f;

		return score;
	}

	private bool IsSandboxSpawnVisibleToOpponent(
		GameObject spawn,
		PlayerState opponent,
		PlayerState respawningPlayer
	)
	{
		if (
			Scene is null ||
			spawn is null ||
			opponent?.GameObject is null
		)
			return false;

		PlayerController controller =
			opponent.Components.Get<PlayerController>();

		Vector3 from =
			controller?.IsValid == true
				? controller.EyePosition
				: opponent.WorldPosition + Vector3.Up * 58f;

		Vector3 to =
			spawn.WorldPosition + Vector3.Up * 48f;

		SceneTrace traceBuilder =
			Scene.Trace
				.Ray( from, to )
				.IgnoreGameObjectHierarchy( opponent.GameObject )
				.WithoutTags( "trigger" )
				.UseHitboxes( false );

		if ( respawningPlayer?.GameObject is not null )
			traceBuilder =
				traceBuilder.IgnoreGameObjectHierarchy( respawningPlayer.GameObject );

		SceneTraceResult trace = traceBuilder.Run();

		return !trace.Hit;
	}

	private void ApplySandboxRuleRuntime( PlayerState player )
	{
		if (
			!Networking.IsHost ||
			Mode != SessionMode.Sandbox ||
			player is null
		)
			return;

		OMRRoundRuleProfile rule =
			ResolveRoundRule( SandboxBotTestModifier );

		bool headshotOnly =
			rule?.HeadshotOnly ??
			SandboxBotTestModifier == RoundModifier.HeadshotOnly;

		player.Components
			.Get<PlayerHealth>()?
			.SetWeaponHeadshotOnlyMode( headshotOnly );

		player.ApplyRoundRule( SandboxBotTestModifier );
		ApplyRoundWeaponModifiersHost( player, rule );
	}

	private void ApplySandboxLoadout(
		PlayerState player
	)
	{
		GameObject primaryWeapon =
			GetSandboxWeaponPrefab(
				player.TeamId
			);

		if ( !EnableSandboxMultiWeaponTesting )
		{
			ApplyWeaponLoadout(
				player,
				primaryWeapon
			);

			return;
		}

		ApplySandboxMultiWeaponTestLoadout(
			player,
			primaryWeapon
		);
	}

	/// <summary>
	/// Sandbox-only test loadout used to validate inventory input and presentation
	/// lifecycle events. Normal match/warmup loadouts continue to use the existing
	/// single-weapon path.
	/// </summary>
	private void ApplySandboxMultiWeaponTestLoadout(
		PlayerState player,
		GameObject primaryWeapon
	)
	{
		if (
			!Networking.IsHost ||
			player is null ||
			primaryWeapon is null
		)
			return;

		BaseInventoryComponent inventory =
			player.Components
				.Get<BaseInventoryComponent>();

		if ( inventory is null )
			return;

		inventory.ForceHolster();

		foreach (
			BaseInventoryItem item in
				inventory.Items.ToList()
		)
		{
			inventory.Remove(
				item
			);
		}

		BaseInventoryItem primaryItem =
			inventory.Pickup(
				primaryWeapon,
				0
			);

		int nextSlot = 1;

		if ( SandboxAdditionalWeapons is not null )
		{
			foreach ( GameObject extraPrefab in SandboxAdditionalWeapons )
			{
				if (
					extraPrefab is null ||
					!extraPrefab.IsValid
				)
					continue;

				if ( nextSlot >= inventory.MaxSlots )
				{
					Log.Warning(
						$"SANDBOX INVENTORY TEST | Inventory has {inventory.MaxSlots} slots; cannot add '{extraPrefab.Name}' to slot {nextSlot + 1}."
					);

					break;
				}

				inventory.Pickup(
					extraPrefab,
					nextSlot
				);

				nextSlot++;
			}
		}

		if ( primaryItem is not null )
		{
			inventory.Switch(
				primaryItem,
				true
			);
		}

		Log.Info(
			$"SANDBOX INVENTORY TEST | OWNER {player.Network.OwnerId} | Items:{inventory.Items.Count()} | Slots:{inventory.MaxSlots}"
		);
	}

	private void RestoreStartingLoadout(
		PlayerState player
	)
	{
		if (
			!Networking.IsHost ||
			player is null
		)
			return;

		BaseInventoryComponent inventory =
			player.Components
				.Get<BaseInventoryComponent>();

		if ( inventory is null )
			return;

		inventory.ForceHolster();

		foreach (
			BaseInventoryItem item in
				inventory.Items.ToList()
		)
		{
			inventory.Remove(
				item
			);
		}

		inventory.GiveLoadout();
	}

	private void ApplyWeaponLoadout(
		PlayerState player,
		GameObject weaponPrefab
	)
	{
		if (
			!Networking.IsHost ||
			player is null ||
			weaponPrefab is null
		)
			return;

		BaseInventoryComponent inventory =
			player.Components
				.Get<BaseInventoryComponent>();

		if ( inventory is null )
			return;

		inventory.ForceHolster();

		foreach (
			BaseInventoryItem item in
				inventory.Items.ToList()
		)
		{
			inventory.Remove(
				item
			);
		}

		BaseInventoryItem newItem =
			inventory.Pickup(
				weaponPrefab,
				-1
			);

		if ( newItem is not null )
		{
			inventory.Switch(
				newItem,
				true
			);
		}
	}

	private GameObject GetSandboxWeaponPrefab(
		int teamId
	)
	{
		if (
			SandboxWeapons is null ||
			SandboxWeapons.Count <= 0
		)
			return null;

		int index =
			teamId == 1
				? SandboxWeaponBIndex
				: SandboxWeaponAIndex;

		index =
			Math.Clamp(
				index,
				0,
				SandboxWeapons.Count - 1
			);

		return SandboxWeapons[index];
	}


	private GameObject GetPreMatchWeaponPrefab(
		PlayerState player
	)
	{
		if ( player is null )
			return null;

		OMRRoundLoadoutDirector director =
			GetRoundLoadoutDirector();

		if (
			director is not null &&
			director.CompetitiveWeaponCount > 0
		)
		{
			int index =
				Math.Clamp(
					player.WarmupWeaponIndex,
					0,
					director.CompetitiveWeaponCount - 1
				);

			return director.GetCompetitiveWeapon(
				index
			);
		}

		// Compatibility fallback for scenes that have not yet received the
		// loadout director component. Sandbox remains functional either way.
		if (
			SandboxWeapons is null ||
			SandboxWeapons.Count <= 0
		)
			return null;

		int fallbackIndex =
			Math.Clamp(
				player.WarmupWeaponIndex,
				0,
				SandboxWeapons.Count - 1
			);

		return SandboxWeapons[fallbackIndex];
	}

	private int ClampWarmupWeaponIndex(
		int index
	)
	{
		int count =
			GetPreMatchWeaponCount();

		if ( count <= 0 )
			return 0;

		return Math.Clamp(
			index,
			0,
			count - 1
		);
	}

	private int GetPreMatchWeaponCount()
	{
		OMRRoundLoadoutDirector director =
			GetRoundLoadoutDirector();

		if (
			director is not null &&
			director.CompetitiveWeaponCount > 0
		)
		{
			return director.CompetitiveWeaponCount;
		}

		return SandboxWeapons?.Count ?? 0;
	}

	private OMRRoundLoadoutDirector GetRoundLoadoutDirector()
	{
		return GameObject.Components
			.Get<OMRRoundLoadoutDirector>();
	}

	private OMRRoundRuleDirector GetRoundRuleDirector()
	{
		return GameObject.Components
			.Get<OMRRoundRuleDirector>();
	}

	private OMRBotManager GetBotManager()
	{
		return GameObject.Components
			.Get<OMRBotManager>();
	}

	private int CycleIndex(
		int current,
		int direction,
		int count
	)
	{
		if ( count <= 0 )
			return 0;

		int next =
			(
				current +
				direction
			) %
			count;

		if ( next < 0 )
			next += count;

		return next;
	}

	// ============================================================
	// MATCH UPDATE
	// ============================================================

	private void UpdateMatch(
		List<PlayerState> players
	)
	{
		if ( players.Count < 2 )
		{
			if (
				Phase !=
				RoundPhase.Waiting
			)
			{
				EnterWaiting();
			}

			return;
		}

		AssignTeams(
			players
		);

		if (
			!PlayersAreReady(
				players
			)
		)
		{
			if (
				Phase !=
					RoundPhase.Waiting
			)
			{
				EnterWaiting();
			}

			return;
		}

		switch ( Phase )
		{
			case RoundPhase.Waiting:
				StartCountdown(
					players,
					RoundModifier.Standard
				);
				break;

			case RoundPhase.Countdown:
				if (
					Time.Now >=
					PhaseEndsAt
				)
				{
					StartFight(
						players
					);
				}
				break;

			case RoundPhase.Fighting:
				CheckForRoundWinner(
					players
				);

				//
				// A kill may have moved us to RoundEnd.
				//
				if (
					Phase !=
						RoundPhase.Fighting
				)
				{
					break;
				}

				if ( UsesHighNoonFlow( ActiveModifier ) )
				{
					UpdateHighNoon(
						players
					);

					break;
				}

				if (
					FightEndsAt > 0f &&
					Time.Now >= FightEndsAt
				)
				{
					ResolveFightTimeout(
						players
					);
				}
				break;

			case RoundPhase.RoundEnd:
				if (
					Time.Now >=
					PhaseEndsAt
				)
				{
					ResolvePostRound(
						players
					);
				}
				break;

			case RoundPhase.WinnerDecision:
				if ( TryHandleBotWinnerDecision( players ) )
					break;

				if (
					Time.Now >=
					DecisionEndsAt
				)
				{
					//
					// Safe default:
					// if the winner does nothing, BANK.
					//
					HandleWinnerDecision(
						PotOwnerTeam,
						true,
						players
					);
				}
				break;

			case RoundPhase.ModifierDecision:
				if ( TryHandleBotModifierDecision( players ) )
					break;

				if (
					Time.Now >=
					DecisionEndsAt
				)
				{
					//
					// Never allow a disconnected / distracted
					// player to lock the match forever.
					//
					int chooserTeam =
						GetOpponentTeam(
							PotOwnerTeam
						);

					HandleModifierDecision(
						chooserTeam,
						ModifierOptionA,
						players
					);
				}
				break;

			case RoundPhase.MatchEnd:
				//
				// Wait for the host to return to menu.
				//
				break;
		}
	}

	// ============================================================
	// PLAYER LOOKUP
	// ============================================================

	private List<PlayerState> GetPlayers()
	{
		return Scene
			.GetAllComponents<PlayerState>()
			.ToList();
	}

	private PlayerState GetLocalPlayer()
	{
		return GetPlayers()
			.FirstOrDefault(
				x =>
					x.IsReady &&
					x.IsLocalHuman
			);
	}

	private PlayerState GetPlayerByOwnerId(
		Guid ownerId
	)
	{
		return GetPlayers()
			.FirstOrDefault(
				x =>
					!x.IsBot &&
					x.Network.OwnerId ==
					ownerId
			);
	}

	private bool PlayersAreReady(
		List<PlayerState> players
	)
	{
		PlayerState playerA =
			players.FirstOrDefault(
				x =>
					x.TeamId == 0
			);

		PlayerState playerB =
			players.FirstOrDefault(
				x =>
					x.TeamId == 1
			);

		if (
			playerA is null ||
			playerB is null
		)
			return false;

		return
			playerA.IsReady &&
			playerB.IsReady;
	}

	private void AssignTeams(
		List<PlayerState> players
	)
	{
		foreach (
			PlayerState player in
				players.Where(
					x =>
						x.TeamId < 0
				)
		)
		{
			bool teamAUsed =
				players.Any(
					x =>
						x != player &&
						x.TeamId == 0
				);

			bool teamBUsed =
				players.Any(
					x =>
						x != player &&
						x.TeamId == 1
				);

			if ( !teamAUsed )
			{
				player.TeamId =
					0;
			}
			else if ( !teamBUsed )
			{
				player.TeamId =
					1;
			}
		}
	}

	// ============================================================
	// WAITING
	// ============================================================

	private void EnterWaiting()
	{
		Phase =
			RoundPhase.Waiting;

		LastRoundWinner =
			-1;

		DecisionEndsAt =
			0f;

		FightEndsAt =
			0f;

		FightMessageEndsAt =
			0f;

		IsSuddenDeath =
			false;

		ResetHighNoonRuntimeState(
			GetPlayers(),
			clearOwnerState: true
		);

		SetSuddenDeathMode(
			GetPlayers(),
			false
		);

		ClearRoundRuleRuntime(
			GetPlayers()
		);

		foreach (
			PlayerState player in
				GetPlayers()
		)
		{
			player.SetMenuControls(
				false
			);

			player.SetRoundControls(
				false
			);
		}

		Log.Info(
			"WAITING FOR READY PLAYERS"
		);
	}

	// ============================================================
	// ROUND START
	// ============================================================

	private void StartNextRound(
		List<PlayerState> players,
		RoundModifier modifier
	)
	{
		RoundNumber++;

		StartCountdown(
			players,
			modifier
		);
	}

	private void StartCountdown(
		List<PlayerState> players,
		RoundModifier modifier
	)
	{
		Phase =
			RoundPhase.Countdown;

		PhaseEndsAt =
			Time.Now +
			CountdownDuration;

		DecisionEndsAt =
			0f;

		LastRoundWinner =
			-1;

		FightEndsAt =
			0f;

		FightMessageEndsAt =
			0f;

		IsSuddenDeath =
			false;

		ResetHighNoonRuntimeState(
			players,
			clearOwnerState: true
		);

		SetSuddenDeathMode(
			players,
			false
		);

		ActiveModifier =
			modifier;

		OMRRoundRuleProfile activeRule =
			ResolveRoundRule(
				ActiveModifier
			);

		if ( Arena is not null )
		{
			Arena.PrepareForRound(
				RoundNumber,
				ActiveModifier,
				activeRule?.ArenaUsage ??
					GetLegacyArenaUsage( ActiveModifier )
			);
		}

		PlayerState playerA =
			players.FirstOrDefault(
				x =>
					x.TeamId == 0
			);

		PlayerState playerB =
			players.FirstOrDefault(
				x =>
					x.TeamId == 1
			);

		//
		// Disable gameplay input before moving/resetting either player.
		// This prevents a held input from leaking into the new round while
		// authoritative ammo and local movement state are being rebuilt.
		//
		foreach (
			PlayerState player in
				players
		)
		{
			player.SetMenuControls(
				false
			);

			player.SetRoundControls(
				false
			);
		}

		GameObject roundWeapon =
			ResolveRoundWeapon(
				ActiveModifier,
				activeRule
			);

		if ( roundWeapon is not null )
		{
			ApplyWeaponLoadout(
				playerA,
				roundWeapon
			);

			ApplyWeaponLoadout(
				playerB,
				roundWeapon
			);
		}
		else
		{
			Log.Warning(
				$"ROUND {RoundNumber} | No configured round weapon; preserving current inventories."
			);
		}

		// Apply host-side weapon modifier snapshots before ResetPlayer restores
		// clip/reserve ammo, so future magazine/ammo modifiers resolve against the
		// same effective stats used by the owning client.
		ApplyRoundWeaponModifiersHost(
			playerA,
			activeRule
		);

		ApplyRoundWeaponModifiersHost(
			playerB,
			activeRule
		);

		ResetPlayer(
			playerA,
			SpawnA,
			SpawnB
		);

		ResetPlayer(
			playerB,
			SpawnB,
			SpawnA
		);

		ApplyRoundRuleRuntime(
			players,
			activeRule
		);

		if ( UsesHighNoonFlow( ActiveModifier ) )
		{
			playerA?.
				LockHighNoonCountdown(
					SpawnB is not null
						? SpawnB.WorldPosition
						: playerB?.WorldPosition ?? Vector3.Zero
				);

			playerB?.
				LockHighNoonCountdown(
					SpawnA is not null
						? SpawnA.WorldPosition
						: playerA?.WorldPosition ?? Vector3.Zero
				);
		}

		string roundWeaponName =
			roundWeapon is null
				? "CURRENT LOADOUT"
				: OMRWeaponUtility.GetDisplayName(
					roundWeapon
				);

		Log.Info(
			$"ROUND {RoundNumber} | COUNTDOWN | MODIFIER {ActiveModifier} | WEAPON {roundWeaponName}"
		);
	}

	private void StartFight(
		List<PlayerState> players
	)
	{
		PlayerState playerA =
			players.FirstOrDefault(
				x =>
					x.TeamId == 0
			);

		PlayerState playerB =
			players.FirstOrDefault(
				x =>
					x.TeamId == 1
			);

		if (
			playerA is null ||
			playerB is null
		)
		{
			EnterWaiting();

			return;
		}

		if (
			!playerA.IsAlive ||
			!playerB.IsAlive
		)
		{
			Log.Warning(
				"Fight attempted to start with a dead player. Resetting round."
			);

			StartCountdown(
				players,
				ActiveModifier
			);

			return;
		}

		if ( UsesHighNoonFlow( ActiveModifier ) )
		{
			StartHighNoonFight(
				players,
				playerA,
				playerB
			);

			return;
		}

		Phase =
			RoundPhase.Fighting;

		IsSuddenDeath =
			false;

		SetSuddenDeathMode(
			players,
			false
		);

		OMRRoundRuleProfile activeRule =
			ResolveRoundRule(
				ActiveModifier
			);

		float resolvedFightDuration =
			ResolveFightDuration(
				activeRule
			);

		FightEndsAt =
			Time.Now +
			resolvedFightDuration;

		FightMessageEndsAt =
			Time.Now +
			1.0f;


		foreach (
			PlayerState player in
				players
		)
		{
			player.SetMenuControls(
				false
			);

			player.SetRoundControls(
				true
			);
		}

		Log.Info(
			$"ROUND {RoundNumber} | FIGHT! | MODIFIER {ActiveModifier} | TIMER {resolvedFightDuration:0.##}s"
		);
	}


	// ============================================================
	// HIGH NOON
	// ============================================================

	private void StartHighNoonFight(
		List<PlayerState> players,
		PlayerState playerA,
		PlayerState playerB
	)
	{
		Phase =
			RoundPhase.Fighting;

		IsSuddenDeath =
			false;

		SetSuddenDeathMode(
			players,
			false
		);

		float minimumDelay =
			MathF.Max(
				MathF.Min(
					HighNoonMinDrawDelay,
					HighNoonMaxDrawDelay
				),
				0.05f
			);

		float maximumDelay =
			MathF.Max(
				MathF.Max(
					HighNoonMinDrawDelay,
					HighNoonMaxDrawDelay
				),
				minimumDelay
			);

		float drawDelay =
			Game.Random.Float(
				minimumDelay,
				maximumDelay
			);

		HighNoonDrawAt =
			Time.Now +
			drawDelay;

		HighNoonState =
			HighNoonStage.WaitingForDraw;

		HighNoonFirstTriggerAt =
			0f;

		HighNoonFirstTriggerTeam =
			-1;

		_highNoonTriggerTimes.Clear();
		_highNoonAdjudicateAt = 0f;

		FightEndsAt =
			HighNoonDrawAt +
			MathF.Max(
				HighNoonDrawDuration,
				1f
			);

		// The center HUD uses this as the DRAW banner lifetime.
		FightMessageEndsAt =
			HighNoonDrawAt +
			1.0f;


		Vector3 lookTargetA =
			SpawnB is not null
				? SpawnB.WorldPosition
				: playerB.WorldPosition;

		Vector3 lookTargetB =
			SpawnA is not null
				? SpawnA.WorldPosition
				: playerA.WorldPosition;

		playerA.BeginHighNoonDrawSequence(
			lookTargetA,
			HighNoonDrawAt,
			RoundNumber
		);

		playerB.BeginHighNoonDrawSequence(
			lookTargetB,
			HighNoonDrawAt,
			RoundNumber
		);

		Log.Info(
			$"ROUND {RoundNumber} | HIGH NOON | READY | DRAW IN {drawDelay:0.###}s | WINDOW {MathF.Max( HighNoonDrawDuration, 1f ):0.##}s"
		);
	}

	private void UpdateHighNoon(
		List<PlayerState> players
	)
	{
		if (
			!UsesHighNoonFlow( ActiveModifier ) ||
			Phase !=
				RoundPhase.Fighting
		)
			return;

		if (
			HighNoonState ==
				HighNoonStage.WaitingForDraw &&
			Time.Now >=
				HighNoonDrawAt
		)
		{
			HighNoonState =
				HighNoonStage.DrawOpen;

			Log.Info(
				$"ROUND {RoundNumber} | HIGH NOON | DRAW!"
			);
		}

		if (
			_highNoonAdjudicateAt > 0f &&
			Time.Now >=
				_highNoonAdjudicateAt
		)
		{
			ResolveHighNoonTriggerRace(
				players
			);

			return;
		}

		if (
			FightEndsAt > 0f &&
			Time.Now >=
				FightEndsAt
		)
		{
			Log.Info(
				$"ROUND {RoundNumber} | HIGH NOON EXPIRED | DRAW"
			);

			EndRound(
				null,
				players
			);
		}
	}

	/// <summary>
	/// Receives the synchronized Time.Now captured on the owning client at the
	/// exact point its revolver PrimaryAttack committed.
	///
	/// The host deliberately compares those timestamps, not RPC arrival order.
	/// </summary>
	public void HandleHighNoonTrigger(
		PlayerState player,
		int roundNumber,
		float firedAt
	)
	{
		if (
			!Networking.IsHost ||
			player is null ||
			Mode !=
				SessionMode.Match ||
			Phase !=
				RoundPhase.Fighting ||
			!UsesHighNoonFlow( ActiveModifier ) ||
			roundNumber !=
				RoundNumber
		)
			return;

		if (
			player.TeamId != 0 &&
			player.TeamId != 1
		)
			return;

		// Time.Now is synchronized, but leave a small tolerance for update/RPC
		// ordering around the exact DRAW boundary.
		if (
			firedAt <
				HighNoonDrawAt -
					0.03f
		)
		{
			Log.Warning(
				$"HIGH NOON trigger rejected | Player {GetTeamName( player.TeamId )} | Before DRAW by {HighNoonDrawAt - firedAt:0.###}s"
			);

			return;
		}

		if (
			firedAt >
				Time.Now +
					0.10f ||
			(
				FightEndsAt > 0f &&
				firedAt >
					FightEndsAt +
						0.05f
			)
		)
		{
			Log.Warning(
				$"HIGH NOON trigger rejected | Player {GetTeamName( player.TeamId )} | Invalid timestamp {firedAt:0.###}"
			);

			return;
		}

		if (
			_highNoonTriggerTimes.ContainsKey(
				player.TeamId
			)
		)
			return;

		float acceptedAt =
			MathF.Max(
				firedAt,
				HighNoonDrawAt
			);

		_highNoonTriggerTimes[
			player.TeamId
		] = acceptedAt;

		if (
			HighNoonFirstTriggerTeam < 0 ||
			acceptedAt <
				HighNoonFirstTriggerAt
		)
		{
			HighNoonFirstTriggerAt =
				acceptedAt;

			HighNoonFirstTriggerTeam =
				player.TeamId;
		}

		float reaction =
			acceptedAt -
			HighNoonDrawAt;

		Log.Info(
			$"ROUND {RoundNumber} | HIGH NOON TRIGGER | PLAYER {GetTeamName( player.TeamId )} | REACTION {reaction * 1000f:0.#}ms"
		);

		if (
			_highNoonTriggerTimes.Count >= 2
		)
		{
			ResolveHighNoonTriggerRace(
				GetPlayers()
			);

			return;
		}

		_highNoonAdjudicateAt =
			Time.Now +
			MathF.Max(
				HighNoonAdjudicationWindow,
				0.02f
			);
	}

	private void ResolveHighNoonTriggerRace(
		List<PlayerState> players
	)
	{
		if (
			Phase !=
				RoundPhase.Fighting ||
			!UsesHighNoonFlow( ActiveModifier ) ||
			_highNoonTriggerTimes.Count == 0
		)
			return;

		_highNoonAdjudicateAt = 0f;

		bool hasA =
			_highNoonTriggerTimes.TryGetValue(
				0,
				out float triggerA
			);

		bool hasB =
			_highNoonTriggerTimes.TryGetValue(
				1,
				out float triggerB
			);

		if (
			hasA &&
			hasB &&
			MathF.Abs(
				triggerA -
				triggerB
			) <=
				MathF.Max(
					HighNoonTieTolerance,
					0f
				)
		)
		{
			Log.Info(
				$"ROUND {RoundNumber} | HIGH NOON | SIMULTANEOUS DRAW | A {(triggerA - HighNoonDrawAt) * 1000f:0.#}ms | B {(triggerB - HighNoonDrawAt) * 1000f:0.#}ms"
			);

			EndRound(
				null,
				players
			);

			return;
		}

		int winnerTeam;

		if ( hasA && hasB )
		{
			winnerTeam =
				triggerA < triggerB
					? 0
					: 1;
		}
		else
		{
			winnerTeam =
				hasA
					? 0
					: 1;
		}

		int loserTeam =
			GetOpponentTeam(
				winnerTeam
			);

		PlayerState winner =
			players.FirstOrDefault(
				x =>
					x.TeamId ==
						winnerTeam
			);

		PlayerState loser =
			players.FirstOrDefault(
				x =>
					x.TeamId ==
						loserTeam
			);

		if (
			winner is null ||
			loser is null
		)
		{
			EndRound(
				null,
				players
			);

			return;
		}

		loser.Components
			.Get<PlayerHealth>()?
			.EliminateForRoundRule();

		Log.Info(
			$"ROUND {RoundNumber} | HIGH NOON WINNER | PLAYER {GetTeamName( winnerTeam )} | REACTION {_highNoonTriggerTimes[winnerTeam] - HighNoonDrawAt:0.###}s"
		);

		EndRound(
			winner,
			players
		);
	}

	private void ResetHighNoonRuntimeState(
		IEnumerable<PlayerState> players,
		bool clearOwnerState
	)
	{
		HighNoonState =
			HighNoonStage.Inactive;

		HighNoonDrawAt =
			0f;

		HighNoonFirstTriggerAt =
			0f;

		HighNoonFirstTriggerTeam =
			-1;

		_highNoonTriggerTimes.Clear();
		_highNoonAdjudicateAt = 0f;

		if (
			!clearOwnerState ||
			players is null
		)
			return;

		foreach (
			PlayerState player in
				players
		)
		{
			player?.
				ClearHighNoonRoundState();
		}
	}

	// ============================================================
	// FIGHT TIMEOUT / SUDDEN DEATH
	// ============================================================

	private void ResolveFightTimeout(
		List<PlayerState> players
	)
	{
		PlayerState playerA =
			players.FirstOrDefault(
				x =>
					x.TeamId == 0
			);

		PlayerState playerB =
			players.FirstOrDefault(
				x =>
					x.TeamId == 1
			);

		if (
			playerA is null ||
			playerB is null
		)
			return;

		//
		// Sudden death has no health tiebreak after its timer.
		// If nobody lands the lethal shot, the round is a draw.
		//
		if ( IsSuddenDeath )
		{
			Log.Info(
				$"ROUND {RoundNumber} | SUDDEN DEATH EXPIRED | DRAW"
			);

			EndRound(
				null,
				players
			);

			return;
		}

		PlayerHealth healthA =
			playerA.Components
				.Get<PlayerHealth>();

		PlayerHealth healthB =
			playerB.Components
				.Get<PlayerHealth>();

		if (
			healthA is null ||
			healthB is null
		)
		{
			Log.Warning(
				"Round timeout could not compare health because a PlayerHealth component is missing."
			);

			EndRound(
				null,
				players
			);

			return;
		}

		float tolerance =
			MathF.Max(
				HealthTieTolerance,
				0f
			);

		float difference =
			healthA.Health -
			healthB.Health;

		if (
			difference >
			tolerance
		)
		{
			Log.Info(
				$"ROUND {RoundNumber} | TIME | PLAYER A WINS ON HEALTH | {healthA.Health:0.#} - {healthB.Health:0.#}"
			);

			EndRound(
				playerA,
				players
			);

			return;
		}

		if (
			difference <
			-tolerance
		)
		{
			Log.Info(
				$"ROUND {RoundNumber} | TIME | PLAYER B WINS ON HEALTH | {healthA.Health:0.#} - {healthB.Health:0.#}"
			);

			EndRound(
				playerB,
				players
			);

			return;
		}

		bool playerAFull =
			MathF.Abs(
				healthA.Health -
				healthA.MaxHealth
			) <=
			tolerance;

		bool playerBFull =
			MathF.Abs(
				healthB.Health -
				healthB.MaxHealth
			) <=
			tolerance;

		//
		// Only a completely untouched 100%-100% timeout gets
		// the extra one-shot phase.
		//
		if (
			playerAFull &&
			playerBFull
		)
		{
			StartSuddenDeath(
				players
			);

			return;
		}

		//
		// Equal health after both players have taken damage is
		// already a draw. No modifier is chosen and no player
		// earns/keeps an unbanked pot from this round.
		//
		Log.Info(
			$"ROUND {RoundNumber} | TIME | EQUAL HEALTH {healthA.Health:0.#} - {healthB.Health:0.#} | DRAW"
		);

		EndRound(
			null,
			players
		);
	}

	private void StartSuddenDeath(
		List<PlayerState> players
	)
	{
		IsSuddenDeath =
			true;

		SetSuddenDeathMode(
			players,
			true
		);

		FightEndsAt =
			Time.Now +
			MathF.Max(
				SuddenDeathDuration,
				1f
			);

		FightMessageEndsAt =
			Time.Now +
			1.5f;


		Log.Info(
			$"ROUND {RoundNumber} | SUDDEN DEATH | ONE SHOT | TIMER {MathF.Max( SuddenDeathDuration, 1f ):0.##}s"
		);
	}

	private void SetSuddenDeathMode(
		IEnumerable<PlayerState> players,
		bool enabled
	)
	{
		if ( !Networking.IsHost )
			return;

		foreach (
			PlayerState player in
				players
		)
		{
			PlayerHealth health =
				player.Components
					.Get<PlayerHealth>();

			health?.
				SetWeaponOneShotMode(
					enabled
				);
		}
	}

	// ============================================================
	// ROUND END
	// ============================================================

	private void CheckForRoundWinner(
		List<PlayerState> players
	)
	{
		PlayerState playerA =
			players.FirstOrDefault(
				x =>
					x.TeamId == 0
			);

		PlayerState playerB =
			players.FirstOrDefault(
				x =>
					x.TeamId == 1
			);

		if (
			playerA is null ||
			playerB is null
		)
			return;

		if (
			playerA.IsAlive &&
			playerB.IsAlive
		)
			return;

		if (
			!playerA.IsAlive &&
			!playerB.IsAlive
		)
		{
			EndRound(
				null,
				players
			);

			return;
		}

		PlayerState winner =
			playerA.IsAlive
				? playerA
				: playerB;

		EndRound(
			winner,
			players
		);
	}

	private void EndRound(
		PlayerState winner,
		List<PlayerState> players
	)
	{
		Phase =
			RoundPhase.RoundEnd;

		PhaseEndsAt =
			Time.Now +
			RoundEndDuration;

		DecisionEndsAt =
			0f;

		FightEndsAt =
			0f;

		FightMessageEndsAt =
			0f;

		IsSuddenDeath =
			false;

		ResetHighNoonRuntimeState(
			players,
			clearOwnerState: true
		);

		SetSuddenDeathMode(
			players,
			false
		);

		ClearRoundRuleRuntime(
			players
		);

		foreach (
			PlayerState player in
				players
		)
		{
			player.SetRoundControls(
				false
			);
		}

		//
		// DRAW
		//
		if ( winner is null )
		{
			LastRoundWinner =
				2;

			//
			// A draw awards nobody the round and does not destroy an
			// existing wager. The current pot owner keeps the exact stake.
			//
			// If this draw happened during an OMR modifier round, that
			// modifier is replayed on the next round so the wager remains
			// unresolved instead of silently becoming safer.
			//
			Log.Info(
				$"ROUND {RoundNumber}: DRAW | BANKED {ScoreA} - {ScoreB} | POT PRESERVED {CurrentPot} | OWNER {GetTeamName( PotOwnerTeam )}"
			);

			return;
		}

		LastRoundWinner =
			winner.TeamId;

		//
		// The current pot owner defended their streak.
		//
		if (
			CurrentPot > 0 &&
			PotOwnerTeam ==
				winner.TeamId
		)
		{
			CurrentPot =
				Math.Min(
					CurrentPot +
						Math.Max(
							OneMoreRoundPotGain,
							1
						),
					Math.Max(
						MaxPot,
						1
					)
				);
		}
		else
		{
			//
			// Either there was no pot yet, or the previous
			// holder lost it.
			//
			// The old unbanked pot simply disappears and the
			// round winner begins a new streak at 1.
			//
			PotOwnerTeam =
				winner.TeamId;

			CurrentPot =
				1;
		}

		Log.Info(
			$"ROUND {RoundNumber}: PLAYER {GetTeamName( winner.TeamId )} WINS | POT OWNER {GetTeamName( PotOwnerTeam )} | POT {CurrentPot}"
		);
	}

	private void ResolvePostRound(
		List<PlayerState> players
	)
	{
		//
		// A draw leaves the wager unresolved. Preserve the current pot and replay
		// the same modifier instead of burning earned-but-unbanked points.
		//
		if (
			LastRoundWinner ==
			2
		)
		{
			StartNextRound(
				players,
				ActiveModifier
			);

			return;
		}

		//
		// Max risk reached.
		//
		// Force banking rather than offering a gamble with no higher payout.
		//
		if (
			CurrentPot >=
			Math.Max(
				MaxPot,
				1
			)
		)
		{
			BankCurrentPot(
				players
			);

			return;
		}

		//
		// If banking already wins the match there is no strategically meaningful
		// reason to ask for another input. Secure the win immediately.
		//
		if (
			GetBankedScore(
				PotOwnerTeam
			) +
			CurrentPot >=
			Math.Max(
				TargetScore,
				1
			)
		)
		{
			BankCurrentPot(
				players
			);

			return;
		}

		//
		// Reveal the two possible next-round conditions before asking the winner
		// whether to risk the current pot. This makes the wager informed rather
		// than blind while preserving the loser's power to choose the condition.
		//
		PrepareModifierOffers();

		EnterWinnerDecision();
	}

	// ============================================================
	// WINNER DECISION
	// ============================================================

	private void EnterWinnerDecision()
	{
		if (
			PotOwnerTeam < 0 ||
			CurrentPot <= 0
		)
			return;

		Phase =
			RoundPhase.WinnerDecision;

		DecisionEndsAt =
			Time.Now +
			DecisionDuration;

		Log.Info(
			$"ONE MORE ROUND | PLAYER {GetTeamName( PotOwnerTeam )} DECIDES | POT {CurrentPot} -> {GetNextRiskPot()} | OFFERS {GetModifierDisplayName( ModifierOptionA )} / {GetModifierDisplayName( ModifierOptionB )}"
		);
	}

	public void RequestBank()
	{
		if ( Networking.IsHost )
		{
			PlayerState localPlayer =
				GetLocalPlayer();

			if ( localPlayer is null )
				return;

			HandleWinnerDecision(
				localPlayer.TeamId,
				true,
				GetPlayers()
			);

			return;
		}

		RequestWinnerDecisionHost(
			true
		);
	}

	public void RequestOneMoreRound()
	{
		if ( Networking.IsHost )
		{
			PlayerState localPlayer =
				GetLocalPlayer();

			if ( localPlayer is null )
				return;

			HandleWinnerDecision(
				localPlayer.TeamId,
				false,
				GetPlayers()
			);

			return;
		}

		RequestWinnerDecisionHost(
			false
		);
	}

	[Rpc.Host]
	private void RequestWinnerDecisionHost(
		bool bank
	)
	{
		if ( !Networking.IsHost )
			return;

		PlayerState caller =
			GetPlayerByOwnerId(
				Rpc.CallerId
			);

		if ( caller is null )
		{
			Log.Warning(
				"Winner decision rejected: caller has no PlayerState."
			);

			return;
		}

		HandleWinnerDecision(
			caller.TeamId,
			bank,
			GetPlayers()
		);
	}

	private void HandleWinnerDecision(
		int teamId,
		bool bank,
		List<PlayerState> players
	)
	{
		if (
			Mode !=
				SessionMode.Match ||
			Phase !=
				RoundPhase.WinnerDecision
		)
			return;

		if (
			teamId !=
				PotOwnerTeam
		)
		{
			Log.Warning(
				$"Winner decision rejected: Player {GetTeamName( teamId )} does not own the pot."
			);

			return;
		}

		if (
			CurrentPot <= 0
		)
			return;

		Log.Info(
			$"OMR DECISION | PLAYER {GetTeamName( teamId )} | CHOICE {(bank ? "BANK" : "ONE MORE ROUND")} | BANKED {GetBankedScore( teamId )}/{Math.Max( TargetScore, 1 )} | POT {CurrentPot} | NEXT {GetNextRiskPot()} | OFFERS {GetModifierDisplayName( ModifierOptionA )} [{ModifierOptionAWeaponPreview}] / {GetModifierDisplayName( ModifierOptionB )} [{ModifierOptionBWeaponPreview}]"
		);

		if ( bank )
		{
			BankCurrentPot(
				players
			);

			return;
		}

		BeginModifierDecision();
	}

	// ============================================================
	// BANK
	// ============================================================

	private void BankCurrentPot(
		List<PlayerState> players
	)
	{
		if (
			PotOwnerTeam < 0 ||
			CurrentPot <= 0
		)
			return;

		int bankingTeam =
			PotOwnerTeam;

		int amount =
			CurrentPot;

		if ( bankingTeam == 0 )
		{
			ScoreA +=
				amount;
		}
		else if ( bankingTeam == 1 )
		{
			ScoreB +=
				amount;
		}

		Log.Info(
			$"BANK | PLAYER {GetTeamName( bankingTeam )} +{amount} | BANKED {ScoreA} - {ScoreB}"
		);

		CurrentPot =
			0;

		PotOwnerTeam =
			-1;

		ActiveModifier =
			RoundModifier.Standard;

		DecisionEndsAt =
			0f;

		ModifierOptionA =
			RoundModifier.HighNoon;

		ModifierOptionB =
			RoundModifier.Standard;

		ModifierOptionAWeaponPreview =
			"RANDOM COMPETITIVE WEAPON";

		ModifierOptionBWeaponPreview =
			"RANDOM COMPETITIVE WEAPON";

		_modifierOffersPrepared =
			false;

		int score =
			bankingTeam == 0
				? ScoreA
				: ScoreB;

		if (
			score >=
				Math.Max(
					TargetScore,
					1
				)
		)
		{
			EndMatch(
				bankingTeam,
				players
			);

			return;
		}

		StartNextRound(
			players,
			RoundModifier.Standard
		);
	}

	// ============================================================
	// MODIFIER DECISION
	// ============================================================

	private void BeginModifierDecision()
	{
		if ( !_modifierOffersPrepared )
		{
			PrepareModifierOffers();
		}

		Phase =
			RoundPhase.ModifierDecision;

		DecisionEndsAt =
			Time.Now +
			DecisionDuration;

		int chooserTeam =
			GetOpponentTeam(
				PotOwnerTeam
			);

		Log.Info(
			$"ONE MORE ROUND ACCEPTED | PLAYER {GetTeamName( chooserTeam )} CHOOSES MODIFIER | OPTIONS {GetModifierDisplayName( ModifierOptionA )} / {GetModifierDisplayName( ModifierOptionB )}"
		);
	}

	private void PrepareModifierOffers()
	{
		OMRRoundRuleDirector ruleDirector =
			GetRoundRuleDirector();

		RoundModifier offerA =
			RoundModifier.HighNoon;

		RoundModifier offerB =
			RoundModifier.Standard;

		bool resolvedOffers =
			ruleDirector is not null &&
			ruleDirector.TryTakeModifierOffers(
				out offerA,
				out offerB
			);

		if ( resolvedOffers )
		{
			ModifierOptionA = offerA;
			ModifierOptionB = offerB;
		}
		else
		{
			// Safe compatibility fallback for scenes that have not received the
			// modular rule director yet.
			if ( RoundNumber % 2 == 0 )
			{
				ModifierOptionA = RoundModifier.HighNoon;
				ModifierOptionB = RoundModifier.Standard;
			}
			else
			{
				ModifierOptionA = RoundModifier.Standard;
				ModifierOptionB = RoundModifier.HighNoon;
			}
		}

		ModifierOptionAWeaponPreview =
			ResolveModifierWeaponPreviewHost(
				ModifierOptionA
			);

		ModifierOptionBWeaponPreview =
			ResolveModifierWeaponPreviewHost(
				ModifierOptionB
			);

		_modifierOffersPrepared =
			true;
	}

	public void RequestModifier(
		RoundModifier modifier
	)
	{
		if ( Networking.IsHost )
		{
			PlayerState localPlayer =
				GetLocalPlayer();

			if ( localPlayer is null )
				return;

			HandleModifierDecision(
				localPlayer.TeamId,
				modifier,
				GetPlayers()
			);

			return;
		}

		RequestModifierHost(
			modifier
		);
	}

	[Rpc.Host]
	private void RequestModifierHost(
		RoundModifier modifier
	)
	{
		if ( !Networking.IsHost )
			return;

		PlayerState caller =
			GetPlayerByOwnerId(
				Rpc.CallerId
			);

		if ( caller is null )
		{
			Log.Warning(
				"Modifier decision rejected: caller has no PlayerState."
			);

			return;
		}

		HandleModifierDecision(
			caller.TeamId,
			modifier,
			GetPlayers()
		);
	}

	private void HandleModifierDecision(
		int teamId,
		RoundModifier modifier,
		List<PlayerState> players
	)
	{
		if (
			Mode !=
				SessionMode.Match ||
			Phase !=
				RoundPhase.ModifierDecision
		)
			return;

		int expectedTeam =
			GetOpponentTeam(
				PotOwnerTeam
			);

		if (
			teamId !=
				expectedTeam
		)
		{
			Log.Warning(
				$"Modifier decision rejected: Player {GetTeamName( teamId )} is not the chooser."
			);

			return;
		}

		bool validChoice =
			modifier ==
				ModifierOptionA ||
			modifier ==
				ModifierOptionB;

		if ( !validChoice )
		{
			Log.Warning(
				$"Modifier decision rejected: {modifier} was not offered."
			);

			return;
		}

		DecisionEndsAt =
			0f;

		ActiveModifier =
			modifier;

		_modifierOffersPrepared =
			false;

		Log.Info(
			$"MODIFIER SELECTED | PLAYER {GetTeamName( teamId )} | {modifier}"
		);

		StartNextRound(
			players,
			modifier
		);
	}

	// ============================================================
	// MATCH END
	// ============================================================

	private void EndMatch(
		int winnerTeam,
		List<PlayerState> players
	)
	{
		Phase =
			RoundPhase.MatchEnd;

		MatchWinnerTeam =
			winnerTeam;

		PhaseEndsAt =
			0f;

		DecisionEndsAt =
			0f;

		FightEndsAt =
			0f;

		FightMessageEndsAt =
			0f;

		IsSuddenDeath =
			false;

		ResetHighNoonRuntimeState(
			players,
			clearOwnerState: true
		);

		SetSuddenDeathMode(
			players,
			false
		);

		ClearRoundRuleRuntime(
			players
		);

		foreach (
			PlayerState player in
				players
		)
		{
			player.SetRoundControls(
				false
			);
		}

		Log.Info(
			$"MATCH OVER | PLAYER {GetTeamName( winnerTeam )} WINS | FINAL {ScoreA} - {ScoreB}"
		);
	}

	// ============================================================
	// HELPERS
	// ============================================================

	private int GetOpponentTeam(
		int teamId
	)
	{
		return teamId switch
		{
			0 => 1,
			1 => 0,
			_ => -1
		};
	}

	private string GetTeamName(
		int teamId
	)
	{
		return teamId switch
		{
			0 => "A",
			1 => "B",
			_ => "?"
		};
	}

	private OMRRoundRuleProfile ResolveRoundRule(
		RoundModifier modifier
	)
	{
		return GetRoundRuleDirector()?.
			ResolveRule( modifier );
	}

	private float ResolveFightDuration(
		OMRRoundRuleProfile rule
	)
	{
		float authoredOverride =
			rule?.FightDurationOverride ?? 0f;

		if ( authoredOverride > 0f )
		{
			return MathF.Max(
				authoredOverride,
				1f
			);
		}

		return MathF.Max(
			FightDuration,
			1f
		);
	}

	private ArenaLayoutUsage GetLegacyArenaUsage(
		RoundModifier modifier
	)
	{
		return modifier == RoundModifier.HighNoon
			? ArenaLayoutUsage.HighNoon
			: ArenaLayoutUsage.Standard;
	}

	private bool UsesHighNoonFlow(
		RoundModifier modifier
	)
	{
		OMRRoundRuleProfile rule =
			ResolveRoundRule(
				modifier
			);

		if (
			rule is not null &&
			rule.IsValid
		)
		{
			return rule.SpecialFlow ==
				OMRRoundSpecialFlow.HighNoon;
		}

		return modifier ==
			RoundModifier.HighNoon;
	}

	public bool ActiveUsesHighNoonFlow =>
		UsesHighNoonFlow( ActiveModifier );

	public int GetBankedScore(
		int teamId
	)
	{
		return teamId switch
		{
			0 => ScoreA,
			1 => ScoreB,
			_ => 0
		};
	}

	public int GetBankScorePreview()
	{
		if (
			PotOwnerTeam < 0 ||
			CurrentPot <= 0
		)
			return 0;

		return Math.Min(
			GetBankedScore(
				PotOwnerTeam
			) +
			CurrentPot,
			Math.Max(
				TargetScore,
				1
			)
		);
	}

	public int GetNextRiskPot()
	{
		if ( CurrentPot <= 0 )
			return 1;

		return Math.Min(
			CurrentPot +
				Math.Max(
					OneMoreRoundPotGain,
					1
				),
			Math.Max(
				MaxPot,
				1
			)
		);
	}

	public string GetModifierWeaponPreview(
		RoundModifier modifier
	)
	{
		if ( modifier == ModifierOptionA )
			return ModifierOptionAWeaponPreview;

		if ( modifier == ModifierOptionB )
			return ModifierOptionBWeaponPreview;

		return "RANDOM COMPETITIVE WEAPON";
	}

	private string ResolveModifierWeaponPreviewHost(
		RoundModifier modifier
	)
	{
		OMRRoundRuleProfile rule =
			ResolveRoundRule(
				modifier
			);

		OMRRoundLoadoutDirector loadouts =
			GetRoundLoadoutDirector();

		GameObject previewWeapon =
			loadouts?.
				PreviewRoundWeapon(
					rule
				);

		if (
			previewWeapon is not null &&
			previewWeapon.IsValid
		)
		{
			return OMRWeaponUtility
				.GetDisplayName(
					previewWeapon
				)
				.ToUpperInvariant();
		}

		if (
			rule is not null &&
			rule.IsValid &&
			rule.WeaponPolicy ==
				OMRRoundWeaponPolicy.FilteredShuffle &&
			rule.AllowedWeaponIds is not null
		)
		{
			string[] ids =
				rule.AllowedWeaponIds
					.Where(
						x => !string.IsNullOrWhiteSpace( x )
					)
					.Select(
						x => x.Trim().ToUpperInvariant()
					)
					.ToArray();

			if ( ids.Length > 0 )
				return string.Join(
					" / ",
					ids
				);
		}

		return modifier == RoundModifier.HighNoon
			? "REVOLVER"
			: "RANDOM COMPETITIVE WEAPON";
	}

	public string GetModifierDisplayName(
		RoundModifier modifier
	)
	{
		OMRRoundRuleDirector director =
			GetRoundRuleDirector();

		if ( director is not null )
			return director.GetDisplayName( modifier );

		return modifier switch
		{
			RoundModifier.HighNoon => "HIGH NOON",
			RoundModifier.LowGravity => "LOW GRAVITY",
			RoundModifier.HeadshotOnly => "HEADSHOT ONLY",
			RoundModifier.LargeCaliber => "LARGE CALIBER",
			RoundModifier.DoubleJump => "DOUBLE JUMP",
			RoundModifier.HipfireOnly => "HIPFIRE ONLY",
			RoundModifier.Blitz => "BLITZ",
			_ => "STANDARD"
		};
	}

	public string GetModifierDescription(
		RoundModifier modifier
	)
	{
		OMRRoundRuleDirector director =
			GetRoundRuleDirector();

		if ( director is not null )
			return director.GetDescription( modifier );

		return modifier switch
		{
			RoundModifier.HighNoon => "Open arena. Draw on the signal.",
			RoundModifier.LowGravity => "Reduced gravity with a competitive weapon.",
			RoundModifier.HeadshotOnly => "Only headshots deal weapon damage.",
			RoundModifier.LargeCaliber => "Smaller magazine. Higher damage and recoil.",
			RoundModifier.DoubleJump => "Gain one additional jump while airborne.",
			RoundModifier.HipfireOnly => "ADS disabled. Fight from the hip.",
			RoundModifier.Blitz => "A fast 30-second round.",
			_ => "Standard arena rules."
		};
	}

	private GameObject ResolveRoundWeapon(
		RoundModifier modifier,
		OMRRoundRuleProfile rule
	)
	{
		OMRRoundLoadoutDirector director =
			GetRoundLoadoutDirector();

		if ( director is null )
		{
			Log.Warning(
				"ROUND LOADOUTS | OMRRoundLoadoutDirector is missing; match loadout rotation is disabled."
			);

			return null;
		}

		if (
			rule is not null &&
			rule.IsValid
		)
		{
			return director.ResolveRoundWeapon(
				RoundNumber,
				rule
			);
		}

		return director.ResolveRoundWeapon(
			RoundNumber,
			modifier
		);
	}

	private void ApplyRoundRuleRuntime(
		IEnumerable<PlayerState> players,
		OMRRoundRuleProfile rule
	)
	{
		if ( !Networking.IsHost )
			return;

		bool headshotOnly =
			rule?.HeadshotOnly ?? false;

		RoundModifier modifier =
			rule?.Modifier ??
			ActiveModifier;

		foreach ( PlayerState player in players )
		{
			if ( player is null )
				continue;

			player.Components
				.Get<PlayerHealth>()?
				.SetWeaponHeadshotOnlyMode(
					headshotOnly
				);

			player.ApplyRoundRule(
				modifier
			);
		}

		if ( rule is not null )
		{
			Log.Info(
				$"ROUND {RoundNumber} | RULE {rule.GetDisplayName()} | Gravity:{MathX.Clamp( rule.GravityScale, 0.05f, 3f ):0.##} | AirJumps:{Math.Clamp( rule.AdditionalAirJumps, 0, 3 )} | AllowAim:{rule.AllowAim} | FightOverride:{MathF.Max( rule.FightDurationOverride, 0f ):0.##} | HeadshotOnly:{headshotOnly} | WeaponPolicy:{rule.WeaponPolicy}"
			);
		}
	}

	private void ClearRoundRuleRuntime(
		IEnumerable<PlayerState> players
	)
	{
		if (
			!Networking.IsHost ||
			players is null
		)
			return;

		foreach ( PlayerState player in players )
		{
			if ( player is null )
				continue;

			ClearRoundWeaponModifiersHost(
				player
			);

			player.Components
				.Get<PlayerHealth>()?
				.SetWeaponHeadshotOnlyMode(
					false
				);

			player.ClearRoundRuleState();
		}
	}

	private void ApplyRoundWeaponModifiersHost(
		PlayerState player,
		OMRRoundRuleProfile rule
	)
	{
		if (
			!Networking.IsHost ||
			player is null
		)
			return;

		// A logical round may reuse the same weapon prefab with a different rule.
		// Remove only our previous round-rule IDs before applying the new set.
		ClearRoundWeaponModifiersHost(
			player
		);

		if (
			rule is null ||
			!rule.IsValid ||
			rule.WeaponModifiers is null ||
			rule.WeaponModifiers.Count <= 0
		)
			return;

		BaseInventoryComponent inventory =
			player.Components
				.Get<BaseInventoryComponent>();

		OneMoreRoundWeapon weapon =
			inventory?.ActiveItem as
				OneMoreRoundWeapon;

		if ( weapon is null )
		{
			Log.Warning(
				$"ROUND {RoundNumber} | RULE {rule.Modifier} | Host could not resolve active weapon for modifier application."
			);
			return;
		}

		HashSet<string> appliedIds =
			new(
				StringComparer.OrdinalIgnoreCase
			);

		foreach ( OMRWeaponModifierDefinition modifier in rule.WeaponModifiers )
		{
			if (
				modifier is null ||
				!modifier.IsValid
			)
				continue;

			string id =
				modifier.ModifierId?.Trim() ??
				string.Empty;

			if (
				string.IsNullOrWhiteSpace( id ) ||
				!appliedIds.Add( id )
			)
				continue;

			weapon.SetStatModifier( modifier );
		}

		if ( appliedIds.Count <= 0 )
			return;

		_hostRoundRuleWeapons[player] = weapon;
		_hostRoundRuleWeaponModifierIds[player] = appliedIds;
	}

	private void ClearRoundWeaponModifiersHost(
		PlayerState player
	)
	{
		if ( player is null )
			return;

		if (
			_hostRoundRuleWeapons.TryGetValue(
				player,
				out OneMoreRoundWeapon weapon
			) &&
			weapon is not null &&
			weapon.IsValid &&
			_hostRoundRuleWeaponModifierIds.TryGetValue(
				player,
				out HashSet<string> ids
			) &&
			ids is not null
		)
		{
			foreach ( string id in ids )
			{
				weapon.RemoveStatModifier( id );
			}
		}

		_hostRoundRuleWeapons.Remove( player );
		_hostRoundRuleWeaponModifierIds.Remove( player );
	}

	// ============================================================
	// PLAYER RESET
	// ============================================================

	private void ResetPlayer(
		PlayerState player,
		GameObject spawn,
		GameObject lookAtSpawn
	)
	{
		if (
			player is null ||
			spawn is null
		)
			return;

		PlayerHealth health =
			player.Components
				.Get<PlayerHealth>();

		if ( health is not null )
		{
			health.ResetHealth();
		}

		ResetWeaponsForRound(
			player
		);

		player.IsAlive =
			true;

		Vector3 lookTarget =
			lookAtSpawn is not null
				? lookAtSpawn.WorldPosition
				: spawn.WorldPosition +
					spawn.WorldRotation.Forward *
					100f;

		player.MoveToRoundSpawn(
			spawn.WorldPosition,
			lookTarget
		);
	}

	/// <summary>
	/// Restores authoritative weapon state at the beginning of a round.
	///
	/// Magazine counts and reserve ammo belong to the host, so this is
	/// deliberately kept in RoundManager rather than the owner-side spawn RPC.
	/// Presentation state is rebuilt by PlayerState on the owning client.
	/// </summary>
	private void ResetWeaponsForRound(
		PlayerState player
	)
	{
		if ( !Networking.IsHost )
			return;

		BaseInventoryComponent inventory =
			player.Components
				.Get<BaseInventoryComponent>();

		if ( inventory is null )
			return;

		foreach (
			BaseCombatWeapon weapon in
				inventory.Items
					.OfType<BaseCombatWeapon>()
		)
		{
			//
			// A reload from the previous round must not be allowed to
			// complete after the new round has already restored ammunition.
			//
			if ( weapon.IsReloading )
			{
				weapon.CancelReload();
			}

			if (
				weapon.UsesPrimaryClip &&
				weapon.PrimaryClipSize >= 0
			)
			{
				weapon.Clip1 =
					weapon.PrimaryClipSize;
			}

			if (
				weapon.UsesSecondaryClip &&
				weapon.SecondaryClipSize >= 0
			)
			{
				int secondaryFill =
					weapon.SecondaryDefaultClip < 0
						? weapon.SecondaryClipSize
						: Math.Min(
							weapon.SecondaryDefaultClip,
							weapon.SecondaryClipSize
						);

				weapon.Clip2 =
					Math.Max(
						secondaryFill,
						0
					);
			}

			//
			// Null ammo type means the weapon intentionally has an
			// unlimited reserve; there is no inventory pool to restore.
			//
			if ( weapon.PrimaryAmmoType is not null )
			{
				inventory.SetAmmo(
					weapon.PrimaryAmmoType,
					Math.Max(
						weapon.StartingAmmo,
						0
					)
				);
			}
		}
	}
}