Inventory/Items.cs

Static catalog of item definitions for an RP gamemode, plus the inventory slot type and inventory rules (5 pocket slots, 30kg max weight). Items carry weight, stack size, price, model and food/health effects, and the definitions are rebuilt when a version constant changes.

namespace HorizonRP;

/// <summary>
/// Un objet d'inventaire. Le poids est en kilos ; un objet « utilisable » est
/// consommé quand on l'utilise et rend de la faim ou de la vie.
/// Un Id ne se change plus une fois le serveur ouvert : il est enregistré dans
/// les inventaires des joueurs.
/// </summary>
public sealed record ItemDefinition(
	string Id,
	string Name,
	string Description,
	string Icon,
	string Category,
	float Weight,
	int MaxStack,
	int Price,
	string WorldModel,
	float Hunger = 0,
	float Health = 0,
	string UseMessage = null )
{
	public bool Usable => Hunger > 0 || Health > 0 || UseMessage is not null;

	/// <summary>
	/// L'image de la case : l'image du modèle (ModelThumbs), l'icône d'un
	/// accessoire, ou null quand l'objet n'a qu'un carton pour modèle (l'icône prend la place).
	/// </summary>
	public string Picture => WeaponItems.AccessoryOf( Id ) is { } accessory
		? WeaponRules.Accessory( accessory )?.Icon
		: string.IsNullOrEmpty( WorldModel ) || (WorldModel.Contains( "cardboardbox01" ) && Id != "carton") ? null : ModelThumbs.Of( WorldModel );
}

public static class Items
{
	/// <summary>
	/// Construite à la lecture, et reconstruite quand <see cref="Version"/> change : un
	/// champ statique garde sa valeur à travers les rechargements à chaud de l'éditeur.
	/// Monter la version à chaque retouche de la liste.
	/// </summary>
	public static IReadOnlyList<ItemDefinition> All
	{
		get
		{
			if ( all is null || builtVersion != Version )
			{
				all = Build();
				builtVersion = Version;
			}
			return all;
		}
	}

	const int Version = 4;
	static int builtVersion;
	static IReadOnlyList<ItemDefinition> all;

