Weapons/WeaponSystem.cs

The host-side weapon system for Horizon RP. It syncs a weapon and accessory catalog from the backend, draws and holsters inventory weapons with per-serial ammo and attachments, validates player and vehicle hit RPCs, drops weapons when a player goes down, and mounts the weapon packages at startup.

NetworkingExternal Download
using SWB.Base;

namespace HorizonRP;

/// <summary>
/// Les armes, façon FiveM. Une arme est un objet d'inventaire (« arme:colt ») qui
/// garde sa série, ses balles et ses accessoires ; on la sort et on la range par
/// Utiliser ou une case rapide. L'hôte la fait apparaître dans la main du joueur
/// (prefab de simple-weapon-base, aux valeurs du catalogue) et la donne à sa
/// machine, qui vise et tire.
///
/// L'hôte tient le catalogue (lu dans l'API, relu quand sa version change, réglable
/// depuis le panneau et le menu admin) et revérifie tout ce qui compte : les balles
/// prises dans l'inventaire, et chaque tir qui touche (arme sortie, cadence,
/// distance, ligne de vue). Les dégâts sont ceux du catalogue, jamais ceux que le
/// tireur annonce.
/// </summary>
[Title( "Horizon RP – Armes" )]
[Icon( "my_location" )]
public sealed partial class WeaponSystem : Component, Component.INetworkListener
{
	public static WeaponSystem Current { get; private set; }

	[Sync( SyncFlags.FromHost )] public string CatalogJson { get; set; }

	/// <summary>Les réglages des accessoires (nom, prix, vente), lus avec le catalogue des armes.</summary>
	[Sync( SyncFlags.FromHost )] public string AccessoriesJson { get; set; }

	List<WeaponEntry> catalog = new();
	List<AccessoryEntry> accessories = new();
	List<AccessoryEntry> publishedAccessories = new();
	string parsedAccessories;
	string version;
	bool loading;

	List<WeaponEntry> published = new();
	string parsedJson;

	protected override void OnAwake()
	{
		Current = this;
		Weapon.InputBlocked = IsInputBlocked;
	}

	/// <summary>
	/// SWB lit le tir directement au clavier : on le coupe quand un menu est ouvert,
	/// pendant une pose de prop ou d'objet, et avec le physgun en main. Une méthode
	/// nommée et pas une lambda : le rechargement à chaud de l'éditeur ne sait pas
	/// toujours remplacer une lambda, et l'ancienne lève alors une exception à chaque
	/// appel (la molette de l'inventaire ne marchait plus).
	/// </summary>
	static bool IsInputBlocked( SWB.Shared.IPlayerBase _ )
	{
		var scene = Game.ActiveScene;
		return UiLock.IsLocked || StaffTool.Equipped
			|| (scene is not null && (scene.GetAllComponents<PropMenu>().Any( x => x.IsPlacing )
				|| scene.GetAllComponents<AdminMenu>().Any( x => x.Building )));
	}

	/// <summary>Le même blocage, pour les touches que le jeu lit lui-même (la lampe des armes FA:S).</summary>
	public static bool InputBlockedNow => IsInputBlocked( null );

	protected override void OnDestroy()
	{
		if ( Current == this )
			Current = null;
	}

	/// <summary>
	/// Les paquets des armes de Facepunch et des bras humains (aussi dans PackageReferences
	/// du .sbproj). Chaque machine les monte au démarrage : un modèle d'arme qui n'est pas
	/// monté quand l'arme sort reste une boîte orange.
	/// </summary>
	static readonly string[] Packages =
	{
		"facepunch.v_first_person_arms_human",
		"facepunch.v_usp", "facepunch.w_usp", "facepunch.v_mp5", "facepunch.w_mp5",
		"facepunch.v_m4a1", "facepunch.w_m4a1", "facepunch.v_m700", "facepunch.w_m700",
		"facepunch.v_spaghellim4", "facepunch.w_spaghellim4",
		// Rust (Facepunch) : modèle à la troisième personne seulement, essai du 28/10/2026.
		"rust.w_ak47",
	};

	protected override void OnStart()
	{
		if ( !Scene.IsEditor )
		{
			_ = MountPackages();
			_ = WorkshopPackages.MountAll();
		}
	}

