Game-side system that keeps each machine's view of player name prefixes in sync. The host rebuilds a serialized prefix snapshot once a second and broadcasts it when it changes; clients store the latest snapshot, ask the host for one every three seconds until they have it, and render player names into colored runs from it.
using Sandbox;
using SboxSuperuser.Core.Effects;
using SboxSuperuser.Core.Prefixes;
using SboxSuperuser.Engine.Prefixes;
namespace SboxSuperuser.Systems;
/// <summary>
/// Prefixes on this machine. The host sends a snapshot whenever the catalog or an online player's prefix changes;
/// clients keep the latest one, ask for it when they have none, and render names from it locally.
/// </summary>
internal sealed class PrefixSystem : GameObjectSystem<PrefixSystem>
{
private const float PublishInterval = 1;
private const float RequestInterval = 3;
private readonly PrefixRenderer _renderer = new();
private readonly PrefixRequestGate _requests = new();
private readonly List<LineRun> _runs = new();
private RealTimeSince _sincePublish = PublishInterval;
private RealTimeSince _sinceRequest = RequestInterval;
private bool _received;
private Connection _host;
public PrefixSystem( Scene scene ) : base( scene )
{
Listen( Stage.StartUpdate, -70, Update, nameof( PrefixSystem ) );
}
internal EffectLibrary Effects { get; } = new();
internal PrefixSnapshot Snapshot { get; private set; } = PrefixSnapshot.Disabled;
/// <summary>The snapshot the host last sent, for players who ask for it.</summary>
internal string Published { get; private set; }
/// <summary>Allows one snapshot reply per connection every two seconds.</summary>
internal bool TryBeginRequest( Guid connection, DateTimeOffset now ) => _requests.TryEnter( connection, now );
/// <summary>Whether Superuser's chat box replaces the built-in one.</summary>
internal bool ChatActive => Snapshot.Enabled && Snapshot.Chat;
/// <summary>UTC milliseconds: the clock every machine animates from.</summary>
internal static double Now => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private void Update()
{
if ( !Networking.IsActive )
{
if ( _received ) Receive( null );
return;
}
if ( Networking.IsHost ) Publish();
else RequestWhenMissing();
}
// Once a second the host rebuilds the snapshot (group changes and joins move prefixes too) and sends it if it changed.
private void Publish()
{
if ( _sincePublish < PublishInterval ) return;
_sincePublish = 0;
var module = Scene.GetSystem<SuperuserSession>()?.GetRuntime()?.Prefixes;
string json;
try
{
json = PrefixCodec.WriteSnapshot( module?.Snapshot() ?? PrefixSnapshot.Disabled );
}
catch ( InvalidOperationException error )
{
Log.Warning( $"[Superuser] Prefixes were not sent: {error.Message}" );
return;
}
if ( json == Published ) return;
Published = json;
Superuser.BroadcastPrefixes( json );
}
private void RequestWhenMissing()
{
if ( !ReferenceEquals( _host, Connection.Host ) )
{
_host = Connection.Host;
_received = false;
}
if ( _received || _sinceRequest < RequestInterval ) return;
_sinceRequest = 0;
Superuser.AskForPrefixes();
}
/// <summary>Applies a snapshot from the host (null clears it) and tells components whose prefix changed.</summary>
internal void Receive( string json )
{
PrefixSnapshot next;
try
{
next = json is null ? PrefixSnapshot.Disabled : PrefixCodec.ReadSnapshot( json );
}
catch ( Exception error ) when ( error is FormatException or ArgumentException or System.Text.Json.JsonException or KeyNotFoundException or InvalidOperationException )
{
Log.Warning( $"[Superuser] Ignored an invalid prefix snapshot; type={error.GetType().Name}." );
return;
}
var previous = Snapshot;
Snapshot = next;
_received = json is not null;
var players = previous.Players.Keys.Union( next.Players.Keys ).ToArray();
foreach ( var steamId in players )
{
var before = previous.For( steamId );
var after = next.For( steamId );
if ( before is not null && after is not null && PrefixCodec.Same( before, after ) ) continue;
if ( before is null && after is null ) continue;
var prefix = PrefixOf( steamId );
Scene.RunEvent<IPrefixChangedEvent>( listener =>
{
try { listener.OnPrefixChanged( steamId, prefix ); }
catch ( Exception error ) { Log.Warning( $"[Superuser] Prefix listener failed; type={error.GetType().Name}." ); }
} );
}
}
internal PlayerPrefix PrefixOf( string steamId )
{
var prefix = Snapshot.For( steamId );
if ( prefix is null ) return null;
var animated = Effects.Resolve( prefix.TagStyle )?.IsAnimated == true || Effects.Resolve( prefix.NameStyle )?.IsAnimated == true;
return new PlayerPrefix( prefix.Id, prefix.Name, prefix.Tag, prefix.Template, animated );
}
/// <summary>Renders a player's name with their prefix into runs; a player without one gets their plain name.</summary>
internal void Render( string steamId, string name, double time, List<LineRun> runs )
{
var prefix = Snapshot.For( steamId );
if ( prefix is null )
{
runs.Add( new LineRun( name ?? "", EffectColor.White, 0, false, false ) );
return;
}
Render( prefix, Effects.Resolve( prefix.TagStyle ), Effects.Resolve( prefix.NameStyle ), name, steamId, time, runs );
}
/// <summary>Renders any definition with explicit effects, for previews of unsaved edits.</summary>
internal void Render( PrefixDefinition prefix, IGlyphEffect tag, IGlyphEffect name, string playerName, string steamId, double time, List<LineRun> runs )
=> _renderer.Render( prefix, tag, name, playerName, time, PrefixSeed.Of( steamId ), runs );
internal StyledLine RenderLine( string steamId, string name, double time )
{
_runs.Clear();
Render( steamId, name, time, _runs );
return new StyledLine( _runs.Select( run => new StyledRun( run.Text, EffectColors.ToColor( run.Color ), run.Gap, run.Bold, run.Italic ) ).ToArray() );
}
}