Money/Atm.cs

Static helper for HZ BANK ATMs. Finds ATM objects, computes screen corners and press range, tracks which player holds which ATM, and exposes the player's bank account and masked account number.

namespace HorizonRP;

/// <summary>
/// Les distributeurs HZ BANK (Julien, 30/10/2026) : l'objet de ville « distributeur »
/// (CityKinds.Atm), modèle models/horizon/props/atm. À la touche E, la vue se pose devant son
/// écran et l'interface s'affiche dans l'écran (UI/AtmScreen.razor) : lecture de la carte, choix
/// du compte, retrait, dépôt, solde.
///
/// Les dépôts et retraits d'espèces ne se font plus qu'ici : l'hôte refuse une opération loin
/// d'un distributeur (<see cref="IsNear"/>), le menu F4 ne fait plus que montrer les soldes.
/// </summary>
public static class Atm
{
	/// <summary>Le modèle du distributeur, celui de l'objet de ville comme du prop du catalogue.</summary>
	public const string ModelPath = "models/horizon/props/atm/atm.vmdl";

	/// <summary>Distance d'usage (pouces, du pied du distributeur au joueur).</summary>
	public const float Reach = 110f;

	/// <summary>
	/// L'écran dans le repère du modèle (pouces ; la face avant regarde +X, outils/props :
	/// ROTATION), lu dans le moteur sur le distributeur de 1,92 m (le modèle Sketchfab qui a remplacé
	/// le premier le 30/10/2026) : les quatre sommets dont l'UV tombe dans l'écran de la texture
	/// (Model.GetVertices). 12,2 × 14,5, plus haut que large, au fond du capot, le haut renversé
	/// vers l'arrière ; l'écran HZ BANK y est peint (outils/props/ecran_atm.py). Une première mesure
	/// sur l'OBJ, axes mal convertis, avait les signes de X et Y inversés : l'interface tombait au-dessus
	/// et à droite de l'écran.
	/// </summary>
	/// (Propriétés, pas des champs : un champ statique garde son ancienne valeur au rechargement à chaud.)
	public static Vector3 ScreenBottom => new( -4.905f, -3.394f, 24.873f );
	public static Vector3 ScreenTop => new( -11.103f, -3.394f, 37.920f );
	public const float ScreenWidth = 12.215f;

	/// <summary>
	/// Ce que l'interface déborde de l'écran, en haut et en bas (pouces, le long de sa pente ;
	/// négatif : elle rentre). +0,3 sur le premier modèle, dont le rebord gris se voyait en deux
	/// fines bandes ; sur l'écran encastré du distributeur de 1,92 m, elle passait sur le cadre en
	/// haut et en bas (Julien, 31/10/2026). Rentrée de 0,2, elle laissait voir un liseré de l'écran
	/// peint dessous : 0, le bord exact de l'écran.
	/// </summary>
	const float ScreenMargin = 0f;

	/// <summary>Les quatre coins de l'interface dans le monde : bas gauche, bas droit, haut droit, haut gauche (vus de face).</summary>
	public static Vector3[] ScreenCorners( GameObject atm )
	{
		var t = atm.WorldTransform;
		var half = Vector3.Left * (ScreenWidth / 2);
		var slope = (ScreenTop - ScreenBottom).Normal * ScreenMargin;
		var bottom = ScreenBottom - slope;
		var top = ScreenTop + slope;
		// Vue de face (on regarde vers -X), la gauche de l'utilisateur est -Y.
		return new[]
		{
			t.PointToWorld( bottom - half ),
			t.PointToWorld( bottom + half ),
			t.PointToWorld( top + half ),
			t.PointToWorld( top - half ),
		};
	}

	/// <summary>
	/// La touche E ne se propose que devant le distributeur, à portée de bras : pouces, de sa façade
	/// aux pieds du joueur local, à plat, et pas plus loin sur le côté que <see cref="PressSide"/> du
	/// milieu de l'écran. Mesurée d'abord du pied du distributeur (75, puis Julien a trouvé que
	/// c'était encore loin, 30/10/2026), puis de l'écran (44) : sur le distributeur de 1,92 m,
	/// l'écran est au fond d'un capot, 21 pouces derrière la façade, et le corps du joueur ne
	/// laissait plus qu'une fenêtre de 7 pouces où la bulle « E » se montrait (Julien, 31/10/2026).
	/// L'hôte, lui, accepte jusqu'à <see cref="Reach"/>.
	/// </summary>
	public const float PressReach = 40f;
	public const float PressSide = 30f;

	/// <summary>La façade du modèle (pouces, x du repère du modèle : Model.Bounds, face avant vers +X).</summary>
	public const float FrontX = 13.32f;

	public static bool InPressReach( GameObject atm )
	{
		if ( RoleplayPlayer.Local is not { } me || !atm.IsValid() )
			return false;
		var t = atm.WorldTransform;
		var front = t.PointToWorld( new Vector3( FrontX, ScreenBottom.y, 0 ) );
		var toPlayer = (me.WorldPosition - front).WithZ( 0 );
		var ahead = Vector3.Dot( toPlayer, t.Rotation.Forward.WithZ( 0 ).Normal );
		var side = MathF.Abs( Vector3.Dot( toPlayer, t.Rotation.Left.WithZ( 0 ).Normal ) );
		return ahead > -4f && ahead <= PressReach && side <= PressSide
			&& MathF.Abs( me.WorldPosition.z - atm.WorldPosition.z ) < 90f;
	}

