Weapon catalog and inventory mapping for Horizon RP. Defines weapon entries, per-weapon serialized data, accessories, grip offsets, and converts weapon or accessory ids into ItemDefinition objects.
namespace HorizonRP;
/// <summary>
/// Une arme du catalogue : le prefab de simple-weapon-base (SWB) qui la dessine
/// et la fait tirer, et les valeurs qu'Horizon RP lui impose à la sortie (dégâts,
/// cadence, chargeur…), réglables depuis le panneau web et le menu admin. Ajouter
/// une arme : poser son prefab SWB dans Assets/prefabs/weapons, puis une ligne au
/// catalogue qui le nomme.
/// </summary>
public sealed class WeaponEntry
{
/// <summary>Identifiant court, jamais changé une fois des joueurs servis : il est dans les inventaires (« arme:colt »).</summary>
public string Id { get; set; }
public string Prefab { get; set; }
/// <summary>Le modèle posé au sol quand on jette l'arme (le « w_ » du prefab).</summary>
public string Model { get; set; }
public string Name { get; set; }
public string Category { get; set; } = "Pistolet";
/// <summary>L'objet de munitions que le rechargement consomme.</summary>
public string Ammo { get; set; }
public float Damage { get; set; }
public int Rpm { get; set; }
public int ClipSize { get; set; }
public int Bullets { get; set; } = 1;
public float Recoil { get; set; }
public float Spread { get; set; }
public float ReloadTime { get; set; }
/// <summary>Multiplie la vitesse de marche, arme en main.</summary>
public float Mobility { get; set; } = 1f;
/// <summary>Multiplicateur des tirs à la tête.</summary>
public float Headshot { get; set; } = 2f;
public float Weight { get; set; } = 1.5f;
/// <summary>Prix de vente à l'Ammu-Nation.</summary>
public int Price { get; set; }
/// <summary>Vendue à l'Ammu-Nation (sinon : staff, ou marché noir plus tard).</summary>
public bool Shop { get; set; } = true;
public bool Active { get; set; } = true;
}
/// <summary>Ce qu'une arme garde avec elle dans l'inventaire, façon FiveM : son numéro de série, ses balles, ses accessoires.</summary>
public sealed class WeaponData
{
public string Serial { get; set; }
public int Ammo { get; set; }
public List<string> Att { get; set; } = new();
/// <summary>
/// Arme de service de l'UCPD, prise à l'armurerie du vestiaire (Police/PoliceService.Service.cs) :
/// ni jetée, ni donnée, ni sauvegardée ; reprise en fin de service. Sa série commence par « UCPD- ».
/// </summary>
public bool Service { get; set; }
public static WeaponData Read( string json )
{
if ( string.IsNullOrEmpty( json ) )
return null;
try { return Json.Deserialize<WeaponData>( json ); }
catch { return null; }
}
public string Write() => Json.Serialize( this );
/// <summary>« HZ-4F2A9C » : lisible par la police, unique en pratique.</summary>
public static string NewSerial() => $"HZ-{Guid.NewGuid().ToString( "N" )[..6].ToUpperInvariant()}";
/// <summary>« UCPD-4F2A9C » : une arme de l'armurerie de la police.</summary>
public static string NewServiceSerial() => $"UCPD-{Guid.NewGuid().ToString( "N" )[..6].ToUpperInvariant()}";
}
/// <summary>
/// Un accessoire d'arme, vendu à part, par sorte (« silencieux de pistolet ») : il se
/// monte sur toute arme qui porte l'un de ses composants SWB (<paramref name="Classes"/>).
/// </summary>
public sealed record AccessoryInfo( string Id, string Name, string Category, string Icon, int Price, string[] Classes, bool Shop = true );
/// <summary>
/// Ce qui se règle d'un accessoire depuis le panneau web et le menu admin (table
/// hz_accessories) : le nom affiché, le prix, la vente à l'Ammu-Nation. Le reste
/// (sorte, icône, pièces SWB où il se monte) est dans le code.
/// </summary>
public sealed class AccessoryEntry
{
public string Id { get; set; }
public string Name { get; set; }
public int Price { get; set; }
public bool Shop { get; set; } = true;
}
/// <summary>La prise en main d'un modèle d'arme venu d'ailleurs (<see cref="WeaponRules.GripOf"/>).</summary>
public readonly record struct WeaponGrip( Vector3 Position, Angles Angles, float Scale );
public static class WeaponRules
{
/// <summary>
/// Le catalogue d'une partie sans base : les armes du pack FA:S 2.0, écrites par
/// outils/fas2/fas2_catalogue.py (Fas2Catalog.cs). La base fait foi. Les armes de la
/// démo SWB, de Facepunch et l'essai de Rust ont été retirées le 28/10/2026.
/// </summary>
public static List<WeaponEntry> Defaults() => Fas2Catalog.All();
/// <summary>
/// Les accessoires, déclarés en double avec l'API (ACCESSOIRES dans api.php), avec le
/// nom, le prix et la vente réglés dans la base (<see cref="WeaponSystem.AccessorySettings"/>).
/// Les rails ne s'achètent pas : SWB les pose tout seuls sous l'accessoire qui en a besoin.
/// </summary>
// Construite à chaque lecture (une trentaine de lignes) : une valeur gardée dans un champ statique
// survivrait aux rechargements à chaud de l'éditeur et ignorerait les changements.
public static IReadOnlyList<AccessoryInfo> Accessories => BuildAccessories().Select( Configured ).ToList();
static AccessoryInfo Configured( AccessoryInfo acc ) =>
WeaponSystem.Current?.AccessorySettings.FirstOrDefault( x => x.Id == acc.Id ) is { } set
? acc with { Name = string.IsNullOrWhiteSpace( set.Name ) ? acc.Name : set.Name, Price = set.Price, Shop = set.Shop }
: acc;
static IReadOnlyList<AccessoryInfo> BuildAccessories() => new List<AccessoryInfo>
{
// Les pièces des armes FA:S (Fas2Attachments.cs), une sorte par modèle du pack. Les
// viseurs et lunettes sont posés sur l'arme par outils/fas2 ; « point_rouge » et
// « lunette » (le CompM4 et l'ACOG) gardent leurs ids d'avant, déjà en inventaire.
new( "point_rouge", "Viseur point rouge", "Viseur", "weapons/fas2/icons/rendus/point_rouge.png", 350, new[] { "ReflexSightBG", "UspRedDot", "Fas2RedDot" } ),
new( "eotech", "Viseur holographique EOTech", "Viseur", "weapons/fas2/icons/rendus/eotech.png", 450, new[] { "Fas2EoTech" } ),
new( "cmore", "Viseur C-More", "Viseur", "weapons/fas2/icons/rendus/cmore.png", 350, new[] { "Fas2CMore" } ),
new( "sightmark", "Viseur Sightmark", "Viseur", "weapons/fas2/icons/rendus/sightmark.png", 300, new[] { "Fas2Sightmark" } ),
new( "docter", "Viseur de pistolet Docter", "Viseur", "weapons/fas2/icons/rendus/docter.png", 300, new[] { "Fas2Docter" } ),
new( "delta_point", "Viseur de pistolet Delta Point", "Viseur", "weapons/fas2/icons/rendus/delta_point.png", 300, new[] { "Fas2DeltaPoint" } ),
new( "lunette", "Lunette ACOG", "Viseur", "weapons/fas2/icons/rendus/lunette.png", 900, new[] { "HunterScope2DBG", "M700Scope", "Fas2Scope" } ),
new( "elcan", "Lunette ELCAN", "Viseur", "weapons/fas2/icons/rendus/elcan.png", 900, new[] { "Fas2Elcan" } ),
new( "pka", "Viseur PK-A", "Viseur", "weapons/fas2/icons/rendus/pka.png", 600, new[] { "Fas2Pka" } ),
new( "ps320", "Lunette PS-320", "Viseur", "weapons/fas2/icons/rendus/ps320.png", 1000, new[] { "Fas2Ps320" } ),
new( "pso1", "Lunette PSO-1", "Viseur", "weapons/fas2/icons/rendus/pso1.png", 900, new[] { "Fas2Pso1" } ),
new( "leupold", "Lunette de précision Leupold", "Viseur", "weapons/fas2/icons/rendus/leupold.png", 1400, new[] { "Fas2Leupold" } ),
new( "lunette_integree", "Lunette intégrée", "Viseur", "weapons/fas2/icons/rendus/lunette_integree.png", 800, new[] { "Fas2IntegratedScope" } ),
new( "silencieux_pistolet", "Silencieux de pistolet", "Bouche", "weapons/fas2/icons/rendus/silencieux_pistolet.png", 600, new[] { "PistolSilencerBG", "UspSilencer", "Fas2PistolSuppressor" } ),
new( "silencieux_fusil", "Silencieux de fusil", "Bouche", "weapons/fas2/icons/rendus/silencieux_fusil.png", 900, new[] { "RifleSilencerBG", "Fas2Suppressor" } ),
new( "silencieux_pompe", "Silencieux de fusil à pompe", "Bouche", "weapons/fas2/icons/rendus/silencieux_pompe.png", 900, new[] { "ShotgunSilencerBG", "Fas2ShotgunSuppressor" } ),
new( "silencieux_precision", "Silencieux de précision", "Bouche", "weapons/fas2/icons/rendus/silencieux_precision.png", 1200, new[] { "SniperSilencerBG", "Fas2SniperSuppressor" } ),
new( "frein_bouche", "Frein de bouche", "Bouche", "weapons/fas2/icons/rendus/frein_bouche.png", 400, new[] { "Fas2MuzzleBrake" } ),
new( "cache_flamme", "Cache-flamme", "Bouche", "weapons/fas2/icons/rendus/cache_flamme.png", 350, new[] { "Fas2FlashHider" } ),
new( "compensateur", "Compensateur", "Bouche", "weapons/fas2/icons/rendus/compensateur.png", 350, new[] { "Fas2Compensator" } ),
new( "choke", "Choke", "Bouche", "weapons/fas2/icons/rendus/choke.png", 300, new[] { "Fas2Choke" } ),
new( "chargeur_etendu", "Chargeur étendu", "Chargeur", "weapons/fas2/icons/rendus/chargeur_etendu.png", 450, new[] { "Fas2Magazine" } ),
new( "chargeur_max", "Chargeur très grande capacité", "Chargeur", "weapons/fas2/icons/rendus/chargeur_max.png", 700, new[] { "Fas2MagazineMax" } ),
new( "poignee", "Poignée avant", "Prise", "weapons/fas2/icons/rendus/poignee.png", 300, new[] { "Fas2Foregrip" } ),
new( "crosse", "Crosse", "Prise", "weapons/fas2/icons/rendus/crosse.png", 350, new[] { "Fas2Stock" } ),
new( "cache_rail", "Cache-rail", "Prise", "weapons/fas2/icons/rendus/cache_rail.png", 150, new[] { "Fas2DustCover" } ),
// Le laser et la lampe : touche Lampe (F) pour les allumer.
new( "laser", "Laser AN/PEQ-2", "Tactique", "weapons/fas2/icons/rendus/laser.png", 500, new[] { "RifleLaserBG", "Fas2Peq2" } ),
new( "laser_vert", "Laser vert AN/PEQ-15", "Tactique", "weapons/fas2/icons/rendus/laser_vert.png", 650, new[] { "Fas2Peq15" } ),
new( "lampe", "Lampe tactique", "Tactique", "weapons/fas2/icons/rendus/lampe.png", 300, new[] { "Fas2Light", "Fas2HandguardLight" } ),
new( "laser_pistolet", "Laser et lampe de pistolet", "Tactique", "weapons/fas2/icons/rendus/laser_pistolet.png", 600, new[] { "Fas2Insight" } ),
};
public static AccessoryInfo Accessory( string id ) => Accessories.FirstOrDefault( x => x.Id == id );
/// <summary>
/// Comment tient en main un modèle qui n'a pas été fait pour le personnage de s&box :
/// décalage depuis l'os de la main (hold_R), angle et taille. Les modèles de Facepunch
/// et de la démo SWB n'en ont pas besoin. Ceux de Rust (paquets « rust.w_* », publiés
/// par Facepunch) sont plus de deux fois trop grands et tournés autrement. Ceux du pack
/// FA:S 2.0 sont faits pour la main de Half-Life 2 (ValveBiped.Bip01_R_Hand), plus haut
/// que la nôtre : un même décalage pour tous (calé sur l'AK-74 le 28/10/2026).
/// </summary>
public static WeaponGrip? GripOf( string model ) => model switch
{
"weapons/rust_ak47/w_ak47.vmdl" => new WeaponGrip( new Vector3( 0, 0, 0 ), new Angles( 0, 0, 0 ), 0.45f ),
_ when model?.StartsWith( "weapons/fas2/" ) == true => new WeaponGrip( new Vector3( 6f, 0, -6.5f ), new Angles( 0, 0, 0 ), 1f ),
_ => null,
};
/// <summary>L'accessoire qu'on achète pour un composant d'arme (« UspSilencer » → silencieux de pistolet).</summary>
public static AccessoryInfo AccessoryForClass( string cls ) => Accessories.FirstOrDefault( x => x.Classes.Contains( cls ) );
}
/// <summary>
/// Les armes et les accessoires en objets d'inventaire, façon FiveM : « arme:colt »
/// (une par case, avec son numéro de série et ses balles) et « accessoire:point_rouge ».
/// Les munitions, elles, sont des objets fixes (Items.cs).
/// </summary>
public static class WeaponItems
{
public const string WeaponPrefix = "arme:";
public const string AccessoryPrefix = "accessoire:";
public static string WeaponItemId( string weaponId ) => $"{WeaponPrefix}{weaponId}";
public static string AccessoryItemId( string id ) => $"{AccessoryPrefix}{id}";
public static string WeaponOf( string itemId ) =>
itemId is not null && itemId.StartsWith( WeaponPrefix ) ? itemId[WeaponPrefix.Length..] : null;
public static string AccessoryOf( string itemId ) =>
itemId is not null && itemId.StartsWith( AccessoryPrefix ) ? itemId[AccessoryPrefix.Length..] : null;
static readonly Dictionary<string, ItemDefinition> cache = new();
/// <summary>
/// La définition d'une arme ou d'un accessoire en poche. Comme pour les props, elle
/// existe même avant le chargement du catalogue : sinon le profil jetterait l'arme.
/// </summary>
public static ItemDefinition Get( string itemId )
{
if ( WeaponOf( itemId ) is { } weaponId && weaponId.Length > 0 )
{
var entry = WeaponSystem.Current?.Get( weaponId );
var key = $"{itemId}|{entry?.Name}|{entry?.Weight}|{entry?.Price}|{entry?.Model}";
if ( cache.TryGetValue( key, out var known ) )
return known;
var item = new ItemDefinition( itemId, entry?.Name ?? "Arme", entry is null ? "Une arme, absente du catalogue pour l'instant." : $"{entry.Category}. Se sort avec Utiliser ou une case rapide, se range de même.",
"my_location", "Armes", entry?.Weight ?? 1.5f, 1, entry?.Price ?? 0, entry?.Model ?? "models/citizen_props/cardboardbox01.vmdl" );
return cache[key] = item;
}
if ( AccessoryOf( itemId ) is { } accId && WeaponRules.Accessory( accId ) is { } acc )
{
// Le nom et le prix se règlent depuis le panneau : ils font partie de la clé.
var key = $"{itemId}|{acc.Name}|{acc.Price}";
if ( cache.TryGetValue( key, out var known ) )
return known;
var item = new ItemDefinition( itemId, acc.Name, $"Accessoire ({acc.Category.ToLowerInvariant()}) : se monte sur une arme compatible, dans le menu de l'arme.",
"build", "Accessoires", 0.2f, 5, acc.Price, "models/citizen_props/cardboardbox01.vmdl" );
return cache[key] = item;
}
return null;
}
/// <summary>Les armes et accessoires du catalogue, en objets (pour les dons du staff).</summary>
public static IEnumerable<ItemDefinition> AllWeapons =>
(WeaponSystem.Current?.Catalog ?? (IReadOnlyList<WeaponEntry>)Array.Empty<WeaponEntry>()).Select( x => Get( WeaponItemId( x.Id ) ) );
public static IEnumerable<ItemDefinition> AllAccessories => WeaponRules.Accessories.Select( x => Get( AccessoryItemId( x.Id ) ) );
}