Character/CharacterRules.cs

Static rules helper for character creation. Validates and normalizes the RP name and the appearance JSON (stripping paid Steam items, clamping sliders, forcing one free skin of the right sex), provides default outfits and tintable variant lookups, and parses/validates the birth date and sex. Also resolves the creation spot from the active map and finds clothing icons/thumbnails.

File Access
namespace HorizonRP;

/// <summary>
/// Les règles de la création de personnage, appliquées par l'hôte : c'est lui
/// qui relit le nom et nettoie l'apparence envoyée par le client.
/// </summary>
public static class CharacterRules
{
	/// <summary>
	/// Où se crée un personnage : une ruelle étroite, toujours la même, trouvée en balayant la
	/// carte. Le personnage regarde vers +X ; la caméra se tient devant lui, la ruelle derrière.
	/// Ceux qui y sont en même temps ne se voient pas et ne s'entendent pas.
	/// Sur Truenorth (30/10/2026) : un mur de brique et un grillage, choisi par Julien parmi quatre
	/// (repère de la map, décalé par TruenorthMap.Offset) ; sur Rp Union City, son ancienne ruelle.
	/// </summary>
	public static Vector3 CreationSpot => Game.ActiveScene?.GetAllComponents<TruenorthMap>().FirstOrDefault() is { } map
		? map.Offset + TruenorthCreationSpot
		: UnionCreationSpot;

	static Vector3 TruenorthCreationSpot => new( 1344, 3648, 8 );
	static Vector3 UnionCreationSpot => new( -5800, -2850, 5498 );
	public const float CreationYaw = 0f;

	/// <summary>
	/// Rend null si tout va bien (et le nom RP et l'apparence nettoyés), sinon le
	/// message à montrer au joueur.
	/// </summary>
	public static string Validate( string firstName, string lastName, string appearanceJson, string sex, out string name, out string cleanJson )
	{
		name = null;
		cleanJson = null;

		var first = CleanName( firstName );
		var last = CleanName( lastName );
		if ( first is null || last is null )
			return "Prénom et nom : 2 à 20 lettres chacun (accents, tiret et apostrophe acceptés).";

		if ( string.IsNullOrWhiteSpace( appearanceJson ) || appearanceJson.Length > 16000 )
			return "Apparence illisible.";

		ClothingContainer container;
		try
		{
			container = ClothingContainer.CreateFromJson( appearanceJson );
		}
		catch
		{
			return "Apparence illisible.";
		}

		if ( container is null )
			return "Apparence illisible.";

		// Seuls les vêtements gratuits livrés avec s&box : un objet payant de
		// l'inventaire Steam ne peut pas être vérifié pour un autre joueur.
		container.Clothing.RemoveAll( x => x.Clothing is null || x.Clothing.SteamItemDefinitionId.HasValue );
		EnsureSkin( container, sex );
		container.Height = Math.Clamp( container.Height, 0, 1 );
		container.Age = Math.Clamp( container.Age, 0, 1 );
		container.Tint = Math.Clamp( container.Tint, 0, 1 );
		// La couleur d'un vêtement : un point de son dégradé, seulement s'il est teintable.
		foreach ( var entry in container.Clothing )
			entry.Tint = entry.Clothing?.AllowTintSelect == true && entry.Tint is { } t ? Math.Clamp( t, 0, 1 ) : null;
		container.Normalize();

		name = $"{first} {last}";
		cleanJson = container.Serialize();
		return null;
	}

	// Le corps : s&box habille un corps réaliste (« Human »), dont le sexe, le
	// visage et la teinte viennent d'un « vêtement de peau » (HasHumanSkin). Les
	// déformations (cou, poitrine, nez, menton…) ne marchent que sur l'ancien corps
	// Citizen : la création ne les propose donc pas.

	static List<Clothing> skins;

	/// <summary>Les peaux réalistes gratuites, dans un ordre stable.</summary>
	static List<Clothing> AllSkins => skins ??= ResourceLibrary.GetAll<Clothing>()
		.Where( x => x.HasHumanSkin && !x.SteamItemDefinitionId.HasValue )
		.OrderBy( x => x.ResourcePath )
		.ToList();

	public static bool IsFemaleSkin( Clothing skin ) => $"{skin?.HumanSkinTags}".Contains( "female" );

	/// <summary>Les visages proposés pour un sexe (« M » ou « F »).</summary>
	public static List<Clothing> SkinsOf( string sex ) => AllSkins.Where( x => IsFemaleSkin( x ) == (sex == "F") ).ToList();