	/// <summary>Le centre de l'écran et sa normale (vers l'utilisateur), dans le monde.</summary>
	public static (Vector3 Center, Vector3 Normal, Vector3 Up) Screen( GameObject atm )
	{
		var t = atm.WorldTransform;
		var center = t.PointToWorld( (ScreenBottom + ScreenTop) / 2 );
		var up = (t.PointToWorld( ScreenTop ) - t.PointToWorld( ScreenBottom )).Normal;
		var normal = Vector3.Cross( t.Rotation.Left, up ).Normal;
		if ( Vector3.Dot( normal, t.Rotation.Forward ) < 0 )
			normal = -normal;
		return (center, normal, up);
	}

	/// <summary>Les distributeurs : ceux de la ville (CityObject) et le prop « Distributeur HZ Bank » posé.</summary>
	public static IEnumerable<GameObject> All( Scene scene ) =>
		scene.GetAllComponents<CityObject>().Where( x => x.Kind == CityKinds.Atm ).Select( x => x.GameObject )
			.Concat( scene.GetAllComponents<PlacedProp>().Where( x => x.IsAtm ).Select( x => x.GameObject ) );

	/// <summary>Le distributeur le plus proche à portée, ou null.</summary>
	public static GameObject Nearest( Scene scene, Vector3 position ) =>
		All( scene ).Select( x => (x, d: x.WorldPosition.WithZ( 0 ).Distance( position.WithZ( 0 ) )) )
			.Where( x => x.d <= Reach && MathF.Abs( x.x.WorldPosition.z - position.z ) < 90f )
			.OrderBy( x => x.d ).Select( x => x.x ).FirstOrDefault();

	public static bool IsNear( RoleplayPlayer player ) =>
		player.IsValid() && Nearest( player.Scene, player.WorldPosition ) is not null;

	// Un seul joueur par distributeur (Julien, 31/10/2026). Tenu par l'hôte seul : qui utilise quel
	// distributeur (Id de l'objet, le même partout : distributeurs et props sont des objets réseau
	// créés par l'hôte). Libéré à la fermeture de l'écran (RoleplayPlayer.ReleaseAtm), et par
	// Tick si l'utilisateur part, s'éloigne ou que le distributeur disparaît.

	static readonly Dictionary<Guid, RoleplayPlayer> users = new();

	/// <summary>Hôte : prend le distributeur pour ce joueur, s'il est libre (ou déjà à lui).</summary>
	public static bool TryTake( GameObject atm, RoleplayPlayer player )
	{
		if ( users.TryGetValue( atm.Id, out var user ) && user != player && StillUsing( atm, user ) )
			return false;
		Release( player );
		users[atm.Id] = player;
		return true;
	}

	/// <summary>Hôte : le joueur tient-il un distributeur (une opération n'est acceptée que de lui) ?</summary>
	public static bool Holds( RoleplayPlayer player ) => users.ContainsValue( player );

	public static void Release( RoleplayPlayer player )
	{
		foreach ( var id in users.Where( x => x.Value == player ).Select( x => x.Key ).ToList() )
			users.Remove( id );
	}

	static bool StillUsing( GameObject atm, RoleplayPlayer user ) =>
		user.IsValid() && atm.IsValid() && !user.OnTheGround
		&& user.WorldPosition.Distance( atm.WorldPosition ) <= Reach * 1.6f;

	/// <summary>Hôte, chaque seconde : les places des partis, des éloignés, des distributeurs retirés.</summary>
	public static void Tick( Scene scene )
	{
		if ( users.Count == 0 )
			return;
		var all = All( scene ).ToDictionary( x => x.Id );
		foreach ( var (id, user) in users.ToList() )
			if ( !all.TryGetValue( id, out var atm ) || !StillUsing( atm, user ) )
				users.Remove( id );
	}

	/// <summary>
	/// Un compte HZ BANK. Pour l'instant chacun n'a que son compte courant ; les comptes
	/// d'entreprise viendront s'ajouter à <see cref="AccountsOf"/> (Kind « entreprise »), et le
	/// distributeur comme le téléphone proposent déjà de choisir.
	/// </summary>
	public sealed record Account( string Id, string Label, string Number, string Kind );

	public const string PersonalId = "perso";

	public static IReadOnlyList<Account> AccountsOf( RoleplayPlayer player )
	{
		if ( player is null )
			return Array.Empty<Account>();
		return new[] { new Account( PersonalId, "Compte courant", Phone.AccountOf( player.SteamId ), "particulier" ) };
	}

	/// <summary>Le solde d'un compte (le compte courant : <see cref="RoleplayPlayer.Bank"/>).</summary>
	public static int BalanceOf( RoleplayPlayer player, string accountId ) =>
		accountId == PersonalId ? player?.Bank ?? 0 : 0;

	/// <summary>« •••• 7890 » : les quatre derniers chiffres, comme sur un ticket.</summary>
	public static string Masked( string number ) =>
		string.IsNullOrEmpty( number ) ? "" : "•••• " + number.Replace( " ", "" )[^4..];
}