The in-game staff admin menu rendered as a Razor panel. Shows a left nav and pages for dashboard, player records (actions, money, jobs, inventory, notes, sanctions, logs), city log, build/placement, placed objects, prop and weapon catalogs, wardrobes, vehicles, and server settings; all mutations are sent as requests to AdminActions, PropSystem and VehicleManager.
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mapping;
@namespace HorizonRP
@inherits PanelComponent
@*
Menu d'administration en jeu (Inser), réservé au staff, rangé comme le
panneau web : une barre de navigation à gauche, la page à droite.
Tableau de bord, Joueurs (la fiche complète et toutes les actions, notes,
sanctions, journal du joueur), Journal (lu dans l'API), puis la ville :
Construire (poser un objet), Objets posés, Props, Portes (de n'importe où), les
personnages : Garde-robe et Tenues de service (UI/WardrobePage.razor), et Serveur
(redémarrage, arrêt, physgun, noclip).
Le mode de pose se passe hors du menu : un fantôme suit le regard, la molette
le tourne, clic gauche pose, clic droit annule. La pose elle-même est une
demande à l'hôte (AdminActions), qui revérifie que l'on est staff.
*@
<root>
@if ( Building )
{
<div class="build-bar">
<div class="build-title">
<i>@(MovingId > 0 ? "open_with" : "add_location_alt")</i>
@(MovingId > 0 ? $"Déplacement de l'objet n°{MovingId}" : $"Pose : {CityKinds.Get( Kind ).Name}")
</div>
<div class="build-model">@BuildSource</div>
<div class="build-keys">
<span><b>Clic gauche</b> poser</span>
<span><b>Molette</b> @($"tourner ({(Yaw.CeilToInt() % 360)}°)")</span>
<span><b>R</b> remettre droit</span>
<span><b>Maj + molette</b> hauteur</span>
<span><b>Clic droit</b> annuler</span>
</div>
</div>
}
@if ( StaffTool.Noclip && !StaffTool.Equipped && !Open && !Building )
{
<div class="build-bar physgun noclip">
<div class="build-title"><i>flight</i> Noclip</div>
<div class="build-keys">
<span><b>ZQSD</b> voler vers le regard</span>
<span><b>Saut / Accroupi</b> monter, descendre</span>
<span><b>Maj</b> vite</span>
<span><b>N</b> couper</span>
</div>
</div>
}
@if ( StaffTool.Equipped && !Open && !Building )
{
@* Le viseur du marteau : on voit ce qu'on va saisir. *@
<div class="physgun-crosshair @(StaffTool.HeldLabel is null ? "" : "holding")"></div>
<div class="build-bar physgun">
<div class="build-title">
<i>pan_tool</i>
@(StaffTool.HeldLabel is null ? "Physgun du staff" : $"Tenu : {StaffTool.HeldLabel} · {StaffTool.HeldDistance / 39.37f:0} m")
</div>
@if ( StaffTool.Hint is not null )
{
<div class="build-model">@StaffTool.Hint</div>
}
<div class="build-keys">
<span><b>Clic gauche maintenu</b> saisir</span>
<span><b>Molette</b> distance</span>
@if ( StaffTool.HeldLabel is not null )
{
<span><b>@HorizonHud.KeyName( "use" ) + souris</b> tourner (Maj : par 15°)</span>
}
<span><b>R</b> remettre droit</span>
<span><b>Clic droit</b> @(StaffTool.HeldLabel is null ? "dégeler l'objet visé" : "figer sur place")</span>
<span><b>@HorizonHud.KeyName( "Drop" )</b> supprimer (prop ou objet posé)</span>
<span><b>X</b> ranger</span>
</div>
</div>
}
@if ( Open && IsStaff )
{
<div class="admin-backdrop">
<div class="admin">
<div class="admin-side">
<div class="side-brand">
<div class="admin-logo"></div>
<div class="admin-title">ADMINISTRATION <span>HORIZON RP</span></div>
</div>
@foreach ( var group in Pages.GroupBy( x => x.Group ) )
{
<div class="nav-group">@group.Key</div>
@* Pas « page » : c'est un mot réservé de Razor (@page). *@
@foreach ( var entry in group )
{
<div class="nav @(Tab == entry.Id ? "active" : "")" onclick=@(() => SetTab( entry.Id ))>
<i>@entry.Icon</i>
<div class="nav-label">@entry.Name</div>
@if ( PageCount( entry.Id ) is { } count )
{
<div class="count">@count</div>
}
</div>
}
}
<div class="side-foot">
<div class="tool @(StaffTool.Equipped ? "on" : "")" onclick=@(() => { StaffTool.Toggle(); SetOpen( false ); })><i>pan_tool</i> @($"Physgun · {(HorizonHud.KeyName( "StaffTool" ))}")</div>
<div class="tool @(StaffTool.Noclip ? "on" : "")" onclick=@(() => StaffTool.ToggleNoclip())><i>flight</i> @($"Noclip · {(HorizonHud.KeyName( "Noclip" ))}")</div>
<div class="side-me">@($"{(Me.RpName)} · staff")</div>
</div>
</div>
<div class="admin-main">
<div class="admin-top">
<div class="top-title"><i>@CurrentPage.Icon</i> @CurrentPage.Name</div>
<div class="top-sub">@CurrentPage.Hint</div>
<div class="admin-close" onclick=@(() => SetOpen( false ))><i>close</i></div>
</div>
<div class="admin-body @(Tab is "joueurs" or "garderobe" or "tenues" ? "fill" : "")">
@if ( Tab == "accueil" )
{
<div class="stats big">
<div class="stat"><div class="stat-label">Joueurs en ligne</div><div class="stat-value">@($"{(RoleplayPlayer.All.Count())} / {(Networking.MaxPlayers)}")</div></div>
<div class="stat"><div class="stat-label">Staff en ligne</div><div class="stat-value">@RoleplayPlayer.All.Count( x => x.IsStaff )</div></div>
<div class="stat"><div class="stat-label">Argent en circulation</div><div class="stat-value">@Format.Money( (int)Math.Min( int.MaxValue, RoleplayPlayer.All.Sum( x => (long)x.Wallet + x.Bank ) ) )</div></div>
<div class="stat"><div class="stat-label">Prochaine paie</div><div class="stat-value">@Clock( HorizonGame.Current?.SecondsToPayday ?? 0 )</div></div>
<div class="stat @(ShutdownMode is null ? "" : "warn")"><div class="stat-label">Arrêt prévu</div><div class="stat-value">@ShutdownText</div></div>
</div>
<div class="stats big">
<div class="stat"><div class="stat-label">Objets de la ville</div><div class="stat-value">@CityObject.All.Count()</div></div>
<div class="stat"><div class="stat-label">Props posés</div><div class="stat-value">@PlacedProp.All.Count()</div></div>
<div class="stat"><div class="stat-label">Portes possédées</div><div class="stat-value">@($"{(DoorRows().Count( x => x.state?.IsOwned == true ))} / {(DoorCount)}")</div></div>
<div class="stat"><div class="stat-label">Véhicules sortis</div><div class="stat-value">@Vehicle.All.Count()</div></div>
<div class="stat"><div class="stat-label">Base de données</div><div class="stat-value">@(Backend.IsOnline ? "Reliée" : "Locale")</div></div>
</div>
<div class="grid2">
<div class="fcard">
<div class="fcard-title"><i>groups</i> En ligne</div>
<div class="rows">
@foreach ( var player in RoleplayPlayer.All.OrderBy( x => x.RpName ) )
{
<div class="row">
<div class="row-icon" style="background-color: @player.Job.Color"><i>@player.Job.Icon</i></div>
<div class="row-main">
<div class="row-title">@player.RpName</div>
<div class="row-sub">@($"{(player.Job.Name)} · {(Format.Money( player.Wallet ))} · {(Meters( player.WorldPosition ))}")</div>
</div>
<div class="btn" onclick=@(() => { SetTab( "joueurs" ); Select( player ); })><i>badge</i> Fiche</div>
</div>
}
</div>
</div>
<div class="fcard">
<div class="fcard-title"><i>history</i> Derniers événements <div class="btn small" onclick=@(() => SetTab( "journal" ))>Tout le journal</div></div>
<div class="rows">
@LogRows( GeneralLogs.Take( 14 ), false )
</div>
</div>
</div>
}
else if ( Tab == "journal" )
{
<div class="search">
<TextEntry class="field grow" Placeholder="Rechercher : nom, action, texte…" Value=@LogQuery OnTextEdited=@((string v) => { LogQuery = v; })></TextEntry>
<div class="btn" onclick=@(() => RefreshLogs())><i>search</i> Rechercher</div>
<div class="btn" onclick=@(() => { LogQuery = ""; RefreshLogs(); })><i>refresh</i> Actualiser</div>
</div>
@* Comme le filtre du panneau : « Jeu » (le serveur, staff en jeu compris), « Staff » (le panneau web). *@
<div class="chips">
@foreach ( var (id, label) in LogSources )
{
<div class="chip @(LogSource == id ? "active" : "")" onclick=@(() => { LogSource = id; RefreshLogs(); })>@label</div>
}
</div>
<div class="rows">
@LogRows( GeneralLogs, true )
</div>
<div class="hint">Les 120 dernières lignes. Effacer une ligne ou un journal se fait dans le panneau web (administrateurs).</div>
}
else if ( Tab == "construire" )
{
<div class="build">
<div class="kinds">
<div class="section-label">Sorte d'objet</div>
@foreach ( var kind in CityKinds.All )
{
<div class="kind @(Kind == kind.Id ? "active" : "")" onclick=@(() => SelectKind( kind.Id ))>
<i>@kind.Icon</i>
<div class="kind-text">
<div class="kind-name">@kind.Name</div>
<div class="kind-desc">@kind.Description</div>
</div>
</div>
}
</div>
<div class="options">
@if ( Kind == CityKinds.Decor )
{
<div class="section-label">Catalogue</div>
<div class="chips">
@foreach ( var category in Categories )
{
<div class="chip @(Category == category ? "active" : "")" onclick=@(() => Category = category)>@category</div>
}
</div>
<div class="catalog">
@foreach ( var entry in CityKinds.Catalog.Where( x => Category == "Tout" || x.Category == Category ) )
{
<div class="model @(Source == entry.Model ? "active" : "")" onclick=@(() => Source = entry.Model)>
<div class="model-pic" style="background-image: url( @ModelThumbs.Of( entry.Model ) )"></div>
<div class="model-name">@entry.Name</div>
</div>
}
</div>
}
else if ( Kind == CityKinds.JobNpc )
{
<div class="section-label">Métier proposé par ce PNJ</div>
<div class="chips">
@foreach ( var job in Jobs.All.Where( x => !x.AdminOnly ) )
{
<div class="chip @(Data == job.Id ? "active" : "")" onclick=@(() => Data = job.Id)>@job.Name</div>
}
</div>
}
<div class="section-label">Modèle (chemin .vmdl, mount://… ou paquet du Workshop org.paquet)</div>
<TextEntry class="field" Value=@Source OnTextEdited=@((string v) => { Source = v?.Trim(); })></TextEntry>
<div class="section-label">Nom affiché (facultatif)</div>
<TextEntry class="field" Value=@Label OnTextEdited=@((string v) => { Label = v; })></TextEntry>
<div class="section-label">Taille</div>
<div class="chips">
@foreach ( var s in Scales )
{
<div class="chip @(MathF.Abs( Scale - s ) < 0.01f ? "active" : "")" onclick=@(() => Scale = s)>@($"×{(s)}")</div>
}
</div>
<div class="place @(ModelSources.LooksValid( Source ) ? "" : "disabled")" onclick=@(() => StartBuild( 0 ))>
<i>add_location_alt</i> Poser en jeu
</div>
</div>
</div>
}
else if ( Tab == "ville" )
{
<div class="rows">
@if ( !CityObject.All.Any() )
{
<div class="empty">Aucun objet posé pour l'instant. Onglet Construire pour commencer.</div>
}
@foreach ( var obj in CityObject.All.OrderBy( x => Distance( x.WorldPosition ) ).Take( 80 ) )
{
<div class="row">
<div class="row-icon"><i>@obj.KindInfo.Icon</i></div>
<div class="row-main">
<div class="row-title">@(string.IsNullOrWhiteSpace( obj.Label ) ? obj.KindInfo.Name : obj.Label) <span>@($"n°{(obj.PlacementId)}")</span></div>
<div class="row-sub">@obj.ModelSource</div>
</div>
<div class="row-dist">@Meters( obj.WorldPosition )</div>
<div class="btn" onclick=@(() => GoTo( obj.WorldPosition ))><i>near_me</i> Aller</div>
<div class="btn" onclick=@(() => StartMove( obj ))><i>open_with</i> Déplacer</div>
<div class="btn danger" onclick=@(() => AdminActions.RequestRemove( obj.PlacementId ))><i>delete</i></div>
</div>
}
</div>
}
else if ( Tab == "joueurs" )
{
<div class="players">
<div class="player-list">
<div class="section-label">@($"En ligne · {(RoleplayPlayer.All.Count())}")</div>
@foreach ( var player in RoleplayPlayer.All.OrderBy( x => x.RpName ) )
{
<div class="pl @(SelectedId == player.SteamId ? "active" : "")" onclick=@(() => Select( player ))>
<div class="row-icon" style="background-color: @player.Job.Color"><i>@player.Job.Icon</i></div>
<div class="row-main">
<div class="row-title">@player.RpName</div>
<div class="row-sub">@($"{(player.Job.Name)} · {(Meters( player.WorldPosition ))}")</div>
</div>
@if ( player.Frozen )
{
<i class="pl-tag">ac_unit</i>
}
@if ( player.IsStaff )
{
<i class="pl-tag staff">shield</i>
}
</div>
}
@* Tous les joueurs de la base, partis compris : nom RP, pseudo ou SteamID. *@
<div class="section-label find-label">Rechercher dans la base</div>
<div class="search">
<TextEntry class="field grow" Placeholder="Nom RP, pseudo, SteamID…" Value=@FindQuery OnTextEdited=@((string v) => { FindQuery = v; })></TextEntry>
<div class="btn" onclick=@(() => Find( 1 ))><i>search</i></div>
</div>
<div class="chips">
@foreach ( var (id, label) in FindFilters )
{
<div class="chip @(FindFilter == id ? "active" : "")" onclick=@(() => { FindFilter = id; Find( 1 ); })>@label</div>
}
</div>
@if ( AdminClient.Found is { } found )
{
<div class="hint">@($"{(found.Total)} joueur(s){(found.Pages > 1 ? $" · page {found.Page} / {found.Pages}" : "")}")</div>
@foreach ( var row in found.Players ?? new() )
{
var r = row;
<div class="pl @(SelectedId == r.Id ? "active" : "")" onclick=@(() => SelectRecord( r ))>
<div class="row-icon" style="background-color: @(Jobs.Get( r.Job )?.Color ?? "#334155")"><i>@(Jobs.Get( r.Job )?.Icon ?? "person")</i></div>
<div class="row-main">
<div class="row-title">@r.Name</div>
<div class="row-sub">@RecordSub( r )</div>
</div>
@if ( r.Banned )
{
<i class="pl-tag banned">block</i>
}
else if ( r.Online )
{
<i class="pl-tag">wifi</i>
}
</div>
}
@if ( found.Pages > 1 )
{
<div class="chips">
<div class="btn small @(found.Page <= 1 ? "disabled" : "")" onclick=@(() => { if ( found.Page > 1 ) Find( found.Page - 1 ); })><i>chevron_left</i></div>
<div class="btn small @(found.Page >= found.Pages ? "disabled" : "")" onclick=@(() => { if ( found.Page < found.Pages ) Find( found.Page + 1 ); })><i>chevron_right</i></div>
</div>
}
}
else
{
<div class="hint">@(Backend.IsOnline ? "Un nom, un pseudo ou un SteamID, ou un filtre : les joueurs partis s'y trouvent aussi." : "Partie locale : pas de base à fouiller.")</div>
}
</div>
<div class="fiche">
@if ( Selected is { } p )
{
<div class="fcard head">
<div class="fiche-head">
<div class="row-icon big" style="background-color: @p.Job.Color"><i>@p.Job.Icon</i></div>
<div class="row-main">
<div class="fiche-name">
@p.RpName
<span class="badge job" style="background-color: @p.Job.Color">@p.Job.Name</span>
@if ( p.IsStaff )
{
<span class="badge">STAFF</span>
}
@if ( p.GodMode )
{
<span class="badge dieu">MODE DIEU</span>
}
</div>
<div class="row-sub">@($"{(p.Network.Owner?.DisplayName)} · {(p.SteamId)} · {(p.PhoneNumber)}")</div>
</div>
</div>
<div class="chips">
<div class="btn" onclick=@(() => GoTo( p.WorldPosition ))><i>near_me</i> Aller</div>
<div class="btn" onclick=@(() => Act( p, "amener" ))><i>person_pin_circle</i> Amener</div>
<div class="btn" onclick=@(() => Act( p, p.Frozen ? "degel" : "gel" ))><i>ac_unit</i> @(p.Frozen ? "Dégeler" : "Geler")</div>
<div class="btn" onclick=@(() => Act( p, "reapparition" ))><i>replay</i> Réapparition</div>
<div class="btn" onclick=@(() => Act( p, "soigner" ))><i>healing</i> Soigner</div>
<div class="btn @(p.GodMode ? "active" : "")" onclick=@(() => Act( p, "dieu" ))><i>shield</i> @(p.GodMode ? "Retirer le mode dieu" : "Mode dieu")</div>
@if ( p.Downed )
{
<div class="btn" onclick=@(() => Act( p, "reanimer" ))><i>emergency</i> Réanimer</div>
}
else
{
<div class="btn danger" onclick=@(() => Act( p, "tuer" ))><i>dangerous</i> Tuer</div>
}
<div class="btn" onclick=@(() => Act( p, "capture" ))><i>photo_camera</i> Capture d'écran</div>
<div class="btn" onclick=@(() => Act( p, "personnage" ))><i>face</i> Refaire le personnage</div>
</div>
</div>
<div class="stats">
<div class="stat"><div class="stat-label">Portefeuille</div><div class="stat-value">@Format.Money( p.Wallet )</div></div>
<div class="stat"><div class="stat-label">Banque</div><div class="stat-value">@Format.Money( p.Bank )</div></div>
<div class="stat"><div class="stat-label">Vie</div><div class="stat-value">@MathF.Ceiling( p.Health )</div></div>
<div class="stat"><div class="stat-label">Faim</div><div class="stat-value">@MathF.Ceiling( p.Hunger )</div></div>
<div class="stat @(p.Frozen ? "cold" : "")"><div class="stat-label">État</div><div class="stat-value">@(p.Frozen ? "Gelé" : p.Held ? "Tenu" : "Libre")</div></div>
<div class="stat"><div class="stat-label">Temps de jeu</div><div class="stat-value">@(File is null ? "–" : Hours( File.Playtime ))</div></div>
<div class="stat"><div class="stat-label">Arrivé le</div><div class="stat-value">@(File is null ? "–" : Day( File.FirstSeen ))</div></div>
</div>
@if ( File?.Player is { Banned: true } banned )
{
<div class="ban-detail">
<i>block</i>
<div class="row-main">@BanText( banned )</div>
<div class="btn" onclick=@(() => AdminActions.RequestRecordAction( p.SteamId, "debannir", 0, "" ))><i>lock_open</i> Débannir</div>
</div>
}
<div class="grid2">
<div class="fcard">
<div class="fcard-title"><i>payments</i> Argent</div>
@* Deux montants exacts, sans crans prédéfinis (Julien) : on tape le nouveau solde, on valide. *@
<div class="section-label">@($"Portefeuille · actuellement {Format.Money( p.Wallet )}")</div>
<div class="search">
<TextEntry class="field grow" Placeholder="Nouveau montant ($)" Value=@WalletDraft OnTextEdited=@((string v) => { WalletDraft = v; })></TextEntry>
<div class="btn @(AmountOf( WalletDraft ) is null ? "disabled" : "")" onclick=@(() => SetMoney( p, "argent_fixer", true ))><i>check</i> Valider</div>
</div>
<div class="section-label">@($"Banque · actuellement {Format.Money( p.Bank )}")</div>
<div class="search">
<TextEntry class="field grow" Placeholder="Nouveau montant ($)" Value=@BankDraft OnTextEdited=@((string v) => { BankDraft = v; })></TextEntry>
<div class="btn @(AmountOf( BankDraft ) is null ? "disabled" : "")" onclick=@(() => SetMoney( p, "banque_fixer", false ))><i>check</i> Valider</div>
</div>
</div>
<div class="fcard">
<div class="fcard-title"><i>work</i> Métier</div>
<div class="chips">
@foreach ( var job in Jobs.All )
{
<div class="chip @(p.JobId == job.Id ? "active" : "")" onclick=@(() => Act( p, "metier", 0, job.Id ))>@job.Name</div>
}
</div>
</div>
</div>
<div class="fcard">
<div class="fcard-title"><i>backpack</i> @($"Inventaire · {(Format.Kilos( InventoryRules.WeightOf( p.PublishedInventory ) ))} / {(Format.Kilos( InventoryRules.MaxWeight ))}")
@if ( p.PublishedInventory.Any() )
{
<div class="btn small danger" onclick=@(() => Act( p, "vider" ))><i>delete_sweep</i> Vider</div>
}
</div>
<div class="give-head">
<div class="section-label">Retirer</div>
<div class="chips">
@foreach ( var (count, label) in TakeCounts )
{
<div class="chip @(TakeCount == count ? "active" : "")" onclick=@(() => TakeCount = count)>@label</div>
}
</div>
</div>
<div class="chips">
@if ( !p.PublishedInventory.Any() )
{
<div class="hint">Inventaire vide.</div>
}
@foreach ( var slot in p.PublishedInventory.Where( x => x.Definition is not null ) )
{
<div class="slot" onclick=@(() => Act( p, "retirer", TakeCount > 0 ? TakeCount : p.PublishedInventory.Where( x => x.Item == slot.Item ).Sum( x => x.Count ), slot.Item ))>
@if ( slot.Definition.Picture is { } picture )
{
<div class="slot-pic" style="background-image: url( @picture )"></div>
}
else
{
<i>@slot.Definition.Icon</i>
}
@slot.Definition.Name <b>@($"×{(slot.Count)}")</b> <i class="remove">remove_circle</i>
</div>
}
</div>
<div class="give-head">
<div class="section-label">Donner</div>
<div class="chips">
@foreach ( var count in GiveCounts )
{
<div class="chip @(GiveCount == count ? "active" : "")" onclick=@(() => GiveCount = count)>@($"×{(count)}")</div>
}
</div>
<div class="chips">
@foreach ( var (id, label) in GiveKinds )
{
<div class="chip @(GiveKind == id ? "active" : "")" onclick=@(() => { GiveKind = id; GiveCount = GiveCounts[0]; })>@label</div>
}
</div>
</div>
<div class="chips">
@foreach ( var item in GiveList )
{
<div class="slot give" onclick=@(() => Act( p, "objet", GiveCount, item.Id ))>
@if ( item.Picture is { } picture )
{
<div class="slot-pic" style="background-image: url( @picture )"></div>
}
else
{
<i>@item.Icon</i>
}
@item.Name
</div>
}
</div>
<div class="hint">Clic sur un objet de l'inventaire : en retirer autant que choisi (« Tout » : tout ce qu'il en a). Un prop donné se pose depuis l'inventaire du joueur.</div>
</div>
<div class="grid2">
<div class="fcard">
<div class="fcard-title"><i>chat</i> Communication</div>
<div class="section-label">@($"Message privé (une annonce à l'écran du joueur) · {(MessageText?.Length ?? 0)} / {(AdminActions.MessageMax)}")</div>
<div class="search">
<TextEntry class="field grow" Placeholder="Votre message…" Value=@MessageText OnTextEdited=@((string v) => { MessageText = v; })></TextEntry>
<div class="btn" onclick=@(() => { Act( p, "message", 0, MessageText ); MessageText = ""; })><i>send</i> Envoyer</div>
</div>
@if ( (MessageText?.Length ?? 0) > AdminActions.MessageMax )
{
<div class="hint warn">@($"Trop long : seuls les {(AdminActions.MessageMax)} premiers caractères partiront.")</div>
}
<div class="section-label">Envoyer auprès de</div>
<div class="chips">
@if ( RoleplayPlayer.All.Count() < 2 )
{
<div class="hint">Personne d'autre en ligne.</div>
}
@foreach ( var other in RoleplayPlayer.All.Where( x => x != p ).OrderBy( x => x.RpName ) )
{
<div class="chip" onclick=@(() => Act( p, "envoyer", 0, other.SteamId.ToString() ))>@other.RpName</div>
}
</div>
</div>
<div class="fcard danger">
<div class="fcard-title"><i>gavel</i> Sanctions</div>
<div class="section-label">Motif</div>
<TextEntry class="field" Placeholder="Motif (montré au joueur)" Value=@Reason OnTextEdited=@((string v) => { Reason = v; })></TextEntry>
<div class="section-label">Durée du bannissement</div>
<div class="chips">
@foreach ( var (hours, label) in BanDurations )
{
<div class="chip @(BanHours == hours ? "active" : "")" onclick=@(() => BanHours = hours)>@label</div>
}
</div>
<div class="chips">
<div class="btn danger" onclick=@(() => Act( p, "expulser", 0, Reason ))><i>logout</i> Expulser</div>
<div class="btn danger strong" onclick=@(() => Act( p, "bannir", BanHours, Reason ))><i>block</i> @($"Bannir ({(BanDurations.First( x => x.hours == BanHours ).label)})")</div>
</div>
</div>
</div>
<div class="grid2">
<div class="fcard">
<div class="fcard-title"><i>badge</i> Identité</div>
<div class="rename">
<TextEntry class="field" Placeholder="Prénom" Value=@FirstName OnTextEdited=@((string v) => { FirstName = v; })></TextEntry>
<TextEntry class="field" Placeholder="Nom" Value=@LastName OnTextEdited=@((string v) => { LastName = v; })></TextEntry>
<div class="btn" onclick=@(() => Act( p, "nom", 0, $"{FirstName}|{LastName}" ))><i>edit</i> Renommer</div>
</div>
<div class="section-label">@($"Véhicules · {(File?.Vehicles?.Count ?? 0)}")</div>
<div class="chips">
@foreach ( var v in File?.Vehicles ?? new() )
{
<div class="slot"><i>directions_car</i> @($"{(VehicleName( v.Model ))} · {(v.Plate)}")</div>
}
</div>
<div class="hint">@($"{(File?.Captures ?? 0)} capture(s) d'écran : elles s'ouvrent dans la fiche du panneau web.")</div>
</div>
<div class="fcard">
<div class="fcard-title"><i>sticky_note_2</i> Notes du staff</div>
<TextEntry class="field notes" Multiline=@true Placeholder="Visibles par le staff seulement, ici et dans le panneau web." Value=@NotesDraft OnTextEdited=@((string v) => { NotesDraft = v; })></TextEntry>
<div class="btn" onclick=@(() => AdminActions.RequestNotes( p.SteamId, NotesDraft ))><i>save</i> Enregistrer les notes</div>
</div>
</div>
<div class="fcard">
<div class="fcard-title"><i>history</i> Journal du joueur <div class="btn small" onclick=@(() => AdminActions.RequestPlayerFile( p.SteamId ))><i>refresh</i> Actualiser</div></div>
<div class="rows">
@if ( File is null )
{
<div class="hint">@(Backend.IsOnline ? "Chargement…" : "Partie locale : pas de journal en base.")</div>
}
else
{
@LogRows( File.Logs ?? new(), false )
}
</div>
</div>
}
else if ( SelectedId != 0 && File?.Player is { } o )
{
@* Un joueur qui n'est pas connecté : ce que la base sait de lui, et ce qui s'y écrit
directement (bannir, débannir, renommer, fixer l'argent), comme le panneau web. *@
<div class="fcard head">
<div class="fiche-head">
<div class="row-icon big" style="background-color: @(Jobs.Get( o.Job )?.Color ?? "#334155")"><i>@(Jobs.Get( o.Job )?.Icon ?? "person")</i></div>
<div class="row-main">
<div class="fiche-name">
@o.Name
<span class="badge">@(o.Online ? "ENCORE NOTÉ EN LIGNE" : "HORS LIGNE")</span>
@if ( o.Banned )
{
<span class="badge banned">BANNI</span>
}
</div>
<div class="row-sub">@($"{(o.DisplayName)} · {(o.SteamId)} · {(o.Phone)}")</div>
</div>
</div>
<div class="hint">@($"Dernière connexion : {(Stamp( o.LastSeen ))}. Ce qui s'écrit ici va directement en base ; il le retrouvera à sa prochaine connexion.")</div>
</div>
<div class="stats">
<div class="stat"><div class="stat-label">Portefeuille</div><div class="stat-value">@Format.Money( o.Wallet )</div></div>
<div class="stat"><div class="stat-label">Banque</div><div class="stat-value">@Format.Money( o.Bank )</div></div>
<div class="stat"><div class="stat-label">Métier</div><div class="stat-value">@(Jobs.Get( o.Job )?.Name ?? o.Job ?? "–")</div></div>
<div class="stat"><div class="stat-label">Temps de jeu</div><div class="stat-value">@Hours( File.Playtime )</div></div>
<div class="stat"><div class="stat-label">Arrivé le</div><div class="stat-value">@Day( File.FirstSeen )</div></div>
</div>
@if ( o.Banned )
{
<div class="ban-detail">
<i>block</i>
<div class="row-main">@BanText( o )</div>
<div class="btn" onclick=@(() => AdminActions.RequestRecordAction( o.Id, "debannir", 0, "" ))><i>lock_open</i> Débannir</div>
</div>
}
<div class="grid2">
<div class="fcard">
<div class="fcard-title"><i>payments</i> Argent</div>
<div class="section-label">Portefeuille</div>
<div class="search">
<TextEntry class="field grow" Placeholder="Nouveau montant ($)" Value=@WalletDraft OnTextEdited=@((string v) => { WalletDraft = v; })></TextEntry>
<div class="btn @(AmountOf( WalletDraft ) is null ? "disabled" : "")" onclick=@(() => SetRecordMoney( o, "portefeuille", true ))><i>check</i> Valider</div>
</div>
<div class="section-label">Banque</div>
<div class="search">
<TextEntry class="field grow" Placeholder="Nouveau montant ($)" Value=@BankDraft OnTextEdited=@((string v) => { BankDraft = v; })></TextEntry>
<div class="btn @(AmountOf( BankDraft ) is null ? "disabled" : "")" onclick=@(() => SetRecordMoney( o, "banque", false ))><i>check</i> Valider</div>
</div>
</div>
<div class="fcard danger">
<div class="fcard-title"><i>gavel</i> Sanctions</div>
@if ( o.Banned )
{
<div class="hint">Déjà banni : débannissez d'abord pour changer la durée ou le motif.</div>
}
else
{
<div class="section-label">Motif</div>
<TextEntry class="field" Placeholder="Motif (montré à sa prochaine connexion)" Value=@Reason OnTextEdited=@((string v) => { Reason = v; })></TextEntry>
<div class="section-label">Durée du bannissement</div>
<div class="chips">
@foreach ( var (hours, label) in BanDurations )
{
<div class="chip @(BanHours == hours ? "active" : "")" onclick=@(() => BanHours = hours)>@label</div>
}
</div>
<div class="chips">
<div class="btn danger strong" onclick=@(() => AdminActions.RequestRecordAction( o.Id, "bannir", BanHours, Reason ))><i>block</i> @($"Bannir ({(BanDurations.First( x => x.hours == BanHours ).label)})")</div>
</div>
}
</div>
</div>
<div class="grid2">
<div class="fcard">
<div class="fcard-title"><i>badge</i> Identité</div>
<div class="rename">
<TextEntry class="field" Placeholder="Prénom" Value=@FirstName OnTextEdited=@((string v) => { FirstName = v; })></TextEntry>
<TextEntry class="field" Placeholder="Nom" Value=@LastName OnTextEdited=@((string v) => { LastName = v; })></TextEntry>
<div class="btn" onclick=@(() => AdminActions.RequestRecordAction( o.Id, "nom", 0, $"{FirstName}|{LastName}" ))><i>edit</i> Renommer</div>
</div>
<div class="section-label">@($"Véhicules · {(File.Vehicles?.Count ?? 0)}")</div>
<div class="chips">
@foreach ( var v in File.Vehicles ?? new() )
{
<div class="slot"><i>directions_car</i> @($"{(VehicleName( v.Model ))} · {(v.Plate)}")</div>
}
</div>
<div class="hint">@($"{(File.Captures)} capture(s) d'écran : elles s'ouvrent dans la fiche du panneau web.")</div>
</div>
<div class="fcard">
<div class="fcard-title"><i>sticky_note_2</i> Notes du staff</div>
<TextEntry class="field notes" Multiline=@true Placeholder="Visibles par le staff seulement, ici et dans le panneau web." Value=@NotesDraft OnTextEdited=@((string v) => { NotesDraft = v; })></TextEntry>
<div class="btn" onclick=@(() => AdminActions.RequestNotes( o.Id, NotesDraft ))><i>save</i> Enregistrer les notes</div>
</div>
</div>
<div class="fcard">
<div class="fcard-title"><i>history</i> Journal du joueur <div class="btn small" onclick=@(() => AdminActions.RequestPlayerFile( o.Id ))><i>refresh</i> Actualiser</div></div>
<div class="rows">
@LogRows( File.Logs ?? new(), false )
</div>
</div>
}
else if ( SelectedId != 0 )
{
<div class="empty">@(Backend.IsOnline ? "Chargement de la fiche…" : "Partie locale : ce joueur n'est pas connecté.")</div>
}
else
{
<div class="empty">Choisissez un joueur dans la liste, ou cherchez-le dans la base.</div>
}
</div>
</div>
}
else if ( Tab == "props" )
{
@* Le catalogue des props des joueurs, le même que la page Props du panneau web. *@
<div class="build">
<div class="prop-list">
<div class="prop-list-head">
<div class="section-label">@(string.IsNullOrWhiteSpace( PropQuery ) ? $"Catalogue · {PropCatalog.Count()} props" : $"Catalogue · {FilteredProps.Count()} sur {PropCatalog.Count()}")</div>
<div class="btn" onclick=@(() => EditProp( null ))><i>add</i> Nouveau</div>
</div>
@* La recherche, comme celle des armes : nom, catégorie ou numéro, sans tenir compte des accents. *@
<div class="search arsenal-search">
<i class="search-icon">search</i>
<TextEntry class="field grow" Placeholder="Rechercher un prop, une catégorie…" Value=@PropQuery OnTextEdited=@((string v) => { PropQuery = v; })></TextEntry>
@if ( !string.IsNullOrWhiteSpace( PropQuery ) )
{
<div class="btn" onclick=@(() => PropQuery = "")><i>close</i></div>
}
</div>
<div class="prop-rows">
@foreach ( var entry in FilteredProps )
{
<div class="prop-row @(PropEdit?.Id == entry.Id && entry.Id != 0 ? "active" : "") @(entry.Active ? "" : "off")" onclick=@(() => EditProp( entry ))>
<div class="prop-pic" style="background-image: url( @ModelThumbs.Of( entry.Model ) )"></div>
<div class="row-main">
<div class="row-title">@entry.Name</div>
<div class="row-sub">@PropSub( entry )</div>
</div>
<div class="prop-give" tooltip="Me le donner" onclick=@((PanelEvent ev) => { ev.StopPropagation(); GiveMeProp( entry.Id ); })><i>back_hand</i></div>
</div>
}
@if ( !FilteredProps.Any() )
{
<div class="empty">Aucun prop ne correspond à « @PropQuery.Trim() ».</div>
}
</div>
</div>
<div class="options">
@if ( PropEdit is not null )
{
<div class="prop-preview">
<div class="prop-pic big" style="background-image: url( @ModelThumbs.Of( PropEdit.Model ) )"></div>
<div class="row-main">
<div class="row-title">@(PropEdit.Id == 0 ? "Nouveau prop" : PropEdit.Name)</div>
<div class="row-sub">@(PropEdit.Id == 0 ? "Ajouté au menu des joueurs dès l'enregistrement." : $"N° {PropEdit.Id} · {PlacedProp.All.Count( x => x.EntryId == PropEdit.Id )} posé(s) en ville")</div>
</div>
</div>
<div class="section-label">Modèle (chemin .vmdl, mount://… ou paquet du Workshop org.paquet)</div>
<TextEntry class="field" [email protected] OnTextEdited=@((string v) => { PropEdit.Model = v?.Trim(); })></TextEntry>
<div class="prop-fields">
<div class="prop-field">
<div class="section-label">Nom</div>
<TextEntry class="field" [email protected] OnTextEdited=@((string v) => { PropEdit.Name = v; })></TextEntry>
</div>
<div class="prop-field">
<div class="section-label">Catégorie</div>
<TextEntry class="field" [email protected] OnTextEdited=@((string v) => { PropEdit.Category = v; })></TextEntry>
</div>
<div class="prop-field small">
<div class="section-label">Prix ($)</div>
<TextEntry class="field" [email protected]() OnTextEdited=@((string v) => { PropEdit.Price = int.TryParse( v, out var n ) ? Math.Clamp( n, 0, 100000 ) : 0; })></TextEntry>
</div>
</div>
<div class="section-label">Métiers qui peuvent le poser (aucun coché : tout le monde)</div>
<div class="chips">
@foreach ( var job in Jobs.All.Where( x => !x.AdminOnly ) )
{
<div class="chip @(PropEdit.JobList.Contains( job.Id ) ? "active" : "")" onclick=@(() => ToggleJob( job.Id ))>@job.Name</div>
}
</div>
<div class="chips">
<div class="chip @(PropEdit.StaffOnly ? "active" : "")" onclick=@(() => PropEdit.StaffOnly = !PropEdit.StaffOnly)><i>shield</i> Réservé au staff</div>
<div class="chip @(PropEdit.Active ? "active" : "")" onclick=@(() => PropEdit.Active = !PropEdit.Active)><i>@(PropEdit.Active ? "visibility" : "visibility_off")</i> @(PropEdit.Active ? "Dans le menu des joueurs" : "Retiré du menu")</div>
</div>
<div class="prop-actions">
<div class="place @(ModelSources.LooksValid( PropEdit.Model ) && !string.IsNullOrWhiteSpace( PropEdit.Name ) ? "" : "disabled")" onclick=@SaveProp><i>save</i> Enregistrer</div>
@if ( PropEdit.Id != 0 )
{
<div class="btn big" onclick=@(() => GiveMeProp( PropEdit.Id ))><i>back_hand</i> Me le donner</div>
<div class="btn danger big" onclick=@DeleteProp><i>delete</i> Supprimer</div>
}
</div>
}
else
{
<div class="empty">Choisissez un prop à gauche, ou « Nouveau ». Les joueurs le retrouvent avec la touche @HorizonHud.KeyName( "PropMenu" ).</div>
}
<div class="section-label">@($"Posés en ville · {(PlacedProp.All.Count())}")</div>
<div class="rows placed">
@foreach ( var prop in PlacedProp.All.OrderBy( x => Distance( x.WorldPosition ) ).Take( 40 ) )
{
<div class="row">
<div class="prop-pic" style="background-image: url( @ModelThumbs.Of( prop.ModelSource ) )"></div>
<div class="row-main">
<div class="row-title">@prop.Label</div>
<div class="row-sub">@prop.OwnerName</div>
</div>
<div class="row-dist">@Meters( prop.WorldPosition )</div>
<div class="btn" onclick=@(() => GoTo( prop.WorldPosition ))><i>near_me</i> Aller</div>
<div class="btn danger" onclick=@(() => PropSystem.Current?.RequestRemove( prop.GameObject.Id ))><i>delete</i> Retirer</div>
</div>
}
</div>
</div>
</div>
}
else if ( Tab == "armes" )
{
@* Le catalogue des armes, le même que la page Armes du panneau web : une
recherche, un onglet par type (et les accessoires), un tri, des tuiles. *@
<div class="arsenal-top">
<div class="search arsenal-search">
<i class="search-icon">search</i>
<TextEntry class="field grow" Placeholder="Rechercher une arme, un calibre…" Value=@WeaponQuery OnTextEdited=@((string v) => { WeaponQuery = v; })></TextEntry>
@if ( !string.IsNullOrWhiteSpace( WeaponQuery ) )
{
<div class="btn" onclick=@(() => WeaponQuery = "")><i>close</i></div>
}
</div>
<div class="chips arsenal-sort">
<div class="sort-label">Trier</div>
@foreach ( var (id, label) in WeaponSorts )
{
<div class="chip @(WeaponSort == id ? "active" : "")" onclick=@(() => WeaponSort = id)>@label</div>
}
</div>
</div>
<div class="chips arsenal-tabs">
@foreach ( var (id, label, icon) in WeaponTabs )
{
<div class="chip tab @(WeaponTab == id ? "active" : "")" onclick=@(() => WeaponTab = id)>
<i>@icon</i> @label <span class="count">@TabCount( id )</span>
</div>
}
</div>
<div class="build">
<div class="prop-list arsenal-list">
@if ( WeaponTab != "accessoires" )
{
<div class="arsenal-legend">
<span><div class="dot dmg"></div> Dégâts</span>
<span><div class="dot rpm"></div> Cadence</span>
<span class="grow"></span>
<span>@($"{(FilteredWeapons.Count())} arme(s)")</span>
</div>
}
<div class="arsenal-grid">
@if ( WeaponTab == "accessoires" )
{
@foreach ( var a in FilteredAccessories )
{
<div class="wtile @(AccessoryEdit?.Id == a.Id ? "active" : "")" onclick=@(() => EditAccessory( a ))>
<div class="wtile-pic icon" style="background-image: url( @a.Icon )"></div>
<div class="wtile-name">@a.Name</div>
<div class="wtile-sub">@($"{(a.Category)} · {(Format.Money( a.Price ))}")</div>
<div class="wtile-give" onclick=@((PanelEvent ev) => { ev.StopPropagation(); GiveMeAccessory( a.Id ); })><i>back_hand</i></div>
</div>
}
}
else
{
@foreach ( var w in FilteredWeapons )
{
<div class="wtile @(WeaponEdit?.Id == w.Id ? "active" : "") @(w.Active ? "" : "off")" onclick=@(() => EditWeapon( w ))>
<div class="wtile-pic @(w.Category == "Explosif" ? "small" : "")" style="background-image: url( @ModelThumbs.Of( w.Model ) )"></div>
<div class="wtile-name">@w.Name</div>
<div class="wtile-sub">@($"{(AmmoShort( w.Ammo ))} · {(w.ClipSize)} coups")</div>
<div class="wtile-bars">
<div class="bar"><div class="bar-fill dmg" style="width: @(Pct( DamageOf( w ), MaxDamage ))%"></div></div>
<div class="bar"><div class="bar-fill rpm" style="width: @(Pct( w.Rpm, MaxRpm ))%"></div></div>
</div>
<div class="wtile-give" onclick=@((PanelEvent ev) => { ev.StopPropagation(); GiveMe( w ); })><i>back_hand</i></div>
</div>
}
}
@if ( WeaponTab == "accessoires" ? !FilteredAccessories.Any() : !FilteredWeapons.Any() )
{
<div class="empty">Rien ne correspond à « @WeaponQuery ».</div>
}
</div>
</div>
<div class="options">
@if ( AccessoryEdit is { } acc && WeaponRules.Accessory( acc.Id ) is { } info )
{
<div class="prop-preview">
<div class="prop-pic big" style="background-image: url( @info.Icon )"></div>
<div class="row-main">
<div class="row-title">@acc.Name</div>
<div class="row-sub">@($"accessoire:{(acc.Id)} · {(info.Category)}")</div>
</div>
@* Pour essayer : l'accessoire dans ses propres poches, à monter au menu de l'arme. *@
<div class="btn" onclick=@(() => GiveMeAccessory( acc.Id ))><i>back_hand</i> Me le donner</div>
</div>
<div class="prop-fields">
<div class="prop-field">
<div class="section-label">Nom affiché en jeu</div>
<TextEntry class="field" [email protected] OnTextEdited=@((string v) => { acc.Name = v; })></TextEntry>
</div>
<div class="prop-field small">
<div class="section-label">Prix ($)</div>
<TextEntry class="field" [email protected]() OnTextEdited=@((string v) => { acc.Price = (int)Num( v, 0, 1000000 ); })></TextEntry>
</div>
</div>
<div class="section-label">Se monte sur</div>
<div class="chips">
@foreach ( var name in FitsOn( info ) )
{
<div class="chip">@name</div>
}
</div>
<div class="chips">
<div class="chip @(acc.Shop ? "active" : "")" onclick=@(() => acc.Shop = !acc.Shop)><i>storefront</i> @(acc.Shop ? "En vente à l'Ammu-Nation" : "Pas en vente")</div>
</div>
<div class="prop-actions">
<div class="place @(string.IsNullOrWhiteSpace( acc.Name ) ? "disabled" : "")" onclick=@SaveAccessory><i>save</i> Enregistrer</div>
</div>
<div class="hint">Les armes où il se monte dépendent des pièces que porte chaque modèle : elles ne se règlent pas ici.</div>
}
else if ( WeaponEdit is { } e )
{
<div class="prop-preview">
<div class="prop-pic big" style="background-image: url( @ModelThumbs.Of( e.Model ) )"></div>
<div class="row-main">
<div class="row-title">@e.Name</div>
<div class="row-sub">@($"arme:{(e.Id)} · {(e.Prefab)}")</div>
</div>
@* Pour essayer : l'arme et un chargeur de balles, dans ses propres poches. *@
<div class="btn" onclick=@(() => GiveMe( e ))><i>back_hand</i> Me la donner</div>
</div>
@* Où elle se place dans le catalogue : la plus forte de chaque valeur fait 100 %. *@
<div class="compare">
@foreach ( var (label, valeur, max, texte) in Comparatif( e ) )
{
<div class="compare-row">
<div class="compare-label">@label</div>
<div class="bar big"><div class="bar-fill" style="width: @(Pct( valeur, max ))%"></div></div>
<div class="compare-value">@texte</div>
</div>
}
</div>
<div class="prop-fields">
<div class="prop-field">
<div class="section-label">Nom affiché en jeu</div>
<TextEntry class="field" [email protected] OnTextEdited=@((string v) => { e.Name = v; })></TextEntry>
</div>
<div class="prop-field small">
<div class="section-label">Prix ($)</div>
<TextEntry class="field" [email protected]() OnTextEdited=@((string v) => { e.Price = (int)Num( v, 0, 1000000 ); })></TextEntry>
</div>
</div>
<div class="weapon-grid">
@foreach ( var (label, get, set) in WeaponNumbers( e ) )
{
<div class="prop-field">
<div class="section-label">@label</div>
<TextEntry class="field" Value=@get() OnTextEdited=@((string v) => set( v ))></TextEntry>
</div>
}
</div>
<div class="chips">
<div class="chip @(e.Shop ? "active" : "")" onclick=@(() => e.Shop = !e.Shop)><i>storefront</i> @(e.Shop ? "En vente à l'Ammu-Nation" : "Pas en vente")</div>
<div class="chip @(e.Active ? "active" : "")" onclick=@(() => e.Active = !e.Active)><i>@(e.Active ? "check_circle" : "block")</i> @(e.Active ? "En service" : "Hors service")</div>
</div>
<div class="prop-actions">
<div class="place @(string.IsNullOrWhiteSpace( e.Name ) ? "disabled" : "")" onclick=@SaveWeapon><i>save</i> Enregistrer</div>
</div>
<div class="hint">Le serveur applique ces valeurs à chaque arme sortie ; une arme déjà en main garde les anciennes jusqu'à être rangée. Ajouter une arme se fait depuis le panneau web (prefab à fournir).</div>
}
else
{
<div class="empty">Choisissez une arme ou un accessoire à gauche.</div>
}
</div>
</div>
}
else if ( Tab is "garderobe" or "tenues" )
{
@* La garde-robe : liste à gauche, son personnage en 3D à droite (UI/WardrobePage.razor). *@
<WardrobePage Mode=@Tab></WardrobePage>
}
else if ( Tab == "vehicules" )
{
<div class="section-label">@($"En ville · {(Vehicle.All.Count())}")</div>
<div class="rows">
@if ( !Vehicle.All.Any() )
{
<div class="empty">Aucun véhicule sorti.</div>
}
@foreach ( var v in Vehicle.All.Where( x => x.IsValid() ).OrderBy( x => Distance( x.WorldPosition ) ) )
{
var car = v;
<div class="row">
<div class="vh-photo" style="background-image: url( ui/vehicules/@(car.CatalogId).png );"><div class="vh-dot" style="background-color: @(car.ColorHex);"></div></div>
<div class="row-main">
<div class="row-title">@(car.Info?.Name ?? car.CatalogId) <span>· @car.Plate</span></div>
<div class="row-sub">@VehicleSub( car )</div>
</div>
<div class="row-dist">@Meters( car.WorldPosition )</div>
<div class="btn" onclick=@(() => GoTo( car.WorldPosition + Vector3.Up * 20 ))><i>near_me</i> Aller</div>
<div class="btn" onclick=@(() => VehicleManager.Current?.RequestStaffAction( car.GameObject.Id, "reparer" ))><i>build</i> Réparer</div>
<div class="btn" onclick=@(() => VehicleManager.Current?.RequestStaffAction( car.GameObject.Id, "retourner" ))><i>flip</i></div>
<div class="btn" onclick=@(() => VehicleManager.Current?.RequestStaffAction( car.GameObject.Id, car.Locked ? "ouvrir" : "verrouiller" ))><i>@(car.Locked ? "lock_open" : "lock")</i></div>
<div class="btn" onclick=@(() => VehicleManager.Current?.RequestStaffAction( car.GameObject.Id, "moteur" ))><i>power_settings_new</i></div>
<div class="btn @(TrunkOpen == car.GameObject.Id ? "active" : "")" onclick=@(() => TrunkOpen = TrunkOpen == car.GameObject.Id ? Guid.Empty : car.GameObject.Id)><i>inventory_2</i> @($"Coffre · {(car.TrunkSlots.Count)}")</div>
<div class="btn danger" onclick=@(() => VehicleManager.Current?.RequestStaffAction( car.GameObject.Id, "ranger" ))><i>delete</i> Ranger</div>
</div>
@if ( TrunkOpen == car.GameObject.Id )
{
@* Le coffre de ce véhicule : prendre un emplacement dans ses poches, ou le supprimer. *@
<div class="fcard">
<div class="fcard-title"><i>inventory_2</i> @($"Coffre · {(Format.Kilos( car.TrunkWeight ))} / {(Format.Kilos( car.TrunkCapacity ))}")</div>
<div class="rows">
@if ( !car.TrunkSlots.Any() )
{
<div class="hint">Vide.</div>
}
@foreach ( var t in car.TrunkSlots.OrderBy( x => x.Slot ) )
{
var slotIndex = t.Slot;
<div class="row">
@if ( t.Definition?.Picture is { } pic )
{
<div class="slot-pic" style="background-image: url( @pic )"></div>
}
<div class="row-main">
<div class="row-title">@(t.Definition?.Name ?? t.Item) <span>@($"· ×{(t.Count)}")</span></div>
<div class="row-sub">@($"emplacement {(slotIndex + 1)} · {(Format.Kilos( t.Weight ))}")</div>
</div>
<div class="btn" onclick=@(() => VehicleManager.Current?.RequestStaffTrunk( car.GameObject.Id, slotIndex, "prendre" ))><i>back_hand</i> Prendre</div>
<div class="btn danger" onclick=@(() => VehicleManager.Current?.RequestStaffTrunk( car.GameObject.Id, slotIndex, "jeter" ))><i>delete</i> Supprimer</div>
</div>
}
</div>
</div>
}
}
</div>
<div class="section-label">Faire apparaître devant moi</div>
<div class="chips">
@foreach ( var (name, hex) in VehicleCatalog.Colors )
{
var color = hex;
<div class="chip @(SpawnColor == color ? "active" : "")" onclick=@(() => SpawnColor = color)><span class="swatch" style="background-color: @color;"></span>@name</div>
}
</div>
<div class="rows">
@foreach ( var m in VehicleCatalog.All )
{
var model = m;
<div class="row">
<div class="vh-photo" style="background-image: url( ui/vehicules/@(model.Id).png );"></div>
<div class="row-main">
<div class="row-title">@model.Name <span>· @model.Id</span></div>
<div class="row-sub">@($"{(model.MaxSpeedKmh)} km/h · 0 à 100 en {(100 / MathF.Max( 1, model.Acceleration )):0.0} s · {(model.Seats)} places · coffre {(model.TrunkKg)} kg · {(Format.Money( model.Price ))}")</div>
</div>
<div class="btn" onclick=@(() => SheetEdit = SheetEdit?.Id == model.Id ? null : VehicleSheet.From( model ))><i>tune</i> Régler</div>
<div class="btn primary" onclick=@(() => VehicleManager.Current?.RequestStaffSpawn( model.Id, SpawnColor ))><i>add_road</i> Faire apparaître</div>
</div>
@if ( SheetEdit is { } sheet && sheet.Id == model.Id )
{
@* La fiche, comme le formulaire « Régler » du panneau web. *@
<div class="fcard">
<div class="prop-fields">
<div class="prop-field">
<div class="section-label">Nom à la concession</div>
<TextEntry class="field" [email protected] OnTextEdited=@((string v) => { sheet.Name = v; })></TextEntry>
</div>
<div class="prop-field small">
<div class="section-label">Prix ($)</div>
<TextEntry class="field" [email protected]() OnTextEdited=@((string v) => { sheet.Price = (int)Num( v, 0, 100000000 ); })></TextEntry>
</div>
</div>
<div class="prop-field">
<div class="section-label">Description</div>
<TextEntry class="field" [email protected] OnTextEdited=@((string v) => { sheet.Description = v; })></TextEntry>
</div>
<div class="prop-field">
<div class="section-label">Modèle (paquet du Workshop ou .vmdl)</div>
<TextEntry class="field" [email protected] OnTextEdited=@((string v) => { sheet.Model = v?.Trim(); })></TextEntry>
</div>
<div class="weapon-grid">
@foreach ( var (key, label, min, max) in VehicleSheet.Fields )
{
var k = key; var lo = min; var hi = max;
<div class="prop-field">
<div class="section-label">@label</div>
<TextEntry class="field" Value=@Txt( sheet.Numbers.GetValueOrDefault( k ) ) OnTextEdited=@((string v) => { sheet.Numbers[k] = Num( v, lo, hi ); })></TextEntry>
</div>
}
</div>
<div class="section-label">Roues motrices et vente</div>
<div class="chips">
@foreach ( var (id, name) in new[] { ("AR", "Propulsion"), ("AV", "Traction"), ("4x4", "Quatre roues motrices") } )
{
var d = id;
<div class="chip @(sheet.Drive == d ? "active" : "")" onclick=@(() => sheet.Drive = d)>@name</div>
}
<div class="chip @(sheet.Shop ? "active" : "")" onclick=@(() => sheet.Shop = !sheet.Shop)><i>storefront</i> @(sheet.Shop ? "En vente à la concession" : "Pas en vente")</div>
</div>
<div class="prop-field">
<div class="section-label">Son du moteur (vide : le synthétisé)</div>
<TextEntry class="field" [email protected] OnTextEdited=@((string v) => { sheet.EngineSound = v?.Trim() ?? ""; })></TextEntry>
</div>
<div class="prop-actions">
<div class="place @(string.IsNullOrWhiteSpace( sheet.Name ) ? "disabled" : "")" onclick=@(() => VehicleManager.Current?.RequestSaveModel( Json.Serialize( sheet ) ))><i>save</i> Enregistrer</div>
</div>
<div class="hint">Le serveur relit les fiches tout de suite. Un véhicule déjà sorti garde ses anciennes valeurs jusqu'à être rangé puis ressorti.</div>
</div>
}
}
</div>
<div class="hint">Un véhicule donné ainsi est à vous, verrouillé, avec une plaque au hasard et sa clé ; il ne va dans aucun garage.</div>
}
else if ( Tab == "reglages" )
{
@* Les réglages du serveur (hz_settings) : les mêmes que la page Réglages du panneau web. *@
@foreach ( var group in ServerSettings.Meta.GroupBy( x => x.Group ) )
{
<div class="fcard">
<div class="fcard-title"><i>tune</i> @group.Key</div>
@foreach ( var setting in group )
{
var s = setting;
@if ( s.Type == "bool" )
{
var on = ServerSettings.Flag( s.Key );
<div class="chips">
<div class="chip @(on ? "active" : "")" onclick=@(() => AdminActions.RequestSettingSave( s.Key, on ? "0" : "1" ))><i>@(on ? "check_box" : "check_box_outline_blank")</i> @s.Label</div>
</div>
}
else if ( s.Type == "steamids" )
{
@* La liste blanche : des SteamID64, avec un nom pour s'y retrouver. *@
var whitelist = ServerSettings.Whitelist;
<div class="section-label">@($"{(s.Label)} · {(whitelist.Count)}")</div>
<div class="rows">
@if ( whitelist.Count == 0 )
{
<div class="hint">Personne pour l'instant.</div>
}
@foreach ( var allowed in whitelist )
{
var wl = allowed;
<div class="row">
<i>person</i>
<div class="row-main">
<div class="row-title">@(string.IsNullOrWhiteSpace( wl.Name ) ? wl.SteamId : wl.Name)</div>
<div class="row-sub">@wl.SteamId</div>
</div>
<div class="btn danger" onclick=@(() => AdminActions.RequestWhitelist( "retirer", wl.SteamId, "" ))><i>person_remove</i> Retirer</div>
</div>
}
</div>
<div class="prop-fields">
<div class="prop-field">
<div class="section-label">SteamID64 (17 chiffres)</div>
<TextEntry class="field" Placeholder="7656119…" Value=@WhitelistId OnTextEdited=@((string v) => { WhitelistId = v; })></TextEntry>
</div>
<div class="prop-field">
<div class="section-label">Nom (facultatif)</div>
<TextEntry class="field" Value=@WhitelistName OnTextEdited=@((string v) => { WhitelistName = v; })></TextEntry>
</div>
<div class="btn" onclick=@(() => { AdminActions.RequestWhitelist( "ajouter", WhitelistId, WhitelistName ); WhitelistId = ""; WhitelistName = ""; })><i>person_add</i> Ajouter</div>
</div>
@if ( RoleplayPlayer.All.Where( x => whitelist.All( e => e.SteamId != x.SteamId.ToString() ) ).ToList() is { Count: > 0 } absent )
{
<div class="section-label">Ajouter un joueur connecté</div>
<div class="chips">
@foreach ( var player in absent.OrderBy( x => x.RpName ) )
{
var pl = player;
<div class="chip" onclick=@(() => AdminActions.RequestWhitelist( "ajouter", pl.SteamId.ToString(), pl.RpName ))><i>add</i> @pl.RpName</div>
}
</div>
}
<div class="hint">Activer une liste vide est refusé : tout le monde serait refusé à la connexion, staff compris. Un joueur déjà connecté n'est pas expulsé ; le staff n'est pas exempté, ajoutez-vous.</div>
}
else
{
<div class="prop-fields">
<div class="prop-field">
<div class="section-label">@($"{s.Label} ({s.Min} à {s.Max})")</div>
<TextEntry class="field" Value=@(SettingDrafts.GetValueOrDefault( s.Key ) ?? ServerSettings.Number( s.Key ).ToString( System.Globalization.CultureInfo.InvariantCulture )) OnTextEdited=@((string v) => { SettingDrafts[s.Key] = v; })></TextEntry>
</div>
<div class="btn" onclick=@(() => { AdminActions.RequestSettingSave( s.Key, SettingDrafts.GetValueOrDefault( s.Key ) ?? ServerSettings.Number( s.Key ).ToString( System.Globalization.CultureInfo.InvariantCulture ) ); SettingDrafts.Remove( s.Key ); })><i>save</i> Enregistrer</div>
</div>
}
}
@if ( group.Key == "Monde" )
{
@* L'heure de la ville en direct (GameClock), et la mettre à une heure : le décalage est
calculé comme adminWorldTime du panneau web, puis enregistré dans « heure_decalage ». *@
<div class="world-clock">
<i>schedule</i>
<div class="row-main">
<div class="clock-now">@GameClock.Text</div>
<div class="row-sub">@(GameClock.IsNight ? "Nuit en ville" : "Jour en ville")</div>
</div>
<TextEntry class="field clock-field" Placeholder="HH:MM" Value=@WorldTimeDraft OnTextEdited=@((string v) => { WorldTimeDraft = v; })></TextEntry>
<div class="btn primary" onclick=@(() => { AdminActions.RequestWorldTime( WorldTimeDraft ); WorldTimeDraft = ""; })><i>update</i> @($"Mettre à {(string.IsNullOrWhiteSpace( WorldTimeDraft ) ? "HH:MM" : WorldTimeDraft.Trim())}")</div>
</div>
<div class="hint">Le jour et la nuit suivent tout de suite, chez tout le monde.</div>
}
</div>
}
<div class="fcard">
<div class="fcard-title"><i>local_hospital</i> Hôpital : là où vous vous tenez</div>
<div class="hint">Le réveil d'un joueur réanimé (dans l'hôpital), et les réapparitions après une mort (devant). Placez-vous, regardez dans le sens voulu, puis posez.</div>
<div class="rows">
<div class="row">
<i>bed</i>
<div class="row-main">Réveil : @(ServerSettings.WakePoint is { } w ? $"{w.X:0}, {w.Y:0}, {w.Z:0} — cap {w.Yaw:0}°" : "aucun")</div>
@if ( ServerSettings.WakePoint is { } wp )
{
<div class="btn" onclick=@(() => GoTo( wp.Position ))><i>near_me</i> Aller</div>
}
<div class="btn primary" onclick=@(() => AdminActions.RequestSettingPoint( "reveil" ))><i>add_location</i> Poser ici</div>
</div>
@{ var points = ServerSettings.RespawnPoints; }
@for ( var i = 0; i < points.Count; i++ )
{
var index = i; var pt = points[i];
<div class="row">
<i>location_on</i>
<div class="row-main">Réapparition @(index + 1) : @($"{pt.X:0}, {pt.Y:0}, {pt.Z:0} — cap {pt.Yaw:0}°")</div>
<div class="btn" onclick=@(() => GoTo( pt.Position ))><i>near_me</i> Aller</div>
<div class="btn danger" onclick=@(() => AdminActions.RequestSettingPoint( $"retirer:{index}" ))><i>delete</i> Retirer</div>
</div>
}
</div>
<div class="chips">
<div class="btn primary" onclick=@(() => AdminActions.RequestSettingPoint( "ajouter" ))><i>add_location_alt</i> Ajouter une réapparition ici</div>
</div>
</div>
}
else if ( Tab == "portes" )
{
<div class="chips">
@foreach ( var (id, name) in DoorFilters )
{
<div class="chip @(DoorFilter == id ? "active" : "")" onclick=@(() => DoorFilter = id)>@name</div>
}
</div>
<div class="rows doors">
@if ( DoorCount == 0 )
{
<div class="empty">Aucune porte trouvée sur la carte (composant Door du moteur).</div>
}
@foreach ( var d in DoorRows().Where( x => MatchesFilter( x.state ) ).OrderBy( x => Distance( x.door.WorldPosition ) ).Take( 100 ) )
{
<div class="row">
<div class="row-icon @(d.state?.Locked == true ? "locked" : "")"><i>@(d.state?.Locked == true ? "lock" : "door_front")</i></div>
<div class="row-main">
<div class="row-title">@(d.state?.DisplayTitle ?? "Porte") <span>@d.key</span></div>
<div class="row-sub">@DoorSub( d.state )</div>
</div>
<div class="row-dist">@Meters( d.door.WorldPosition )</div>
<div class="btn" onclick=@(() => GoTo( d.door.WorldPosition ))><i>near_me</i> Aller</div>
<div class="btn" onclick=@(() => AdminActions.RequestDoorLock( d.key, d.state?.Locked != true ))><i>@(d.state?.Locked == true ? "lock_open" : "lock")</i> @(d.state?.Locked == true ? "Déverrouiller" : "Verrouiller")</div>
@if ( d.state?.IsOwned == true )
{
<div class="btn danger" onclick=@(() => AdminActions.RequestDoorRelease( d.key ))><i>key_off</i> Reprendre</div>
}
</div>
}
</div>
<div class="hint">Le prix, le nom et les métiers d'une porte se règlent devant elle (touche P) ou depuis le panneau web.</div>
}
else
{
<div class="server">
<div class="card">
<div class="card-title"><i>dns</i> Serveur</div>
<div class="stats">
<div class="stat"><div class="stat-label">Joueurs</div><div class="stat-value">@($"{(RoleplayPlayer.All.Count())} / {(Networking.MaxPlayers)}")</div></div>
<div class="stat"><div class="stat-label">Prochaine paie</div><div class="stat-value">@Clock( HorizonGame.Current?.SecondsToPayday ?? 0 )</div></div>
<div class="stat @(ShutdownMode is null ? "" : "warn")"><div class="stat-label">Arrêt</div><div class="stat-value">@ShutdownText</div></div>
</div>
<div class="section-label">Redémarrer (le serveur se relance tout seul)</div>
<div class="chips">
@foreach ( var delay in Delays )
{
<div class="chip" onclick=@(() => AdminActions.RequestServer( "redemarrer", delay ))>@DelayLabel( delay )</div>
}
</div>
<div class="section-label">Fermer (il ne se relance pas)</div>
<div class="chips">
@foreach ( var delay in Delays )
{
<div class="chip minus" onclick=@(() => AdminActions.RequestServer( "arreter", delay ))>@(delay == 0 ? "Couper maintenant" : DelayLabel( delay ))</div>
}
</div>
@if ( ShutdownMode is not null )
{
<div class="place" onclick=@(() => AdminActions.RequestServer( "annuler", 0 ))><i>cancel</i> Annuler l'arrêt</div>
}
<div class="hint">Tout le monde est prévenu, sauvegardé puis déconnecté proprement. Un serveur fermé se relance depuis la machine qui l'héberge (lancer.bat).</div>
</div>
<div class="card">
<div class="card-title"><i>pan_tool</i> Physgun du staff</div>
<div class="hint">Comme sur Garry's Mod : visez un joueur, un objet de la ville, un objet au sol ou un véhicule vide, maintenez le clic gauche, déplacez-le. Molette : distance. Clic droit pendant la prise : le figer sur place (un joueur reste gelé). Les objets de la ville gardent leur nouvelle place.</div>
<div class="place" onclick=@(() => { StaffTool.Toggle(); SetOpen( false ); })><i>pan_tool</i> @($"{(StaffTool.Equipped ? "Ranger le physgun" : "Prendre le physgun")} (X)")</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
}
</root>
@code
{
static readonly float[] Scales = { 0.5f, 1f, 1.5f, 2f, 3f };
public bool Open { get; private set; }
public bool Building { get; private set; }
string Tab { get; set; } = "accueil";
/// <summary>Les pages de la barre de navigation, rangées comme le panneau web.</summary>
record AdminPage( string Id, string Name, string Icon, string Group, string Hint );
// Une propriété, pas un champ : un tableau statique garderait son ancienne valeur
// après un rechargement à chaud dans l'éditeur.
static AdminPage[] Pages => new AdminPage[]
{
new( "accueil", "Tableau de bord", "dashboard", "Général", "La ville en un coup d'œil" ),
new( "joueurs", "Joueurs", "groups", "Général", "La fiche complète, les actions et les sanctions" ),
new( "journal", "Journal", "history", "Général", "Tout ce qui s'est passé, lu dans la base" ),
new( "construire", "Construire", "construction", "Ville", "Poser un décor, un garage, un PNJ, un point d'apparition" ),
new( "ville", "Objets posés", "apartment", "Ville", "Les objets de la ville, du plus proche au plus loin" ),
new( "props", "Props", "chair", "Ville", "Le catalogue des props et ceux posés en ville" ),
new( "armes", "Armes", "my_location", "Ville", "Le catalogue des armes : dégâts, cadence, prix, Ammu-Nation" ),
new( "vehicules", "Véhicules", "directions_car", "Ville", "Les véhicules en ville, et en faire apparaître un" ),
new( "portes", "Portes", "door_front", "Ville", "Toutes les portes de la carte, de n'importe où" ),
new( "garderobe", "Garde-robe", "checkroom", "Personnages", "Tous les vêtements du jeu : les essayer en 3D, les masquer à la création" ),
new( "tenues", "Tenues de service", "local_police", "Personnages", "La tenue de chaque grade de la police, essayée en 3D avant d'être enregistrée" ),
new( "serveur", "Serveur", "dns", "Système", "Redémarrer, fermer, outils du staff" ),
new( "reglages", "Réglages", "tune", "Système", "Mort et hôpital, faim, paie, véhicules : les réglages du serveur" ),
};
/// <summary>Les valeurs tapées dans l'onglet Réglages, pas encore enregistrées.</summary>
Dictionary<string, string> SettingDrafts { get; } = new();
/// <summary>L'heure tapée pour la ville (« HH:MM »), onglet Réglages.</summary>
string WorldTimeDraft { get; set; } = "";
/// <summary>Le SteamID64 et le nom tapés pour la liste blanche, onglet Réglages.</summary>
string WhitelistId { get; set; } = "";
string WhitelistName { get; set; } = "";
/// <summary>Le véhicule dont le coffre est déplié (onglet Véhicules).</summary>
Guid TrunkOpen { get; set; }
/// <summary>La fiche de véhicule en cours de réglage (onglet Véhicules), ou null.</summary>
VehicleSheet SheetEdit { get; set; }
/// <summary>La couleur choisie pour faire apparaître un véhicule (onglet Véhicules).</summary>
string SpawnColor { get; set; } = VehicleCatalog.Colors[0].Hex;
static string VehicleSub( Vehicle car )
{
var driver = car.DriverController?.GetComponent<RoleplayPlayer>()?.RpName;
var state = $"moteur {(MathF.Round( car.EngineHealth / 10 ))} % · caisse {(MathF.Round( car.BodyHealth / 10 ))} %";
var passengers = car.Seats.Count( s => s.IsOccupied && !s.IsDriver );
var trunk = car.TrunkSlots.Count == 0 ? "coffre vide" : $"coffre {car.TrunkSlots.Count} · {Format.Kilos( car.TrunkWeight )}";
return $"{(string.IsNullOrEmpty( car.OwnerName ) ? "sans propriétaire" : car.OwnerName)}{(car.VehicleId > 0 ? "" : " (staff)")} · {(driver is null ? "personne au volant" : $"au volant : {driver}")}{(passengers > 0 ? $" +{passengers}" : "")} · {(car.EngineOn ? "moteur en marche" : "moteur coupé")} · {(car.Locked ? "verrouillé" : "ouvert")} · {state} · {trunk}";
}
AdminPage CurrentPage => Pages.FirstOrDefault( x => x.Id == Tab ) ?? Pages[0];
int? PageCount( string id ) => id switch
{
"joueurs" => RoleplayPlayer.All.Count(),
"ville" => CityObject.All.Count(),
"vehicules" => Vehicle.All.Count(),
"props" => PlacedProp.All.Count(),
"armes" => WeaponSystem.Current?.Catalog.Count( x => x.Active ),
"portes" => DoorCount,
"tenues" => ServerSettings.Uniforms.TryGetValue( PoliceUniform.Service, out var grades ) ? grades.Count( g => g.Value.Count > 0 ) : 0,
_ => null,
};
void SetTab( string id )
{
Tab = id;
// Le tableau de bord et le journal lisent le journal général dans l'API.
if ( id is "accueil" or "journal" )
RefreshLogs();
}
// Journal
string LogQuery { get; set; } = "";
/// <summary>« jeu », « admin » ou "" (tout) : le filtre de l'onglet Journal ; le tableau de bord montre tout.</summary>
string LogSource { get; set; } = "";
static (string Id, string Label)[] LogSources => new[] { ("", "Tout"), ("jeu", "Jeu"), ("admin", "Staff (panneau web)") };
void RefreshLogs() => AdminActions.RequestLogs( LogQuery ?? "", 0, Tab == "journal" ? LogSource : "" );
IEnumerable<Backend.StaffLog> GeneralLogs => AdminClient.LogsFor == 0 && AdminClient.Logs is { } logs ? logs : Enumerable.Empty<Backend.StaffLog>();
/// <summary>Une ligne de journal : l'heure, l'action, qui, le détail.</summary>
RenderFragment LogRows( IEnumerable<Backend.StaffLog> logs, bool withPlayer ) => @<text>
@if ( !logs.Any() )
{
<div class="hint">@(Backend.IsOnline ? "Rien pour l'instant." : "Partie locale : pas de journal en base.")</div>
}
@foreach ( var log in logs )
{
<div class="log">
<div class="log-time">@Stamp( log.CreatedAt )</div>
<div class="log-action @(log.Source == "admin" ? "from-panel" : "")">@log.Action</div>
<div class="log-main">
@if ( withPlayer && !string.IsNullOrEmpty( log.RpName ) )
{
<div class="log-who">@log.RpName</div>
}
<div class="log-text">@log.Details</div>
</div>
<div class="log-actor">@log.Actor</div>
</div>
}
</text>;
/// <summary>« 2026-10-28 16:03:01 » → « 28/10 16:03 ».</summary>
static string Stamp( string at ) => DateTime.TryParse( at, out var d ) ? d.ToString( "dd/MM HH:mm" ) : at;
static string Day( string at ) => DateTime.TryParse( at, out var d ) ? Format.Date( d.ToString( "yyyy-MM-dd" ) ) : "–";
static string Hours( int seconds ) => seconds < 3600 ? $"{seconds / 60} min" : $"{seconds / 3600} h {seconds % 3600 / 60:00}";
static string VehicleName( string model ) => VehicleCatalog.Get( model )?.Name ?? model;
// Onglet Props : une copie de la fiche en cours, envoyée à l'hôte à l'enregistrement.
PropEntry PropEdit { get; set; }
IEnumerable<PropEntry> PropCatalog => (PropSystem.Current?.Catalog ?? (IReadOnlyList<PropEntry>)Array.Empty<PropEntry>())
.OrderBy( x => x.Category ).ThenBy( x => x.Name );
string PropQuery { get; set; } = "";
/// <summary>Le catalogue filtré par la recherche : chaque mot tapé doit se trouver dans le nom, la catégorie ou le numéro.</summary>
IEnumerable<PropEntry> FilteredProps
{
get
{
var mots = Fold( PropQuery ).Split( ' ', StringSplitOptions.RemoveEmptyEntries );
return mots.Length == 0
? PropCatalog
: PropCatalog.Where( e => mots.All( m => Fold( $"{e.Name} {e.Category} {e.Id}" ).Contains( m ) ) );
}
}
/// <summary>Minuscules sans accents, pour qu'« etagere » trouve « Étagère ».</summary>
static string Fold( string s )
{
var b = new System.Text.StringBuilder();
foreach ( var c in (s ?? "").ToLowerInvariant() )
b.Append( c switch
{
'à' or 'â' or 'ä' or 'á' => 'a',
'é' or 'è' or 'ê' or 'ë' => 'e',
'î' or 'ï' or 'í' => 'i',
'ô' or 'ö' or 'ó' => 'o',
'ù' or 'û' or 'ü' or 'ú' => 'u',
'ç' => 'c',
'ÿ' => 'y',
_ => c,
} );
return b.ToString();
}
/// <summary>Le prop dans son propre inventaire, par l'action staff « objet » (la même que pour les armes).</summary>
void GiveMeProp( long entryId )
{
if ( Me is not null && entryId != 0 )
Act( Me, "objet", 1, PropItems.IdOf( entryId ) );
}
void EditProp( PropEntry entry )
{
PropEdit = entry is null
? new PropEntry { Category = "Divers", Active = true }
: new PropEntry { Id = entry.Id, Model = entry.Model, Name = entry.Name, Category = entry.Category, Price = entry.Price, Jobs = entry.Jobs, StaffOnly = entry.StaffOnly, Active = entry.Active };
}
void ToggleJob( string id )
{
var jobs = PropEdit.JobList.ToList();
if ( !jobs.Remove( id ) )
jobs.Add( id );
PropEdit.Jobs = string.Join( ",", jobs );
}
void SaveProp()
{
var e = PropEdit;
PropSystem.Current?.StaffSaveEntry( e.Id, e.Model, e.Name, e.Category, e.Price, e.Jobs, e.StaffOnly, e.Active );
// Une création se referme : elle reviendra dans la liste avec son numéro.
if ( e.Id == 0 )
PropEdit = null;
}
void DeleteProp()
{
PropSystem.Current?.StaffDeleteEntry( PropEdit.Id );
PropEdit = null;
}
static string PropSub( PropEntry e )
{
var parts = new List<string> { e.Category, e.Price == 0 ? "gratuit" : Format.Money( e.Price ) };
if ( e.JobList.Any() )
parts.Add( string.Join( ", ", e.JobList.Select( id => Jobs.All.FirstOrDefault( j => j.Id == id )?.Name ?? id ) ) );
if ( e.StaffOnly )
parts.Add( "staff" );
if ( !e.Active )
parts.Add( "retiré" );
return string.Join( " · ", parts );
}
string Kind { get; set; } = CityKinds.Decor;
string Category { get; set; } = "Tout";
string Source { get; set; } = CityKinds.Catalog[0].Model;
string Label { get; set; }
string Data { get; set; } = Jobs.DefaultId;
float Scale { get; set; } = 1f;
// Mode de pose
long MovingId { get; set; }
string BuildSource { get; set; }
float Yaw { get; set; }
float Height { get; set; }
GameObject ghost;
RealTimeSince sinceBuildStart;
IEnumerable<string> Categories => new[] { "Tout" }.Concat( CityKinds.Catalog.Select( x => x.Category ).Distinct() );
RoleplayPlayer Me => RoleplayPlayer.Local;
bool IsStaff => Me?.IsStaff ?? false;
protected override void OnUpdate()
{
if ( !IsStaff )
{
if ( Open ) SetOpen( false );
if ( Building ) StopBuild();
return;
}
TakeNotes();
// Échap referme ce menu (et ne laisse pas s'ouvrir le menu pause en même temps).
if ( Open && Input.EscapePressed )
{
Input.EscapePressed = false;
SetOpen( false );
return;
}
if ( Input.Pressed( "AdminMenu" ) )
{
if ( Building ) StopBuild();
SetOpen( !Open );
}
if ( Building )
UpdateBuild();
}
protected override void OnDisabled()
{
StopBuild();
UiLock.Set( "admin", false );
}
void SetOpen( bool open )
{
Open = open;
UiLock.Set( "admin", open );
if ( !open )
return;
// À l'ouverture, la page reprend des nouvelles de l'API.
if ( Tab is "accueil" or "journal" )
RefreshLogs();
if ( SelectedId != 0 )
AdminActions.RequestPlayerFile( SelectedId );
}
void SelectKind( string kind )
{
Kind = kind;
// Un nouveau choix de sorte propose son modèle par défaut, sauf en décor
// où l'on garde la sélection du catalogue.
if ( kind != CityKinds.Decor )
Source = CityKinds.Get( kind ).DefaultModel;
}
// Pose et déplacement
void StartBuild( long movingId )
{
if ( !ModelSources.LooksValid( Source ) && movingId == 0 )
return;
StopBuild();
MovingId = movingId;
BuildSource = Source;
Building = true;
sinceBuildStart = 0;
SetOpen( false );
ghost = new GameObject( true, "Horizon – fantôme de pose" );
ghost.NetworkMode = NetworkMode.Never;
var renderer = ghost.Components.Create<ModelRenderer>();
renderer.Tint = new Color( 0.45f, 1f, 0.65f, 0.65f );
_ = LoadGhost( renderer, BuildSource );
}
async Task LoadGhost( ModelRenderer renderer, string source )
{
var model = await ModelSources.Load( source );
if ( renderer.IsValid() )
renderer.Model = model;
}
void StartMove( CityObject obj )
{
Source = obj.ModelSource;
Scale = obj.WorldScale.x;
Yaw = obj.WorldRotation.Angles().yaw;
Height = 0;
StartBuild( obj.PlacementId );
}
void StopBuild()
{
Building = false;
MovingId = 0;
if ( ghost.IsValid() )
ghost.Destroy();
ghost = null;
}
void UpdateBuild()
{
var camera = Scene.Camera;
var me = Me;
if ( camera is null || me is null || !ghost.IsValid() )
return;
var start = camera.WorldPosition;
var trace = Scene.Trace.Ray( start, start + camera.WorldRotation.Forward * 2500 )
.IgnoreGameObjectHierarchy( me.GameObject )
.IgnoreGameObjectHierarchy( ghost );
var moving = MovingId > 0 ? CityObject.All.FirstOrDefault( x => x.PlacementId == MovingId ) : null;
if ( moving.IsValid() )
trace = trace.IgnoreGameObjectHierarchy( moving.GameObject );
var result = trace.Run();
var wheel = Input.MouseWheel.y;
if ( Input.Keyboard.Down( "shift" ) )
Height += wheel * 4f;
else
Yaw += wheel * 15f;
if ( Input.Pressed( "reload" ) )
{
Yaw = 0;
Height = 0;
}
ghost.WorldPosition = result.EndPosition + Vector3.Up * Height;
ghost.WorldRotation = Rotation.FromYaw( Yaw );
ghost.WorldScale = Scale;
// Le clic qui a lancé la pose ne doit pas poser l'objet aussitôt.
if ( sinceBuildStart < 0.3f )
return;
if ( Input.Pressed( "attack1" ) )
{
if ( MovingId > 0 )
AdminActions.RequestMove( MovingId, ghost.WorldPosition, ghost.WorldRotation, Scale );
else
AdminActions.RequestPlace( Kind, BuildSource, Label, Kind == CityKinds.JobNpc ? Data : "", ghost.WorldPosition, ghost.WorldRotation, Scale );
// En pose, on reste en mode pose pour enchaîner les objets ; un
// déplacement, lui, est terminé.
if ( MovingId > 0 )
StopBuild();
}
else if ( Input.Pressed( "attack2" ) )
{
StopBuild();
}
}
// Armes
/// <summary>Une copie de l'arme en cours de réglage, envoyée à l'hôte à l'enregistrement.</summary>
WeaponEntry WeaponEdit { get; set; }
string WeaponQuery { get; set; } = "";
string WeaponTab { get; set; } = "tout";
string WeaponSort { get; set; } = "nom";
// Propriétés, pas des champs : un champ statique survivrait au rechargement à chaud.
static (string Id, string Label, string Icon)[] WeaponTabs => new[]
{
("tout", "Tout", "apps"),
("Pistolet", "Pistolets", "front_hand"),
("Pistolet-mitrailleur", "Pistolets-mitrailleurs", "bolt"),
("Fusil d'assaut", "Fusils d'assaut", "my_location"),
("Fusil de précision", "Précision", "center_focus_strong"),
("Fusil à pompe", "Fusils à pompe", "blur_on"),
("Mitrailleuse", "Mitrailleuses", "grain"),
("Lance-projectiles", "Lance-projectiles", "rocket_launch"),
("Explosif", "Explosifs", "local_fire_department"),
("accessoires", "Accessoires", "build"),
};
static (string Id, string Label)[] WeaponSorts => new[] { ("nom", "Nom"), ("degats", "Dégâts"), ("cadence", "Cadence"), ("prix", "Prix") };
bool Matches( params string[] textes ) =>
string.IsNullOrWhiteSpace( WeaponQuery ) || textes.Any( t => t?.Contains( WeaponQuery.Trim(), StringComparison.OrdinalIgnoreCase ) == true );
IEnumerable<WeaponEntry> FilteredWeapons
{
get
{
var liste = WeaponCatalogList.Where( w => (WeaponTab == "tout" || w.Category == WeaponTab) && Matches( w.Name, w.Id, w.Category, AmmoShort( w.Ammo ) ) );
return WeaponSort switch
{
"degats" => liste.OrderByDescending( DamageOf ),
"cadence" => liste.OrderByDescending( w => w.Rpm ),
"prix" => liste.OrderByDescending( w => w.Price ),
_ => liste.OrderBy( w => w.Name ),
};
}
}
IEnumerable<AccessoryInfo> FilteredAccessories => WeaponRules.Accessories.Where( a => Matches( a.Name, a.Id, a.Category ) ).OrderBy( a => a.Category ).ThenBy( a => a.Name );
int TabCount( string id ) => id == "accessoires" ? WeaponRules.Accessories.Count
: WeaponCatalogList.Count( w => id == "tout" || w.Category == id );
/// <summary>« 9×19 mm » plutôt que « Munitions 9×19 mm ».</summary>
static string AmmoShort( string ammo ) => (Items.Get( ammo )?.Name ?? ammo ?? "").Replace( "Munitions ", "" ).Replace( "Cartouches ", "" );
static float DamageOf( WeaponEntry w ) => w.Damage * Math.Max( 1, w.Bullets );
float MaxDamage => WeaponCatalogList.Select( DamageOf ).DefaultIfEmpty( 1 ).Max();
float MaxRpm => WeaponCatalogList.Select( w => (float)w.Rpm ).DefaultIfEmpty( 1 ).Max();
static int Pct( float valeur, float max ) => max <= 0 ? 0 : Math.Clamp( (int)(100 * valeur / max), 2, 100 );
IEnumerable<(string Label, float Valeur, float Max, string Texte)> Comparatif( WeaponEntry e ) => new[]
{
("Dégâts", DamageOf( e ), MaxDamage, e.Bullets > 1 ? $"{e.Damage} × {e.Bullets}" : $"{e.Damage}"),
("Cadence", (float)e.Rpm, MaxRpm, $"{e.Rpm}/min"),
("Chargeur", (float)e.ClipSize, WeaponCatalogList.Select( w => (float)w.ClipSize ).DefaultIfEmpty( 1 ).Max(), $"{e.ClipSize}"),
("Recul", e.Recoil, WeaponCatalogList.Select( w => w.Recoil ).DefaultIfEmpty( 1 ).Max(), $"{e.Recoil:0.#}"),
("Précision", 1f - e.Spread, 1f, $"{(1f - e.Spread) * 100:0} %"),
};
IEnumerable<WeaponEntry> WeaponCatalogList => (WeaponSystem.Current?.Catalog ?? (IReadOnlyList<WeaponEntry>)Array.Empty<WeaponEntry>())
.OrderByDescending( x => x.Active ).ThenBy( x => x.Category ).ThenBy( x => x.Name );
void EditWeapon( WeaponEntry w )
{
WeaponEdit = Json.Deserialize<WeaponEntry>( Json.Serialize( w ) );
AccessoryEdit = null;
}
/// <summary>Une copie des réglages de l'accessoire choisi, envoyée à l'hôte à l'enregistrement.</summary>
AccessoryEntry AccessoryEdit { get; set; }
void EditAccessory( AccessoryInfo a )
{
AccessoryEdit = new AccessoryEntry { Id = a.Id, Name = a.Name, Price = a.Price, Shop = a.Shop };
WeaponEdit = null;
}
void SaveAccessory() => WeaponSystem.Current?.StaffSaveAccessory( Json.Serialize( AccessoryEdit ) );
void GiveMeAccessory( string id )
{
if ( Me is not null )
Act( Me, "objet", 1, WeaponItems.AccessoryItemId( id ) );
}
/// <summary>Les pièces SWB que porte chaque prefab d'arme, lues une fois par prefab.</summary>
readonly Dictionary<string, HashSet<string>> partsOfPrefab = new();
/// <summary>Les armes du catalogue qui portent l'une des pièces de l'accessoire.</summary>
IEnumerable<string> FitsOn( AccessoryInfo acc )
{
var names = new List<string>();
foreach ( var w in WeaponCatalogList )
{
if ( string.IsNullOrEmpty( w.Prefab ) )
continue;
if ( !partsOfPrefab.TryGetValue( w.Prefab, out var parts ) )
{
parts = new();
try
{
if ( ResourceLibrary.Get<PrefabFile>( w.Prefab ) is { } file && SceneUtility.GetPrefabScene( file ) is { } scene )
foreach ( var part in scene.Components.GetAll<SWB.Base.Attachments.Attachment>( FindMode.EverythingInSelfAndDescendants ) )
parts.Add( part.GetType().Name );
}
catch { }
partsOfPrefab[w.Prefab] = parts;
}
if ( acc.Classes.Any( parts.Contains ) )
names.Add( w.Name );
}
if ( names.Count == 0 )
names.Add( "Aucune arme du catalogue" );
return names;
}
void SaveWeapon() => WeaponSystem.Current?.StaffSaveWeapon( Json.Serialize( WeaponEdit ) );
/// <summary>L'arme au staff qui la règle (le don d'une arme apporte trois chargeurs ; journalisé comme tout don).</summary>
void GiveMe( WeaponEntry e )
{
if ( Me is not null )
Act( Me, "objet", 1, WeaponItems.WeaponItemId( e.Id ) );
}
/// <summary>Un nombre tapé (virgule ou point), borné.</summary>
static float Num( string v, float min, float max ) =>
float.TryParse( (v ?? "").Replace( ',', '.' ), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var n ) ? Math.Clamp( n, min, max ) : min;
static string Txt( float v ) => v.ToString( "0.##", System.Globalization.CultureInfo.InvariantCulture );
/// <summary>Les valeurs de tir, mêmes bornes que l'API (WEAPON_LIMITS).</summary>
static IEnumerable<(string Label, Func<string> Get, Action<string> Set)> WeaponNumbers( WeaponEntry e ) => new (string, Func<string>, Action<string>)[]
{
("Dégâts par balle", () => Txt( e.Damage ), v => e.Damage = Num( v, 0, 500 )),
("Multiplicateur tête", () => Txt( e.Headshot ), v => e.Headshot = Num( v, 1, 5 )),
("Cadence (/min)", () => e.Rpm.ToString(), v => e.Rpm = (int)Num( v, 1, 1500 )),
("Chargeur", () => e.ClipSize.ToString(), v => e.ClipSize = (int)Num( v, 1, 200 )),
("Plombs par tir", () => e.Bullets.ToString(), v => e.Bullets = (int)Num( v, 1, 20 )),
("Recul", () => Txt( e.Recoil ), v => e.Recoil = Num( v, 0, 10 )),
("Dispersion", () => Txt( e.Spread ), v => e.Spread = Num( v, 0, 2 )),
("Rechargement (s)", () => Txt( e.ReloadTime ), v => e.ReloadTime = Num( v, 0.2f, 15 )),
("Mobilité", () => Txt( e.Mobility ), v => e.Mobility = Num( v, 0.3f, 1.5f )),
("Poids (kg)", () => Txt( e.Weight ), v => e.Weight = Num( v, 0, 30 )),
};
// Joueurs
// Les munitions se donnent par dizaines.
int[] GiveCounts => GiveKind == "munitions" ? new[] { 10, 30, 100 } : new[] { 1, 5, 10 };
// Les durées du panneau web (heures ; 0 : définitif). Une propriété : un tableau statique
// garderait son ancienne valeur après un rechargement à chaud.
static (int hours, string label)[] BanDurations => new[] { (1, "1 h"), (24, "24 h"), (72, "3 jours"), (168, "7 jours"), (720, "30 jours"), (0, "Définitif") };
/// <summary>Combien retirer d'un clic sur un objet de l'inventaire (0 : tout ce qu'il en a).</summary>
int TakeCount { get; set; } = 1;
static (int Count, string Label)[] TakeCounts => new[] { (1, "×1"), (5, "×5"), (10, "×10"), (0, "Tout") };
/// <summary>Les nouveaux soldes tapés dans la carte Argent (portefeuille, banque).</summary>
string WalletDraft { get; set; } = "";
string BankDraft { get; set; } = "";
/// <summary>Un montant tapé, en dollars entiers positifs (espaces et « $ » tolérés), ou null s'il est illisible.</summary>
static int? AmountOf( string draft )
{
var s = new string( (draft ?? "").Where( char.IsDigit ).ToArray() );
return long.TryParse( s, out var n ) && n <= AdminActions.MoneyMax ? (int)n : null;
}
/// <summary>En ligne : le solde posé tel quel (« argent_fixer » / « banque_fixer »).</summary>
void SetMoney( RoleplayPlayer player, string action, bool wallet )
{
if ( AmountOf( wallet ? WalletDraft : BankDraft ) is not { } value )
return;
Act( player, action, value );
if ( wallet ) WalletDraft = ""; else BankDraft = "";
}
/// <summary>Hors ligne : le montant exact, écrit en base (« portefeuille » ou « banque »).</summary>
void SetRecordMoney( Backend.StaffPlayerRow row, string type, bool wallet )
{
if ( AmountOf( wallet ? WalletDraft : BankDraft ) is not { } value )
return;
AdminActions.RequestRecordAction( row.Id, type, value, "" );
if ( wallet ) WalletDraft = ""; else BankDraft = "";
}
/// <summary>« Motif · jusqu'au 31/10 à 14:00 · par Untel », comme la fiche du panneau web.</summary>
static string BanText( Backend.StaffPlayerRow row )
{
var until = string.IsNullOrEmpty( row.BannedUntil ) ? "définitif" : DateTime.TryParse( row.BannedUntil, out var d ) ? $"jusqu'au {d:dd/MM/yyyy} à {d:HH:mm}" : $"jusqu'au {row.BannedUntil}";
return $"Banni : {(string.IsNullOrWhiteSpace( row.BanReason ) ? "sans motif" : row.BanReason)} · {until} · par {(string.IsNullOrWhiteSpace( row.BannedBy ) ? "–" : row.BannedBy)}";
}
// Recherche dans la base (serverStaffPlayers) : les joueurs partis aussi.
string FindQuery { get; set; } = "";
string FindFilter { get; set; } = "tous";
static (string Id, string Label)[] FindFilters => new[] { ("tous", "Tous"), ("horsligne", "Hors ligne"), ("bannis", "Bannis") };
void Find( int page ) => AdminActions.RequestFindPlayers( FindQuery ?? "", FindFilter, page );
static string RecordSub( Backend.StaffPlayerRow row )
{
var state = row.Banned ? "banni" : row.Online ? "en ligne" : $"vu le {Stamp( row.LastSeen )}";
return $"{(Jobs.Get( row.Job )?.Name ?? row.Job ?? "–")} · {state}";
}
/// <summary>Un joueur trouvé en base : sa fiche en ligne s'il est connecté, sinon celle de la base.</summary>
void SelectRecord( Backend.StaffPlayerRow row )
{
if ( RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == row.Id ) is { } connected )
{
Select( connected );
return;
}
if ( row.Id == 0 )
return;
SelectedId = row.Id;
var parts = (row.RpName ?? "").Split( ' ', 2 );
FirstName = parts[0];
LastName = parts.Length > 1 ? parts[1] : "";
MessageText = "";
Reason = "";
WalletDraft = "";
BankDraft = "";
NotesDraft = "";
notesFromVersion = -1;
AdminActions.RequestPlayerFile( row.Id );
}
ulong SelectedId { get; set; }
int GiveCount { get; set; } = 1;
string GiveKind { get; set; } = "objets";
static (string Id, string Label)[] GiveKinds => new[] { ("objets", "Objets"), ("munitions", "Munitions"), ("armes", "Armes"), ("accessoires", "Accessoires"), ("props", "Props") };
IEnumerable<ItemDefinition> GiveList => GiveKind switch
{
"props" => PropItems.All,
"armes" => WeaponItems.AllWeapons.Where( x => x is not null ),
"accessoires" => WeaponItems.AllAccessories.Where( x => x is not null ),
"munitions" => Items.All.Where( x => x.Category == "Munitions" ),
_ => Items.All.Where( x => x.Category != "Munitions" ),
};
string FirstName { get; set; }
string LastName { get; set; }
string MessageText { get; set; } = "";
string Reason { get; set; } = "";
int BanHours { get; set; } = 24;
string NotesDraft { get; set; } = "";
int notesFromVersion = -1;
RoleplayPlayer Selected => RoleplayPlayer.All.FirstOrDefault( x => x.SteamId == SelectedId );
/// <summary>Ce que l'API sait du joueur choisi (identité et bannissement, notes, ancienneté, véhicules, journal).</summary>
Backend.StaffFileResponse File => AdminClient.FileFor == SelectedId ? AdminClient.File : null;
void Select( RoleplayPlayer player )
{
SelectedId = player.SteamId;
var parts = (player.RpName ?? "").Split( ' ', 2 );
FirstName = parts[0];
LastName = parts.Length > 1 ? parts[1] : "";
MessageText = "";
Reason = "";
WalletDraft = "";
BankDraft = "";
NotesDraft = "";
notesFromVersion = -1;
AdminActions.RequestPlayerFile( player.SteamId );
}
/// <summary>Les notes arrivent de l'API : elles remplissent le champ une fois, sans écraser ce qu'on tape.</summary>
void TakeNotes()
{
if ( File is { } file && notesFromVersion < 0 )
{
NotesDraft = file.Notes ?? "";
notesFromVersion = AdminClient.Version;
}
}
void Act( RoleplayPlayer player, string action, int value = 0, string text = null )
=> AdminActions.RequestPlayerAction( player.SteamId, action, value, text );
// Portes
static readonly (string id, string name)[] DoorFilters =
{
("toutes", "Toutes"), ("possedees", "Possédées"), ("verrouillees", "Verrouillées"), ("metier", "De métier"), ("achetables", "À vendre"),
};
string DoorFilter { get; set; } = "toutes";
int DoorCount => DoorSystem.Current?.Known.Count() ?? 0;
IEnumerable<(string key, Door door, DoorState state)> DoorRows() =>
DoorSystem.Current is { } doors
? doors.Known.Select( x => (x.key, x.door, doors.StateOf( x.key )) )
: Enumerable.Empty<(string, Door, DoorState)>();
bool MatchesFilter( DoorState state ) => DoorFilter switch
{
"possedees" => state?.IsOwned == true,
"verrouillees" => state?.Locked == true,
"metier" => state?.IsJobDoor == true,
"achetables" => state?.CanBuy == true,
_ => true,
};
static string DoorSub( DoorState state )
{
if ( state is null )
return "Pas encore réglée";
var who = state.IsOwned
? $"À {state.OwnerName}{(state.CoOwners.Count > 0 ? $" (+{state.CoOwners.Count})" : "")}"
: state.IsJobDoor ? $"Métiers : {string.Join( ", ", state.JobList.Select( x => Jobs.Get( x )?.Name ?? x ) )}"
: state.Buyable ? $"À vendre · {Format.Money( state.Price )}" : "Non achetable";
return state.Locked ? $"{who} · verrouillée" : who;
}
// Serveur
static readonly int[] Delays = { 60, 300, 600, 0 };
string ShutdownMode => HorizonGame.Current?.ShutdownMode;
string ShutdownText => ShutdownMode is null
? "Aucun"
: $"{(ShutdownMode == "redemarrer" ? "Redémarrage" : "Fermeture")} dans {Clock( HorizonGame.Current.ShutdownSeconds )}";
static string DelayLabel( int seconds ) => seconds == 0 ? "Maintenant" : $"Dans {seconds / 60} min";
static string Clock( int seconds ) => $"{seconds / 60}:{seconds % 60:00}";
void GoTo( Vector3 position )
{
Me?.TeleportTo( position + Vector3.Up * 8 );
SetOpen( false );
}
void CycleJob( RoleplayPlayer player )
{
var jobs = Jobs.All.ToList();
var next = jobs[(jobs.FindIndex( x => x.Id == player.JobId ) + 1) % jobs.Count];
AdminActions.RequestPlayerAction( player.SteamId, "metier", 0, next.Id );
}
float Distance( Vector3 position ) => Me is null ? 0 : Me.WorldPosition.Distance( position );
/// <summary>Les unités s&box sont des pouces : 39,37 par mètre.</summary>
string Meters( Vector3 position ) => $"{(Distance( position ) / 39.37f):0} m";
protected override int BuildHash()
{
var objects = CityObject.All.Aggregate( 0, ( h, o ) => HashCode.Combine( h, o.PlacementId, o.Label, o.WorldPosition.x.FloorToInt() ) );
var players = RoleplayPlayer.All.Aggregate( 0, ( h, p ) => HashCode.Combine( h, p.RpName, p.JobId, p.Wallet, p.Health.CeilToInt(), p.Downed ) );
objects = HashCode.Combine( objects, ServerSettings.Version, SettingDrafts.Count,
// L'horloge de la ville avance en direct dans l'onglet Réglages.
Tab == "reglages" ? GameClock.Text : null, WorldTimeDraft, WhitelistId, WhitelistName );
// La recherche en base et la fiche d'un joueur hors ligne.
objects = HashCode.Combine( objects, FindQuery, FindFilter, HashCode.Combine( WalletDraft, BankDraft ), TakeCount, LogSource, AdminClient.Found?.Page );
var selected = Selected;
var fiche = selected is null ? 0 : HashCode.Combine( selected.Frozen, selected.Hunger.CeilToInt(), selected.Bank, selected.InventoryJson, selected.IsStaff, selected.GodMode );
var doors = Tab == "portes" ? DoorRows().Aggregate( 0, ( h, d ) => HashCode.Combine( h, d.key, d.state?.OwnerId, d.state?.Locked, d.state?.Title ) ) : 0;
var server = HashCode.Combine( HorizonGame.Current?.ShutdownMode, HorizonGame.Current?.ShutdownSeconds, StaffTool.Equipped, StaffTool.Noclip, StaffTool.HeldLabel, (StaffTool.HeldDistance / 39.37f).FloorToInt(), StaffTool.Hint );
return HashCode.Combine(
HashCode.Combine( Open, Building, Tab, Kind, Category, Source, Scale, Data ),
HashCode.Combine( MovingId, Yaw.CeilToInt(), IsStaff, PropSystem.Current?.CatalogJson, PlacedProp.All.Count(),
HashCode.Combine( PropEdit, PropEdit?.Jobs, PropEdit?.StaffOnly, PropEdit?.Active, PropEdit?.Model, PropEdit?.Name ) ),
objects,
players,
HashCode.Combine( SelectedId, GiveCount, DoorFilter, FirstName, LastName, fiche,
HashCode.Combine( AdminClient.Version, LogQuery, MessageText, Reason, BanHours, NotesDraft, GiveKind ),
HashCode.Combine( WeaponSystem.Current?.CatalogJson, WeaponEdit?.Id, WeaponEdit?.Name, WeaponEdit?.Shop, WeaponEdit?.Active,
WeaponSystem.Current?.AccessoriesJson, HashCode.Combine( AccessoryEdit?.Id, AccessoryEdit?.Name, AccessoryEdit?.Shop, WeaponQuery, WeaponTab, WeaponSort ) ) ),
doors,
server );
}
}