Phone/Phone.cs

Phone data helpers and client cache for the HorizonRP phone. Derives phone and bank account numbers from SteamID, normalizes typed numbers, and holds contacts, messages, calls, bank operations, fines, bills and photo textures for the UI. Photo images are requested over HTTP from the host-provided PhotoBaseUrl.

Http CallsExternal Download
namespace HorizonRP;

/// <summary>
/// Les données du téléphone, partagées par l'hôte et l'interface.
/// </summary>
public static class Phone
{
	/// <summary>
	/// Le numéro d'un joueur, « 123-4567 » (sept chiffres : l'indicatif « (555) » est retiré
	/// le 29/10/2026, à la demande de Julien, pour faire simple), tiré de son SteamID : le même
	/// à chaque connexion, sans rien stocker. Un mélange de bits (SplitMix64) évite que deux
	/// SteamID voisins aient des numéros voisins ; deux joueurs sur dix millions pourraient
	/// tomber sur le même, on l'accepte. numeroDe() d'api.php fait le même calcul.
	/// </summary>
	public static string NumberOf( ulong steamId )
	{
		if ( steamId == 0 )
			return null;

		var x = steamId + 0x9E3779B97F4A7C15UL;
		x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
		x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
		x ^= x >> 31;

		var digits = (x % 10_000_000UL).ToString( "0000000" );
		return $"{digits[..3]}-{digits[3..]}";
	}

	/// <summary>
	/// Ramène une saisie au format du jeu (« 123-4567 »), ou null. Sept chiffres ; une ancienne
	/// saisie à dix chiffres commençant par 555 (« (555) 123-4567 ») est encore comprise.
	/// </summary>
	public static string Normalize( string input )
	{
		var digits = new string( (input ?? "").Where( char.IsDigit ).ToArray() );
		if ( digits.Length == 10 && digits.StartsWith( "555" ) )
			digits = digits[3..];
		if ( digits.Length != 7 )
			return null;

		return $"{digits[..3]}-{digits[3..]}";
	}

	/// <summary>
	/// « HZ12 3456 7890 » : le numéro de compte HZ BANK d'un joueur, tiré de son SteamID par le
	/// même mélange que son numéro de téléphone, sur une autre graine (compteDe() d'api.php fait
	/// le même calcul ; John Doe : HZ72 5579 4044).
	/// </summary>
	public static string AccountOf( ulong steamId )
	{
		if ( steamId == 0 )
			return null;
		var x = steamId + 0xD1B54A32D192ED03UL;
		x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
		x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
		x ^= x >> 31;
		var d = (x % 10_000_000_000UL).ToString( "0000000000" );
		return $"HZ{d[..2]} {d[2..6]} {d[6..]}";
	}

	/// <summary>Une saisie de numéro de compte (« HZ12 3456 7890 », « 1234567890 ») au format, ou null.</summary>
	public static string NormalizeAccount( string input )
	{
		var d = new string( (input ?? "").Where( char.IsDigit ).ToArray() );
		return d.Length == 10 ? $"HZ{d[..2]} {d[2..6]} {d[6..]}" : null;
	}

	public static RoleplayPlayer PlayerOfAccount( string account ) =>
		account is null ? null : RoleplayPlayer.All.FirstOrDefault( x => x.ProfileLoaded && AccountOf( x.SteamId ) == account );

	/// <summary>Le texte d'un SMS qui porte une photo : « [photo:12:clé] ».</summary>
	public static string PhotoMarker( long id, string key ) => $"[photo:{id}:{key}]";

	/// <summary>Le SMS est-il une photo ? (rend son numéro et sa clé).</summary>
	public static bool IsPhoto( string text, out long id, out string key )
	{
		id = 0;
		key = null;
		if ( text is null || !text.StartsWith( "[photo:" ) || !text.EndsWith( "]" ) )
			return false;
		var parts = text[7..^1].Split( ':' );
		if ( parts.Length != 2 || !long.TryParse( parts[0], out id ) || parts[1].Length != 32 )
			return false;
		key = parts[1];
		return true;
	}