	static async Task MountPackages()
	{
		foreach ( var ident in Packages )
		{
			try
			{
				var package = await Package.Fetch( ident, false );
				if ( package is not null )
					await package.MountAsync();
			}
			catch ( Exception e )
			{
				Log.Warning( $"[Horizon] Paquet d'arme {ident} : {e.Message}" );
			}
		}
	}

	public IReadOnlyList<WeaponEntry> Catalog
	{
		get
		{
			if ( Networking.IsHost )
				return catalog;

			if ( parsedJson != CatalogJson )
			{
				parsedJson = CatalogJson;
				published = string.IsNullOrEmpty( CatalogJson ) ? new() : Json.Deserialize<List<WeaponEntry>>( CatalogJson ) ?? new();
			}
			return published;
		}
	}

	public WeaponEntry Get( string id ) => id is null ? null : Catalog.FirstOrDefault( x => x.Id == id );

	/// <summary>Vide sans base : les accessoires gardent alors les valeurs du code.</summary>
	public IReadOnlyList<AccessoryEntry> AccessorySettings
	{
		get
		{
			if ( Networking.IsHost )
				return accessories;

			if ( parsedAccessories != AccessoriesJson )
			{
				parsedAccessories = AccessoriesJson;
				publishedAccessories = string.IsNullOrEmpty( AccessoriesJson ) ? new() : Json.Deserialize<List<AccessoryEntry>>( AccessoriesJson ) ?? new();
			}
			return publishedAccessories;
		}
	}

	protected override void OnUpdate()
	{
		// Reposé à chaque image, sur toutes les machines : après un rechargement à chaud,
		// le délégué gardé par SWB peut pointer sur l'ancienne version du code.
		Weapon.InputBlocked = IsInputBlocked;

		if ( Scene.IsEditor || !Networking.IsHost || !Networking.IsActive )
			return;

		if ( version is null && !loading )
			_ = LoadCatalog();

		EnsureSettings();
	}

	/// <summary>Les réglages de SWB (une seule fois par partie) : rechargement automatique, sans aide à la visée ni menu Q.</summary>
	void EnsureSettings()
	{
		if ( WeaponSettings.Instance.IsValid() )
			return;

		var go = new GameObject( true, "Horizon RP – réglages des armes" );
		var settings = go.Components.Create<WeaponSettings>();
		settings.Customization = false;
		settings.AimAssist = false;
		settings.AutoReload = true;
		go.NetworkSpawn();
	}

	// Catalogue (hôte)

	async Task LoadCatalog()
	{
		loading = true;
		try
		{
			if ( !Backend.IsOnline )
			{
				catalog = WeaponRules.Defaults();
				version = "local";
			}
			else
			{
				var response = await Backend.LoadWeapons();
				if ( response?.Weapons is null )
				{
					Log.Warning( "[Horizon] Catalogue des armes indisponible (API injoignable)" );
					return;
				}
				catalog = response.Weapons;
				accessories = response.Accessories ?? new();
				version = response.Version ?? "";
			}

			CatalogJson = Json.Serialize( catalog );
			AccessoriesJson = Json.Serialize( accessories );
			Log.Info( $"[Horizon] {catalog.Count} arme(s) au catalogue" );
		}
		finally
		{
			loading = false;
		}
	}

	/// <summary>Hôte, à chaque échange avec l'API : un catalogue changé ailleurs (panneau web) est relu.</summary>
	public void CheckVersion( string apiVersion )
	{
		if ( !string.IsNullOrEmpty( apiVersion ) && version is not null && version != "local" && apiVersion != version && !loading )
			_ = LoadCatalog();
	}

	/// <summary>Le menu admin en jeu règle une arme : l'API l'enregistre, puis le catalogue est relu.</summary>
	[Rpc.Host]
	public void StaffSaveWeapon( string json )
	{
		var staff = Caller();
		if ( staff is null || !staff.IsStaff )
			return;

		var edited = Json.Deserialize<WeaponEntry>( json ?? "" );
		if ( edited is null || Get( edited.Id ) is null )
			return;

		_ = SaveWeapon( staff, edited );
	}