	/// <summary>La tenue de départ de chaque sexe, en vêtements gratuits livrés avec s&box.</summary>
	static readonly Dictionary<string, string[]> DefaultOutfits = new()
	{
		["M"] = new[]
		{
			"models/citizen_clothes/hair/hair_shortscruffy/hair_shortscruffy_brown.clothing",
			"models/citizen_clothes/shirt/polo_shirt/polo_shirt.clothing",
			"models/citizen_clothes/trousers/jeans/jeans.clothing",
			"models/citizen_clothes/shoes/sneakers/sneakers.clothing",
		},
		["F"] = new[]
		{
			"models/citizen_clothes/hair/hair_longbrown/models/hair_longbrown.clothing",
			"models/citizen_clothes/shirt/tshirt/tshirt.clothing",
			"models/citizen_clothes/trousers/jeans/jeans_black.clothing",
			"models/citizen_clothes/shoes/flatshoes/textures/flat_shoes.clothing",
		},
	};

	/// <summary>
	/// Habille de la tenue de départ du sexe choisi : tout ce qui était porté part
	/// (une robe sur un corps d'homme, une barbe sur une femme), la peau reste.
	/// </summary>
	public static void DressDefault( ClothingContainer container, string sex )
	{
		if ( container is null )
			return;

		container.Clothing.RemoveAll( x => x.Clothing?.HasHumanSkin != true );
		foreach ( var path in DefaultOutfits[sex == "F" ? "F" : "M"] )
		{
			if ( ResourceLibrary.Get<Clothing>( path ) is { } item )
				container.Add( item );
		}
		UseVariants( container );
		EnsureSkin( container, sex );
	}

	// Les variantes de couleur (outils/vetements/variantes.py) : des copies teintables des
	// vêtements clairs de s&box, même modèle et même icône. Le créateur montre la copie à
	// la place de l'original ; sans teinte choisie, elle a exactement l'air de l'original.

	const string VariantFolder = "clothing/horizon/variantes/";

	static Dictionary<Clothing, Clothing> variants;

	// Vignettes : 79 vêtements du Workshop sur 278 sont livrés sans icône (pas de .clothing.png),
	// et leur case restait vide dans la création. Les nôtres sont rendues dans l'éditeur
	// (outils/vetements/vignettes.cs du jeu) dans ui/vetements/, au nom tiré du chemin.

	public const string ThumbnailFolder = "ui/vetements/";

	static readonly Dictionary<Clothing, string> icons = new();

	/// <summary>« blackjacket/black puffy jacket.clothing » → « ui/vetements/blackjacket_black_puffy_jacket.png ».</summary>
	public static string ThumbnailPath( Clothing item )
	{
		var name = (item?.ResourcePath ?? "").Replace( '\\', '/' ).ToLowerInvariant();
		if ( name.EndsWith( ".clothing" ) )
			name = name[..^".clothing".Length];
		var slug = new string( name.Select( c => char.IsLetterOrDigit( c ) ? c : '_' ).ToArray() );
		return $"{ThumbnailFolder}{slug}.png";
	}

	/// <summary>L'icône livrée avec le vêtement, sinon notre vignette, sinon rien.</summary>
	public static string IconOf( Clothing item )
	{
		if ( item is null )
			return "";
		if ( icons.TryGetValue( item, out var known ) )
			return known;
		var own = item.Icon.Path;
		var path = !string.IsNullOrEmpty( own ) && FileSystem.Mounted.FileExists( own ) ? own
			: FileSystem.Mounted.FileExists( ThumbnailPath( item ) ) ? ThumbnailPath( item )
			: "";
		// Rien de connu tant que les paquets ne sont pas montés : on ne retient pas l'absence.
		if ( path.Length > 0 )
			icons[item] = path;
		return path;
	}

	/// <summary>Oublie les listes de vêtements lues : de nouveaux paquets viennent d'être montés.</summary>
	public static void ForgetClothing()
	{
		skins = null;
		variants = null;
		icons.Clear();
	}

	public static bool IsVariant( Clothing item ) =>
		item?.ResourcePath?.Replace( '\\', '/' ).StartsWith( VariantFolder, StringComparison.OrdinalIgnoreCase ) == true;

