Police/PoliceService.Evidence.cs

Partial PoliceService handling seized items (scelles): records seizure lots to a backend evidence registry with retry and give-back, serves the MDT evidence list to officers over RPC, and closes/destroys/transfers/restitutes seals. Host-only logic, with rank gating and wallet/item restitution.

Networking
namespace HorizonRP;

/// <summary>
/// Les scellés (chantier « Ordinateurs de police », comme les casiers à pièces à conviction des
/// serveurs FiveM) : une saisie (menu F6, PoliceActions.RequestSeize*) ne va plus dans les poches
/// de l'agent, elle part au registre des scellés (hz_evidence) sous un numéro de scellé, un par
/// objet (et un pour le liquide). L'objet n'existe plus en jeu que là. Le registre se consulte
/// dans l'appli « Scellés » du MDT (poste du commissariat, tablette, ordinateur de bord, et la
/// salle des scellés : point <see cref="PointEvidence"/>) ; un Inspecteur au moins le clôt :
/// restitution (les objets reviennent réellement dans les poches du propriétaire, s'il est en
/// ville), destruction, transfert (à la justice, à un autre service : une note le dit).
///
/// Rien ne se perd si l'API flanche : un refus (ok: false) rend aussitôt la saisie à son
/// propriétaire ; une API injoignable garde la saisie en attente chez l'hôte, réessayée toutes
/// les 20 s, puis rendue au propriétaire au bout de <see cref="SealGiveUpSeconds"/> secondes
/// plutôt que de dépendre de la mémoire d'un serveur qui peut redémarrer.
/// </summary>
public sealed partial class PoliceService
{
	/// <summary>Les postes informatiques du commissariat (plusieurs : un par bureau), posés par le staff.</summary>
	public const string PointComputer = ServerSettings.PolicePointComputers;

	/// <summary>La salle des scellés : E y ouvre le registre.</summary>
	public const string PointEvidence = ServerSettings.PolicePointEvidence;

	/// <summary>Restituer ou détruire un scellé : Inspecteur au moins (la même règle que l'API).</summary>
	public const int RankEvidenceClose = 3;

	/// <summary>Transférer un scellé (justice, laboratoire…) : Agent au moins.</summary>
	public const int RankEvidenceTransfer = 1;

	public static int RankForOutcome( string outcome ) => outcome == "transfere" ? RankEvidenceTransfer : RankEvidenceClose;

	/// <summary>Une saisie que l'API n'a pas pu enregistrer est rendue au bout de ce temps (s).</summary>
	public const float SealGiveUpSeconds = 600f;

	public static List<ServerSettings.Point> ComputerPoints => ServerSettings.PoliceComputers;
	public static List<ServerSettings.Point> EvidencePoints =>
		ServerSettings.PoliceEvidenceRoom is { } room ? new List<ServerSettings.Point> { room } : new List<ServerSettings.Point>();

	/// <summary>Une saisie entre la poche du citoyen et le registre (hôte seul).</summary>
	sealed class SealLot
	{
		public long Number { get; init; }
		public ulong CopSteam { get; init; }
		public string CopSignature { get; init; }
		public ulong OwnerSteam { get; init; }
		public string OwnerName { get; init; }
		public List<Backend.MdtEvidenceItem> Items { get; init; } = new();
		public int Cash { get; set; }
		public string Location { get; init; }
		public string Summary { get; init; }
		public RealTimeSince Since { get; init; }
		public bool Sending { get; set; }
		public bool Returning { get; set; }
		public bool Warned { get; set; }
	}

	static readonly List<SealLot> pendingSeals = new();
	static long nextSealLot;
	static bool sealLoop;

	/// <summary>Les scellés déjà lus par l'hôte (listes envoyées aux agents) : la clôture s'appuie sur eux, jamais sur le client.</summary>
	static readonly Dictionary<long, Backend.MdtEvidence> evidenceSeen = new();