	static IReadOnlyList<ItemDefinition> Build() => new List<ItemDefinition>
	{
		// Nourriture
		new( "pain", "Bagel", "Un bagel au sésame, encore tiède.", "bakery_dining", "Nourriture", 0.25f, 10, 8, "models/citizen_props/chippacket01.vmdl", Hunger: 20 ),
		new( "sandwich", "Sandwich", "Jambon-beurre.", "lunch_dining", "Nourriture", 0.3f, 10, 25, "models/citizen_props/chippacket01.vmdl", Hunger: 40 ),
		new( "hotdog", "Hot-dog", "Le classique d'Union City.", "fastfood", "Nourriture", 0.3f, 10, 18, "models/citizen_props/hotdog01.vmdl", Hunger: 35 ),
		new( "glace", "Glace", "Un cornet vanille.", "icecream", "Nourriture", 0.2f, 5, 12, "models/citizen_props/icecreamcone01.vmdl", Hunger: 15 ),
		new( "soda", "Soda", "Une canette bien fraîche.", "local_drink", "Boisson", 0.35f, 10, 6, "models/citizen_props/sodacan01.vmdl", Hunger: 8 ),
		new( "cafe", "Café", "Pour tenir jusqu'à la fin du service.", "coffee", "Boisson", 0.3f, 5, 5, "models/citizen_props/coffeemug01.vmdl", Hunger: 5 ),

		// Soins
		new( "bandage", "Bandage", "Rend 15 points de vie.", "healing", "Soins", 0.1f, 10, 30, "models/citizen_props/cardboardbox01.vmdl", Health: 15 ),
		new( "trousse", "Trousse de soin", "Rend 50 points de vie.", "medical_services", "Soins", 1.2f, 3, 80, "models/citizen_props/cardboardbox01.vmdl", Health: 50 ),

		// Divers
		new( "journal", "Union City Times", "Le journal du jour.", "newspaper", "Divers", 0.2f, 5, 3, "models/citizen_props/newspaper01.vmdl", UseMessage: "Vous parcourez les gros titres du Union City Times." ),
		new( "pied_de_biche", "Pied-de-biche", "Lourd et utile. Rien de plus, pour l'instant.", "hardware", "Outils", 2.5f, 1, 150, "models/citizen_props/crowbar01.vmdl" ),
		new( "carton", "Carton", "Pour déménager… ou cacher quelque chose.", "inventory_2", "Divers", 1f, 5, 10, "models/citizen_props/cardboardbox01.vmdl" ),

		// Munitions : à la cartouche, comme sur FiveM ; le rechargement les prend ici.
		// <munitions> (outils/fas2/munitions.py)
		new( "munitions_9mm", "Munitions 9×19 mm", "Pistolets et pistolets-mitrailleurs (Glock, MP5, Uzi…).", "radio_button_checked", "Munitions", 0.012f, 250, 2, "models/swb/bullets/9mm/9mm.vmdl" ),
		new( "munitions_9x18", "Munitions 9×18 mm Makarov", "PM, PMM, OTs-33, PP-19 Bizon.", "radio_button_checked", "Munitions", 0.011f, 250, 2, "models/swb/bullets/9mm/9mm.vmdl" ),
		new( "munitions_45", "Munitions .45 ACP", "M1911, USP, UMP-45, Vector.", "radio_button_checked", "Munitions", 0.015f, 250, 3, "models/swb/bullets/9mm/9mm.vmdl" ),
		new( "munitions_357", "Munitions .357 Magnum", "Desert Eagle .357.", "radio_button_checked", "Munitions", 0.015f, 250, 3, "models/swb/bullets/9mm/9mm.vmdl" ),
		new( "munitions_50ae", "Munitions .50 AE", "Desert Eagle.", "radio_button_checked", "Munitions", 0.022f, 100, 6, "models/swb/bullets/9mm/9mm.vmdl" ),
		new( "munitions_454", "Munitions .454 Casull", "Raging Bull.", "radio_button_checked", "Munitions", 0.02f, 100, 6, "models/swb/bullets/9mm/9mm.vmdl" ),
		new( "munitions_57", "Munitions 5,7×28 mm", "FN P90.", "radio_button_checked", "Munitions", 0.006f, 250, 3, "models/swb/bullets/9mm/9mm.vmdl" ),
		new( "munitions_545", "Munitions 5,45×39 mm", "AK-74, AKS-74U, AN-94, AK-12.", "radio_button_checked", "Munitions", 0.011f, 250, 4, "models/swb/bullets/308/308.vmdl" ),
		new( "munitions_556", "Munitions 5,56×45 mm", "M4A1, M16A2, G36, AUG, FAMAS, M249…", "radio_button_checked", "Munitions", 0.012f, 250, 4, "models/swb/bullets/308/308.vmdl" ),
		new( "munitions_762", "Munitions 7,62×39 mm", "AK-47, AK-104, SKS, RPK, Sako RK 95.", "radio_button_checked", "Munitions", 0.016f, 250, 4, "models/swb/bullets/308/308.vmdl" ),
		new( "munitions_308", "Munitions 7,62×51 mm", "G3, FAL, M14, M24, M60, SR-25…", "radio_button_checked", "Munitions", 0.025f, 100, 6, "models/swb/bullets/308/308.vmdl" ),
		new( "munitions_762r", "Munitions 7,62×54 mmR", "SVD, Kar98k.", "radio_button_checked", "Munitions", 0.022f, 100, 7, "models/swb/bullets/308/308.vmdl" ),
		new( "munitions_9x39", "Munitions 9×39 mm", "VSS, AS Val, OTs-14.", "radio_button_checked", "Munitions", 0.023f, 100, 6, "models/swb/bullets/308/308.vmdl" ),
		new( "munitions_127", "Munitions 12,7×55 mm", "ASh-12.", "radio_button_checked", "Munitions", 0.06f, 60, 10, "models/swb/bullets/20mm/20mm.vmdl" ),
		new( "munitions_50bmg", "Munitions .50 BMG", "Barrett M82.", "radio_button_checked", "Munitions", 0.11f, 50, 15, "models/swb/bullets/20mm/20mm.vmdl" ),
		new( "munitions_12", "Cartouches calibre 12", "Fusils à pompe et à canons juxtaposés.", "radio_button_checked", "Munitions", 0.04f, 100, 4, "models/swb/bullets/12gauge/12gauge.vmdl" ),
		new( "munitions_23", "Cartouches 23 mm", "KS-23.", "radio_button_checked", "Munitions", 0.09f, 50, 8, "models/swb/bullets/12gauge/12gauge.vmdl" ),
		new( "munitions_40mm", "Grenades 40 mm explosives", "M79.", "radio_button_checked", "Munitions", 0.23f, 12, 60, "weapons/fas2/projectiles/m79_grenade.vmdl" ),
		new( "munitions_rpg7", "Roquette PG-7V", "RPG-7.", "radio_button_checked", "Munitions", 2.2f, 4, 400, "weapons/fas2/projectiles/rpg7_rocket.vmdl" ),
		new( "munitions_rpg26", "Roquette RPG-26", "Tube RPG-26 : une roquette par tube.", "radio_button_checked", "Munitions", 2.9f, 2, 600, "weapons/fas2/projectiles/rpg26_rocket.vmdl" ),
		new( "grenade_m67", "Grenade M67", "Grenade à fragmentation.", "radio_button_checked", "Munitions", 0.4f, 6, 150, "weapons/fas2/projectiles/m67_thrown.vmdl" ),
		new( "grenade_m84", "Grenade M84", "Grenade assourdissante : flash et sifflement, sans blesser.", "radio_button_checked", "Munitions", 0.24f, 6, 120, "weapons/fas2/projectiles/m84_thrown.vmdl" ),
		new( "grenade_m18", "Fumigène M18", "Un nuage de fumée épaisse, une demi-minute.", "radio_button_checked", "Munitions", 0.54f, 6, 60, "weapons/fas2/projectiles/m18_thrown.vmdl" ),
		new( "mine_claymore", "Mine Claymore M18A1", "Mine antipersonnel dirigée : elle part quand on passe devant.", "radio_button_checked", "Munitions", 1.6f, 3, 500, "weapons/fas2/projectiles/claymore.vmdl" ),
		// </munitions>
	};