	/// <summary>Chaque original qui a sa copie teintable, et cette copie.</summary>
	public static Dictionary<Clothing, Clothing> Variants
	{
		get
		{
			if ( variants is { Count: > 0 } )
				return variants;
			var all = ResourceLibrary.GetAll<Clothing>().ToList();
			var copies = all.Where( IsVariant ).ToList();
			var found = new Dictionary<Clothing, Clothing>();
			foreach ( var original in all.Where( x => !IsVariant( x ) && !x.SteamItemDefinitionId.HasValue ) )
			{
				var name = System.IO.Path.GetFileName( original.ResourcePath );
				var copy = copies.FirstOrDefault( c => System.IO.Path.GetFileName( c.ResourcePath ).Equals( name, StringComparison.OrdinalIgnoreCase )
					&& c.Model == original.Model && c.MaterialGroup == original.MaterialGroup );
				if ( copy is not null )
					found[original] = copy;
			}
			// Rien trouvé (ressources pas encore chargées) : on réessaiera.
			return variants = found;
		}
	}

	/// <summary>Remplace les originaux portés par leur copie teintable (même allure, couleur au choix).</summary>
	public static void UseVariants( ClothingContainer container )
	{
		if ( container is null )
			return;
		foreach ( var entry in container.Clothing )
		{
			if ( entry.Clothing is not null && Variants.TryGetValue( entry.Clothing, out var copy ) )
				entry.Clothing = copy;
		}
	}

	/// <summary>
	/// Garantit une seule peau, et du bon sexe. Une peau de l'autre sexe est
	/// remplacée par celle de même rang (le 2e visage d'homme devient le 2e de femme).
	/// </summary>
	public static void EnsureSkin( ClothingContainer container, string sex )
	{
		var choices = SkinsOf( sex );
		if ( container is null || choices.Count == 0 )
			return;

		var current = container.Clothing.FirstOrDefault( x => x.Clothing?.HasHumanSkin == true )?.Clothing;
		if ( current is not null && choices.Contains( current ) && container.Clothing.Count( x => x.Clothing?.HasHumanSkin == true ) == 1 )
			return;

		var rank = current is null ? 0 : Math.Max( 0, SkinsOf( IsFemaleSkin( current ) ? "F" : "M" ).IndexOf( current ) );
		container.Clothing.RemoveAll( x => x.Clothing?.HasHumanSkin == true );
		container.Add( choices[rank % choices.Count] );
	}

	/// <summary>
	/// Date de naissance saisie « JJ/MM/AAAA » (le jeu parle français) et sexe
	/// (« M »/« F »). Le personnage a entre 18 et 90 ans. Rend null si tout va bien.
	/// </summary>
	public static string ValidateIdentity( string birthDate, string sex, out string isoBirth, out string cleanSex )
	{
		isoBirth = null;
		cleanSex = sex is "M" or "F" ? sex : null;
		if ( cleanSex is null )
			return "Choisissez le sexe du personnage.";

		var date = ParseBirthDate( birthDate );
		if ( date is null )
			return "Date de naissance : JJ/MM/AAAA, par exemple 14/07/1998.";

		var age = DateTime.UtcNow.Year - date.Value.Year - (DateTime.UtcNow.DayOfYear < date.Value.DayOfYear ? 1 : 0);
		if ( age < 18 || age > 90 )
			return "Le personnage doit avoir entre 18 et 90 ans.";

		isoBirth = date.Value.ToString( "yyyy-MM-dd" );
		return null;
	}

	/// <summary>« 14/07/1998 » (ou 14-07-1998, 14.07.1998) → la date, ou null.</summary>
	public static DateTime? ParseBirthDate( string input )
	{
		var parts = (input ?? "").Split( '/', '-', '.', ' ' ).Where( x => x.Length > 0 ).ToArray();
		if ( parts.Length != 3 || !int.TryParse( parts[0], out var day ) || !int.TryParse( parts[1], out var month ) || !int.TryParse( parts[2], out var year ) )
			return null;
		if ( year < 1900 || month < 1 || month > 12 || day < 1 || day > DateTime.DaysInMonth( year, month ) )
			return null;
		return new DateTime( year, month, day );
	}

	/// <summary>« jEAN-luc » → « Jean-Luc » ; null si ce n'est pas un nom.</summary>
	public static string CleanName( string value )
	{
		var s = (value ?? "").Trim();
		if ( s.Length < 2 || s.Length > 20 )
			return null;

		if ( !s.All( c => char.IsLetter( c ) || c == '-' || c == '\'' || c == ' ' ) || !char.IsLetter( s[0] ) )
			return null;

		var result = new System.Text.StringBuilder();
		var capital = true;
		foreach ( var c in s.ToLowerInvariant() )
		{
			result.Append( capital ? char.ToUpperInvariant( c ) : c );
			capital = c is '-' or ' ' or '\'';
		}
		return result.ToString();
	}
}