	static RoleplayPlayer OnlinePlayer( ulong steamId ) =>
		steamId == 0 ? null : RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steamId && x.ProfileLoaded );

	static RoleplayPlayer OnlinePlayer( string steamId ) =>
		ulong.TryParse( steamId, out var id ) ? OnlinePlayer( id ) : null;

	/// <summary>Le nom d'un objet du registre : « Glock-20 (n° 4412) », « Pain ×3 », « 1 200 $ en liquide ».</summary>
	public static string EvidenceLabel( string item, int count, string data, int cash, string knownName = null )
	{
		if ( string.IsNullOrEmpty( item ) )
			return cash > 0 ? $"{Format.Money( cash )} en liquide" : "Scellé vide";
		var name = Items.Get( item )?.Name ?? (string.IsNullOrWhiteSpace( knownName ) ? item : knownName);
		if ( count > 1 )
			return $"{name} ×{count}";
		var serial = WeaponItems.WeaponOf( item ) is not null ? WeaponData.Read( data )?.Serial : null;
		return serial is null ? name : $"{name} (n° {serial})";
	}

	// ------------------------------------------------------------------ mise sous scellé

	/// <summary>
	/// Hôte : ce que <see cref="PoliceActions"/> vient de retirer au citoyen part sous scellé. La
	/// saisie est déjà sortie des poches : à partir d'ici, elle est soit au registre, soit en
	/// attente chez l'hôte, soit rendue — jamais perdue.
	/// </summary>
	public static void SealSeized( RoleplayPlayer cop, RoleplayPlayer owner, List<Backend.MdtEvidenceItem> items, int cash, string summary )
	{
		if ( (items is null || items.Count == 0) && cash <= 0 )
			return;
		var lot = new SealLot
		{
			Number = ++nextSealLot,
			CopSteam = cop.SteamId,
			CopSignature = MdtSignature( cop ),
			OwnerSteam = owner.SteamId,
			OwnerName = owner.RpName,
			Items = items ?? new(),
			Cash = cash,
			Location = LocationOf( cop.WorldPosition ),
			Summary = summary,
			Since = 0,
		};
		pendingSeals.Add( lot );
		_ = SealAsync( lot );
	}

	static async Task SealAsync( SealLot lot )
	{
		if ( lot.Sending )
			return;
		lot.Sending = true;
		Backend.MdtEvidenceResponse response;
		try
		{
			response = await Backend.MdtEvidenceAdd( lot.OwnerSteam, lot.OwnerName, lot.Items, lot.Cash, null, lot.Location, lot.CopSignature, lot.CopSteam );
		}
		finally
		{
			lot.Sending = false;
		}

		var cop = OnlinePlayer( lot.CopSteam );
		var owner = OnlinePlayer( lot.OwnerSteam );

		if ( response?.Ok == true )
		{
			pendingSeals.Remove( lot );
			var seals = (response.Evidence ?? new())
				.Select( e => $"n° {e.Seal ?? e.Id.ToString()} ({EvidenceLabel( e.Item, e.Count, null, e.Cash )})" )
				.ToList();
			var list = seals.Count > 0 ? string.Join( ", ", seals ) : lot.Summary;
			cop?.Notify( $"Sous scellé : {list}.", "info" );
			owner?.Notify( $"Saisi par l'UCPD et placé sous scellé : {lot.Summary}.", "erreur" );
			HorizonGame.Current?.AddLog( "police_scelle", lot.OwnerSteam, $"{lot.CopSignature} place sous scellé {lot.Summary} saisi sur {lot.OwnerName} ({lot.OwnerSteam}) : {list}" );
			return;
		}

		if ( response is not null )
		{
			// Un refus de l'API (données invalides…) ne se rattrape pas en réessayant : on rend.
			Log.Warning( $"[Horizon] Scellé refusé ({lot.Summary}) : {response.Error}" );
			cop?.Notify( $"Registre des scellés : {response.Error ?? "saisie refusée"}. La saisie est rendue au citoyen.", "erreur" );
			GiveBack( lot, "le registre des scellés a refusé la saisie" );
			return;
		}

		// L'API ne répond pas : la saisie attend chez l'hôte, réessayée d'elle-même.
		if ( !lot.Warned )
		{
			lot.Warned = true;
			cop?.Notify( $"Registre des scellés injoignable : la saisie ({lot.Summary}) est gardée en attente (lot provisoire P-{lot.Number}), nouvel essai automatique.", "erreur" );
			owner?.Notify( $"Saisi par l'UCPD : {lot.Summary}.", "erreur" );
		}
		StartSealLoop();
	}

	/// <summary>Rend une saisie à son propriétaire (en ville) ; ce qui n'entre pas dans ses poches attend qu'il fasse de la place.</summary>
	static void GiveBack( SealLot lot, string why )
	{
		lot.Returning = true;
		var owner = OnlinePlayer( lot.OwnerSteam );
		if ( owner is null )
		{
			// Il a quitté la ville : la saisie l'attend chez l'hôte, rendue à son retour.
			StartSealLoop();
			return;
		}

		var returned = new List<string>();
		if ( lot.Cash > 0 )
		{
			owner.Wallet += lot.Cash;
			returned.Add( Format.Money( lot.Cash ) );
			lot.Cash = 0;
		}
		foreach ( var entry in lot.Items.ToList() )
		{
			var def = Items.Get( entry.Item );
			if ( def is null )
			{
				lot.Items.Remove( entry );
				continue;
			}
			var added = owner.AddItem( def, entry.Count, entry.Data );
			if ( added <= 0 )
				continue;
			returned.Add( EvidenceLabel( entry.Item, added, entry.Data, 0 ) );
			entry.Count -= added;
			if ( entry.Count <= 0 )
				lot.Items.Remove( entry );
		}

		if ( returned.Count > 0 )
		{
			owner.Notify( $"UCPD : objets rendus ({why}) : {string.Join( ", ", returned )}.", "info" );
			HorizonGame.Current?.AddLog( "police_scelle_rendu", lot.OwnerSteam, $"Saisie de {lot.CopSignature} rendue à {lot.OwnerName} ({why}) : {string.Join( ", ", returned )}" );
			HorizonGame.Current?.SaveNow( owner );
		}

		if ( lot.Items.Count == 0 && lot.Cash <= 0 )
			pendingSeals.Remove( lot );
		else
		{
			owner.Notify( "UCPD : vos poches sont pleines, le reste de vos objets vous sera rendu dès que vous aurez de la place.", "erreur" );
			StartSealLoop();
		}
	}

	static void StartSealLoop()
	{
		if ( sealLoop )
			return;
		_ = SealLoopAsync();
	}

	/// <summary>Hôte : tant qu'une saisie attend, toutes les 20 s, on réessaie le registre ou on rend.</summary>
	static async Task SealLoopAsync()
	{
		sealLoop = true;
		try
		{
			while ( pendingSeals.Count > 0 )
			{
				await GameTask.Delay( 20_000 );
				if ( Current is null || !Networking.IsHost )
					return;
				foreach ( var lot in pendingSeals.ToList() )
				{
					if ( lot.Sending )
						continue;
					if ( lot.Returning )
						GiveBack( lot, "saisie non enregistrée" );
					else if ( lot.Since > SealGiveUpSeconds || !Backend.IsOnline )
					{
						OnlinePlayer( lot.CopSteam )?.Notify( $"Registre des scellés toujours injoignable : le lot provisoire P-{lot.Number} est rendu au citoyen.", "erreur" );
						GiveBack( lot, "le registre des scellés ne répondait pas" );
					}
					else
						await SealAsync( lot );
				}
			}
		}
		finally
		{
			sealLoop = false;
		}
	}

	// ------------------------------------------------------------------ registre

	/// <summary>Une page du registre : <paramref name="status"/> « stocke » (sous scellé), « restitue », « detruit », « transfere » ou vide.</summary>
	[Rpc.Host]
	public static void MdtRequestEvidence( string status, string ownerSteam, string query, int page )
	{
		var cop = MdtCaller();
		if ( cop is null )
			return;
		status = status is "stocke" or "restitue" or "detruit" or "transfere" ? status : "";
		ownerSteam = ulong.TryParse( ownerSteam, out var owner ) && owner > 0 ? owner.ToString() : "";
		_ = MdtEvidenceAsync( cop, status, ownerSteam, MdtClip( query, 64 ), Math.Clamp( page, 1, 500 ) );
	}

	public static string EvidenceKey( string status, string ownerSteam, string query, int page ) =>
		$"{status}|{ownerSteam}|{(query ?? "").Trim()}|{page}";

	static async Task MdtEvidenceAsync( RoleplayPlayer cop, string status, string ownerSteam, string query, int page )
	{
		var response = await Backend.MdtEvidenceList( status, ownerSteam, query, page );
		var key = EvidenceKey( status, ownerSteam, query, page );
		var answer = response ?? new Backend.MdtEvidenceResponse { Ok = false, Error = "Le registre ne répond pas." };
		if ( answer.Ok )
			foreach ( var e in answer.Evidence ?? new() )
				evidenceSeen[e.Id] = e;
		else
			answer.Error ??= "Registre indisponible.";
		var json = Json.Serialize( answer );
		MdtSend( cop, () => MdtToEvidence( key, json ) );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	static void MdtToEvidence( string key, string json )
	{
		PoliceTabletClient.Evidence[key] = Json.Deserialize<Backend.MdtEvidenceResponse>( json ) ?? new() { Ok = false, Error = "Réponse illisible." };
		PoliceTabletClient.Version++;
	}

	/// <summary>Chez l'agent : un scellé a changé, les pages du registre se redemandent.</summary>
	[Rpc.Broadcast( NetFlags.HostOnly )]
	static void MdtToEvidenceChanged()
	{
		PoliceTabletClient.Evidence.Clear();
		PoliceTabletClient.EvidenceSerial++;
		PoliceTabletClient.Version++;
	}

	/// <summary>Le scellé tel que l'API le rend maintenant (l'état en mémoire peut être dépassé).</summary>
	static async Task<Backend.MdtEvidence> EvidenceFresh( long id )
	{
		var known = evidenceSeen.GetValueOrDefault( id );
		var response = await Backend.MdtEvidenceList( "", "", known?.Seal ?? id.ToString(), 1 );
		var row = response?.Evidence?.FirstOrDefault( x => x.Id == id );
		if ( row is not null )
			evidenceSeen[id] = row;
		return row ?? (response is null ? known : null);
	}

	/// <summary>
	/// Clôture d'un scellé : <paramref name="action"/> « restitue » ou « detruit » (Inspecteur au
	/// moins), « transfere » (Agent au moins). La restitution remet réellement les objets dans les
	/// poches du propriétaire : elle est refusée s'il n'est pas en ville, en détention, ou s'il n'a
	/// pas la place. L'API n'écrit que le registre ; c'est l'hôte qui rend l'objet.
	/// </summary>
	[Rpc.Host]
	public static void MdtCloseEvidence( long id, string action, string notes )
	{
		if ( action is not ("restitue" or "detruit" or "transfere") )
			return;
		var cop = MdtCaller( RankForOutcome( action ), action == "transfere" ? "Transférer un scellé" : "Restituer ou détruire un scellé" );
		if ( cop is null || id <= 0 )
			return;
		notes = MdtClip( notes, 500 );
		if ( action == "transfere" && notes.Length == 0 )
		{
			cop.Notify( "Un transfert se justifie : à qui, pourquoi (note obligatoire).", "erreur" );
			return;
		}
		_ = MdtCloseEvidenceAsync( cop, id, action, notes );
	}

	/// <summary>Un scellé à la fois : deux clics rapides ne restituent pas deux fois.</summary>
	static readonly HashSet<long> closingEvidence = new();

	static async Task MdtCloseEvidenceAsync( RoleplayPlayer cop, long id, string action, string notes )
	{
		if ( !closingEvidence.Add( id ) )
			return;
		try
		{
			var row = await EvidenceFresh( id );
			if ( !cop.IsValid() )
				return;
			if ( row is null )
			{
				cop.Notify( $"Scellé n°{id} introuvable au registre.", "erreur" );
				return;
			}
			if ( !row.Active )
			{
				cop.Notify( $"Le scellé n° {row.Seal ?? id.ToString()} est déjà clos.", "erreur" );
				return;
			}

			var label = EvidenceLabel( row.Item, row.Count, row.DataJson, row.Cash, row.ItemName );
			RoleplayPlayer owner = null;
			ItemDefinition def = null;
			if ( action == "restitue" )
			{
				owner = OnlinePlayer( row.Owner );
				if ( owner is null )
				{
					cop.Notify( $"Restitution impossible : {row.OwnerName ?? "le propriétaire"} n'est pas en ville. Le scellé reste au registre.", "erreur" );
					return;
				}
				if ( owner.Jailed )
				{
					cop.Notify( $"Restitution impossible : {owner.RpName} est en détention.", "erreur" );
					return;
				}
				if ( !row.IsCash )
				{
					def = Items.Get( row.Item );
					if ( def is null )
					{
						cop.Notify( $"Restitution impossible : « {row.Item} » n'existe plus en ville. Détruisez ou transférez le scellé.", "erreur" );
						return;
					}
					if ( owner.RoomFor( def ) < Math.Max( 1, row.Count ) )
					{
						cop.Notify( $"Restitution impossible : {owner.RpName} n'a pas la place pour {label}. Qu'il vide ses poches.", "erreur" );
						return;
					}
				}
			}

			var response = await Backend.MdtEvidenceClose( id, action, MdtSignature( cop ), cop.PoliceGrade, notes );
			if ( !cop.IsValid() )
				return;
			if ( response?.Ok != true )
			{
				cop.Notify( response?.Error ?? "Le registre ne répond pas : le scellé reste en l'état.", "erreur" );
				return;
			}

			var seal = row.Seal ?? id.ToString();
			string done;
			if ( action == "restitue" )
			{
				if ( owner.IsValid() )
				{
					if ( row.IsCash || def is null )
						owner.Wallet += row.Cash;
					else
					{
						var added = owner.AddItem( def, Math.Max( 1, row.Count ), row.DataJson );
						if ( added < Math.Max( 1, row.Count ) )
							// Le cas rare où ses poches se sont remplies entre la vérification et la réponse.
							SealGiveBackLater( owner.SteamId, owner.RpName, MdtSignature( cop ), row.Item, Math.Max( 1, row.Count ) - added, row.DataJson );
					}
					owner.Notify( $"UCPD : le scellé n° {seal} vous est restitué ({label}).", "info" );
					HorizonGame.Current?.SaveNow( owner );
				}
				else
					SealGiveBackLater( row.Owner is { } o && ulong.TryParse( o, out var os ) ? os : 0, row.OwnerName, MdtSignature( cop ), row.Item, Math.Max( 1, row.Count ), row.DataJson, row.IsCash ? row.Cash : 0 );
				done = $"Scellé n° {seal} restitué à {row.OwnerName ?? owner?.RpName ?? "son propriétaire"} : {label}.";
			}
			else if ( action == "detruit" )
				done = $"Scellé n° {seal} détruit : {label}.";
			else
				done = $"Scellé n° {seal} transféré : {label} — {notes}.";

			if ( response.Evidence is { } updated && updated.Id == id )
				row = updated;
			else
				row.Status = action;
			evidenceSeen[id] = row;
			cop.Notify( done, "info" );
			HorizonGame.Current?.AddLog( "police_scelle_" + action, ulong.TryParse( row.Owner, out var ownerId ) ? ownerId : 0, $"{done} Par {MdtSignature( cop )}{(notes.Length > 0 && action != "transfere" ? $" ({notes})" : "")}" );
			MdtSend( cop, () => MdtToEvidenceChanged() );
		}
		finally
		{
			closingEvidence.Remove( id );
		}
	}

	/// <summary>Une restitution qui n'a pas pu entrer en entier : le reste suit le chemin des saisies rendues.</summary>
	static void SealGiveBackLater( ulong ownerSteam, string ownerName, string by, string item, int count, string data, int cash = 0 )
	{
		if ( ownerSteam == 0 )
			return;
		var lot = new SealLot
		{
			Number = ++nextSealLot,
			OwnerSteam = ownerSteam,
			OwnerName = ownerName,
			CopSignature = by,
			Items = string.IsNullOrEmpty( item ) || count <= 0 ? new() : new() { new Backend.MdtEvidenceItem { Item = item, Count = count, Data = data } },
			Cash = cash,
			Summary = EvidenceLabel( item, count, data, cash ),
			Since = 0,
			Returning = true,
		};
		pendingSeals.Add( lot );
		StartSealLoop();
	}
}