	public static RoleplayPlayer PlayerOf( string number ) =>
		number is null ? null : RoleplayPlayer.All.FirstOrDefault( x => x.PhoneNumber == number );
}

public sealed class PhoneContact
{
	public long Id { get; set; }
	public string Name { get; set; }
	public string Number { get; set; }
}

public sealed class PhoneMessage
{
	public long Id { get; set; }
	public string From { get; set; }
	public string To { get; set; }
	public string Text { get; set; }
	public string CreatedAt { get; set; }
}

/// <summary>Un appel du journal (écran « Récents » ; manqué s'il n'a pas été décroché).</summary>
public sealed class PhoneCallLog
{
	public long Id { get; set; }
	public string From { get; set; }
	public string To { get; set; }
	public bool Answered { get; set; }
	public int Duration { get; set; }
	public bool Seen { get; set; }
	public string CreatedAt { get; set; }
}

/// <summary>Une photo de la galerie (hz_photos) ; l'image se lit à l'API avec sa clé.</summary>
public sealed class PhonePhoto
{
	public long Id { get; set; }
	public string Key { get; set; }
	public string Camera { get; set; }
	public string CreatedAt { get; set; }
	public int Width { get; set; }
	public int Height { get; set; }
}

public sealed class PhoneAd
{
	public string Name { get; set; }
	public string Number { get; set; }
	public string Text { get; set; }
	public string At { get; set; }
}

/// <summary>Un appel en cours. Sonne, puis Active quand le destinataire décroche.</summary>
public sealed class PhoneCall
{
	public long Id { get; set; }
	public ulong Caller { get; set; }
	public ulong Callee { get; set; }
	public string CallerNumber { get; set; }
	public string CalleeNumber { get; set; }
	public bool Active { get; set; }

	/// <summary>Heure UTC (secondes Unix) du décroché ; chaque machine en déduit la durée.</summary>
	public long AnsweredAt { get; set; }

	public bool Involves( ulong steamId ) => steamId != 0 && (Caller == steamId || Callee == steamId);

	public ulong Other( ulong steamId ) => Caller == steamId ? Callee : Caller;

	public string OtherNumber( ulong steamId ) => Caller == steamId ? CalleeNumber : CallerNumber;
}

/// <summary>
/// Ce que le téléphone du joueur local connaît : contacts, conversations,
/// annonces de la session. Rempli par l'hôte (PhoneSystem) ; l'interface lit ici.
/// </summary>
public static class PhoneClient
{
	public static List<PhoneContact> Contacts { get; set; } = new();
	public static List<PhoneMessage> Messages { get; set; } = new();

	/// <summary>Le journal des appels, le plus récent en tête.</summary>
	public static List<PhoneCallLog> Calls { get; set; } = new();

	/// <summary>Les appels manqués pas encore vus (écran verrouillé, pastille de l'appli).</summary>
	public static List<PhoneCallLog> MissedCalls( string myNumber ) =>
		Calls.Where( x => x.To == myNumber && !x.Answered && !x.Seen ).ToList();
	public static List<PhoneAd> Ads { get; } = new();

	/// <summary>HZ BANK : le numéro de compte et les derniers mouvements (le plus récent en tête).</summary>
	public static string Account { get; set; }
	public static List<Bank.Op> BankOps { get; set; } = new();
	public static bool BankLoaded { get; set; }
	public static bool LastTransferOk { get; set; }
	public static int TransferSerial { get; set; }

	/// <summary>Appli Factures : les amendes de l'UCPD (serverPoliceFines), la plus récente en tête.</summary>
	public static List<Backend.MdtFine> Fines { get; set; } = new();
	public static bool FinesLoaded { get; set; }

	/// <summary>Les amendes encore dues et leur total.</summary>
	public static IEnumerable<Backend.MdtFine> UnpaidFines => Fines.Where( x => x.Unpaid );
	public static int UnpaidTotal => UnpaidFines.Sum( x => x.Amount );

	/// <summary>L'amende dont le paiement attend sa réponse (0 : aucune).</summary>
	public static long FinePaying { get; set; }

	/// <summary>Appli Factures, même liste : les factures de soins de l'UCFD EMS (serverEmsBills).</summary>
	public static List<Backend.EmsBill> Bills { get; set; } = new();
	public static bool BillsLoaded { get; set; }

