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.
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();
}
}