	async Task SaveWeapon( RoleplayPlayer staff, WeaponEntry edited )
	{
		if ( !Backend.IsOnline )
		{
			// Sans base : le réglage vaut pour cette partie seulement.
			catalog.RemoveAll( x => x.Id == edited.Id );
			catalog.Add( edited );
			CatalogJson = Json.Serialize( catalog );
			staff.Notify( $"{edited.Name} réglée (partie locale, non enregistrée).", "info" );
			return;
		}

		var response = await Backend.SaveWeapon( edited, $"{staff.RpName} ({staff.SteamId})" );
		if ( response?.Ok != true )
		{
			staff.Notify( response?.Error ?? "API injoignable : arme non enregistrée.", "erreur" );
			return;
		}

		staff.Notify( $"{edited.Name} enregistrée au catalogue.", "info" );
		await LoadCatalog();
	}

	/// <summary>Le menu admin en jeu règle un accessoire (nom, prix, vente) : l'API l'enregistre, puis le catalogue est relu.</summary>
	[Rpc.Host]
	public void StaffSaveAccessory( string json )
	{
		var staff = Caller();
		if ( staff is null || !staff.IsStaff )
			return;

		var edited = Json.Deserialize<AccessoryEntry>( json ?? "" );
		if ( edited is null || WeaponRules.Accessory( edited.Id ) is null )
			return;
		edited.Price = Math.Clamp( edited.Price, 0, 1000000 );

		_ = SaveAccessory( staff, edited );
	}

	async Task SaveAccessory( RoleplayPlayer staff, AccessoryEntry edited )
	{
		if ( !Backend.IsOnline )
		{
			accessories.RemoveAll( x => x.Id == edited.Id );
			accessories.Add( edited );
			AccessoriesJson = Json.Serialize( accessories );
			staff.Notify( $"{edited.Name} réglé (partie locale, non enregistrée).", "info" );
			return;
		}

		var response = await Backend.SaveAccessory( edited, $"{staff.RpName} ({staff.SteamId})" );
		if ( response?.Ok != true )
		{
			staff.Notify( response?.Error ?? "API injoignable : accessoire non enregistré.", "erreur" );
			return;
		}

		staff.Notify( $"{edited.Name} enregistré au catalogue.", "info" );
		await LoadCatalog();
	}

	// Sortir et ranger (hôte)

	static WeaponHolder HolderOf( RoleplayPlayer player ) => player?.GetComponent<WeaponHolder>();

	/// <summary>Ce que l'hôte sait de l'arme sortie de chaque joueur : les balles qu'elle peut avoir au plus.</summary>
	sealed class Drawn
	{
		public int MaxClip;
		public int Clip;
		public RealTimeSince Window;
		public int HitsInWindow;
	}

	readonly Dictionary<ulong, Drawn> drawn = new();

	public void ToggleWeapon( RoleplayPlayer player, InventorySlot slot )
	{
		var holder = HolderOf( player );
		if ( holder is null || player.NeedsCharacter || player.OnTheGround )
			return;

		var data = WeaponData.Read( slot.Data );
		var wasThis = data is not null && holder.ActiveSerial == data.Serial;
		Holster( player );
		// Une arme sortie range la clé de véhicule tenue en main.
		player.HeldKey = null;
		if ( !wasThis )
			Draw( player, slot );
	}

