The host-side phone system for a roleplay gamemode. Handles SMS, contacts, voice calls, bank transfers, fine and medical bill payment, public ads and staff reports, syncing state to clients over RPC and storing offline data in telephone.json.
namespace HorizonRP;
/// <summary>
/// Le téléphone, côté hôte : SMS (gardés en base, distribués même hors ligne),
/// contacts, appels vocaux, virements, amendes de l'UCPD (HZ BANK), annonces
/// publiques et signalements au staff. Le 911 n'est pas un appel vocal : il passe
/// par PoliceService (Police/PoliceService.Dispatch.cs). Les appels en cours sont publiés dans <see cref="CallsJson"/> : chaque
/// machine sait ainsi qui parle à qui, ce dont RpVoice a besoin pour router la
/// voix.
///
/// Les envois vers un seul joueur passent par un Rpc.Broadcast restreint à sa
/// connexion (Rpc.FilterInclude), comme le fait BaseChair du moteur.
/// </summary>
[Title( "Horizon RP – Téléphone" )]
[Icon( "smartphone" )]
public sealed class PhoneSystem : Component, Component.INetworkListener
{
public static PhoneSystem Current { get; private set; }
/// <summary>Un appel qui sonne plus longtemps est un appel manqué.</summary>
public const float RingSeconds = 30f;
public const int AdPrice = 20;
const string LocalFile = "telephone.json";
[Sync( SyncFlags.FromHost )] public string CallsJson { get; set; }
readonly List<PhoneCall> calls = new();
readonly Dictionary<long, RealTimeSince> ringing = new();
long nextCallId;
List<PhoneCall> parsedCalls = new();
string parsedJson;
protected override void OnAwake() => Current = this;
protected override void OnDestroy()
{
if ( Current == this )
Current = null;
}
// Appels, lus sur toutes les machines
public IReadOnlyList<PhoneCall> Calls
{
get
{
if ( Networking.IsHost )
return calls;
if ( parsedJson != CallsJson )
{
parsedJson = CallsJson;
parsedCalls = string.IsNullOrEmpty( CallsJson ) ? new() : Json.Deserialize<List<PhoneCall>>( CallsJson ) ?? new();
}
return parsedCalls;
}
}
public PhoneCall CallOf( ulong steamId ) => Calls.FirstOrDefault( x => x.Involves( steamId ) );
/// <summary>Ces deux joueurs sont-ils en communication (décroché) ?</summary>
public bool Talking( ulong a, ulong b ) =>
a != 0 && b != 0 && Calls.Any( x => x.Active && x.Involves( a ) && x.Involves( b ) );
void PublishCalls() => CallsJson = Json.Serialize( calls );
protected override void OnFixedUpdate()
{
if ( !Networking.IsHost )
return;
foreach ( var call in calls.Where( x => !x.Active && ringing.TryGetValue( x.Id, out var t ) && t > RingSeconds ).ToList() )
{
// Le destinataire voit « Appel manqué : <contact> » chez lui (ToClientCallLog).
End( call, "Pas de réponse." );
}
}
// Données du téléphone d'un joueur
/// <summary>Appelé par HorizonGame une fois le profil chargé.</summary>
public async Task LoadFor( RoleplayPlayer player )
{
var number = player.PhoneNumber;
List<PhoneContact> contacts;
List<PhoneMessage> messages;
var callLog = new List<PhoneCallLog>();
if ( Backend.IsOnline )
{
var response = await Backend.LoadPhone( player.SteamId, number );
if ( response is null || !response.Ok )
return;
contacts = response.Contacts ?? new();
messages = response.Messages ?? new();
callLog = response.Calls ?? new();
}
else
{
var local = LocalData();
contacts = local.Contacts.Where( x => x.Owner == player.SteamId ).Select( x => x.Contact ).ToList();
messages = local.Messages.Where( x => x.From == number || x.To == number ).TakeLast( 300 ).ToList();
}
if ( player.Network.Owner is not { } connection )
return;
using ( Rpc.FilterInclude( connection ) )
{
ToClientData( Json.Serialize( contacts ), Json.Serialize( messages ), Json.Serialize( callLog ) );
ToClientPhotoBase( Backend.PhotoBaseUrl );
}
}
/// <summary>D'où se lisent les photos (celles reçues par SMS comprises).</summary>
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToClientPhotoBase( string url ) => PhoneClient.PhotoBaseUrl = url;
// Appareil photo et galerie
/// <summary>Le joueur vient de prendre une photo : un jeton pour qu'il l'envoie lui-même à l'API.</summary>
[Rpc.Host]
public void RequestPhotoToken( string camera )
{
var player = Caller();
if ( player is null || !player.ProfileLoaded )
return;
_ = PhotoTokenAsync( player, camera == "avant" ? "avant" : "arriere" );
}
async Task PhotoTokenAsync( RoleplayPlayer player, string camera )
{
string token = null;
if ( Backend.IsOnline )
token = (await Backend.RequestPhoto( player.SteamId, camera ))?.Token;
if ( !player.IsValid() || player.Network.Owner is not { } connection )
return;
using ( Rpc.FilterInclude( connection ) )
ToClientPhotoToken( token, Backend.PhotoUploadUrl, Backend.PhotoBaseUrl );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToClientPhotoToken( string token, string uploadUrl, string baseUrl )
{
PhoneClient.PhotoBaseUrl = baseUrl;
PhoneCamera.Upload( token, uploadUrl );
}
/// <summary>L'application Photos s'ouvre : la galerie du joueur.</summary>
[Rpc.Host]
public void RequestPhotos()
{
var player = Caller();
if ( player is not null && player.ProfileLoaded )
_ = PhotosAsync( player );
}
async Task PhotosAsync( RoleplayPlayer player )
{
var photos = new List<PhonePhoto>();
if ( Backend.IsOnline )
photos = (await Backend.PhotoList( player.SteamId ))?.Photos ?? new();
if ( !player.IsValid() || player.Network.Owner is not { } connection )
return;
using ( Rpc.FilterInclude( connection ) )
ToClientPhotos( Json.Serialize( photos ), Backend.PhotoBaseUrl );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToClientPhotos( string photosJson, string baseUrl )
{
PhoneClient.PhotoBaseUrl = baseUrl;
PhoneClient.Photos = Json.Deserialize<List<PhonePhoto>>( photosJson ) ?? new();
PhoneClient.PhotosLoaded = true;
PhoneClient.Version++;
}
/// <summary>Retire une photo de la galerie (qui l'a reçue par SMS la garde).</summary>
[Rpc.Host]
public void RequestPhotoDelete( long id )
{
var player = Caller();
if ( player is not null && Backend.IsOnline )
_ = Backend.PhotoDelete( player.SteamId, id );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToClientData( string contactsJson, string messagesJson, string callsJson )
{
PhoneClient.Contacts = Json.Deserialize<List<PhoneContact>>( contactsJson ) ?? new();
PhoneClient.Messages = Json.Deserialize<List<PhoneMessage>>( messagesJson ) ?? new();
PhoneClient.Calls = Json.Deserialize<List<PhoneCallLog>>( callsJson ) ?? new();
PhoneClient.Version++;
// Des appels manqués pendant qu'on n'était pas en ville : l'écran verrouillé les montre.
if ( RoleplayPlayer.Local is { } me && PhoneClient.MissedCalls( me.PhoneNumber ).Count is var missed and > 0 )
HudNotifications.Push( missed == 1 ? "Téléphone : 1 appel manqué." : $"Téléphone : {missed} appels manqués.", "telephone" );
}
/// <summary>Chez l'appelant et l'appelé : un appel s'ajoute en tête du journal.</summary>
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToClientCallLog( string callJson )
{
if ( Json.Deserialize<PhoneCallLog>( callJson ) is not { } call )
return;
PhoneClient.Calls.RemoveAll( x => x.Id == call.Id && call.Id != 0 );
PhoneClient.Calls.Insert( 0, call );
PhoneClient.Version++;
if ( RoleplayPlayer.Local is { } me && call.To == me.PhoneNumber && !call.Answered && !call.Seen )
HudNotifications.Push( $"Appel manqué : {PhoneClient.NameOf( call.From )}", "telephone" );
}
/// <summary>
/// Un appel fini part au journal (hz_calls) et dans le téléphone des deux. Manqué s'il n'a pas
/// été décroché, sauf refusé par le destinataire (qui l'a vu).
/// </summary>
async Task LogCall( PhoneCall call, bool refused )
{
var duration = call.Active ? (int)Math.Max( 0, DateTimeOffset.UtcNow.ToUnixTimeSeconds() - call.AnsweredAt ) : 0;
var log = new PhoneCallLog { From = call.CallerNumber, To = call.CalleeNumber, Answered = call.Active, Duration = duration, Seen = call.Active || refused, CreatedAt = DateTime.Now.ToString( "yyyy-MM-dd HH:mm:ss" ) };
if ( Backend.IsOnline )
{
var response = await Backend.LogCall( call.CallerNumber, call.CalleeNumber, call.Caller, call.Active, refused, duration );
if ( response?.Call is { } saved )
log = saved;
}
var json = Json.Serialize( log );
foreach ( var steamId in new[] { call.Caller, call.Callee } )
if ( PlayerBySteam( steamId )?.Network.Owner is { } connection )
using ( Rpc.FilterInclude( connection ) )
ToClientCallLog( json );
}
/// <summary>Un appel vers quelqu'un qui n'est pas en ville : un appel manqué l'attend.</summary>
async Task CallAbsent( RoleplayPlayer player, string number )
{
if ( !Backend.IsOnline )
{
player.Notify( "Ce numéro n'est pas joignable.", "erreur" );
return;
}
var response = await Backend.LogCall( player.PhoneNumber, number, player.SteamId, false, false, 0 );
if ( !player.IsValid() )
return;
if ( response is null || !response.Ok || !response.Exists )
{
player.Notify( "Numéro non attribué.", "erreur" );
return;
}
player.Notify( $"{number} n'est pas en ville : il verra votre appel manqué.", "info" );
if ( response.Call is { } saved && player.Network.Owner is { } connection )
using ( Rpc.FilterInclude( connection ) )
ToClientCallLog( Json.Serialize( saved ) );
}
/// <summary>L'application Téléphone ouverte : les appels manqués sont vus.</summary>
[Rpc.Host]
public void RequestCallsSeen()
{
var player = Caller();
if ( player is null )
return;
if ( Backend.IsOnline )
_ = Backend.CallsSeen( player.PhoneNumber );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToClientMessage( string messageJson, bool incoming )
{
var message = Json.Deserialize<PhoneMessage>( messageJson );
if ( message is null )
return;
PhoneClient.Messages.Add( message );
if ( incoming )
{
PhoneClient.Unread.Add( message.From );
HudNotifications.Push( $"SMS de {PhoneClient.NameOf( message.From )} : {message.Text}", "sms" );
}
PhoneClient.Version++;
}
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToClientContact( string contactJson, bool deleted )
{
var contact = Json.Deserialize<PhoneContact>( contactJson );
if ( contact is null )
return;
PhoneClient.Contacts.RemoveAll( x => x.Id == contact.Id );
if ( !deleted )
PhoneClient.Contacts.Add( contact );
PhoneClient.Contacts = PhoneClient.Contacts.OrderBy( x => x.Name ).ToList();
PhoneClient.Version++;
}
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToEveryoneAd( string adJson )
{
var ad = Json.Deserialize<PhoneAd>( adJson );
if ( ad is null )
return;
PhoneClient.Ads.Insert( 0, ad );
if ( PhoneClient.Ads.Count > 50 )
PhoneClient.Ads.RemoveAt( PhoneClient.Ads.Count - 1 );
HudNotifications.Push( $"Annonce de {ad.Name} : {ad.Text}", "annonce" );
PhoneClient.Version++;
}
// Demandes du téléphone (sur l'hôte)
RoleplayPlayer Caller() =>
RoleplayPlayer.All.FirstOrDefault( x => Rpc.Caller is not null && x.Network.OwnerId == Rpc.Caller.Id );
static RoleplayPlayer PlayerBySteam( ulong steamId ) => RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == steamId );
void SendTo( RoleplayPlayer player, Action send )
{
if ( player?.Network.Owner is not { } connection )
return;
using ( Rpc.FilterInclude( connection ) )
send();
}
[Rpc.Host]
public void RequestSms( string to, string text )
{
var player = Caller();
to = Phone.Normalize( to );
text = (text ?? "").Trim();
if ( player is null || to is null || text.Length == 0 )
return;
if ( text.Length > 500 )
text = text[..500];
_ = SmsAsync( player, player.PhoneNumber, to, text );
}
async Task SmsAsync( RoleplayPlayer sender, string from, string to, string text )
{
PhoneMessage message;
if ( Backend.IsOnline )
{
var response = await Backend.SendSms( from, to, sender.SteamId, text );
if ( response is { Ok: true, Exists: false } )
{
if ( sender.IsValid() )
sender.Notify( "Numéro non attribué.", "erreur" );
return;
}
message = response?.Message;
}
else
{
var local = LocalData();
message = new PhoneMessage { Id = local.Messages.Count + 1, From = from, To = to, Text = text, CreatedAt = DateTime.Now.ToString( "yyyy-MM-dd HH:mm:ss" ) };
local.Messages.Add( message );
SaveLocal( local );
}
if ( message is null )
{
sender.Notify( "SMS non envoyé (réseau indisponible).", "erreur" );
return;
}
var json = Json.Serialize( message );
SendTo( sender, () => ToClientMessage( json, false ) );
// Destinataire en ligne : il le reçoit tout de suite ; sinon, à sa
// prochaine connexion (il est en base).
var target = Phone.PlayerOf( to );
if ( target is not null && target != sender )
SendTo( target, () => ToClientMessage( json, true ) );
}
[Rpc.Host]
public void RequestSaveContact( long id, string name, string number )
{
var player = Caller();
number = Phone.Normalize( number );
name = (name ?? "").Trim();
if ( player is null )
return;
if ( number is null || name.Length == 0 )
{
player.Notify( "Contact : un nom et un numéro à 7 chiffres (« 123-4567 »).", "erreur" );
return;
}
if ( name.Length > 40 )
name = name[..40];
_ = ContactAsync( player, id, name, number );
}
async Task ContactAsync( RoleplayPlayer player, long id, string name, string number )
{
PhoneContact contact;
if ( Backend.IsOnline )
{
var response = await Backend.SaveContact( player.SteamId, id, name, number );
contact = response?.Contact;
if ( contact is null )
{
player.Notify( response?.Error ?? "Contact non enregistré (réseau indisponible).", "erreur" );
return;
}
}
else
{
var local = LocalData();
local.Contacts.RemoveAll( x => x.Owner == player.SteamId && x.Contact.Id == id && id > 0 );
contact = new PhoneContact { Id = id > 0 ? id : local.Contacts.Count + 1, Name = name, Number = number };
local.Contacts.Add( new LocalContact { Owner = player.SteamId, Contact = contact } );
SaveLocal( local );
}
var json = Json.Serialize( contact );
SendTo( player, () => ToClientContact( json, false ) );
}
/// <summary>
/// Le joueur supprime un SMS (<paramref name="id"/> > 0) ou toute la conversation avec
/// <paramref name="peer"/>, de son téléphone seulement : l'autre garde le sien. Son téléphone
/// l'a déjà retiré de l'écran ; ici, la base.
/// </summary>
[Rpc.Host]
public void RequestDeleteSms( string peer, long id )
{
var player = Caller();
if ( player is null || string.IsNullOrEmpty( player.PhoneNumber ) )
return;
peer = Phone.Normalize( peer );
if ( id <= 0 && peer is null )
return;
if ( Backend.IsOnline )
{
_ = Backend.DeleteSms( player.PhoneNumber, peer, id );
return;
}
var local = LocalData();
var me = player.PhoneNumber;
local.Messages.RemoveAll( x => id > 0 ? x.Id == id && (x.From == me || x.To == me)
: (x.From == me && x.To == peer) || (x.To == me && x.From == peer) );
SaveLocal( local );
}
[Rpc.Host]
public void RequestDeleteContact( long id )
{
var player = Caller();
if ( player is null )
return;
if ( Backend.IsOnline )
{
_ = Backend.DeleteContact( player.SteamId, id );
}
else
{
var local = LocalData();
local.Contacts.RemoveAll( x => x.Owner == player.SteamId && x.Contact.Id == id );
SaveLocal( local );
}
var json = Json.Serialize( new PhoneContact { Id = id } );
SendTo( player, () => ToClientContact( json, true ) );
}
// Appels
[Rpc.Host]
public void RequestCall( string number )
{
var player = Caller();
number = Phone.Normalize( number );
if ( player is null || number is null )
return;
var target = Phone.PlayerOf( number );
if ( target == player )
{
player.Notify( "C'est votre propre numéro.", "erreur" );
return;
}
if ( target is null )
{
_ = CallAbsent( player, number );
return;
}
if ( CallOf( player.SteamId ) is not null )
{
player.Notify( "Vous êtes déjà en ligne.", "erreur" );
return;
}
if ( CallOf( target.SteamId ) is not null )
{
player.Notify( $"{number} est occupé.", "erreur" );
return;
}
var call = new PhoneCall
{
Id = ++nextCallId,
Caller = player.SteamId,
Callee = target.SteamId,
CallerNumber = player.PhoneNumber,
CalleeNumber = number,
};
calls.Add( call );
ringing[call.Id] = 0;
PublishCalls();
}
[Rpc.Host]
public void RequestAnswer()
{
var player = Caller();
var call = player is null ? null : calls.FirstOrDefault( x => x.Callee == player.SteamId && !x.Active );
if ( call is null )
return;
call.Active = true;
call.AnsweredAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
ringing.Remove( call.Id );
PublishCalls();
}
[Rpc.Host]
public void RequestHangUp()
{
var player = Caller();
var call = player is null ? null : CallOf( player.SteamId );
if ( call is null )
return;
var other = PlayerBySteam( call.Other( player.SteamId ) );
// Le destinataire qui raccroche pendant la sonnerie refuse l'appel (il l'a vu) ; l'appelant
// qui renonce laisse un appel manqué.
End( call, null, refused: !call.Active && call.Callee == player.SteamId );
// L'appelant qui renonce : le destinataire n'a qu'un appel manqué (son téléphone le dit).
if ( call.Active || call.Callee == player.SteamId )
other?.Notify( call.Active ? "Appel terminé." : "Appel refusé.", "info" );
}
void End( PhoneCall call, string reasonForCaller, bool refused = false )
{
calls.Remove( call );
ringing.Remove( call.Id );
PublishCalls();
_ = LogCall( call, refused );
if ( reasonForCaller is not null )
PlayerBySteam( call.Caller )?.Notify( reasonForCaller, "info" );
}
public void OnDisconnected( Connection channel )
{
ulong steamId = channel.SteamId;
foreach ( var call in calls.Where( x => x.Involves( steamId ) ).ToList() )
{
End( call, null );
PlayerBySteam( call.Other( steamId ) )?.Notify( "La communication a été coupée.", "info" );
}
}
// Banque, annonces, signalement
[Rpc.Host]
public void RequestTransfer( string recipient, int amount, string note )
{
var player = Caller();
if ( player is null || !player.ProfileLoaded )
return;
_ = TransferAsync( player, recipient, amount, note );
}
/// <summary>
/// Un virement HZ BANK. Le destinataire : un numéro de téléphone (il doit être en ville) ou un
/// numéro de compte (en ville, ou crédité en base par l'API s'il ne l'est pas).
/// </summary>
async Task TransferAsync( RoleplayPlayer player, string recipient, int amount, string note )
{
note = (note ?? "").Trim();
if ( note.Length > 60 )
note = note[..60];
if ( amount <= 0 || amount > player.Bank )
{
player.Notify( "Montant invalide ou solde insuffisant.", "erreur" );
ToClientTransferDone( false );
return;
}
var account = Phone.NormalizeAccount( recipient );
var target = Phone.PlayerOf( Phone.Normalize( recipient ) ) ?? Phone.PlayerOfAccount( account );
string offlineName = null;
if ( target is null )
{
if ( account is null )
{
player.Notify( "Destinataire introuvable : un numéro de compte HZ BANK, ou le téléphone d'une personne en ville.", "erreur" );
ToClientTransferDone( false );
return;
}
if ( !Backend.IsOnline )
{
player.Notify( "Virement impossible pour le moment.", "erreur" );
ToClientTransferDone( false );
return;
}
var response = await Backend.BankTransfer( player.SteamId, player.RpName, account, amount, note );
if ( !player.IsValid() )
return;
if ( response is null || !response.Ok || response.Online )
{
player.Notify( response?.Error ?? "Virement impossible pour le moment.", "erreur" );
ToClientTransferDone( false );
return;
}
offlineName = response.Name;
}
else if ( target == player || !target.ProfileLoaded )
{
player.Notify( target == player ? "C'est votre propre compte." : "Virement impossible pour le moment.", "erreur" );
ToClientTransferDone( false );
return;
}
var name = target?.RpName ?? offlineName;
var label = note.Length > 0 ? note : $"Virement à {name}";
if ( !Bank.Move( player, -amount, "virement_envoye", label, name ) )
{
player.Notify( "Solde insuffisant.", "erreur" );
ToClientTransferDone( false );
return;
}
if ( target is not null )
{
Bank.Move( target, amount, "virement_recu", note.Length > 0 ? note : $"Virement de {player.RpName}", player.RpName );
target.Notify( $"HZ BANK : virement reçu de {player.RpName}, +{Format.Money( amount )}", "argent" );
HorizonGame.Current?.SaveNow( target );
}
player.Notify( $"HZ BANK : {Format.Money( amount )} envoyés à {name}.", "argent" );
HorizonGame.Current?.AddLog( "virement", player.SteamId, $"{Format.Money( amount )} vers {name}{(target is null ? " (hors ligne)" : $" ({target.SteamId})")}{(note.Length > 0 ? $" : « {note} »" : "")}" );
HorizonGame.Current?.SaveNow( player );
ToClientTransferDone( true );
// Le mouvement part à l'API sans être attendu (Bank.Move) : lui laisser le temps d'arriver.
await GameTask.Delay( 600 );
if ( player.IsValid() )
await SendBankHistory( player );
if ( target.IsValid() )
await SendBankHistory( target );
}
/// <summary>L'application HZ BANK s'ouvre : le numéro de compte et les derniers mouvements.</summary>
[Rpc.Host]
public void RequestBankHistory()
{
var player = Caller();
if ( player is not null && player.ProfileLoaded )
_ = SendBankHistory( player );
}
async Task SendBankHistory( RoleplayPlayer player )
{
var ops = new List<Bank.Op>();
if ( Backend.IsOnline )
{
var response = await Backend.BankHistory( player.SteamId, 60 );
ops = response?.Ops ?? new();
}
if ( !player.IsValid() || player.Network.Owner is not { } connection )
return;
using ( Rpc.FilterInclude( connection ) )
ToClientBank( Phone.AccountOf( player.SteamId ), Json.Serialize( ops ) );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToClientBank( string account, string opsJson )
{
PhoneClient.Account = account;
PhoneClient.BankOps = Json.Deserialize<List<Bank.Op>>( opsJson ) ?? new();
PhoneClient.BankLoaded = true;
PhoneClient.Version++;
}
// Factures : les amendes de l'UCPD (outils/police/CONCEPTION.md). Une appli du téléphone à part
// depuis le 30/10/2026 ; c'était l'écran « À payer » de HZ BANK.
/// <summary>L'appli Factures s'ouvre (ou le téléphone, pour sa pastille) : les amendes du joueur, et ses factures de soins.</summary>
[Rpc.Host]
public void RequestFines()
{
var player = Caller();
if ( player is not null && player.ProfileLoaded )
_ = SendFines( player );
}
async Task SendFines( RoleplayPlayer player )
{
var fines = new List<Backend.MdtFine>();
if ( Backend.IsOnline )
fines = (await Backend.MdtFines( player.SteamId ))?.Fines ?? new();
if ( !player.IsValid() )
return;
var json = Json.Serialize( fines );
SendTo( player, () => ToClientFines( json ) );
// Les factures de soins de l'UCFD vont avec : la même liste de l'appli Factures.
await SendBills( player );
}
/// <summary>
/// Hôte : renvoie au joueur ses amendes et factures de soins quand elles changent hors du
/// téléphone (amende dressée, facture établie, paiement à un accueil). La pastille de l'appli
/// Factures suit tout de suite, sans qu'il ait à l'ouvrir.
/// </summary>
public void RefreshInvoices( RoleplayPlayer player )
{
if ( player.IsValid() && player.ProfileLoaded )
_ = SendFines( player );
}
// Factures : les factures de soins de l'UCFD EMS (Police/PoliceService.Ems.cs, Ems/EmsActions.cs)
async Task SendBills( RoleplayPlayer player )
{
var bills = new List<Backend.EmsBill>();
if ( Backend.IsOnline )
bills = (await Backend.EmsBills( player.SteamId ))?.Bills ?? new();
if ( !player.IsValid() )
return;
var json = Json.Serialize( bills );
SendTo( player, () => ToClientBills( json ) );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToClientBills( string billsJson )
{
PhoneClient.Bills = Json.Deserialize<List<Backend.EmsBill>>( billsJson ) ?? new();
PhoneClient.BillsLoaded = true;
PhoneClient.BillPaying = 0;
PhoneClient.Version++;
}
/// <summary>Les joueurs dont un paiement de facture est en cours (un double clic ne paie pas deux fois).</summary>
readonly HashSet<ulong> payingBills = new();
[Rpc.Host]
public void RequestBillPay( long billId )
{
var player = Caller();
if ( player is null || !player.ProfileLoaded || billId <= 0 )
return;
_ = BillPayAsync( player, billId );
}
/// <summary>Payer une facture de soins depuis l'appli Factures : comme une amende (l'API écrit l'opération « soins »).</summary>
async Task BillPayAsync( RoleplayPlayer player, long billId )
{
if ( !Backend.IsOnline )
{
player.Notify( "HZ BANK : paiement impossible pour le moment.", "erreur" );
await SendBills( player );
return;
}
if ( !payingBills.Add( player.SteamId ) )
return;
try
{
var response = await Backend.EmsBillPay( player.SteamId, billId, player.Bank );
if ( !player.IsValid() )
return;
if ( response?.Ok != true )
player.Notify( response?.Error ?? "HZ BANK : paiement impossible pour le moment.", "erreur" );
else
{
player.Bank = Math.Max( 0, response.Bank );
HorizonGame.Current?.SaveNow( player );
player.Notify( $"HZ BANK : facture de soins n°{billId} réglée, −{Format.Money( response.Amount )}.", "argent" );
HorizonGame.Current?.AddLog( "ems_facture_payee", player.SteamId, $"Facture de soins n°{billId} : {Format.Money( response.Amount )} (téléphone)" );
}
}
finally
{
payingBills.Remove( player.SteamId );
}
await GameTask.Delay( 400 );
if ( !player.IsValid() )
return;
await SendBills( player );
await SendBankHistory( player );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToClientFines( string finesJson )
{
PhoneClient.Fines = Json.Deserialize<List<Backend.MdtFine>>( finesJson ) ?? new();
PhoneClient.FinesLoaded = true;
PhoneClient.FinePaying = 0;
PhoneClient.Version++;
}
/// <summary>Les joueurs dont un paiement d'amende est en cours (un double clic ne paie pas deux fois).</summary>
readonly HashSet<ulong> payingFines = new();
[Rpc.Host]
public void RequestFinePay( long fineId )
{
var player = Caller();
if ( player is null || !player.ProfileLoaded || fineId <= 0 )
return;
_ = FinePayAsync( player, fineId );
}
/// <summary>
/// Payer une amende depuis l'appli Factures. Le jeu, autorité sur le solde d'un joueur connecté, envoie
/// ce solde (balance) ; l'API débite à partir de lui, écrit l'opération « amende », crédite le
/// trésor de la ville et rend le nouveau solde, que le jeu pose sur le joueur. Aucune opération
/// bancaire n'est écrite ici (Bank.Move la doublerait).
/// </summary>
async Task FinePayAsync( RoleplayPlayer player, long fineId )
{
if ( !Backend.IsOnline )
{
player.Notify( "HZ BANK : paiement impossible pour le moment.", "erreur" );
await SendFines( player );
return;
}
if ( !payingFines.Add( player.SteamId ) )
return;
try
{
await PayFine( player, fineId );
}
finally
{
payingFines.Remove( player.SteamId );
}
await AfterFinePay( player );
}
async Task PayFine( RoleplayPlayer player, long fineId )
{
// Le client ne dit que quelle amende : montant, état et solde suffisant, l'API les vérifie
// en base et répond par un message en cas de refus (« déjà payée », « il manque 120 $ »).
// Le solde tenu par le jeu fait foi (celui de la base peut être en retard d'une sauvegarde).
var response = await Backend.MdtFinePay( player.SteamId, fineId, player.Bank );
if ( !player.IsValid() )
return;
if ( response?.Ok != true )
{
player.Notify( response?.Error ?? "HZ BANK : paiement impossible pour le moment.", "erreur" );
return;
}
// L'API a écrit l'opération « amende » et crédité le trésor : le jeu ne pose que le solde.
player.Bank = Math.Max( 0, response.Bank );
HorizonGame.Current?.SaveNow( player );
player.Notify( $"HZ BANK : amende n°{fineId} réglée, −{Format.Money( response.Amount )}.", "argent" );
HorizonGame.Current?.AddLog( "amende_payee", player.SteamId, $"Amende n°{fineId} : {Format.Money( response.Amount )} (téléphone)" );
}
/// <summary>Après un paiement (réussi ou non) : la liste à jour, et l'opération « amende » dans l'historique.</summary>
async Task AfterFinePay( RoleplayPlayer player )
{
await GameTask.Delay( 400 );
if ( !player.IsValid() )
return;
await SendFines( player );
await SendBankHistory( player );
}
/// <summary>Chez l'émetteur : le virement est passé (l'appli revient à l'accueil) ou non.</summary>
[Rpc.Broadcast( NetFlags.HostOnly )]
void ToClientTransferDone( bool ok )
{
PhoneClient.LastTransferOk = ok;
PhoneClient.TransferSerial++;
PhoneClient.Version++;
}
[Rpc.Host]
public void RequestAd( string text )
{
var player = Caller();
text = (text ?? "").Trim();
if ( player is null || text.Length == 0 || !player.ProfileLoaded )
return;
if ( text.Length > 200 )
text = text[..200];
if ( player.Wallet < AdPrice )
{
player.Notify( $"Une annonce coûte {Format.Money( AdPrice )}.", "erreur" );
return;
}
player.Wallet -= AdPrice;
var ad = new PhoneAd { Name = player.RpName, Number = player.PhoneNumber, Text = text, At = DateTime.Now.ToString( "HH:mm" ) };
ToEveryoneAd( Json.Serialize( ad ) );
HorizonGame.Current?.AddLog( "annonce_jeu", player.SteamId, text );
}
[Rpc.Host]
public void RequestReport( string text )
{
var player = Caller();
text = (text ?? "").Trim();
if ( player is null || text.Length == 0 )
return;
if ( text.Length > 500 )
text = text[..500];
foreach ( var staff in RoleplayPlayer.All.Where( x => x.IsStaff ) )
staff.Notify( $"Signalement de {player.RpName} : {text}", "annonce" );
player.Notify( "Signalement envoyé au staff.", "info" );
HorizonGame.Current?.AddLog( "signalement", player.SteamId, text );
}
// Mode hors ligne (partie lancée depuis l'éditeur)
sealed class LocalContact
{
public ulong Owner { get; set; }
public PhoneContact Contact { get; set; }
}
sealed class LocalPhone
{
public List<LocalContact> Contacts { get; set; } = new();
public List<PhoneMessage> Messages { get; set; } = new();
}
LocalPhone LocalData() => FileSystem.Data.ReadJsonOrDefault<LocalPhone>( LocalFile ) ?? new();
void SaveLocal( LocalPhone data ) => FileSystem.Data.WriteJson( LocalFile, data );
}