	public static IEnumerable<Backend.EmsBill> UnpaidBills => Bills.Where( x => x.Unpaid );
	public static int UnpaidBillsTotal => UnpaidBills.Sum( x => x.Amount );

	/// <summary>Tout ce qui reste à payer (amendes et factures de soins) : pastille et résumé de l'appli Factures.</summary>
	public static int UnpaidCount => UnpaidFines.Count() + UnpaidBills.Count();
	public static int UnpaidAll => UnpaidTotal + UnpaidBillsTotal;

	/// <summary>La facture dont le paiement attend sa réponse (0 : aucune).</summary>
	public static long BillPaying { get; set; }

	/// <summary>Le 911 : la réponse de l'hôte à l'appel d'urgence envoyé (écran de confirmation).</summary>
	public static bool EmergencyOk { get; set; }
	public static long EmergencyId { get; set; }
	public static int EmergencySerial { get; set; }

	/// <summary>La galerie (la plus récente en tête) et l'adresse d'où se lisent les images.</summary>
	public static List<PhonePhoto> Photos { get; set; } = new();
	public static bool PhotosLoaded { get; set; }
	public static string PhotoBaseUrl { get; set; }

	static readonly Dictionary<long, Texture> photoTextures = new();
	static readonly HashSet<long> photoLoading = new();

	/// <summary>L'image d'une photo, ou null tant qu'elle se télécharge (Version augmente à l'arrivée).</summary>
	public static Texture PhotoTexture( long id, string key )
	{
		if ( photoTextures.TryGetValue( id, out var texture ) )
			return texture;
		if ( PhotoBaseUrl is not null && photoLoading.Add( id ) )
			_ = LoadPhoto( id, key );
		return null;
	}

	/// <summary>Une photo qu'on vient de prendre : on a déjà l'image, inutile de la retélécharger.</summary>
	public static void KeepPhoto( long id, Texture texture ) => photoTextures[id] = texture;

	static async Task LoadPhoto( long id, string key )
	{
		try
		{
			var bytes = await Http.RequestBytesAsync( $"{PhotoBaseUrl}&id={id}&k={key}" );
			var bitmap = bytes is { Length: > 100 } ? Bitmap.CreateFromBytes( bytes ) : null;
			if ( bitmap is not null )
			{
				photoTextures[id] = bitmap.ToTexture();
				Version++;
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Horizon] Photo {id} : {e.Message}" );
		}
		finally
		{
			photoLoading.Remove( id );
		}
	}

	/// <summary>Le texte d'un SMS tel qu'on le montre en aperçu (une photo devient « Photo »).</summary>
	public static string Preview( string text ) => Phone.IsPhoto( text, out _, out _ ) ? "📷 Photo" : text;

	/// <summary>Numéros dont on a des SMS non lus.</summary>
	public static HashSet<string> Unread { get; } = new();

	/// <summary>Augmente à chaque changement : sert au BuildHash de l'interface.</summary>
	public static int Version { get; set; }

	/// <summary>Un texte où chaque numéro enregistré dans le répertoire est remplacé par le nom du contact.</summary>
	public static string WithNames( string text )
	{
		if ( string.IsNullOrEmpty( text ) )
			return text;
		foreach ( var contact in Contacts )
			if ( !string.IsNullOrEmpty( contact.Number ) && !string.IsNullOrWhiteSpace( contact.Name ) )
				text = text.Replace( contact.Number, contact.Name );
		return text;
	}

	public static string NameOf( string number )
	{
		var contact = Contacts.FirstOrDefault( x => x.Number == number );
		if ( contact is not null )
			return contact.Name;

		return number;
	}

	/// <summary>Les conversations, la plus récente en tête : (numéro de l'autre, dernier message).</summary>
	public static IEnumerable<(string Number, PhoneMessage Last)> Conversations( string myNumber ) =>
		Messages
			.GroupBy( x => x.From == myNumber ? x.To : x.From )
			.Select( g => (g.Key, g.OrderBy( x => x.Id ).Last()) )
			.OrderByDescending( x => x.Item2.Id );
}