	void Draw( RoleplayPlayer player, InventorySlot slot )
	{
		var holder = HolderOf( player );
		var entry = Get( WeaponItems.WeaponOf( slot.Item ) );
		if ( entry is null || !entry.Active )
		{
			player.Notify( "Cette arme n'est plus en service.", "erreur" );
			return;
		}

		// Menotté ou en prison (Police/) : pas d'arme.
		if ( player.Frozen || player.Restrained || player.Cuffed || player.Jailed )
		{
			player.Notify( player.Jailed ? "Pas d'arme en prison." : "Impossible de sortir une arme maintenant.", "erreur" );
			return;
		}

		var data = WeaponData.Read( slot.Data ) ?? new WeaponData { Serial = WeaponData.NewSerial() };
		data.Ammo = Math.Clamp( data.Ammo, 0, ClipOf( entry, data ) );
		// Une grenade (ou une mine) est elle-même ce qu'on lance : sortie, elle est prête, le premier
		// clic la lance. Sans ça, il fallait cliquer une fois pour « recharger » (Julien). Les
		// suivantes, s'il y en a dans l'inventaire, se rechargent seules après le lancer.
		if ( Explosives.Get( entry.Id ) is { Mode: "lance" or "mine" } && data.Ammo <= 0 )
			data.Ammo = 1;
		slot.Data = data.Write();

		var prefab = GameObject.GetPrefab( entry.Prefab );
		if ( prefab is null )
		{
			player.Notify( $"Arme introuvable ({entry.Prefab}).", "erreur" );
			Log.Warning( $"[Horizon] Prefab d'arme introuvable : {entry.Prefab}" );
			return;
		}

		var go = prefab.Clone( new CloneConfig( player.WorldTransform, player.GameObject, false, $"Arme – {entry.Name}" ) );
		Apply( go, entry, data );
		// Chaque machine la pose elle-même sur la main de sa copie du corps, juste avant le rendu
		// (WeaponHolder.FollowHand) : ce que le réseau envoie de sa position ne compte pas. Surtout
		// pas de go.Network.Flags ici : avant NetworkSpawn, l'arme n'a pas d'accès réseau à elle et
		// ce sont les drapeaux du JOUEUR qui changeaient — NoTransformSync figeait sa position chez
		// tous les autres (essai à deux du 30/10/2026).
		go.NetworkSpawn( false, player.Network.Owner );

		holder.ActiveSerial = data.Serial;
		holder.ActiveWeaponId = entry.Id;
		holder.ActiveObject = go;
		drawn[player.SteamId] = new Drawn { MaxClip = data.Ammo, Clip = data.Ammo };

		go.GetComponent<Weapon>( true )?.OnCarryStart();
		HorizonGame.Current?.AddLog( "arme_sortie", player.SteamId, $"{entry.Name} ({data.Serial})" );
	}

	/// <summary>Les valeurs du catalogue, posées avant la mise en réseau : la machine du joueur les reçoit avec l'arme.</summary>
	static void Apply( GameObject go, WeaponEntry entry, WeaponData data )
	{
		var weapon = go.GetComponent<Weapon>( true );
		if ( weapon is null )
			return;

		weapon.DisplayName = entry.Name;
		weapon.ReloadTime = entry.ReloadTime;
		weapon.Mobility = entry.Mobility;

		if ( weapon.Primary is { } primary )
		{
			primary.Damage = entry.Damage;
			primary.RPM = entry.Rpm;
			primary.ClipSize = entry.ClipSize;
			primary.Bullets = Math.Max( 1, entry.Bullets );
			primary.Recoil = entry.Recoil;
			primary.Spread = entry.Spread;
			primary.AmmoType = entry.Ammo;
			primary.Ammo = data.Ammo;
			primary.InfiniteAmmo = InfiniteAmmoType.disabled;
		}

		// Les accessoires montés sur cette arme, et eux seuls.
		foreach ( var attachment in go.Components.GetAll<SWB.Base.Attachments.Attachment>( FindMode.EverythingInSelfAndDescendants ) )
			attachment.Enable = data.Att.Contains( attachment.GetType().Name );
	}

	/// <summary>Range l'arme sortie et garde ses balles dans la case de l'inventaire.</summary>
	public void Holster( RoleplayPlayer player )
	{
		var holder = HolderOf( player );
		if ( holder is null || holder.ActiveSerial is null )
			return;

		var slot = player.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == holder.ActiveSerial );
		if ( slot is not null && drawn.TryGetValue( player.SteamId, out var state ) )
		{
			var data = WeaponData.Read( slot.Data );
			data.Ammo = Math.Clamp( state.Clip, 0, state.MaxClip );
			slot.Data = data.Write();
			player.PublishInventory();
		}

		if ( holder.ActiveObject.IsValid() )
			holder.ActiveObject.Destroy();