	/// <summary>Un objet fixe, ou un prop du catalogue porté en poche (« prop:12 »).</summary>
	public static ItemDefinition Get( string id ) => All.FirstOrDefault( x => x.Id == id ) ?? PropItems.Get( id ) ?? WeaponItems.Get( id ) ?? VehicleKeys.Get( id );
}

/// <summary>Une case de l'inventaire.</summary>
public sealed class InventorySlot
{
	public int Slot { get; set; }
	public string Item { get; set; }
	public int Count { get; set; }

	/// <summary>Ce que l'objet garde avec lui (une arme : série, balles, accessoires) ; null pour le reste.</summary>
	public string Data { get; set; }

	// Calculés : ni enregistrés ni envoyés (la fiche entière partait avec chaque case).
	[System.Text.Json.Serialization.JsonIgnore] public ItemDefinition Definition => Items.Get( Item );
	[System.Text.Json.Serialization.JsonIgnore] public float Weight => (Definition?.Weight ?? 0) * Count;
}

/// <summary>Les règles de l'inventaire, communes à l'hôte et à l'interface.</summary>
public static class InventoryRules
{
	/// <summary>
	/// Les poches : cinq cases, aux touches 1 à 5. Le reste ira dans un sac à dos, plus
	/// tard, comme sur les serveurs FiveM. (La barre du HUD y ajoute « Mains », sans
	/// touche : tout ranger, ne rien tenir.)
	/// </summary>
	public const int Slots = 5;

	/// <summary>Les poches qui se servent au clavier : toutes.</summary>
	public const int HotbarSlots = 5;

	/// <summary>La touche d'une poche : 1 pour la première.</summary>
	public static string KeyOf( int pocket ) => $"Slot{pocket + 1}";

	public const float MaxWeight = 30f;

	public static float WeightOf( IEnumerable<InventorySlot> slots ) => slots.Sum( x => x.Weight );
}