		holder.ActiveObject = null;
		holder.ActiveSerial = null;
		holder.ActiveWeaponId = null;
		drawn.Remove( player.SteamId );
	}

	/// <summary>Avant de jeter, donner ou retirer une arme : si c'est celle qui est sortie, on la range.</summary>
	public void HolsterIfActive( RoleplayPlayer player, InventorySlot slot )
	{
		var serial = WeaponData.Read( slot?.Data )?.Serial;
		if ( serial is not null && HolderOf( player )?.ActiveSerial == serial )
			Holster( player );
	}

	// Demandes de la machine du tireur (hôte)

	RoleplayPlayer Caller() =>
		RoleplayPlayer.All.FirstOrDefault( x => Rpc.Caller is not null && x.Network.OwnerId == Rpc.Caller.Id );

	/// <summary>Le rechargement : l'hôte ôte les balles de l'inventaire, et le chargeur peut en contenir d'autant plus.</summary>
	[Rpc.Host]
	public void RequestTakeAmmo( int amount )
	{
		var player = Caller();
		var holder = HolderOf( player );
		var entry = holder?.ActiveEntry;
		if ( entry is null || !drawn.TryGetValue( player.SteamId, out var state ) )
			return;

		var clip = ClipOf( entry, WeaponData.Read( player.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == holder.ActiveSerial )?.Data ) );
		amount = Math.Clamp( amount, 0, clip );
		var left = amount;
		foreach ( var slot in player.Inventory.Where( x => x.Item == entry.Ammo ).OrderByDescending( x => x.Slot ).ToList() )
		{
			var take = Math.Min( left, slot.Count );
			player.RemoveFrom( slot, take );
			left -= take;
			if ( left == 0 )
				break;
		}

		state.MaxClip = Math.Min( clip, state.MaxClip + (amount - left) );
	}

	/// <summary>Les balles du chargeur, que la machine du tireur annonce : jamais plus que ce qu'il a pu y mettre.</summary>
	[Rpc.Host]
	public void ReportClip( string serial, int clip )
	{
		var player = Caller();
		if ( player is null || HolderOf( player )?.ActiveSerial != serial || !drawn.TryGetValue( player.SteamId, out var state ) )
			return;

		// Un chargeur qui se vide ne se remplit pas sans passer par RequestTakeAmmo.
		state.Clip = Math.Clamp( clip, 0, state.MaxClip );
		state.MaxClip = state.Clip;
	}

	/// <summary>
	/// Une balle a touché un joueur, d'après la machine du tireur. L'hôte vérifie que
	/// l'arme est sortie, que la cadence est possible, que la cible est à portée et en
	/// vue, puis ôte les dégâts du catalogue (et le bonus de la tête).
	/// </summary>
	[Rpc.Host( NetFlags.Unreliable )]
	public void RequestHit( Guid targetId, Vector3 hitPosition, bool headshot )
	{
		var shooter = Caller();
		var holder = HolderOf( shooter );
		var entry = holder?.ActiveEntry;
		var target = Scene.Directory.FindByGuid( targetId )?.GetComponent<RoleplayPlayer>();
		if ( entry is null || target is null || target == shooter || target.Health <= 0 || !drawn.TryGetValue( shooter.SteamId, out var state ) )
			return;

		// La cadence : pas plus de touches qu'en tirant à fond, avec de la marge.
		if ( state.Window > 1f )
		{
			state.Window = 0;
			state.HitsInWindow = 0;
		}
		var allowed = entry.Rpm / 60f * Math.Max( 1, entry.Bullets ) * 1.5f + 2;
		if ( ++state.HitsInWindow > allowed )
			return;

		// La touche doit être sur la cible, et la cible à portée.
		if ( hitPosition.Distance( target.WorldPosition ) > 140 || shooter.WorldPosition.Distance( target.WorldPosition ) > 12000 )
			return;

		// Et en vue : rien du décor entre l'œil du tireur et l'impact.
		var eye = shooter.GetComponent<PlayerController>()?.EyePosition ?? shooter.WorldPosition + Vector3.Up * 64;
		var toHit = hitPosition - eye;
		var sightTrace = Scene.Trace.Ray( eye, eye + toHit * 0.95f )
			.IgnoreGameObjectHierarchy( shooter.GameObject )
			.IgnoreGameObjectHierarchy( target.GameObject )
			.WithoutTags( "player", "trigger" );
		// À bord, la boîte de collision de son véhicule ne l'abrite pas (on tire par la vitre).
		if ( target.GameObject.GetComponentInParent<Vehicle>() is { } aboard )
			sightTrace = sightTrace.IgnoreGameObject( aboard.GameObject );
		// Les vitres de la map ne cachent pas la cible : la balle les traverse (World/MapGlass.cs).
		if ( MapGlass.ThroughGlass( sightTrace, eye, eye + toHit * 0.95f ).Hit )
			return;
		// La vitre traversée pour l'atteindre vole en éclats.
		target.GameObject.GetComponentInParent<Vehicle>()?.BreakGlassOnLine( eye, hitPosition );

		// Mode dieu (staff) : touché, mais aucun dégât.
		if ( target.GodMode )
			return;
		var damage = entry.Damage * (headshot ? entry.Headshot : 1f);
		target.Health = MathF.Max( 0, target.Health - damage );

		if ( target.Health <= 0 )
		{
			// Le détail d'un meurtre : l'arme et son numéro de série, la distance, la tête ou non.
			var meters = shooter.WorldPosition.Distance( target.WorldPosition ) * 0.0254f;
			var serial = string.IsNullOrEmpty( holder.ActiveSerial ) ? "" : $" (n° {holder.ActiveSerial})";
			var where = headshot ? ", tir à la tête" : "";
			var inCar = target.GameObject.GetComponentInParent<Vehicle>() is { } car ? $", à bord de {car.Plate}" : "";
			HorizonGame.Current?.AddLog( "abattu", target.SteamId,
				$"{target.RpName} abattu par {shooter.RpName} ({shooter.SteamId}) au {entry.Name}{serial}, à {meters:0} m{where}{inCar} ({damage:0} de dégâts au dernier tir)" );
			HorizonGame.Current?.Down( target, $"abattu au {entry.Name}{serial} à {meters:0} m{where}", shooter );
		}
	}

	/// <summary>
	/// Une balle a touché un véhicule, d'après la machine du tireur : la pièce (caisse, vitre, roue)
	/// et le point, dans le repère de la caisse. Mêmes contrôles que pour un joueur (arme sortie,
	/// cadence, portée, vue). Les dégâts suivent la munition de l'arme (Ballistics).
	///
	/// L'hôte refaisait le tir sur sa propre copie et exigeait le même point à 12 pouces près : une
	/// voiture qui roule n'est pas au même endroit chez lui (la latence), et sur le serveur dédié
	/// aucune balle ne passait — « aucun dégât » (Julien, 30/10/2026). Le tireur, qui voit ce qu'il
	/// vise, dit ce qu'il a touché ; l'hôte vérifie que le point tient dans le véhicule et qu'il le voit.
	/// </summary>
	[Rpc.Host( NetFlags.Unreliable )]
	public void RequestVehicleHit( Guid vehicleId, Vector3 localPoint, Vector3 localNormal, int part, int index, int piece )
	{
		var shooter = Caller();
		var holder = HolderOf( shooter );
		var entry = holder?.ActiveEntry;
		var vehicle = Scene.Directory.FindByGuid( vehicleId )?.GetComponent<Vehicle>();
		if ( entry is null || vehicle is null || !vehicle.Ready || !drawn.TryGetValue( shooter.SteamId, out var state ) )
			return;

		if ( state.Window > 1f )
		{
			state.Window = 0;
			state.HitsInWindow = 0;
		}
		var allowed = entry.Rpm / 60f * Math.Max( 1, entry.Bullets ) * 1.5f + 2;
		if ( ++state.HitsInWindow > allowed )
			return;

		// Le point doit tenir dans le véhicule (roues comprises, sous la caisse) et la pièce exister.
		if ( vehicle.Rig is not { } rig || !vehicle.BodyFrame( out var frame ) )
			return;
		var bounds = rig.BodyBounds;
		if ( localPoint.x < bounds.Mins.x - 30 || localPoint.x > bounds.Maxs.x + 30 || localPoint.y < bounds.Mins.y - 30
			|| localPoint.y > bounds.Maxs.y + 30 || localPoint.z < bounds.Mins.z - 40 || localPoint.z > bounds.Maxs.z + 30 )
			return;
		if ( part is < 0 or > (int)Vehicle.ShotPart.Wheel || part == (int)Vehicle.ShotPart.Wheel && (index < 0 || index >= rig.Wheels.Length) )
			return;
		var point = frame.PointToWorld( localPoint );
		var normal = frame.Rotation * localNormal.Normal;

		var eye = shooter.GetComponent<PlayerController>()?.EyePosition ?? shooter.WorldPosition + Vector3.Up * 64;
		if ( eye.Distance( point ) > 12000 || shooter.GameObject.GetComponentInParent<Vehicle>() == vehicle )
			return;
		var dir = (point - eye).Normal;

		var sight = MapGlass.ThroughGlass( Scene.Trace.Ray( eye, point - dir * 2 )
			.IgnoreGameObjectHierarchy( shooter.GameObject )
			.IgnoreGameObjectHierarchy( vehicle.GameObject )
			.WithoutTags( "player", "trigger" ), eye, point - dir * 2 );
		if ( sight.Hit )
			return;

		var round = Ballistics.Of( entry.Ammo );
		var energy = round.Energy / Math.Max( 1, entry.Bullets );
		switch ( (Vehicle.ShotPart)part )
		{
			case Vehicle.ShotPart.Wheel:
				vehicle.Puncture( index );
				break;
			case Vehicle.ShotPart.Glass:
				vehicle.BreakGlass( index, piece );
				vehicle.LoseHealth( energy / (Vehicle.BodyDamageDivisor * 2.5f), 0 );
				break;
			default:
				vehicle.TakeBullet( point, normal, round, energy );
				break;
		}
	}

	/// <summary>
	/// Hôte : le joueur tombe inconscient. L'arme sortie tombe près du corps, avec son numéro et ses
	/// balles, et se ramasse (Julien, 30/10/2026) ; une arme de service se range seulement : elle se
	/// rend au vestiaire, jamais au sol.
	/// </summary>
	public void DropOnDown( RoleplayPlayer player )
	{
		var serial = HolderOf( player )?.ActiveSerial;
		Holster( player );
		if ( string.IsNullOrEmpty( serial ) )
			return;
		var slot = player.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == serial );
		if ( slot?.Definition is not { } item || PoliceService.IsServiceSlot( slot ) )
			return;
		var data = slot.Data;
		player.RemoveFrom( slot, 1 );
		DroppedItem.Spawn( item, 1, HorizonGame.NearBody( Scene, player ), data );
		HorizonGame.Current?.AddLog( "arme_lachee", player.SteamId, $"{item.Name} ({serial}) tombé au sol (inconscient)" );
	}

	/// <summary>Lâcher l'arme en main (touche A) : elle tombe devant le joueur, avec ses données.</summary>
	[Rpc.Host]
	public void RequestDropActive()
	{
		var player = Caller();
		var serial = HolderOf( player )?.ActiveSerial;
		if ( serial is null )
			return;
		// Une arme de service ne se lâche pas (Police/PoliceService.Service.cs).
		if ( PoliceService.IsServiceSlot( player.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == serial ) ) )
		{
			player.Notify( "Arme de service : elle ne se lâche pas. Rendez-la au vestiaire.", "erreur" );
			return;
		}

		Holster( player );
		var slot = player.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == serial );
		if ( slot?.Definition is not { } item )
			return;

		var data = slot.Data;
		player.RemoveFrom( slot, 1 );
		var start = player.WorldPosition + player.WorldRotation.Forward.WithZ( 0 ).Normal * 50 + Vector3.Up * 40;
		var ground = Scene.Trace.Ray( start, start + Vector3.Down * 200 ).IgnoreGameObjectHierarchy( player.GameObject ).Run();
		DroppedItem.Spawn( item, 1, ground.Hit ? ground.EndPosition : player.WorldPosition, data );

		player.Notify( $"{item.Name} lâché au sol.", "info" );
		HorizonGame.Current?.AddLog( "arme_lachee", player.SteamId, $"{item.Name} ({serial}) lâché au sol" );
	}

	// Accessoires (menu de l'arme, touche W)

	/// <summary>Les composants d'accessoire que porte une arme (classes SWB), lus dans son prefab.</summary>
	/// <summary>
	/// Les balles que le chargeur de cette arme peut tenir : celles du catalogue, ou celles
	/// d'un chargeur grande capacité monté (<see cref="Fas2Magazine"/>, lu dans le prefab).
	/// </summary>
	public static int ClipOf( WeaponEntry entry, WeaponData data )
	{
		var clip = entry.ClipSize;
		if ( data?.Att is { Count: > 0 } att )
		{
			foreach ( var mag in AttachmentsOf( entry ).OfType<Fas2Magazine>().Where( x => att.Contains( x.GetType().Name ) ) )
				clip = Math.Max( clip, mag.Capacity );
		}
		return clip;
	}

	public static IEnumerable<SWB.Base.Attachments.Attachment> AttachmentsOf( WeaponEntry entry ) =>
		GameObject.GetPrefab( entry?.Prefab )?.Components.GetAll<SWB.Base.Attachments.Attachment>( FindMode.EverythingInSelfAndDescendants )
		?? Enumerable.Empty<SWB.Base.Attachments.Attachment>();

	/// <summary>
	/// Monte (ou démonte) un accessoire sur l'arme de cette série. Monter prend l'objet
	/// dans l'inventaire et rend celui qui occupait la même place (deux viseurs ne vont
	/// pas ensemble) ; démonter le rend. L'arme sortie est ressortie pour l'afficher.
	/// </summary>
	[Rpc.Host]
	public void RequestAttachment( string serial, string cls, bool mount )
	{
		var player = Caller();
		var slot = player?.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == serial );
		var entry = Get( WeaponItems.WeaponOf( slot?.Item ) );
		var data = WeaponData.Read( slot?.Data );
		if ( entry is null || data is null )
			return;

		var attachment = AttachmentsOf( entry ).FirstOrDefault( x => x.GetType().Name == cls );
		var accessory = WeaponRules.AccessoryForClass( cls );
		if ( attachment is null || accessory is null )
		{
			player.Notify( "Cet accessoire ne va pas sur cette arme.", "erreur" );
			return;
		}

		var itemId = WeaponItems.AccessoryItemId( accessory.Id );
		var item = Items.Get( itemId );
		if ( mount )
		{
			if ( data.Att.Contains( cls ) )
				return;
			var owned = player.Inventory.Where( x => x.Item == itemId ).OrderByDescending( x => x.Slot ).FirstOrDefault();
			if ( owned is null )
			{
				player.Notify( $"Vous n'avez pas de {accessory.Name}.", "erreur" );
				return;
			}
			player.RemoveFrom( owned, 1 );

			// Une seule pièce par place (viseur, bouche…) : l'ancienne revient dans l'inventaire.
			foreach ( var other in data.Att.ToList() )
			{
				var otherAtt = AttachmentsOf( entry ).FirstOrDefault( x => x.GetType().Name == other );
				if ( otherAtt is null || otherAtt.Category != attachment.Category )
					continue;
				data.Att.Remove( other );
				if ( WeaponRules.AccessoryForClass( other ) is { } back )
					player.AddItem( Items.Get( WeaponItems.AccessoryItemId( back.Id ) ), 1 );
			}
			data.Att.Add( cls );
			player.Notify( $"{accessory.Name} monté sur {entry.Name}.", "info" );
		}
		else
		{
			if ( !data.Att.Remove( cls ) )
				return;
			if ( player.AddItem( item, 1 ) == 0 )
			{
				data.Att.Add( cls );
				player.Notify( "Plus de place dans l'inventaire pour l'accessoire.", "erreur" );
				return;
			}
			player.Notify( $"{accessory.Name} démonté.", "info" );
		}

		slot.Data = data.Write();
		player.PublishInventory();
		HorizonGame.Current?.AddLog( "accessoire", player.SteamId, $"{accessory.Name} {(mount ? "monté sur" : "démonté de")} {entry.Name} ({serial})" );

		// L'arme sortie se ressort avec ses nouvelles pièces (les balles restent).
		if ( HolderOf( player )?.ActiveSerial == serial )
		{
			Holster( player );
			Draw( player, player.Inventory.First( x => WeaponData.Read( x.Data )?.Serial == serial ) );
		}
	}

	// Connexions

	/// <summary>L'arme d'un joueur qui part : HorizonGame la range (et garde ses balles) avant de sauvegarder.</summary>
	public void OnDisconnected( Connection channel )
	{
		if ( Networking.IsHost )
			drawn.Remove( channel.SteamId );
	}
}