using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Text; using System.Text.RegularExpressions; using Facepunch.Extend; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using Tebex.Plugin; using UnityEngine; namespace Oxide.Plugins { [Info("TebexPlugin", "Tebex", "3.1.0")] [Description("Official Tebex monetization plugin for Rust")] public class TebexPlugin : RustPlugin { #region Constants private const string PermAdmin = "tebexplugin.admin"; private const string DataFilePlayerIds = "Tebex/player_ids"; private const string DataFilePermissions = "Tebex/permission_ledger"; private const int JoinFlushThreshold = 10; private const int TelemetryFlushThreshold = 10; private const int TelemetryBufferMax = 500; private const int DefaultNextCheckSeconds = 120; private const string UiName = "Tebex.BuyUi"; private const string DebugUiName = "Tebex.DebugUi"; private const string TelemetryUiName = "Tebex.TelemetryUi"; private const string CommandRefUiName = "Tebex.CommandRefUi"; private const string UiOverlay = "Overlay"; #endregion #region Fields private TebexConfig _config; private Tebex.Plugin.Store _serverInfo; private string _publicToken; private bool _secretValid; private int _nextCheckSeconds = DefaultNextCheckSeconds; private readonly Dictionary _joinTimes = new(); private readonly Dictionary _playerLookup = new(); private readonly List _pendingJoinEvents = new(); private readonly Dictionary _permissionLedger = new(); private readonly HashSet _inflightCommandIds = new(); private readonly List _delayedCommands = new(); private List _categories = new(); private List _communityGoals = new(); private DateTime _lastListingRefresh = DateTime.MinValue; private readonly Dictionary _telemetryIndex = new(); private readonly Dictionary _uiStates = new(); private Timer _queueTimer; private Timer _joinFlushTimer; private Timer _telemetryTimer; private Timer _listingTimer; private int _timerEpoch; private DateTime _lastQueueTickAt; private DateTime _lastJoinFlushTickAt; private DateTime _lastTelemetryTickAt; private DateTime _lastListingTickAt; private DateTime _nextQueueTickAt; private DateTime _nextJoinFlushTickAt; private DateTime _nextTelemetryTickAt; private DateTime _nextListingTickAt; private const double HookSlowThresholdMs = 100.0; private const double UiHookSlowThresholdMs = 400.0; private readonly Dictionary _hookStats = new(); private readonly Dictionary _debugUiRefresh = new(); private enum DebugView { Main, Telemetry } private readonly Dictionary _debugView = new(); private readonly Dictionary _debugActionFeedback = new(); private readonly Dictionary _commandRefFeedback = new(); private bool _warnedUndecodableImages; private const int TelemetrySentHistoryMax = 50; private readonly Queue _telemetrySentHistory = new(); private static readonly JsonSerializerSettings JsonSettings = new() { NullValueHandling = NullValueHandling.Ignore, MissingMemberHandling = MissingMemberHandling.Ignore, DateParseHandling = DateParseHandling.DateTimeOffset }; #endregion #region Configuration public class TebexConfig { [JsonProperty("secret_key")] public string SecretKey { get; set; } = ""; [JsonProperty("buy_command")] public string BuyCommand { get; set; } = "buy"; [JsonProperty("subs_command")] public string SubsCommand { get; set; } = "subs"; [JsonProperty("disable_ui")] public bool DisableUi { get; set; } = true; [JsonProperty("admin_command")] public string AdminCommand { get; set; } = "tebex"; [JsonProperty("debug")] public bool Debug { get; set; } = false; [JsonProperty("auto_report_logs")] public bool AutoReportLogs { get; set; } = true; [JsonProperty("enable_basket")] public bool EnableBasket { get; set; } = true; [JsonProperty("check_for_updates")] public bool CheckForUpdates { get; set; } = true; [JsonProperty("track_permissions")] public bool TrackPermissions { get; set; } = true; [JsonProperty("custom_permission_commands")] public List CustomPermissionCommands { get; set; } = new(); [JsonProperty("queue_check_seconds")] public int QueueCheckSeconds { get; set; } = DefaultNextCheckSeconds; [JsonProperty("listing_refresh_seconds")] public int ListingRefreshSeconds { get; set; } = 120; [JsonProperty("telemetry_flush_seconds")] public int TelemetryFlushSeconds { get; set; } = 120; [JsonProperty("join_flush_seconds")] public int JoinFlushSeconds { get; set; } = 60; } public class CustomPermissionCommand { [JsonProperty("label")] public string Label { get; set; } = ""; [JsonProperty("grant_command")] public string GrantCommand { get; set; } = ""; [JsonProperty("revoke_command")] public string RevokeCommand { get; set; } = ""; } protected override void LoadDefaultConfig() { _config = new TebexConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject() ?? new TebexConfig(); } catch (Exception e) { PrintWarning($"Failed to read config ({e.Message}); using defaults."); _config = new TebexConfig(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(_config); private string ResolveSecret() { var env = Environment.GetEnvironmentVariable("TEBEX_SECRET_KEY"); if (!string.IsNullOrWhiteSpace(env)) return env.Trim(); return _config?.SecretKey?.Trim() ?? ""; } #endregion #region Lifecycle private void Init() { permission.RegisterPermission(PermAdmin, this); AddCovalenceCommand(_config.AdminCommand, nameof(CmdTebex)); AddCovalenceCommand(_config.BuyCommand, nameof(CmdBuy)); AddCovalenceCommand(_config.SubsCommand, nameof(CmdSubs)); LoadPlayerLookup(); LoadPermissionLedger(); lang.RegisterMessages(LangDefaults, this); } private void OnServerInitialized() { LoadEmbeddedImages(); CheckForUpdate(); var key = ResolveSecret(); if (string.IsNullOrEmpty(key)) { PrintWarning("No Tebex secret key set. Run `tebex secret ` from console or set TEBEX_SECRET_KEY."); return; } VerifySecret(key, (ok, info, err) => { if (!ok) { PrintWarning($"Tebex secret key invalid: {err}"); _secretValid = false; return; } _secretValid = true; _serverInfo = info; Puts($"Authenticated with {info.server?.name ?? "Tebex"} (account: {info.account?.name})."); StartTimers(); RefreshListing(); FetchCommunityGoals(); }); } private void CheckForUpdate() { if (!_config.CheckForUpdates) return; ApiRequest("/versions/oxide", RequestMethod.GET, null, (code, response) => { if (code != 200 || string.IsNullOrEmpty(response)) { if (_config.Debug) PrintWarning($"[Tebex] update check failed: HTTP {code}"); return; } try { var latest = JsonConvert.DeserializeObject(response)?.version; if (!System.Version.TryParse(latest ?? "", out var latestVersion) || !System.Version.TryParse(Version.ToString(), out var currentVersion)) { if (_config.Debug) PrintWarning($"[Tebex] update check returned an unrecognized version: {latest}"); return; } if (latestVersion > currentVersion) { PrintWarning($"A new version of the Tebex plugin is available: {latest} (you are running {Version}). " + "Download it from https://github.com/tebexio/Tebex-Rust/releases"); } else if (_config.Debug) { Puts(currentVersion > latestVersion ? $"[Tebex] running {Version}, which is newer than the latest release ({latest})." : $"[Tebex] plugin is up to date ({Version})."); } } catch (Exception e) { if (_config.Debug) PrintWarning($"[Tebex] update check failed: {e.Message}"); } }, requireSecret: false); } private void Unload() { try { FlushAllUi(); FlushTelemetry(force: true); FlushJoinEvents(force: true); SavePlayerLookup(); SavePermissionLedger(); } catch (Exception e) { PrintWarning($"Error during unload: {e.Message}"); } finally { _timerEpoch++; _queueTimer?.Destroy(); _joinFlushTimer?.Destroy(); _telemetryTimer?.Destroy(); _listingTimer?.Destroy(); } } private void StartTimers() { var epoch = ++_timerEpoch; _queueTimer?.Destroy(); _joinFlushTimer?.Destroy(); _telemetryTimer?.Destroy(); _listingTimer?.Destroy(); ScheduleQueueTick(epoch); ScheduleJoinFlushTick(epoch); ScheduleTelemetryTick(epoch); ScheduleListingTick(epoch); } private void ScheduleQueueTick(int epoch) { if (epoch != _timerEpoch) return; var interval = Math.Max(_nextCheckSeconds, 30); _nextQueueTickAt = DateTime.UtcNow.AddSeconds(interval); _queueTimer = timer.In(interval, () => { if (epoch != _timerEpoch) return; _lastQueueTickAt = DateTime.UtcNow; try { CheckQueue(); } catch (Exception e) { PrintWarning($"CheckQueue tick failed: {e.Message}"); } ScheduleQueueTick(epoch); }); } private void ScheduleJoinFlushTick(int epoch) { if (epoch != _timerEpoch) return; var interval = Math.Max(_config.JoinFlushSeconds, 15); _nextJoinFlushTickAt = DateTime.UtcNow.AddSeconds(interval); _joinFlushTimer = timer.In(interval, () => { if (epoch != _timerEpoch) return; _lastJoinFlushTickAt = DateTime.UtcNow; try { FlushJoinEvents(false); } catch (Exception e) { PrintWarning($"FlushJoinEvents tick failed: {e.Message}"); } ScheduleJoinFlushTick(epoch); }); } private void ScheduleTelemetryTick(int epoch) { if (epoch != _timerEpoch) return; var interval = Math.Max(_config.TelemetryFlushSeconds, 30); _nextTelemetryTickAt = DateTime.UtcNow.AddSeconds(interval); _telemetryTimer = timer.In(interval, () => { if (epoch != _timerEpoch) return; _lastTelemetryTickAt = DateTime.UtcNow; try { FlushTelemetry(false); } catch (Exception e) { PrintWarning($"FlushTelemetry tick failed: {e.Message}"); } ScheduleTelemetryTick(epoch); }); } private void ScheduleListingTick(int epoch) { if (epoch != _timerEpoch) return; var interval = Math.Max(_config.ListingRefreshSeconds, 30); _nextListingTickAt = DateTime.UtcNow.AddSeconds(interval); _listingTimer = timer.In(interval, () => { if (epoch != _timerEpoch) return; _lastListingTickAt = DateTime.UtcNow; try { RefreshListing(); FetchCommunityGoals(); } catch (Exception e) { PrintWarning($"RefreshListing tick failed: {e.Message}"); } ScheduleListingTick(epoch); }); } private static double SlowThresholdFor(string name) => name == "tebex.ui" || name == "tebex.dbg" || name == "CmdBuy" || name == "CmdTebex" ? UiHookSlowThresholdMs : HookSlowThresholdMs; private void MeasureHook(string name, Action body) { var sw = System.Diagnostics.Stopwatch.StartNew(); try { body(); } finally { sw.Stop(); var ms = sw.Elapsed.TotalMilliseconds; if (!_hookStats.TryGetValue(name, out var stats)) { stats = new HookStats(); _hookStats[name] = stats; } stats.Count++; stats.TotalMs += ms; if (ms > stats.MaxMs) stats.MaxMs = ms; var threshold = SlowThresholdFor(name); if (ms > threshold) { stats.LastSlowAt = DateTime.UtcNow; ReportTelemetry("WARN", $"hook '{name}' exceeded {threshold:0}ms", null, new Dictionary { ["last_ms"] = Math.Round(ms), ["max_ms"] = Math.Round(stats.MaxMs) }); } } } #endregion #region Hooks private void OnPlayerConnected(BasePlayer player) => MeasureHook("OnPlayerConnected", () => { if (player == null || !player.IsConnected) return; var id = player.UserIDString; var rec = new JoinRecord { Id = id, Name = player.displayName, JoinedAt = DateTime.UtcNow }; _joinTimes[id] = rec; _pendingJoinEvents.Add(new Tebex.Plugin.JoinEvent(id, "server.join", GetAnonymizedPlayerIp(player))); if (_secretValid) { if (_playerLookup.TryGetValue(id, out var lookup) && lookup.PluginUsernameId > 0) { CheckOnlineCommandsForPlayer(lookup.PluginUsernameId); } if (_pendingJoinEvents.Count >= JoinFlushThreshold) FlushJoinEvents(false); } }); private void OnPlayerDisconnected(BasePlayer player, string reason) => MeasureHook("OnPlayerDisconnected", () => { if (player == null) return; var id = player.UserIDString; _pendingJoinEvents.Add(new Tebex.Plugin.JoinEvent(id, "server.leave", GetAnonymizedPlayerIp(player))); _joinTimes.Remove(id); if (_pendingJoinEvents.Count >= JoinFlushThreshold) FlushJoinEvents(false); DestroyUi(player); DestroyDebugUi(player); }); #endregion #region Command Dispatch private void CmdTebex(IPlayer player, string command, string[] args) => MeasureHook("CmdTebex", () => { if (args == null || args.Length == 0) { if (!IsAdminPlayer(player)) { if (player.IsServer) { player.Reply(Lang("StoreUnavailable", player.Id)); return; } if (!_secretValid) { player.Reply(Lang("StoreUnavailable", player.Id)); return; } if (player.Object is BasePlayer bp) OpenStoreForPlayer(player, bp); return; } if (player.IsServer || _config.DisableUi) player.Reply(Lang("HelpHeader", player.Id)); HandleHelp(player); return; } var sub = args[0].ToLowerInvariant(); var rest = args.Skip(1).ToArray(); switch (sub) { case "secret": HandleSecret(player, rest); break; case "info": HandleInfo(player); break; case "forcecheck": HandleForceCheck(player, rest); break; case "checkout": HandleCheckout(player, rest); break; case "sendlink": HandleSendLink(player, rest); break; case "debug": HandleDebug(player, rest); break; case "ban": HandleBan(player, rest); break; case "goals": HandleGoals(player); break; case "lookup": HandleLookup(player, rest); break; case "replay": HandleReplay(player, rest); break; case "selftest": HandleSelfTest(player); break; case "reload": HandleReload(player); break; case "help": HandleHelp(player); break; default: player.Reply(IsOperator(player) ? $"Unknown subcommand '{sub}'. Try `{_config.AdminCommand} help`." : $"Unknown subcommand '{sub}'. Use /{_config.BuyCommand} to open the store."); break; } }); private void CmdBuy(IPlayer player, string command, string[] args) => MeasureHook("CmdBuy", () => { if (player.IsServer) { player.Reply("The /buy command must be run as a player."); return; } if (!_secretValid) { player.Reply(Lang("StoreUnavailable", player.Id)); return; } var basePlayer = player.Object as BasePlayer; if (basePlayer == null) return; OpenStoreForPlayer(player, basePlayer); }); private void CmdSubs(IPlayer player, string command, string[] args) => MeasureHook("CmdSubs", () => { if (player.IsServer) { player.Reply($"The /{_config.SubsCommand} command must be run as a player."); return; } const string portalUrl = "https://portal.tebex.io"; player.Reply($"You may log in and check your active subscriptions here: {portalUrl}"); if (player.Object is BasePlayer bp) { bp.ConsoleMessage(portalUrl); bp.ChatMessage("The link has also been sent to your F1 console. Click the timestamp to copy it."); } }); private void OpenStoreForPlayer(IPlayer player, BasePlayer basePlayer) { var uiEnabled = !_config.DisableUi; ReportTelemetry("INFO", $"store opened, ui {(uiEnabled ? "enabled" : "disabled")}", null); if (!uiEnabled) { var webUrl = ResolveWebstoreUrl(); basePlayer.ConsoleMessage(webUrl); basePlayer.ChatMessage("Our webstore link has been sent to your F1 console. Click the timestamp to copy the link."); return; } OpenBuyUi(basePlayer); } private bool IsAdminPlayer(IPlayer player) => player.IsServer || player.IsAdmin || (!string.IsNullOrEmpty(player.Id) && permission.UserHasPermission(player.Id, PermAdmin)); private bool IsOperator(IPlayer player) => IsAdminPlayer(player) || (player.Object is BasePlayer bp && bp.net?.connection != null && bp.net.connection.authLevel >= 1); private bool RequireAdmin(IPlayer player) { if (IsAdminPlayer(player)) return true; player.Reply(Lang("NoPermission", player.Id)); return false; } private bool RequireSecret(IPlayer player) { if (_secretValid) return true; player.Reply(Lang("SecretMissing", player.Id)); return false; } #endregion #region Subcommand Handlers private void HandleSecret(IPlayer player, string[] args) { if (!RequireAdmin(player)) return; if (args.Length < 1) { player.Reply($"Usage: {_config.AdminCommand} secret "); return; } var key = args[0].Trim(); VerifySecret(key, (ok, info, err) => { if (!ok) { player.Reply($"Secret key rejected: {err}"); return; } _config.SecretKey = key; SaveConfig(); _secretValid = true; _serverInfo = info; player.Reply($"Secret key saved. Linked to '{info.server?.name}' on '{info.account?.domain}'."); StartTimers(); RefreshListing(); FetchCommunityGoals(); }); } private void HandleInfo(IPlayer player) { if (!RequireAdmin(player)) return; if (!RequireSecret(player)) return; ApiGet("/information", (ok, info, err) => { if (!ok) { player.Reply($"Failed to fetch info: {err}"); return; } var sb = new StringBuilder(); sb.AppendLine($"Server : {info.server?.name}"); sb.AppendLine($"Account: {info.account?.name} ({info.account?.domain})"); player.Reply(sb.ToString().TrimEnd()); }); } private void HandleForceCheck(IPlayer player, string[] args) { if (!RequireAdmin(player)) return; if (!RequireSecret(player)) return; if (args != null && args.Length > 0) { var name = args[0]; var rec = FindPlayerByUsername(name); if (rec == null) { player.Reply($"No cached Tebex id for player '{name}'. Run a normal `forcecheck` first or wait for the next /queue tick."); return; } player.Reply($"Checking online commands for {rec.Username} (Tebex id {rec.PluginUsernameId})..."); CheckOnlineCommandsForPlayer(rec.PluginUsernameId, n => player.Reply($"Executed {n} command(s) for {rec.Username}.")); return; } player.Reply("Forcing queue check..."); CheckQueue(n => player.Reply($"Queue check complete. Executed {n} command(s).")); } private void HandleCheckout(IPlayer player, string[] args) { if (!RequireSecret(player)) return; if (args.Length < 1) { player.Reply($"Usage: {_config.AdminCommand} checkout "); return; } if (!int.TryParse(args[0], out var packageId)) { player.Reply("Package id must be a number."); return; } if (player.IsServer) { player.Reply("Checkout requires a player context. Use sendlink instead."); return; } CreateCheckout(packageId, player.Id, (ok, url, err) => { if (!ok) { player.Reply($"Checkout failed: {err}"); return; } SendCheckoutLink(player, url, packageId); }); } private void HandleSendLink(IPlayer player, string[] args) { if (!RequireAdmin(player)) return; if (!RequireSecret(player)) return; if (args.Length < 2) { player.Reply($"Usage: {_config.AdminCommand} sendlink "); return; } if (!int.TryParse(args[0], out var packageId)) { player.Reply("Package id must be a number."); return; } var target = covalence.Players.FindPlayer(args[1]); if (target == null) { player.Reply($"Player '{args[1]}' not found."); return; } CreateCheckout(packageId, target.Id, (ok, url, err) => { if (!ok) { player.Reply($"Checkout failed: {err}"); return; } if (target.IsConnected) SendCheckoutLink(target, url, packageId); player.Reply($"Sent checkout link for package {packageId} to {target.Name}: {url}"); }); } private void HandleDebug(IPlayer player, string[] args) { if (!RequireAdmin(player)) return; if (args.Length < 1) { if (!player.IsServer && player.Object is BasePlayer basePlayer) { OpenDebugUi(basePlayer); return; } player.Reply($"Debug logging is currently {(_config.Debug ? "ON" : "OFF")}. Usage: {_config.AdminCommand} debug [true|false]"); return; } if (!bool.TryParse(args[0], out var enabled)) { player.Reply("Argument must be true or false."); return; } _config.Debug = enabled; SaveConfig(); player.Reply($"Debug logging is now {(enabled ? "ON" : "OFF")}."); } private void HandleBan(IPlayer player, string[] args) { if (!RequireAdmin(player)) return; if (!RequireSecret(player)) return; if (args.Length < 2) { player.Reply($"Usage: {_config.AdminCommand} ban [ip]"); return; } var name = args[0]; var ip = args.Length >= 3 ? args[args.Length - 1] : ""; var reasonParts = ip == "" ? args.Skip(1) : args.Skip(1).Take(args.Length - 2); var reason = string.Join(" ", reasonParts); var body = JsonConvert.SerializeObject(new { user = name, reason, ip }, JsonSettings); ApiRequest("/bans", RequestMethod.POST, body, (code, response) => { if (code == 201 || code == 200) player.Reply($"Banned '{name}' from the webstore."); else player.Reply($"Ban failed (HTTP {code}): {response}"); }); } private void HandleGoals(IPlayer player) { if (!RequireAdmin(player)) return; if (!RequireSecret(player)) return; ApiGet>("/community_goals", (ok, list, err) => { if (!ok) { player.Reply($"Failed to fetch community goals: {err}"); return; } var goals = list ?? new List(); _communityGoals = goals; if (goals.Count == 0) { player.Reply("No community goals configured."); return; } var sb = new StringBuilder(); sb.AppendLine("Community goals:"); foreach (var g in goals) sb.AppendLine($" {g.name}: {g.current:0}/{g.target:0} ({g.status})"); player.Reply(sb.ToString().TrimEnd()); }); } private void HandleLookup(IPlayer player, string[] args) { player.Reply("User lookup is not available in this build."); } private void HandleReplay(IPlayer player, string[] args) { if (!RequireAdmin(player)) return; if (!_config.TrackPermissions) { player.Reply("Permission tracking is disabled (set track_permissions to true in the config)."); return; } if (args == null || args.Length < 1) { player.Reply($"Usage: {_config.AdminCommand} replay "); return; } if (args[0].Equals("all", StringComparison.OrdinalIgnoreCase)) { int players = 0, grants = 0; foreach (var rec in _permissionLedger.Values.ToList()) { var applied = ReplayLedger(rec); if (applied > 0) { players++; grants += applied; } } player.Reply($"Replayed {grants} tracked permission(s)/group(s) across {players} player(s)."); return; } var record = FindLedgerRecord(_permissionLedger, args[0]); if (record == null) { player.Reply($"No tracked permissions for '{args[0]}'. They may not have purchased a permission package, or tracking was off when they did."); return; } if (record.Entries.Count == 0) { player.Reply($"{record.Username} has no tracked permissions to replay."); return; } var count = ReplayLedger(record); player.Reply($"Replayed {count} tracked permission(s)/group(s) for {record.Username} ({record.Uuid})."); } private void HandleReload(IPlayer player) { if (!RequireAdmin(player)) return; LoadConfig(); player.Reply("Configuration reloaded."); var key = ResolveSecret(); if (string.IsNullOrEmpty(key)) { _secretValid = false; player.Reply("No secret key set after reload."); return; } VerifySecret(key, (ok, info, err) => { if (!ok) { _secretValid = false; player.Reply($"Secret invalid after reload: {err}"); return; } _secretValid = true; _serverInfo = info; StartTimers(); RefreshListing(); FetchCommunityGoals(); player.Reply("Reauthenticated and refreshed."); }); } private void HandleHelp(IPlayer player) { if (!IsOperator(player)) { player.Reply($"Use /{_config.BuyCommand} to open the store."); return; } if (!player.IsServer && !_config.DisableUi && player.Object is BasePlayer bp) { OpenCommandReferenceUi(bp); return; } player.Reply(BuildCommandReferenceText()); } private string BuildCommandReferenceText() { int width = 0; foreach (var row in CommandReferenceTable) { var len = FormatCommandSyntax(row.Syntax).Length; if (len > width) width = len; } var sb = new StringBuilder(); sb.AppendLine("Tebex commands:"); string section = null; foreach (var row in CommandReferenceTable) { if (row.Section != section) { section = row.Section; sb.AppendLine(); sb.AppendLine(section); } sb.AppendLine($" {FormatCommandSyntax(row.Syntax).PadRight(width)} {row.Description}"); } return sb.ToString().TrimEnd(); } #endregion #region Models private class JoinRecord { public string Id; public string Name; public DateTime JoinedAt; } private class DelayedCommand { public Tebex.Plugin.Command Command; public BasePlayer Player; public DateTime FireAt; } private class TelemetryEvent { [JsonProperty("level")] public string Level; [JsonProperty("message")] public string Message; [JsonProperty("trace")] public string Trace; [JsonProperty("count")] public int Count; [JsonProperty("first_seen")] public DateTime FirstSeen; [JsonProperty("last_seen")] public DateTime LastSeen; [JsonProperty("metadata")] public Dictionary? Metadata; [JsonIgnore] public string Signature => $"{Level}|{Message?.Substring(0, Math.Min(120, Message?.Length ?? 0))}|{Trace?.Substring(0, Math.Min(200, Trace?.Length ?? 0))}"; } private enum BuyUiView { Store, Basket } private class BuyUiState { public int CategoryId; public int Page; public int? OpenPackageId; public BuyUiView View = BuyUiView.Store; public string BasketIdent = string.Empty; public Tebex.Headless.Basket Basket; public string LastBasketError = string.Empty; public readonly HashSet InflightAdds = new HashSet(); public bool OverlayDrawn; } private class HookStats { public long Count; public double TotalMs; public double MaxMs; public DateTime LastSlowAt; public double AvgMs => Count == 0 ? 0 : TotalMs / Count; } private class JoinDataFile { public Dictionary Joins = new(); } private class TelemetryDataFile { public List Events = new(); } private class PlayerLookupRecord { public int PluginUsernameId; public string Uuid = string.Empty; public string Username = string.Empty; public DateTime LastSeen; } private class PlayerLookupDataFile { public Dictionary Players = new(); } private class PermissionLedgerEntry { public string Type = string.Empty; public string Value = string.Empty; public long PackageRef; public string RawCommand = string.Empty; public DateTime GrantedAt; } private class PermissionLedgerRecord { public string Uuid = string.Empty; public string Username = string.Empty; public List Entries = new(); } private class PermissionLedgerDataFile { public Dictionary Players = new(); } #endregion #region API Client private void DebugHttpRequest(RequestMethod method, string url, IDictionary headers, string body) { if (!_config.Debug) return; var headerStr = headers == null ? "" : string.Join(", ", headers.Select(h => string.Equals(h.Key, "X-Tebex-Secret", StringComparison.OrdinalIgnoreCase) ? $"{h.Key}=" : $"{h.Key}={h.Value}")); Puts($"[Tebex HTTP] -> {method} {url}\n headers: {headerStr}\n body: {Truncate(body, 1000) ?? ""}"); } private void DebugHttpResponse(RequestMethod method, string url, int code, string response, double elapsedMs = -1) { if (!_config.Debug) return; var timing = elapsedMs >= 0 ? $" ({elapsedMs:0}ms)" : ""; Puts($"[Tebex HTTP] <- {method} {url} -> {code}{timing}\n body: {Truncate(response, 1000) ?? ""}"); } private void SendHttp(RequestMethod method, string url, string body, IDictionary headers, float timeoutSeconds, Action callback) { #if TEBEX_DEV if (url != null && url.Contains("://plugin.tebex.io")) { url = url.Replace("://plugin.tebex.io", "://plugin.dev.tebex.io"); } if (url != null && url.Contains("://headless.tebex.io")) { url = url.Replace("://headless.tebex.io", "://headless.dev.tebex.io"); } #endif DebugHttpRequest(method, url, headers, body); var startedAt = UnityEngine.Time.realtimeSinceStartup; webrequest.Enqueue(url, body ?? "", (code, response) => { var elapsedMs = (UnityEngine.Time.realtimeSinceStartup - startedAt) * 1000.0; DebugHttpResponse(method, url, code, response, elapsedMs); try { callback?.Invoke(code, response); } catch (Exception e) { if (!url.Contains("https://plugin-logs.tebex.io")) { ReportTelemetry("ERROR", $"Callback for {method} {url} threw: {e.Message}", e.StackTrace); PrintError($"Tebex callback error: {e.Message}"); } else { PrintError($"Failed to send telemetry: {e.Message}"); } } }, this, method, headers as Dictionary, timeoutSeconds); } private void ApiRequest(string path, RequestMethod method, string body, Action callback, bool requireSecret = true) { var url = $"https://plugin.tebex.io{path}"; var headers = new Dictionary { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }; var key = ResolveSecret(); if (requireSecret) { if (string.IsNullOrEmpty(key)) { callback?.Invoke(0, "no secret key configured"); return; } headers["X-Tebex-Secret"] = key; } else if (!string.IsNullOrEmpty(key)) { headers["X-Tebex-Secret"] = key; } SendHttp(method, url, body, headers, 30f, callback); } private void HeadlessRequest(RequestMethod method, string apiPath, string body, Action callback) { var url = $"https://headless.tebex.io/api/{apiPath}"; var headers = new Dictionary { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }; SendHttp(method, url, body, headers, 30f, callback); } private void HeadlessGet(string accountScopedPath, Action callback) where T : class { if (string.IsNullOrEmpty(_publicToken)) { callback?.Invoke(false, null, "Public token not set, use tebex secret to set key and retrieve token"); return; } var path = $"accounts/{Uri.EscapeDataString(_publicToken)}/{accountScopedPath}"; HeadlessRequest(RequestMethod.GET, path, null, (code, response) => { if (code < 200 || code >= 300) { callback?.Invoke(false, null, $"HTTP {code}: {Truncate(response, 200)}"); return; } try { var parsed = JsonConvert.DeserializeObject(response, JsonSettings); callback?.Invoke(true, parsed, null); } catch (Exception e) { callback?.Invoke(false, null, $"Parse error: {e.Message}"); } }); } private void ApiGet(string path, Action callback, bool requireSecret = true) where T : class { ApiRequest(path, RequestMethod.GET, null, (code, response) => { if (code < 200 || code >= 300) { callback?.Invoke(false, null, $"HTTP {code}: {Truncate(response, 200)}"); return; } try { var parsed = JsonConvert.DeserializeObject(response, JsonSettings); callback?.Invoke(true, parsed, null); } catch (Exception e) { callback?.Invoke(false, null, $"Parse error: {e.Message}"); } }, requireSecret); } private void VerifySecret(string key, Action callback) { var headers = new Dictionary { ["Content-Type"] = "application/json", ["Accept"] = "application/json", ["X-Tebex-Secret"] = key }; SendHttp(RequestMethod.GET, "https://plugin.tebex.io/information", null, headers, 15f, (code, response) => { if (code < 200 || code >= 300) { callback(false, null, $"HTTP {code}: {Truncate(response, 200)}"); return; } try { var info = JsonConvert.DeserializeObject(response, JsonSettings); if (!info.account.game_type.ToLower().Equals("rust")) { callback(false, info, "Invalid game type: that is not a Rust game server key"); return; } _publicToken = info.account.public_token; callback(true, info, null); } catch (Exception e) { callback(false, null, $"Parse error: {e.Message}"); } }); } private void PluginCheckout(int packageId, string usernameId, Action callback) { var payload = new Tebex.Plugin.CreateCheckoutPayload { package_id = packageId, username_id = usernameId }; var body = JsonConvert.SerializeObject(payload, JsonSettings); ApiRequest("/checkout", RequestMethod.POST, body, (code, response) => { if (code < 200 || code >= 300) { callback?.Invoke(false, null, ExtractApiError(code, response)); return; } try { var parsed = JsonConvert.DeserializeObject(response, JsonSettings); if (parsed == null || string.IsNullOrEmpty(parsed.ident)) { callback?.Invoke(false, null, "checkout response missing basket ident"); return; } callback?.Invoke(true, parsed, null); } catch (Exception e) { callback?.Invoke(false, null, $"checkout parse: {e.Message}"); } }); } private void CreateCheckout(int packageId, string usernameId, Action callback) { PluginCheckout(packageId, usernameId, (ok, resp, err) => { if (!ok) { callback?.Invoke(false, null, err); return; } callback?.Invoke(true, resp.url, null); }); } private class ApiErrorBody { public string title = null; public string detail = null; } private string ExtractApiError(int code, string response) { if (!string.IsNullOrEmpty(response)) { try { var err = JsonConvert.DeserializeObject(response, JsonSettings); if (err != null && !string.IsNullOrEmpty(err.title)) { return !string.IsNullOrEmpty(err.detail) && err.detail != err.title ? $"{err.title}: {err.detail}" : err.title; } } catch { } } return $"HTTP {code}: {Truncate(response, 200)}"; } private bool ApplyBasketResponse(BuyUiState state, string response, out string err) { try { var wrapped = JsonConvert.DeserializeObject(response, JsonSettings); if (wrapped?.data == null) { err = "basket response missing data"; return false; } state.Basket = wrapped.data; if (!string.IsNullOrEmpty(wrapped.data.ident)) state.BasketIdent = wrapped.data.ident; err = null; return true; } catch (Exception e) { err = $"basket parse: {e.Message}"; return false; } } private void RefreshBasket(BasePlayer player, Action cb) { if (player == null) { cb?.Invoke(false, "no player"); return; } if (!_uiStates.TryGetValue(player.userID, out var state)) { cb?.Invoke(false, "no ui state"); return; } if (string.IsNullOrEmpty(state.BasketIdent)) { cb?.Invoke(false, "no basket"); return; } if (string.IsNullOrEmpty(_publicToken)) { cb?.Invoke(false, "public_token not set"); return; } var path = $"accounts/{Uri.EscapeDataString(_publicToken)}/baskets/{Uri.EscapeDataString(state.BasketIdent)}"; HeadlessRequest(RequestMethod.GET, path, null, (code, response) => { if (code == 404) { state.BasketIdent = string.Empty; state.Basket = null; cb?.Invoke(false, "basket not found"); return; } if (code < 200 || code >= 300) { cb?.Invoke(false, ExtractApiError(code, response)); return; } cb?.Invoke(ApplyBasketResponse(state, response, out var err), err); }); } private void EnsureBasket(BasePlayer player, int seedPackageId, Action cb) { if (player == null) { cb?.Invoke(false, "no player"); return; } if (!_uiStates.TryGetValue(player.userID, out var state)) { state = new BuyUiState(); _uiStates[player.userID] = state; } if (!string.IsNullOrEmpty(state.BasketIdent)) { cb?.Invoke(true, null); return; } PluginCheckout(seedPackageId, player.UserIDString, (ok, resp, err) => { if (!ok) { cb?.Invoke(false, err); return; } state.BasketIdent = resp.ident; cb?.Invoke(true, null); }); } private void AddToBasket(BasePlayer player, int packageId, Action cb) { if (player == null) { cb?.Invoke(false, "no player"); return; } if (!_uiStates.TryGetValue(player.userID, out var state)) { state = new BuyUiState(); _uiStates[player.userID] = state; } state.InflightAdds.Add(packageId); EnsureBasket(player, packageId, (ok, err) => { if (!ok) { state.InflightAdds.Remove(packageId); cb?.Invoke(false, err); return; } var pkgType = AllPackages().FirstOrDefault(p => p.id == packageId)?.type; var body = JsonConvert.SerializeObject(new Tebex.Headless.AddPackagePayload(packageId, type: string.IsNullOrEmpty(pkgType) ? null : pkgType), JsonSettings); var path = $"baskets/{Uri.EscapeDataString(state.BasketIdent)}/packages"; HeadlessRequest(RequestMethod.POST, path, body, (code, response) => { state.InflightAdds.Remove(packageId); if (code < 200 || code >= 300) { cb?.Invoke(false, ExtractApiError(code, response)); return; } var applied = ApplyBasketResponse(state, response, out var aerr); cb?.Invoke(applied, aerr); }); }); } private void RemoveFromBasket(BasePlayer player, int packageId, Action cb) { if (player == null) { cb?.Invoke(false, "no player"); return; } if (!_uiStates.TryGetValue(player.userID, out var state) || string.IsNullOrEmpty(state.BasketIdent)) { cb?.Invoke(false, "no basket"); return; } var body = JsonConvert.SerializeObject(new Tebex.Headless.PackageRemovePayload(packageId), JsonSettings); var path = $"baskets/{Uri.EscapeDataString(state.BasketIdent)}/packages/remove"; HeadlessRequest(RequestMethod.POST, path, body, (code, response) => { if (code < 200 || code >= 300) { cb?.Invoke(false, ExtractApiError(code, response)); return; } var applied = ApplyBasketResponse(state, response, out var aerr); cb?.Invoke(applied, aerr); }); } #endregion #region Queue Processing private void CheckQueue() => CheckQueue(null); private void CheckQueue(Action onComplete) { if (!_secretValid) { onComplete?.Invoke(0); return; } ApiGet("/queue", (ok, q, err) => { if (!ok) { ReportTelemetry("WARN", $"queue fetch failed: {err}", null); onComplete?.Invoke(0); return; } if (q.meta != null) { if (q.meta.next_check > 0 && q.meta.next_check != _nextCheckSeconds) { _nextCheckSeconds = q.meta.next_check; } } if (q.players != null) { foreach (var dp in q.players) { RememberPlayer(dp.id, dp.uuid, dp.name); } } var pendingPlayers = (q.players ?? new List()) .Where(p => IsPlayerOnline(p.uuid)) .ToList(); if (pendingPlayers.Count == 0 && (q.meta == null || !q.meta.execute_offline)) { onComplete?.Invoke(0); return; } int remaining = pendingPlayers.Count + (q.meta != null && q.meta.execute_offline ? 1 : 0); int totalExecuted = 0; void Done(int n) { totalExecuted += n; remaining--; if (remaining <= 0) onComplete?.Invoke(totalExecuted); } foreach (var qp in pendingPlayers) { CheckOnlineCommandsForPlayer(qp.id, Done); } if (q.meta != null && q.meta.execute_offline) { CheckOfflineCommands(Done); } }); } private void CheckOnlineCommandsForPlayer(int playerUsernameId, Action onComplete = null) { ApiGet($"/queue/online-commands/{playerUsernameId}", (ok, resp, err) => { if (!ok) { ReportTelemetry("WARN", $"online-commands fetch failed for {playerUsernameId}: {err}", null); onComplete?.Invoke(0); return; } if (resp?.player != null && resp.commands != null) { foreach (var c in resp.commands) { c.player ??= new Tebex.Plugin.PlayerInfo(); if (string.IsNullOrEmpty(c.player.uuid)) c.player.uuid = resp.player.id; if (string.IsNullOrEmpty(c.player.id)) c.player.id = resp.player.id; if (string.IsNullOrEmpty(c.player.username)) c.player.username = resp.player.username; if (c.player.plugin_username_id == 0) c.player.plugin_username_id = playerUsernameId; } } var n = ProcessCommands(resp.commands, online: true); onComplete?.Invoke(n); }); } private void CheckOfflineCommands(Action onComplete = null) { ApiGet("/queue/offline-commands", (ok, resp, err) => { if (!ok) { ReportTelemetry("WARN", $"offline-commands fetch failed: {err}", null); onComplete?.Invoke(0); return; } var n = ProcessCommands(resp.commands, online: false); onComplete?.Invoke(n); }); } private int ProcessCommands(List commands, bool online) { if (commands == null || commands.Count == 0) return 0; var executedNow = new List(); int delayedCount = 0; foreach (var cmd in commands) { if (cmd.player != null) { RememberPlayer(cmd.player.plugin_username_id, cmd.player.uuid, cmd.player.username); } if (_inflightCommandIds.Contains(cmd.id)) continue; _inflightCommandIds.Add(cmd.id); var basePlayer = online ? FindOnlinePlayer(cmd.player?.uuid) : null; if (online && basePlayer == null) { _inflightCommandIds.Remove(cmd.id); continue; } if (online && cmd.conditions?.slots > 0 && basePlayer != null) { var slotsFree = CountFreeInventorySlots(basePlayer); if (slotsFree < cmd.conditions.slots) { basePlayer.IPlayer?.Reply($"You need at least {cmd.conditions.slots} free inventory slots to receive a Tebex package. Free up space."); _inflightCommandIds.Remove(cmd.id); continue; } } var delay = cmd.conditions?.delay ?? 0; if (delay > 0) { _delayedCommands.Add(new DelayedCommand { Command = cmd, Player = basePlayer, FireAt = DateTime.UtcNow.AddSeconds(delay) }); timer.In(delay, () => ExecuteAndAck(cmd, basePlayer)); delayedCount++; } else { var line = SubstituteVars(cmd.command_to_run, basePlayer, cmd.player); ExecuteServerCommand(line); TrackPermissionCommand(line, cmd); executedNow.Add(cmd.id); } } if (executedNow.Count > 0) AckCommands(executedNow); return executedNow.Count + delayedCount; } private void ExecuteAndAck(Tebex.Plugin.Command cmd, BasePlayer player) { try { var line = SubstituteVars(cmd.command_to_run, player, cmd.player); ExecuteServerCommand(line); TrackPermissionCommand(line, cmd); AckCommands(new List { cmd.id }); } catch (Exception e) { ReportTelemetry("ERROR", $"Failed to execute Tebex command {cmd.id}: {e.Message}", e.StackTrace); _inflightCommandIds.Remove(cmd.id); } finally { _delayedCommands.RemoveAll(d => d.Command.id == cmd.id); } } private void ExecuteServerCommand(string command) { if (string.IsNullOrWhiteSpace(command)) return; if (_config.Debug) Puts($"[Tebex] exec: {command}"); try { Server.Command(command); } catch (Exception e) { ReportTelemetry("ERROR", $"command exec threw: {e.Message}", e.StackTrace); PrintError($"Failed running '{command}': {e.Message}"); } } private string SubstituteVars(string template, BasePlayer onlinePlayer, Tebex.Plugin.PlayerInfo player) { if (string.IsNullOrEmpty(template)) return template; var name = onlinePlayer?.displayName ?? player?.username ?? ""; var id = onlinePlayer?.UserIDString ?? player?.uuid ?? player?.id ?? ""; return template .Replace("{username}", name) .Replace("{name}", name) .Replace("{id}", id) .Replace("{steamid}", id); } private void AckCommands(List ids) { if (ids == null || ids.Count == 0) return; var body = JsonConvert.SerializeObject(new { ids }, JsonSettings); ApiRequest("/queue", RequestMethod.DELETE, body, (code, response) => { if (code < 200 || code >= 300) { ReportTelemetry("WARN", $"ack failed for [{string.Join(",", ids)}]: HTTP {code} {Truncate(response, 200)}", null); foreach (var id in ids) _inflightCommandIds.Remove(id); } }); } private static int CountFreeInventorySlots(BasePlayer player) { if (player?.inventory == null) return 0; int free = 0; free += FreeSlots(player.inventory.containerMain); free += FreeSlots(player.inventory.containerBelt); return free; } private static int FreeSlots(ItemContainer container) { if (container == null) return 0; return container.capacity - container.itemList.Count; } #endregion #region Listing Refresh private void RefreshListing() { if (string.IsNullOrEmpty(_config.SecretKey)) { if (_config.Debug) PrintWarning("Skipping listing refresh: secret_key is not set."); return; } HeadlessGet("categories?includePackages=1", (ok, resp, err) => { if (!ok) { ReportTelemetry("WARN", $"headless listing fetch failed: {err}", null); return; } _categories = resp?.data ?? new List(); _lastListingRefresh = DateTime.UtcNow; WarnOnUndecodableImages(); if (_config.Debug) Puts($"[Tebex] listing refreshed: {_categories.Count} categories."); }); } private void FetchCommunityGoals() { if (!_secretValid) return; ApiGet>("/community_goals", (ok, list, err) => { if (!ok) { if (_config.Debug) PrintWarning($"[Tebex] community_goals fetch failed: {err}"); return; } _communityGoals = list ?? new List(); }); } private void WarnOnUndecodableImages() { if (_warnedUndecodableImages) return; var affected = new List(); foreach (var p in AllPackages()) { if (p == null || string.IsNullOrEmpty(p.image)) continue; if (IsDecodableImageUrl(p.image)) continue; affected.Add(p.name ?? $"#{p.id}"); } if (affected.Count == 0) return; _warnedUndecodableImages = true; var sample = string.Join(", ", affected.Take(3)); if (affected.Count > 3) sample += $", and {affected.Count - 3} more"; PrintWarning($"{affected.Count} package image(s) are in a format the Rust client cannot display " + $"(WebP, AVIF, SVG or GIF): {sample}. Those packages show a placeholder in the in-game " + "store. Re-upload the images as PNG or JPG in your Tebex webstore to fix this."); } private IEnumerable AllPackages() { foreach (var c in _categories ?? new List()) { if (c?.packages == null) continue; foreach (var p in c.packages) yield return p; } } #endregion #region Player Tracking private void FlushJoinEvents(bool force) { if (_pendingJoinEvents.Count == 0) return; if (!force && _pendingJoinEvents.Count < JoinFlushThreshold && _config.JoinFlushSeconds > 0) { } if (!_secretValid) { if (_pendingJoinEvents.Count > 1000) _pendingJoinEvents.RemoveRange(0, _pendingJoinEvents.Count - 1000); return; } var batch = _pendingJoinEvents.ToList(); _pendingJoinEvents.Clear(); var body = JsonConvert.SerializeObject(batch, JsonSettings); ApiRequest("/events", RequestMethod.POST, body, (code, response) => { if (code < 200 || code >= 300) { ReportTelemetry("WARN", $"join events flush failed: HTTP {code}", null); _pendingJoinEvents.InsertRange(0, batch); if (_pendingJoinEvents.Count > 1000) _pendingJoinEvents.RemoveRange(0, _pendingJoinEvents.Count - 1000); } }); } private void RememberPlayer(int pluginUsernameId, string uuid, string username) { if (pluginUsernameId <= 0 || string.IsNullOrEmpty(uuid)) return; if (!_playerLookup.TryGetValue(uuid, out var rec)) { rec = new PlayerLookupRecord { Uuid = uuid }; _playerLookup[uuid] = rec; } rec.PluginUsernameId = pluginUsernameId; if (!string.IsNullOrEmpty(username)) rec.Username = username; rec.LastSeen = DateTime.UtcNow; } private PlayerLookupRecord FindPlayerByUsername(string username) { if (string.IsNullOrEmpty(username)) return null; foreach (var rec in _playerLookup.Values) { if (string.Equals(rec.Username, username, StringComparison.OrdinalIgnoreCase)) return rec; } return null; } private void LoadPlayerLookup() { if (!Interface.Oxide.DataFileSystem.ExistsDatafile(DataFilePlayerIds)) return; try { var data = Interface.Oxide.DataFileSystem.ReadObject(DataFilePlayerIds); if (data?.Players != null) { foreach (var players in data.Players) { _playerLookup[players.Key] = players.Value; } } } catch { } } private void SavePlayerLookup() { try { var data = new PlayerLookupDataFile { Players = new Dictionary(_playerLookup) }; Interface.Oxide.DataFileSystem.WriteObject(DataFilePlayerIds, data); } catch (Exception e) { PrintWarning($"Could not save player lookup data: {e.Message}"); } } private void TrackPermissionCommand(string command, Tebex.Plugin.Command cmd) { if (!_config.TrackPermissions) return; try { bool isGrant; string type, value; if (TryParsePermissionGrant(command, out isGrant, out type, out value)) { } else { var custom = MatchCustomCommand(_config.CustomPermissionCommands, command, out isGrant); if (custom == null) { if (_config.Debug && LooksLikePermissionCommand(command)) Puts($"[Tebex] ledger skipped unrecognized permission command: {command}"); return; } type = "custom"; value = custom.Label; } var uuid = cmd?.player?.uuid ?? ""; if (uuid.Length == 0) return; var username = cmd?.player?.username ?? ""; var changed = ApplyLedgerMutation(_permissionLedger, uuid, username, isGrant, type, value, cmd?.package_ref ?? 0, command); if (!changed) return; SavePermissionLedger(); if (_config.Debug) Puts($"[Tebex] ledger {(isGrant ? "tracked" : "removed")} {type} '{value}' for {username} ({uuid})."); } catch (Exception e) { PrintWarning($"Permission ledger tracking failed: {e.Message}"); } } private static bool ApplyLedgerMutation( Dictionary ledger, string uuid, string username, bool isGrant, string type, string value, long packageRef, string rawCommand) { if (ledger == null) return false; if (string.IsNullOrEmpty(uuid)) return false; if (string.IsNullOrEmpty(value)) return false; if (!ledger.TryGetValue(uuid, out var rec)) { if (!isGrant) return false; rec = new PermissionLedgerRecord { Uuid = uuid }; ledger[uuid] = rec; } rec.Entries ??= new List(); if (string.IsNullOrEmpty(rec.Uuid)) rec.Uuid = uuid; var changed = false; if (!string.IsNullOrEmpty(username) && rec.Username != username) { rec.Username = username; changed = true; } var removed = rec.Entries.RemoveAll(e => e != null && string.Equals(e.Type, type, StringComparison.OrdinalIgnoreCase) && string.Equals(e.Value, value, StringComparison.OrdinalIgnoreCase)); if (isGrant) { rec.Entries.Add(new PermissionLedgerEntry { Type = type, Value = value, PackageRef = packageRef, RawCommand = rawCommand ?? string.Empty, GrantedAt = DateTime.UtcNow, }); return true; } if (removed > 0) changed = true; if (rec.Entries.Count == 0) ledger.Remove(uuid); return changed; } private static string BuildReplayCommand(PermissionLedgerRecord rec, PermissionLedgerEntry e, bool carbon = false) { if (rec == null || e == null) return string.Empty; if (!string.Equals(e.Type, "group", StringComparison.OrdinalIgnoreCase) && !string.Equals(e.Type, "permission", StringComparison.OrdinalIgnoreCase)) return e.RawCommand ?? string.Empty; var id = rec.Uuid ?? string.Empty; if (id.Length == 0 || string.IsNullOrWhiteSpace(e.Value)) return string.Empty; var arg = QuoteIfNeeded(e.Value); var prefix = carbon ? "c." : "oxide."; return string.Equals(e.Type, "group", StringComparison.OrdinalIgnoreCase) ? $"{prefix}usergroup add {id} {arg}" : $"{prefix}grant user {id} {arg}"; } private static bool IsCarbon { get { #if CARBON return true; #else return false; #endif } } private static string QuoteIfNeeded(string arg) { if (string.IsNullOrEmpty(arg)) return arg; return arg.IndexOf(' ') >= 0 || arg.IndexOf('\t') >= 0 ? $"\"{arg}\"" : arg; } private static List BuildReplayCommands(PermissionLedgerRecord rec, bool carbon = false) { var commands = new List(); if (rec?.Entries == null) return commands; foreach (var e in rec.Entries) { var line = BuildReplayCommand(rec, e, carbon); if (!string.IsNullOrWhiteSpace(line)) commands.Add(line); } return commands; } private static List NormalizeLedger(Dictionary ledger) { var repairs = new List(); if (ledger == null) return repairs; var drop = new List(); foreach (var kv in ledger) { var rec = kv.Value; if (rec == null) { drop.Add(kv.Key); repairs.Add($"{kv.Key}: record was null; dropped"); continue; } if (string.IsNullOrEmpty(rec.Uuid)) { rec.Uuid = kv.Key; repairs.Add($"{kv.Key}: missing steamid field, backfilled from the key " + "(replay would have issued a malformed command)"); } if (rec.Entries == null) { rec.Entries = new List(); repairs.Add($"{kv.Key}: entry list was null, reset to empty"); } rec.Entries.RemoveAll(e => { if (!string.IsNullOrWhiteSpace(BuildReplayCommand(rec, e))) return false; repairs.Add($"{kv.Key} ({rec.Username}): dropped unreplayable entry " + $"type='{e?.Type}' value='{e?.Value}'"); return true; }); if (rec.Entries.Count == 0) drop.Add(kv.Key); } foreach (var key in drop) ledger.Remove(key); return repairs; } private static bool TryParsePermissionGrant(string command, out bool isGrant, out string type, out string value) { isGrant = false; type = string.Empty; value = string.Empty; if (string.IsNullOrWhiteSpace(command)) return false; var t = TokenizeCommand(command); if (t.Count < 4) return false; var verb = t[0].ToLowerInvariant(); if (verb.StartsWith("o.")) verb = "oxide." + verb.Substring(2); else if (verb.StartsWith("c.")) verb = "oxide." + verb.Substring(2); switch (verb) { case "oxide.grant": case "oxide.revoke": if (!t[1].Equals("user", StringComparison.OrdinalIgnoreCase)) return false; type = "permission"; value = t[3]; isGrant = verb == "oxide.grant"; return true; case "oxide.usergroup": var op = t[1].ToLowerInvariant(); if (op != "add" && op != "remove") return false; type = "group"; value = t[3]; isGrant = op == "add"; return true; default: return false; } } private static List TokenizeCommand(string command) { var tokens = new List(); if (string.IsNullOrWhiteSpace(command)) return tokens; var sb = new StringBuilder(); var inQuotes = false; var started = false; foreach (var ch in command) { if (ch == '"') { inQuotes = !inQuotes; started = true; continue; } if (!inQuotes && (ch == ' ' || ch == '\t')) { if (started) { tokens.Add(sb.ToString()); sb.Length = 0; started = false; } continue; } sb.Append(ch); started = true; } if (started) tokens.Add(sb.ToString()); return tokens; } private static bool LooksLikePermissionCommand(string command) { if (string.IsNullOrWhiteSpace(command)) return false; var c = command.TrimStart().ToLowerInvariant(); return c.StartsWith("oxide.grant") || c.StartsWith("o.grant") || c.StartsWith("oxide.revoke") || c.StartsWith("o.revoke") || c.StartsWith("oxide.usergroup") || c.StartsWith("o.usergroup") || c.StartsWith("c.grant") || c.StartsWith("c.revoke") || c.StartsWith("c.usergroup"); } private static CustomPermissionCommand? MatchCustomCommand( List? configured, string command, out bool isGrant) { isGrant = false; if (configured == null) return null; var first = FirstToken(command); if (first.Length == 0) return null; foreach (var cc in configured) { if (cc == null || string.IsNullOrWhiteSpace(cc.Label)) continue; if (!string.IsNullOrWhiteSpace(cc.GrantCommand) && string.Equals(first, FirstToken(cc.GrantCommand), StringComparison.OrdinalIgnoreCase)) { isGrant = true; return cc; } if (!string.IsNullOrWhiteSpace(cc.RevokeCommand) && string.Equals(first, FirstToken(cc.RevokeCommand), StringComparison.OrdinalIgnoreCase)) { isGrant = false; return cc; } } return null; } private static string FirstToken(string command) { if (string.IsNullOrWhiteSpace(command)) return string.Empty; var trimmed = command.TrimStart(); var sp = trimmed.IndexOfAny(new[] { ' ', '\t' }); return sp < 0 ? trimmed : trimmed.Substring(0, sp); } private static PermissionLedgerRecord? FindLedgerRecord( Dictionary ledger, string userArg) { if (ledger == null || string.IsNullOrEmpty(userArg)) return null; if (ledger.TryGetValue(userArg, out var byId)) return byId; foreach (var rec in ledger.Values) { if (rec != null && string.Equals(rec.Username, userArg, StringComparison.OrdinalIgnoreCase)) return rec; } return null; } private int ReplayLedger(PermissionLedgerRecord rec) { var commands = BuildReplayCommands(rec, IsCarbon); foreach (var line in commands) ExecuteServerCommand(line); return commands.Count; } private void LoadPermissionLedger() { if (!Interface.Oxide.DataFileSystem.ExistsDatafile(DataFilePermissions)) return; try { var data = Interface.Oxide.DataFileSystem.ReadObject(DataFilePermissions); if (data?.Players == null) { HandleUnreadableLedger("it is empty or truncated (no \"Players\" object)"); return; } foreach (var kv in data.Players) _permissionLedger[kv.Key] = kv.Value; var repairs = NormalizeLedger(_permissionLedger); if (repairs.Count > 0) { PrintWarning($"Permission ledger: repaired {repairs.Count} problem(s) while loading. " + "Replay would have silently done nothing for these:"); foreach (var repair in repairs) PrintWarning($" - {repair}"); } } catch (Exception e) { HandleUnreadableLedger(e.Message); } } private void HandleUnreadableLedger(string reason) { _permissionLedger.Clear(); var preserved = PreserveUnreadableLedger(); PrintError( $"Permission ledger oxide/data/{DataFilePermissions}.json could not be used ({reason}). " + (preserved != null ? $"The file was copied to {preserved}; starting a fresh ledger. " + "Recover entitlements from that copy before relying on `replay`." : "It could NOT be copied aside - back it up MANUALLY now, because the next " + "purchased permission will overwrite it.")); } private static string? PreserveUnreadableLedger() { try { var relative = DataFilePermissions.Replace('/', System.IO.Path.DirectorySeparatorChar) + ".json"; var source = System.IO.Path.Combine(Interface.Oxide.DataDirectory, relative); if (!System.IO.File.Exists(source)) return null; var stem = $"{source}.unreadable-{DateTime.UtcNow:yyyyMMdd-HHmmss}"; var destination = stem; for (var attempt = 2; System.IO.File.Exists(destination) && attempt < 100; attempt++) destination = $"{stem}-{attempt}"; if (System.IO.File.Exists(destination)) return null; System.IO.File.Copy(source, destination, false); return System.IO.Path.GetFileName(destination); } catch { return null; } } private void SavePermissionLedger() { try { var data = new PermissionLedgerDataFile { Players = new Dictionary(_permissionLedger) }; Interface.Oxide.DataFileSystem.WriteObject(DataFilePermissions, data); } catch (Exception e) { PrintWarning($"Could not save permission ledger: {e.Message}"); } } private static bool IsPlayerOnline(string steamId) { if (string.IsNullOrEmpty(steamId)) return false; return BasePlayer.activePlayerList.Any(p => p.UserIDString == steamId); } private static BasePlayer FindOnlinePlayer(string steamId) { if (string.IsNullOrEmpty(steamId)) return null; return BasePlayer.activePlayerList.FirstOrDefault(p => p.UserIDString == steamId); } #endregion #region Telemetry private void ReportTelemetry(string level, string message, string trace, Dictionary? metadata=null) { if (!_config.AutoReportLogs) return; var ev = new TelemetryEvent { Level = level, Message = message ?? "", Trace = trace, Count = 1, FirstSeen = DateTime.UtcNow, LastSeen = DateTime.UtcNow, Metadata = metadata, }; var sig = ev.Signature; if (_telemetryIndex.TryGetValue(sig, out var existing)) { existing.Count++; existing.LastSeen = DateTime.UtcNow; if (ev.Metadata != null) existing.Metadata = ev.Metadata; } else { _telemetryIndex[sig] = ev; if (_telemetryIndex.Count > TelemetryBufferMax) { var oldest = _telemetryIndex.OrderBy(kv => kv.Value.FirstSeen).First().Key; _telemetryIndex.Remove(oldest); } } if (_telemetryIndex.Count >= TelemetryFlushThreshold) FlushTelemetry(false); } public class PluginEvent { [JsonProperty("game_id")] public string GameId { get; set; } [JsonProperty("framework_id")] public string FrameworkId { get; set; } [JsonProperty("runtime_version")] public string RuntimeVersion { get; set; } [JsonProperty("framework_version")] public string FrameworkVersion { get; set; } [JsonProperty("plugin_version")] public string PluginVersion { get; set; } [JsonProperty("server_id")] public string ServerId { get; set; } [JsonProperty("event_message")] public string EventMessage { get; set; } [JsonProperty("event_level")] public String EventLevel { get; set; } [JsonProperty("metadata")] public Dictionary? Metadata { get; set; } [JsonProperty("trace")] public string Trace { get; set; } [JsonProperty("store_url")] public string StoreUrl { get; set; } [JsonProperty("server_ip")] public string ServerIp { get; set; } } private void FlushTelemetry(bool force) { if (_telemetryIndex.Count == 0) return; if (!_config.AutoReportLogs) { _telemetryIndex.Clear(); return; } var batch = _telemetryIndex.Values.ToList(); _telemetryIndex.Clear(); var key = ResolveSecret(); var payload = new List(); foreach (var e in batch) { var pluginEvent = new PluginEvent { FrameworkId = "Oxide", FrameworkVersion = Interface.Oxide.RootPluginManager == null ? "unknown" : Oxide.Core.OxideMod.Version.ToString(), PluginVersion = Version.ToString(), GameId = "rust", RuntimeVersion = Environment.Version.ToString(), ServerId = (_serverInfo?.server.id).ToString(), EventMessage = e.Message, EventLevel = e.Level, Trace = e.Trace, Metadata = e.Metadata, }; payload.Add(pluginEvent); } var body = JsonConvert.SerializeObject(payload, JsonSettings); var headers = new Dictionary { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }; if (!string.IsNullOrEmpty(key)) headers["X-Tebex-Secret"] = key; SendHttp(RequestMethod.POST, "https://plugin-logs.tebex.io/events", body, headers, 15f, (code, response) => { if (code < 200 || code >= 300) { foreach (var ev in batch) { var sig = ev.Signature; if (_telemetryIndex.TryGetValue(sig, out var existing)) { existing.Count += ev.Count; if (ev.LastSeen > existing.LastSeen) existing.LastSeen = ev.LastSeen; } else if (_telemetryIndex.Count < TelemetryBufferMax) { _telemetryIndex[sig] = ev; } } } else { foreach (var sent in payload) { _telemetrySentHistory.Enqueue(sent); while (_telemetrySentHistory.Count > TelemetrySentHistoryMax) _telemetrySentHistory.Dequeue(); } } }); } #endregion #region UI Constants private const string ColorRedAccent = "0.85 0.20 0.20 1"; private const string ColorPanelBg = "0.10 0.10 0.12 1"; private const string ColorCardBg = "0.165 0.165 0.165 1"; private const string ColorSidebarBg = "0.08 0.08 0.10 1"; private const string ColorSidebarRow = "0.16 0.16 0.20 1"; private const string ColorGreenCta = "0.18 0.65 0.32 1"; private const string ColorInBasketCta = "0.14 0.38 0.22 1"; private const string ColorBlueCta = "0.20 0.42 0.78 1"; private const string ColorPriceGreen = "0.30 0.78 0.40 1"; private const string ColorTextDim = "0.65 0.65 0.7 1"; private const string ColorTextWhite = "1 1 1 1"; private const string ColorBlack = "0 0 0 1"; private const string ColorDivider = "1 1 1 0.08"; private const string ColorFooterBg = "0.05 0.05 0.05 1"; private const string ColorNavBtn = "0.16 0.16 0.20 1"; private const string ColorScrollHandle = "0.16 0.16 0.20 1"; private const float ScrollbarPx = 5f; private const float PanelPadX = 0.025f; private const float PanelPadY = PanelPadX * BuyPanelPixelAspect; private const float BodyYMin = 0.077f, BodyYMax = 0.915f; private const float BodyXMin = PanelPadX; private const float BodyXMax = 1f - PanelPadX; private const float SidebarXMax = 0.22f; private const float GridBodyXMin = 0.235f; private const string ColorHealthPanelBg = "0.114 0.122 0.122 1"; private const string ColorHealthAccent = "0.698 0.196 0.196 1"; private const string ColorHealthDivider = "1 1 1 0.04"; private const string ColorHealthIconBtn = "0.20 0.20 0.20 1"; private const string ColorHealthTextDim = "0.73 0.73 0.73 1"; private const string ColorHealthGreen = "0.18 0.73 0.35 1"; private const string ColorHealthRed = "1 0.067 0.067 1"; private const string ColorHealthOrange = "1 0.52 0 1"; private const string ColorHealthBadPill = "0.35 0.14 0.14 1"; private const string ColorHealthTileBlueBg = "0.125 0.173 0.204 1"; private const string ColorHealthTileBlueBar = "0.176 0.380 0.537 1"; private const string ColorHealthTileGreyBg = "0.235 0.243 0.243 1"; private const string ColorHealthTileGreyBar = "0.730 0.730 0.730 1"; private const string ColorHealthTileRedBg = "0.290 0.110 0.110 1"; private const string ColorHealthTileRedBar = "1 0.067 0.067 1"; private const float HcRefH = 743f; private const float HcColGap = 0.018f; private const float HcColW = (1f - PanelPadX * 2f - HcColGap) / 2f; private const float HcCol0X0 = PanelPadX; private const float HcCol0X1 = HcCol0X0 + HcColW; private const float HcCol1X0 = HcCol0X1 + HcColGap; private const float HcCol1X1 = 1f - PanelPadX; private const float HcAccentYMin = 0.9955f; private const float HcHeaderBtnYMin = 0.9381f, HcHeaderBtnYMax = 0.9825f; private const float HcHeaderBtnW = (HcHeaderBtnYMax - HcHeaderBtnYMin) * 1.242f / BuyPanelPixelAspect; private const float HcDividerYMin = 0.9165f, HcDividerYMax = 0.9192f; private const float HcSubtitleYMin = 0.8721f, HcSubtitleYMax = 0.9058f; private const float HcPillYMin = 0.8210f, HcPillYMax = 0.8641f; private const float HcPillGap = 0.005f; private const float HcPillUnitW = (1f - PanelPadX * 2f - HcPillGap * 2f) / 3f; private const float HcPillMarkW = 0.070f, HcPillInnerGap = 0.002f; private const float HcCardHeaderH = 39f / HcRefH; private const float HcCardPadB = 8f / HcRefH; private const float HcLabelX0 = 0.052f, HcLabelX1 = 0.53f; private const float HcKvValueX0 = 0.60f, HcKvValueX1 = 0.945f; private static readonly float[] HcColX = { 0.55f, 0.71f, 0.85f }; private const float HcColXEnd = 0.99f; private const float HcTilesTop = 0.330f; private const float HcTileH = 53f / HcRefH, HcTileGap = 18f / HcRefH; private const float HcTileBarW = 3f / 697f; private const float HcTileTextX0 = 0.095f; private const float HcFooterYMin = 0.0175f, HcFooterYMax = 0.0511f; private const string HealthPanelAnchorMin = "0.12 0.06"; private const string HealthPanelAnchorMax = "0.88 0.94"; private const string ColorCmdRefSubtitle = "0.30 0.78 0.92 1"; private const string ColorCmdRefSection = "0.220 0.090 0.075 1"; private const string ColorCmdRefSectionTx = "0.900 0.380 0.220 1"; private const string ColorCmdRefRow = "0.145 0.145 0.145 1"; private const float CrRefH = 720f; private const float CrBodyYMin = 0.075f, CrBodyYMax = 0.855f; private const float CrSectionPx = 24f, CrRowPx = 28f; private const float CrSectionGapPx = 12f, CrRowGapPx = 2f; private const float CrPadTopPx = 4f, CrPadBotPx = 8f; private const float CrTextX0 = 0.020f, CrRowTextX0 = 0.038f; private const float CrSyntaxX1 = 0.520f, CrTextX1 = 0.985f; #endregion #region Embedded Assets private const string LogoPngBase64 = "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"; private const float LogoAspect = 256f / 135f; #pragma warning disable CS0414 private uint _logoImageId; #pragma warning restore CS0414 private void LoadEmbeddedImages() { var community = CommunityEntity.ServerInstance; if (community == null) return; _logoImageId = StoreEmbeddedPng("logo", LogoPngBase64, community); } private uint StoreEmbeddedPng(string label, string base64, CommunityEntity community) { try { if (string.IsNullOrEmpty(base64) || base64.StartsWith("__")) return 0; var bytes = Convert.FromBase64String(base64); return FileStorage.server.Store(bytes, FileStorage.Type.png, community.net.ID); } catch (Exception e) { PrintWarning($"Failed to load embedded {label} image: {e.Message}"); return 0; } } #endregion #region UI private void SendUi(BasePlayer player, CuiElementContainer elements) { if (player == null || elements == null) return; foreach (var element in elements) { if (element?.Components == null) continue; foreach (var component in element.Components) { var rect = component as CuiRectTransformComponent; if (rect == null) continue; if (string.IsNullOrEmpty(rect.OffsetMin)) rect.OffsetMin = "0 0"; if (string.IsNullOrEmpty(rect.OffsetMax)) rect.OffsetMax = "0 0"; } } CuiHelper.AddUi(player, elements); } private void OpenBuyUi(BasePlayer player) { if (player == null) return; FetchCommunityGoals(); if (!_uiStates.ContainsKey(player.userID)) _uiStates[player.userID] = new BuyUiState { CategoryId = _categories.FirstOrDefault()?.id ?? 0, Page = 0 }; DestroyUi(player); DrawBuyUi(player); } private void DestroyUi(BasePlayer player) { if (player == null) return; CuiHelper.DestroyUi(player, UiName); if (_uiStates.TryGetValue(player.userID, out var st)) st.OverlayDrawn = false; } private void FlushAllUi() { foreach (var p in BasePlayer.activePlayerList) { CuiHelper.DestroyUi(p, UiName); CuiHelper.DestroyUi(p, DebugUiName); CuiHelper.DestroyUi(p, TelemetryUiName); CuiHelper.DestroyUi(p, CommandRefUiName); } _uiStates.Clear(); _commandRefFeedback.Clear(); foreach (var t in _debugUiRefresh.Values) t?.Destroy(); _debugUiRefresh.Clear(); _debugView.Clear(); } private static int BasketQuantity(BuyUiState state, int packageId) { var bp = state?.Basket?.packages?.FirstOrDefault(x => x.id == packageId); if (bp == null) return 0; var qty = bp.in_basket?.quantity ?? 1; return qty < 1 ? 1 : qty; } private void BuildPackageCta(BuyUiState state, int packageId, out string text, out string command, out string color) { if (!_config.EnableBasket) { text = "BUY NOW"; command = $"tebex.ui buy {packageId}"; color = ColorGreenCta; return; } var qty = BasketQuantity(state, packageId); if (qty > 0) { text = qty > 1 ? $"IN BASKET ({qty})" : "IN BASKET"; command = "tebex.ui view basket"; color = ColorInBasketCta; return; } text = "ADD TO BASKET"; command = $"tebex.ui add {packageId}"; color = ColorGreenCta; } private static readonly string[] UndecodableImageExtensions = { ".webp", ".avif", ".svg", ".gif" }; private static bool IsDecodableImageUrl(string? url) { if (url == null || url.Length == 0) return false; var lower = url.ToLowerInvariant(); var path = lower; var cut = path.IndexOfAny(new[] { '?', '#' }); if (cut >= 0) path = path.Substring(0, cut); foreach (var ext in UndecodableImageExtensions) { if (path.EndsWith(ext)) return false; var bare = ext.Substring(1); if (lower.Contains("format=" + bare) || lower.Contains("fm=" + bare)) return false; } return true; } private void DrawBuyUi(BasePlayer player) { if (!_uiStates.TryGetValue(player.userID, out var state)) { state = new BuyUiState { CategoryId = _categories.FirstOrDefault()?.id ?? 0, Page = 0 }; _uiStates[player.userID] = state; } var elements = new CuiElementContainer(); if (state.OverlayDrawn) { CuiHelper.DestroyUi(player, UiName + ".Panel"); } else { CuiHelper.DestroyUi(player, UiName); var overlay = new CuiElementContainer(); overlay.Add(new CuiPanel { Image = { Color = "0 0 0 0.85" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, UiOverlay, UiName); SendUi(player, overlay); state.OverlayDrawn = true; } elements.Add(new CuiPanel { Image = { Color = ColorPanelBg }, RectTransform = { AnchorMin = "0.12 0.06", AnchorMax = "0.88 0.94" } }, UiName, UiName + ".Panel"); elements.Add(new CuiPanel { Image = { Color = ColorRedAccent }, RectTransform = { AnchorMin = "0 0.992", AnchorMax = "1 1" } }, UiName + ".Panel", UiName + ".Accent"); elements.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = "0 0.918", AnchorMax = "1 0.992" } }, UiName + ".Panel", UiName + ".Header"); elements.Add(new CuiPanel { Image = { Color = ColorDivider }, RectTransform = { AnchorMin = "0 0.915", AnchorMax = "1 0.917" } }, UiName + ".Panel", UiName + ".HeaderDivider"); elements.Add(new CuiPanel { Image = { Color = ColorFooterBg }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 0.075" } }, UiName + ".Panel", UiName + ".Footer"); elements.Add(new CuiPanel { Image = { Color = ColorDivider }, RectTransform = { AnchorMin = "0 0.075", AnchorMax = "1 0.077" } }, UiName + ".Panel", UiName + ".FooterDivider"); if (!_config.EnableBasket && state.View == BuyUiView.Basket) state.View = BuyUiView.Store; if (state.View == BuyUiView.Basket) { DrawBasketHeader(elements, state); DrawBasketView(player, state, elements); DrawFooter(elements, state, checkoutContext: true); } else { DrawStoreHeader(elements, state); if (!state.OpenPackageId.HasValue) DrawStoreSidebar(elements, state); DrawStoreBody(elements, state); DrawFooter(elements, state, checkoutContext: false); } if (!string.IsNullOrEmpty(state.LastBasketError)) { elements.Add(new CuiLabel { Text = { Text = state.LastBasketError, FontSize = 11, Align = TextAnchor.MiddleRight, Color = "0.95 0.35 0.35 1" }, RectTransform = { AnchorMin = "0.30 0.905", AnchorMax = "0.78 0.93" } }, UiName + ".Panel"); } SendUi(player, elements); } private void DrawStoreHeader(CuiElementContainer elements, BuyUiState state) { var title = _serverInfo?.account?.name; if (string.IsNullOrEmpty(title)) title = "Tebex"; elements.Add(new CuiLabel { Text = { Text = title, FontSize = 22, Align = TextAnchor.MiddleLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{PanelPadX:0.000} 0", AnchorMax = "0.90 1" } }, UiName + ".Header"); elements.Add(new CuiButton { Button = { Color = ColorRedAccent, Command = "tebex.ui close" }, RectTransform = { AnchorMin = $"{1f - PanelPadX - CloseBtnW:0.000} 0.18", AnchorMax = $"{1f - PanelPadX:0.000} 0.82" }, Text = { Text = "×", FontSize = 18, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, UiName + ".Header"); } private const float CloseBtnW = 0.045f; private void DrawStoreSidebar(CuiElementContainer elements, BuyUiState state) { elements.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = $"0 {BodyYMin:0.000}", AnchorMax = $"{SidebarXMax:0.000} {BodyYMax:0.000}" } }, UiName + ".Panel", UiName + ".Sidebar"); elements.Add(new CuiPanel { Image = { Color = ColorDivider }, RectTransform = { AnchorMin = $"{SidebarXMax:0.000} {BodyYMin:0.000}", AnchorMax = $"{SidebarXMax + 0.002f:0.000} {BodyYMax:0.000}" } }, UiName + ".Panel", UiName + ".SidebarDivider"); var cats = _categories ?? new List(); const int maxRows = 14; const float rowHeight = 0.055f; for (int i = 0; i < cats.Count && i < maxRows; i++) { var c = cats[i]; var yMax = 0.97f - (i * rowHeight); var yMin = yMax - rowHeight + 0.005f; var bg = c.id == state.CategoryId ? ColorSidebarRow : "0 0 0 0"; var rowName = $"{UiName}.Cat{c.id}"; elements.Add(new CuiPanel { Image = { Color = bg }, RectTransform = { AnchorMin = $"0 {yMin:0.000}", AnchorMax = $"1 {yMax:0.000}" } }, UiName + ".Sidebar", rowName); elements.Add(new CuiLabel { Text = { Text = Truncate(c.name, 24).ToUpperInvariant(), FontSize = 13, Align = TextAnchor.MiddleLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{PanelPadX / SidebarXMax:0.000} 0", AnchorMax = "1 1" } }, rowName); elements.Add(new CuiButton { Button = { Color = "0 0 0 0", Command = $"tebex.ui cat {c.id}" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, Text = { Text = "" } }, rowName); } } private void DrawStoreBody(CuiElementContainer elements, BuyUiState state) { var bodyXMin = state.OpenPackageId.HasValue ? BodyXMin : GridBodyXMin; elements.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = $"{bodyXMin:0.000} {BodyYMin:0.000}", AnchorMax = $"{BodyXMax:0.000} {BodyYMax:0.000}" } }, UiName + ".Panel", UiName + ".Body"); if (state.OpenPackageId.HasValue) { DrawPackageDetail(elements, null, state, AllPackages().FirstOrDefault(p => p.id == state.OpenPackageId.Value)); } else { DrawPackageGrid(elements, state); } } private void DrawBasketHeader(CuiElementContainer elements, BuyUiState state) { var title = _serverInfo?.account?.name; if (string.IsNullOrEmpty(title)) title = "Tebex"; elements.Add(new CuiLabel { Text = { Text = title, FontSize = 22, Align = TextAnchor.MiddleLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{PanelPadX:0.000} 0", AnchorMax = "0.90 1" } }, UiName + ".Header"); elements.Add(new CuiButton { Button = { Color = ColorRedAccent, Command = "tebex.ui close" }, RectTransform = { AnchorMin = $"{1f - PanelPadX - CloseBtnW:0.000} 0.18", AnchorMax = $"{1f - PanelPadX:0.000} 0.82" }, Text = { Text = "×", FontSize = 18, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, UiName + ".Header"); } private void DrawFooter(CuiElementContainer elements, BuyUiState state, bool checkoutContext) { const float footerBtnW = 0.18f; const float footerCtaW = 0.17f; var ctaXMin = 1f - PanelPadX - footerCtaW; elements.Add(new CuiButton { Button = { Color = ColorNavBtn, Command = "tebex.ui webstore" }, RectTransform = { AnchorMin = $"{PanelPadX:0.000} 0.20", AnchorMax = $"{PanelPadX + footerBtnW:0.000} 0.80" }, Text = { Text = "VISIT WEBSTORE", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, UiName + ".Footer"); if (checkoutContext) { elements.Add(new CuiButton { Button = { Color = ColorBlueCta, Command = "tebex.ui view store" }, RectTransform = { AnchorMin = $"{ctaXMin:0.000} 0.20", AnchorMax = $"{1f - PanelPadX:0.000} 0.80" }, Text = { Text = "BACK TO STORE", FontSize = 13, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, UiName + ".Footer"); return; } if (!_config.EnableBasket) return; int itemCount = state.Basket?.packages?.Sum(bp => Math.Max(1, bp.in_basket?.quantity ?? 1)) ?? 0; var sym = _serverInfo?.account?.currency?.symbol ?? "$"; var iso = _serverInfo?.account?.currency?.iso4217 ?? ""; float total = state.Basket?.total_price ?? 0f; var pillTop = $"{itemCount} ITEM{(itemCount == 1 ? "" : "S")}"; var pillBot = string.IsNullOrEmpty(iso) ? $"{sym}{total:0.00}" : $"{sym}{total:0.00} {iso}"; var pillXMax = ctaXMin - 0.01f; elements.Add(new CuiLabel { Text = { Text = pillTop, FontSize = 11, Align = TextAnchor.LowerRight, Color = ColorTextDim, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.60 0.50", AnchorMax = $"{pillXMax:0.000} 0.86" } }, UiName + ".Footer"); elements.Add(new CuiLabel { Text = { Text = pillBot, FontSize = 12, Align = TextAnchor.UpperRight, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.60 0.14", AnchorMax = $"{pillXMax:0.000} 0.50" } }, UiName + ".Footer"); elements.Add(new CuiButton { Button = { Color = ColorBlueCta, Command = "tebex.ui view basket" }, RectTransform = { AnchorMin = $"{ctaXMin:0.000} 0.20", AnchorMax = $"{1f - PanelPadX:0.000} 0.80" }, Text = { Text = "BASKET", FontSize = 13, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, UiName + ".Footer"); } private void DrawPackageGrid(CuiElementContainer elements, BuyUiState state) { var category = (_categories ?? new List()).FirstOrDefault(c => c.id == state.CategoryId) ?? (_categories ?? new List()).FirstOrDefault(); if (category == null) { elements.Add(new CuiLabel { Text = { Text = "No packages available.", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "0.8 0.8 0.8 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, UiName + ".Body"); return; } state.CategoryId = category.id; var packages = (category.packages ?? new List()).ToList(); if (packages.Count == 0) { elements.Add(new CuiLabel { Text = { Text = $"There are no available products in {category.name}", FontSize = 16, Align = TextAnchor.MiddleCenter, Color = ColorTextDim }, RectTransform = { AnchorMin = "0.05 0", AnchorMax = "0.95 1" } }, UiName + ".Body"); return; } const float RefH = 720f; float RefW = RefH * ScreenAspect; const float panelWFrac = BuyPanelWFrac, panelHFrac = BuyPanelHFrac; const float bodyWFrac = BodyXMax - GridBodyXMin, bodyHFrac = BodyYMax - BodyYMin; float viewportPxW = bodyWFrac * panelWFrac * RefW - ScrollbarPx; float viewportPxH = bodyHFrac * panelHFrac * RefH; const int cols = 3; const float gutterX = 0.02f; float cellW = (1f - gutterX * (cols + 1)) / cols; float cardPxW = cellW * viewportPxW; float cardPxH = cardPxW * (0.426f / 0.42f); const float rowGapPx = 14f; float padTopPx = gutterX * viewportPxW, padBotPx = padTopPx; float rowPitchPx = cardPxH + rowGapPx; int nRows = (int)Math.Ceiling(packages.Count / (double)cols); float contentPx = padTopPx + nRows * cardPxH + Math.Max(0, nRows - 1) * rowGapPx + padBotPx; float extra = Math.Max(0f, contentPx - viewportPxH); float H = viewportPxH + extra; const string scrollName = UiName + ".Grid"; elements.Add(new CuiElement { Name = scrollName, Parent = UiName + ".Body", Components = { new CuiScrollViewComponent { Horizontal = false, Vertical = true, MovementType = UnityEngine.UI.ScrollRect.MovementType.Clamped, Inertia = true, DecelerationRate = 0.135f, ScrollSensitivity = 24f, ContentTransform = new CuiRectTransform { AnchorMin = "0 0", AnchorMax = "1 1", OffsetMin = $"0 {-extra:0}", OffsetMax = $"{-ScrollbarPx:0} 0" }, VerticalScrollbar = new CuiScrollbar { AutoHide = false, Size = ScrollbarPx, HandleColor = ColorScrollHandle, TrackColor = "0 0 0 0" } }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" } } }); for (int i = 0; i < packages.Count; i++) { var p = packages[i]; int row = i / cols, col = i % cols; float xMin = gutterX + col * (cellW + gutterX); float xMax = xMin + cellW; float topPx = padTopPx + row * rowPitchPx; float yMax = 1f - topPx / H; float yMin = 1f - (topPx + cardPxH) / H; var cardName = $"{UiName}.Pkg{p.id}"; elements.Add(new CuiPanel { Image = { Color = ColorCardBg }, RectTransform = { AnchorMin = $"{xMin:0.0000} {yMin:0.0000}", AnchorMax = $"{xMax:0.0000} {yMax:0.0000}" } }, scrollName, cardName); const string ImgSquareMin = "0.287 0.54", ImgSquareMax = "0.713 0.96"; if (IsDecodableImageUrl(p.image)) { elements.Add(new CuiElement { Parent = cardName, Components = { new CuiRawImageComponent { Url = p.image, Sprite = "assets/content/textures/generic/fulltransparent.tga" }, new CuiRectTransformComponent { AnchorMin = ImgSquareMin, AnchorMax = ImgSquareMax } } }); } else { elements.Add(new CuiPanel { Image = { Color = "0.10 0.10 0.12 1" }, RectTransform = { AnchorMin = ImgSquareMin, AnchorMax = ImgSquareMax } }, cardName); } var displayName = Truncate(p.name ?? "", 48).ToUpperInvariant(); elements.Add(new CuiLabel { Text = { Text = displayName, FontSize = 13, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.05 0.35", AnchorMax = "0.95 0.50" } }, cardName); elements.Add(new CuiLabel { Text = { Text = FormatPrice(p), FontSize = 14, Align = TextAnchor.MiddleCenter, Color = ColorPriceGreen, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.05 0.22", AnchorMax = "0.95 0.34" } }, cardName); BuildPackageCta(state, p.id, out var gridCtaText, out var gridCtaCmd, out var gridCtaColor); elements.Add(new CuiButton { Button = { Color = gridCtaColor, Command = gridCtaCmd }, RectTransform = { AnchorMin = "0.06 0.06", AnchorMax = "0.94 0.20" }, Text = { Text = gridCtaText, FontSize = 12, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, cardName); elements.Add(new CuiButton { Button = { Color = "0 0 0 0", Command = $"tebex.ui pkg {p.id}" }, RectTransform = { AnchorMin = "0.05 0.35", AnchorMax = "0.95 0.96" }, Text = { Text = "" } }, cardName); } } private void DrawPackageDetail(CuiElementContainer elements, BasePlayer player, BuyUiState state, Tebex.Headless.Package p) { if (p == null) { state.OpenPackageId = null; elements.Add(new CuiLabel { Text = { Text = "Package not found.", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "0.8 0.4 0.4 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, UiName + ".Body"); return; } const float detailBodyWFrac = BodyXMax - BodyXMin, detailBodyHFrac = BodyYMax - BodyYMin; const float padX = PanelPadX / detailBodyWFrac; const float padY = PanelPadY / detailBodyHFrac; const float navYMin = 0.905f, navYMax = 0.99f; const float cardYMax = navYMin - padY; const float titleYMax = cardYMax - padY, titleYMin = titleYMax - 0.075f; const float priceYMax = titleYMin - 0.008f, priceYMin = priceYMax - 0.060f; const float descYMax = priceYMin - 0.015f, descYMin = descYMax - 0.165f; const float ctaYMax = descYMin - 0.028f, ctaYMin = ctaYMax - 0.067f; const float cardYMin = ctaYMin - padY; const float textXMin = 0.38f, textXMax = 1f - padX; const string detailNav = UiName + ".DetailNav"; elements.Add(new CuiPanel { Image = { Color = ColorNavBtn }, RectTransform = { AnchorMin = $"{(0f - BodyXMin) / detailBodyWFrac:0.0000} {navYMin:0.000}", AnchorMax = $"{(1f - BodyXMin) / detailBodyWFrac:0.0000} {navYMax:0.000}" } }, UiName + ".Body", detailNav); elements.Add(new CuiLabel { Text = { Text = "«", FontSize = 17, Align = TextAnchor.MiddleLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{PanelPadX:0.000} 0", AnchorMax = $"{PanelPadX + 0.020f:0.000} 1" } }, detailNav); elements.Add(new CuiLabel { Text = { Text = "BACK TO STORE", FontSize = 13, Align = TextAnchor.MiddleLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{PanelPadX + 0.023f:0.000} 0", AnchorMax = "0.55 1" } }, detailNav); elements.Add(new CuiButton { Button = { Color = "0 0 0 0", Command = "tebex.ui back" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, Text = { Text = "" } }, detailNav); const string detailCard = UiName + ".DetailCard"; elements.Add(new CuiPanel { Image = { Color = ColorCardBg }, RectTransform = { AnchorMin = $"0 {cardYMin:0.0000}", AnchorMax = $"1 {cardYMax:0.0000}" } }, UiName + ".Body", detailCard); const float panelWFrac = BuyPanelWFrac, panelHFrac = BuyPanelHFrac; float detailBodyAspect = (detailBodyWFrac * panelWFrac) / (detailBodyHFrac * panelHFrac) * ScreenAspect; SquareInRect(detailBodyAspect, padX, 0.40f, 0.35f, titleYMax, out float imX0, out float imY0, out float imX1, out float imY1); var detailImgMin = $"{imX0:0.0000} {imY0:0.0000}"; var detailImgMax = $"{imX1:0.0000} {imY1:0.0000}"; if (IsDecodableImageUrl(p.image)) { elements.Add(new CuiElement { Parent = UiName + ".Body", Components = { new CuiRawImageComponent { Url = p.image, Sprite = "assets/content/textures/generic/fulltransparent.tga" }, new CuiRectTransformComponent { AnchorMin = detailImgMin, AnchorMax = detailImgMax } } }); } else { elements.Add(new CuiPanel { Image = { Color = ColorPanelBg }, RectTransform = { AnchorMin = detailImgMin, AnchorMax = detailImgMax } }, UiName + ".Body"); } elements.Add(new CuiLabel { Text = { Text = Truncate(p.name ?? "", 64).ToUpperInvariant(), FontSize = 22, Align = TextAnchor.UpperLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{textXMin:0.0000} {titleYMin:0.0000}", AnchorMax = $"{textXMax:0.0000} {titleYMax:0.0000}" } }, UiName + ".Body"); elements.Add(new CuiLabel { Text = { Text = FormatPrice(p), FontSize = 18, Align = TextAnchor.UpperLeft, Color = ColorPriceGreen, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{textXMin:0.0000} {priceYMin:0.0000}", AnchorMax = $"{textXMax:0.0000} {priceYMax:0.0000}" } }, UiName + ".Body"); elements.Add(new CuiLabel { Text = { Text = StripHtml(p.description ?? ""), FontSize = 13, Align = TextAnchor.UpperLeft, Color = ColorTextDim }, RectTransform = { AnchorMin = $"{textXMin:0.0000} {descYMin:0.0000}", AnchorMax = $"{textXMax:0.0000} {descYMax:0.0000}" } }, UiName + ".Body"); BuildPackageCta(state, p.id, out var detailCtaText, out var detailCtaCmd, out var detailCtaColor); elements.Add(new CuiButton { Button = { Color = detailCtaColor, Command = detailCtaCmd }, RectTransform = { AnchorMin = $"{textXMin:0.0000} {ctaYMin:0.0000}", AnchorMax = $"{textXMin + 0.25f:0.0000} {ctaYMax:0.0000}" }, Text = { Text = detailCtaText, FontSize = 14, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, UiName + ".Body"); } private readonly Dictionary _qrCache = new Dictionary(); private Tebex.QR.QrCode TryGetQr(string? url) { if (string.IsNullOrEmpty(url)) return null; if (_qrCache.TryGetValue(url, out var cached)) return cached; try { var qr = Tebex.QR.QrCode.Encode(url); _qrCache[url] = qr; return qr; } catch (Exception) { _qrCache[url] = null; return null; } } private const float ScreenAspect = 16f / 9f; private const float BuyPanelWFrac = 0.88f - 0.12f; private const float BuyPanelHFrac = 0.94f - 0.06f; private const float BuyPanelPixelAspect = ScreenAspect * (BuyPanelWFrac / BuyPanelHFrac); private static void SquareInRect(float parentAspect, float xMin, float yMin, float xMax, float yMax, out float sxMin, out float syMin, out float sxMax, out float syMax) { float availWpx = (xMax - xMin) * parentAspect; float availHpx = (yMax - yMin); float sidePx = Math.Min(availWpx, availHpx); float sqW = sidePx / parentAspect; float sqH = sidePx; float cx = (xMin + xMax) / 2f; float cy = (yMin + yMax) / 2f; sxMin = cx - sqW / 2f; sxMax = cx + sqW / 2f; syMin = cy - sqH / 2f; syMax = cy + sqH / 2f; } private void DrawQrCode(CuiElementContainer elements, string parent, string anchorMin, string anchorMax, Tebex.QR.QrCode qr, float parentAspect) { if (qr == null) return; var modules = qr.GetModules(); int size = modules.GetLength(0); const int quietZone = 4; int totalUnits = size + quietZone * 2; ParseAnchor(anchorMin, out float xMin, out float yMin); ParseAnchor(anchorMax, out float xMax, out float yMax); SquareInRect(parentAspect, xMin, yMin, xMax, yMax, out float bxMin, out float byMin, out float bxMax, out float byMax); elements.Add(new CuiPanel { Image = { Color = ColorTextWhite }, RectTransform = { AnchorMin = $"{bxMin:0.0000} {byMin:0.0000}", AnchorMax = $"{bxMax:0.0000} {byMax:0.0000}" } }, parent); float unitX = (bxMax - bxMin) / totalUnits; float unitY = (byMax - byMin) / totalUnits; for (int r = 0; r < size; r++) for (int c = 0; c < size; c++) { if (!modules[r, c]) continue; float modXMin = bxMin + (c + quietZone) * unitX; float modYMax = byMax - (r + quietZone) * unitY; float modXMax = modXMin + unitX; float modYMin = modYMax - unitY; elements.Add(new CuiPanel { Image = { Color = ColorBlack }, RectTransform = { AnchorMin = $"{modXMin:0.0000} {modYMin:0.0000}", AnchorMax = $"{modXMax:0.0000} {modYMax:0.0000}", OffsetMin = "0 0", OffsetMax = "1 1" } }, parent); } } private static void ParseAnchor(string anchor, out float x, out float y) { x = 0; y = 0; if (string.IsNullOrEmpty(anchor)) return; var parts = anchor.Split(' '); if (parts.Length < 2) return; float.TryParse(parts[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out x); float.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out y); } private const float CardXMin = PanelPadX, CardXMax = 1f - PanelPadX; private const float CardWFrac = CardXMax - CardXMin; private const float BasketCardYMin = 0.479f, BasketCardYMax = BodyYMax - PanelPadY; private const float CheckoutCardYMin = 0.100f, CheckoutCardYMax = 0.473f; private const float BasketRowFracOfCard = 0.211f; private const float BasketRowsXMin = 0.024f, BasketRowsXMax = 0.977f; private const float BasketRowsYMin = 0.205f, BasketRowsYMax = 0.851f; private void DrawBasketView(BasePlayer player, BuyUiState state, CuiElementContainer elements) { var packages = state.Basket?.packages ?? new List(); if (packages.Count == 0) { elements.Add(new CuiLabel { Text = { Text = "Your basket is empty.", FontSize = 18, Align = TextAnchor.MiddleCenter, Color = ColorTextDim }, RectTransform = { AnchorMin = "0.20 0.45", AnchorMax = "0.80 0.55" } }, UiName + ".Panel"); return; } const string basketCardName = UiName + ".BasketCard"; elements.Add(new CuiPanel { Image = { Color = ColorCardBg }, RectTransform = { AnchorMin = $"{CardXMin:0.0000} {BasketCardYMin:0.0000}", AnchorMax = $"{CardXMax:0.0000} {BasketCardYMax:0.0000}" } }, UiName + ".Panel", basketCardName); elements.Add(new CuiLabel { Text = { Text = "YOUR BASKET", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = ColorTextDim, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0 0.86", AnchorMax = "1 0.98" } }, basketCardName); elements.Add(new CuiButton { Button = { Color = ColorNavBtn, Command = "tebex.ui clearbasket" }, RectTransform = { AnchorMin = "0.828 0.875", AnchorMax = "0.977 0.965" }, Text = { Text = "CLEAR BASKET", FontSize = 10, Align = TextAnchor.MiddleCenter, Color = ColorTextDim, Font = "robotocondensed-bold.ttf" } }, basketCardName); elements.Add(new CuiPanel { Image = { Color = ColorDivider }, RectTransform = { AnchorMin = "0.024 0.851", AnchorMax = "0.977 0.853" } }, basketCardName); var sym = _serverInfo?.account?.currency?.symbol ?? "$"; var iso = _serverInfo?.account?.currency?.iso4217 ?? ""; const float RefH = 720f; const float RefW = RefH * ScreenAspect; const float basketCardHFrac = BasketCardYMax - BasketCardYMin; float cardPxW = CardWFrac * BuyPanelWFrac * RefW; float cardPxH = basketCardHFrac * BuyPanelHFrac * RefH; float viewportPxW = (BasketRowsXMax - BasketRowsXMin) * cardPxW; float viewportPxH = (BasketRowsYMax - BasketRowsYMin) * cardPxH; float rowPxH = BasketRowFracOfCard * cardPxH; float contentPx = packages.Count * rowPxH; float extra = Math.Max(0f, contentPx - viewportPxH); float H = viewportPxH + extra; bool overflows = extra > 0.5f; float rowAspect = viewportPxW / rowPxH; const float thumbYMin = 0.225f, thumbYMax = 0.775f, thumbXMin = 0.033f; float thumbXMax = thumbXMin + (thumbYMax - thumbYMin) / rowAspect; string thumbAnchorMin = $"{thumbXMin:0.0000} {thumbYMin:0.0000}"; string thumbAnchorMax = $"{thumbXMax:0.0000} {thumbYMax:0.0000}"; const string basketScrollName = basketCardName + ".Rows"; elements.Add(new CuiElement { Name = basketScrollName, Parent = basketCardName, Components = { new CuiScrollViewComponent { Horizontal = false, Vertical = true, MovementType = UnityEngine.UI.ScrollRect.MovementType.Clamped, Inertia = true, DecelerationRate = 0.135f, ScrollSensitivity = 24f, ContentTransform = new CuiRectTransform { AnchorMin = "0 0", AnchorMax = "1 1", OffsetMin = $"0 {-extra:0}", OffsetMax = "0 0" }, VerticalScrollbar = new CuiScrollbar { AutoHide = false, Size = 5f, HandleColor = overflows ? ColorScrollHandle : "0 0 0 0", TrackColor = "0 0 0 0" } }, new CuiRectTransformComponent { AnchorMin = "0.024 0.205", AnchorMax = "0.977 0.851" } } }); for (int i = 0; i < packages.Count; i++) { var bp = packages[i]; float topPx = i * rowPxH; float yMax = 1f - topPx / H; float yMin = 1f - (topPx + rowPxH) / H; var rowName = $"{basketCardName}.Row{i}"; elements.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = $"0 {yMin:0.0000}", AnchorMax = $"1 {yMax:0.0000}" } }, basketScrollName, rowName); elements.Add(new CuiPanel { Image = { Color = ColorDivider }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 0.02" } }, rowName); var imgUrl = AllPackages().FirstOrDefault(p => p.id == bp.id)?.image; if (IsDecodableImageUrl(imgUrl)) { elements.Add(new CuiElement { Parent = rowName, Components = { new CuiRawImageComponent { Url = imgUrl, Sprite = "assets/content/textures/generic/fulltransparent.tga" }, new CuiRectTransformComponent { AnchorMin = thumbAnchorMin, AnchorMax = thumbAnchorMax } } }); } else { elements.Add(new CuiPanel { Image = { Color = ColorPanelBg }, RectTransform = { AnchorMin = thumbAnchorMin, AnchorMax = thumbAnchorMax } }, rowName); } var nameText = Truncate(bp.name ?? "", 48).ToUpperInvariant(); elements.Add(new CuiLabel { Text = { Text = nameText, FontSize = 13, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.15 0", AnchorMax = "0.85 1" } }, rowName); int qty = bp.in_basket?.quantity ?? 1; if (qty < 1) qty = 1; if (qty > 1) { elements.Add(new CuiLabel { Text = { Text = $"×{qty}", FontSize = 12, Align = TextAnchor.MiddleRight, Color = ColorTextDim }, RectTransform = { AnchorMin = "0.70 0", AnchorMax = "0.775 1" } }, rowName); } float lineTotal = (bp.in_basket?.price ?? 0f) * qty; var priceStr = string.IsNullOrEmpty(iso) ? $"{sym}{lineTotal:0.00}" : $"{sym}{lineTotal:0.00} {iso}"; elements.Add(new CuiLabel { Text = { Text = priceStr, FontSize = 13, Align = TextAnchor.MiddleRight, Color = ColorTextDim }, RectTransform = { AnchorMin = "0.78 0", AnchorMax = "0.905 1" } }, rowName); elements.Add(new CuiButton { Button = { Color = "0 0 0 0", Command = $"tebex.ui remove {bp.id}" }, RectTransform = { AnchorMin = "0.925 0.28", AnchorMax = "0.975 0.72" }, Text = { Text = "×", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = ColorTextDim } }, rowName); } elements.Add(new CuiLabel { Text = { Text = "Total:", FontSize = 15, Align = TextAnchor.MiddleLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.024 0.100", AnchorMax = "0.45 0.190" } }, basketCardName); elements.Add(new CuiLabel { Text = { Text = "Final price may vary based on your local taxes.", FontSize = 11, Align = TextAnchor.MiddleLeft, Color = ColorTextDim }, RectTransform = { AnchorMin = "0.024 0.045", AnchorMax = "0.60 0.125" } }, basketCardName); float total = state.Basket?.total_price ?? 0f; var totalStr = string.IsNullOrEmpty(iso) ? $"{sym}{total:0.00}" : $"{sym}{total:0.00} {iso}"; elements.Add(new CuiLabel { Text = { Text = totalStr, FontSize = 20, Align = TextAnchor.MiddleRight, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.45 0.090", AnchorMax = "0.977 0.200" } }, basketCardName); const string checkoutCardName = UiName + ".CheckoutCard"; elements.Add(new CuiPanel { Image = { Color = ColorCardBg }, RectTransform = { AnchorMin = $"{CardXMin:0.0000} {CheckoutCardYMin:0.0000}", AnchorMax = $"{CardXMax:0.0000} {CheckoutCardYMax:0.0000}" } }, UiName + ".Panel", checkoutCardName); elements.Add(new CuiLabel { Text = { Text = "CHECKOUT", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = ColorTextDim, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0 0.86", AnchorMax = "1 0.99" } }, checkoutCardName); const float checkoutCardHFrac = CheckoutCardYMax - CheckoutCardYMin; float checkoutCardAspect = (CardWFrac / checkoutCardHFrac) * BuyPanelPixelAspect; const string QrBandMin = "0.22 0.07", QrBandMax = "0.43 0.80"; var checkoutUrl = state.Basket?.links?.checkout; var qr = TryGetQr(checkoutUrl); if (qr != null) { DrawQrCode(elements, checkoutCardName, QrBandMin, QrBandMax, qr, checkoutCardAspect); } else { SquareInRect(checkoutCardAspect, 0.22f, 0.07f, 0.43f, 0.80f, out float fbXMin, out float fbYMin, out float fbXMax, out float fbYMax); elements.Add(new CuiPanel { Image = { Color = "0.05 0.05 0.07 1" }, RectTransform = { AnchorMin = $"{fbXMin:0.0000} {fbYMin:0.0000}", AnchorMax = $"{fbXMax:0.0000} {fbYMax:0.0000}" } }, checkoutCardName, checkoutCardName + ".QrFallback"); elements.Add(new CuiLabel { Text = { Text = "QR unavailable\n(URL too long)", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = ColorTextDim }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, checkoutCardName + ".QrFallback"); } elements.Add(new CuiLabel { Text = { Text = "Scan the QR code to checkout.", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.47 0.600", AnchorMax = "0.775 0.700" } }, checkoutCardName); elements.Add(new CuiLabel { Text = { Text = "You can also click the button below\nto receive a clickable link.", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite }, RectTransform = { AnchorMin = "0.47 0.440", AnchorMax = "0.775 0.600" } }, checkoutCardName); elements.Add(new CuiButton { Button = { Color = ColorGreenCta, Command = "tebex.ui checkout" }, RectTransform = { AnchorMin = "0.490 0.200", AnchorMax = "0.754 0.320" }, Text = { Text = "GET CHECKOUT LINK", FontSize = 13, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, checkoutCardName); } private void OpenDebugUi(BasePlayer player) { if (player == null) return; _debugView[player.userID] = DebugView.Main; CuiHelper.DestroyUi(player, TelemetryUiName); CuiHelper.DestroyUi(player, CommandRefUiName); DrawDebugUi(player); if (_debugUiRefresh.TryGetValue(player.userID, out var existing)) existing?.Destroy(); _debugUiRefresh[player.userID] = null; ScheduleDebugUiRefresh(player); } private void ScheduleDebugUiRefresh(BasePlayer player) { var uid = player.userID; if (!_debugUiRefresh.ContainsKey(uid)) return; _debugUiRefresh[uid] = timer.In(1f, () => { if (!_debugUiRefresh.ContainsKey(uid)) return; if (player == null || !player.IsConnected) { DestroyDebugUi(player); return; } try { var view = _debugView.TryGetValue(uid, out var v) ? v : DebugView.Main; if (view == DebugView.Telemetry) RefreshTelemetryUiData(player); else RefreshDebugUiData(player); } catch (Exception e) { PrintWarning($"Debug UI refresh failed: {e.Message}"); } ScheduleDebugUiRefresh(player); }); } private void DestroyDebugUi(BasePlayer player) { if (player == null) return; CuiHelper.DestroyUi(player, DebugUiName); CuiHelper.DestroyUi(player, TelemetryUiName); CuiHelper.DestroyUi(player, CommandRefUiName); _commandRefFeedback.Remove(player.userID); _debugView.Remove(player.userID); _debugActionFeedback.Remove(player.userID); if (_debugUiRefresh.TryGetValue(player.userID, out var t)) { t?.Destroy(); _debugUiRefresh.Remove(player.userID); } } private void DrawDebugUi(BasePlayer player) { CuiHelper.DestroyUi(player, DebugUiName); var elements = new CuiElementContainer(); elements.Add(new CuiPanel { Image = { Color = "0 0 0 0.85" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, UiOverlay, DebugUiName); var panel = DebugUiName + ".Panel"; elements.Add(new CuiPanel { Image = { Color = ColorHealthPanelBg }, RectTransform = { AnchorMin = HealthPanelAnchorMin, AnchorMax = HealthPanelAnchorMax } }, DebugUiName, panel); elements.Add(new CuiPanel { Image = { Color = ColorHealthAccent }, RectTransform = { AnchorMin = $"0 {HcAccentYMin:0.000}", AnchorMax = "1 1" } }, panel); elements.Add(new CuiLabel { Text = { Text = "Plugin Health Check", FontSize = 25, Align = TextAnchor.MiddleLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{PanelPadX:0.000} 0.930", AnchorMax = $"0.70 {HcHeaderBtnYMax:0.000}" } }, panel); float closeX0 = 1f - PanelPadX - HcHeaderBtnW; float refreshX1 = closeX0 - 0.010f; elements.Add(new CuiButton { Button = { Color = ColorHealthIconBtn, Command = "tebex.dbg refresh" }, RectTransform = { AnchorMin = $"{refreshX1 - HcHeaderBtnW * 2f:0.000} {HcHeaderBtnYMin:0.000}", AnchorMax = $"{refreshX1:0.000} {HcHeaderBtnYMax:0.000}" }, Text = { Text = "REFRESH", FontSize = 11, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, panel); elements.Add(new CuiButton { Button = { Color = ColorHealthAccent, Command = "tebex.dbg close" }, RectTransform = { AnchorMin = $"{closeX0:0.000} {HcHeaderBtnYMin:0.000}", AnchorMax = $"{1f - PanelPadX:0.000} {HcHeaderBtnYMax:0.000}" }, Text = { Text = "×", FontSize = 18, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, panel); elements.Add(new CuiPanel { Image = { Color = ColorHealthDivider }, RectTransform = { AnchorMin = $"0 {HcDividerYMin:0.000}", AnchorMax = $"1 {HcDividerYMax:0.000}" } }, panel); var serverName = _serverInfo?.server?.name ?? "-"; var accountName = _serverInfo?.account?.name ?? "-"; elements.Add(new CuiLabel { Text = { Text = $"{Truncate(serverName, 40).ToUpperInvariant()} | {Truncate(accountName, 40).ToUpperInvariant()}", FontSize = 12, Align = TextAnchor.MiddleLeft, Color = ColorHealthTextDim, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{PanelPadX:0.000} {HcSubtitleYMin:0.000}", AnchorMax = $"{1f - PanelPadX:0.000} {HcSubtitleYMax:0.000}" } }, panel); foreach (var spec in HealthCards) DrawHealthCard(elements, spec); DrawDebugButton(elements, "Force Queue Check", "tebex.dbg forcecheck", HealthTileStyle.Primary, 0, 0, "Run deliverables now"); DrawDebugButton(elements, "Refresh Store", "tebex.dbg refreshlisting", HealthTileStyle.Primary, 0, 1, "Reload package listing"); DrawDebugButton(elements, "View Telemetry", "tebex.dbg viewtelemetry", HealthTileStyle.Primary, 1, 0, "Open logs & joins page"); DrawDebugButton(elements, "Toggle Debug Log", "tebex.dbg toggledebug", HealthTileStyle.Neutral, 1, 1, "Verbose server logs"); DrawDebugButton(elements, "Clear Telemetry", "tebex.dbg cleartelemetry", HealthTileStyle.Destructive, 2, 0, "Flush queue log"); DrawDebugButton(elements, "Clear Joins", "tebex.dbg clearjoins", HealthTileStyle.Destructive, 2, 1, "Flush join events"); elements.Add(new CuiLabel { Text = { Text = "Actions post results to your chat and the Telemetry & Joins page", FontSize = 10, Align = TextAnchor.MiddleLeft, Color = ColorHealthTextDim }, RectTransform = { AnchorMin = $"{PanelPadX:0.000} {HcFooterYMin:0.000}", AnchorMax = $"0.55 {HcFooterYMax:0.000}" } }, panel); AddDebugUiData(elements, player.userID); SendUi(player, elements); } private const int DebugHookRowCount = 6; private const int HealthPillCount = 3; private static readonly (string Key, string Title, int Col, int Band, int Rows, string[] Cols)[] HealthCards = { ("Timers", "TIMERS", 0, 0, 4, new[] { "EVERY", "LAST", "NEXT" }), ("Hooks", "HOOKS", 1, 0, DebugHookRowCount, new[] { "COUNT", "AVG", "MAX" }), ("Telemetry", "TELEMETRY QUEUED", 0, 1, 3, null), ("Joins", "JOINS / COMMANDS", 1, 1, 3, null), }; private static readonly (float YMin, float YMax)[] HealthCardBands = { (0.535f, 0.793f), (0.348f, 0.509f), }; private string HealthCardName(string key) => $"{DebugUiName}.Card.{key}"; private string HealthRowName(string key, int index) => $"{HealthCardName(key)}.Row{index}"; private string HealthPillName(int index) => $"{DebugUiName}.Pill{index}"; private static void HealthRowBounds(int band, int rows, int index, out float yMin, out float yMax) { float cardH = HealthCardBands[band].YMax - HealthCardBands[band].YMin; float top = 1f - HcCardHeaderH / cardH; float bottom = HcCardPadB / cardH; float rowH = (top - bottom) / rows; yMax = top - index * rowH; yMin = yMax - rowH; } private void DrawHealthCard(CuiElementContainer elements, (string Key, string Title, int Col, int Band, int Rows, string[] Cols) spec) { var name = HealthCardName(spec.Key); float x0 = spec.Col == 0 ? HcCol0X0 : HcCol1X0; float x1 = spec.Col == 0 ? HcCol0X1 : HcCol1X1; var band = HealthCardBands[spec.Band]; float cardH = band.YMax - band.YMin; elements.Add(new CuiPanel { Image = { Color = ColorCardBg }, RectTransform = { AnchorMin = $"{x0:0.000} {band.YMin:0.000}", AnchorMax = $"{x1:0.000} {band.YMax:0.000}" } }, DebugUiName + ".Panel", name); float hdrYMin = 1f - HcCardHeaderH / cardH; elements.Add(new CuiLabel { Text = { Text = spec.Title, FontSize = 11, Align = TextAnchor.MiddleLeft, Color = ColorHealthTextDim, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{HcLabelX0:0.000} {hdrYMin:0.000}", AnchorMax = $"{HcLabelX1:0.000} 1" } }, name); if (spec.Cols == null) return; for (int i = 0; i < spec.Cols.Length && i < HcColX.Length; i++) { elements.Add(new CuiLabel { Text = { Text = spec.Cols[i], FontSize = 11, Align = TextAnchor.MiddleLeft, Color = ColorHealthTextDim, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{HcColX[i]:0.000} {hdrYMin:0.000}", AnchorMax = $"{HealthColXEnd(i):0.000} 1" } }, name); } } private static float HealthColXEnd(int i) => i + 1 < HcColX.Length ? HcColX[i + 1] - 0.01f : HcColXEnd; private void AddHealthPill(CuiElementContainer elements, int i, string label, bool ok) { float x0 = PanelPadX + i * (HcPillUnitW + HcPillGap); var name = HealthPillName(i); elements.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = $"{x0:0.000} {HcPillYMin:0.000}", AnchorMax = $"{x0 + HcPillUnitW:0.000} {HcPillYMax:0.000}" } }, DebugUiName + ".Panel", name); float labelX1 = (HcPillUnitW - HcPillMarkW - HcPillInnerGap) / HcPillUnitW; float markX0 = (HcPillUnitW - HcPillMarkW) / HcPillUnitW; var labelBox = name + ".Label"; elements.Add(new CuiPanel { Image = { Color = ColorCardBg }, RectTransform = { AnchorMin = "0 0", AnchorMax = $"{labelX1:0.000} 1" } }, name, labelBox); elements.Add(new CuiLabel { Text = { Text = label.ToUpperInvariant(), FontSize = 12, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.04 0", AnchorMax = "0.96 1" } }, labelBox); var markBox = name + ".Mark"; elements.Add(new CuiPanel { Image = { Color = ok ? ColorCardBg : ColorHealthBadPill }, RectTransform = { AnchorMin = $"{markX0:0.000} 0", AnchorMax = "1 1" } }, name, markBox); elements.Add(new CuiLabel { Text = { Text = ok ? "OK" : "ERROR", FontSize = 11, Align = TextAnchor.MiddleCenter, Color = ok ? ColorHealthGreen : ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.04 0", AnchorMax = "0.96 1" } }, markBox); } private void AddTableRow(CuiElementContainer elements, string cardKey, int band, int rows, int index, string label, string c0, string c1, string c2, string? c2Color) { var name = HealthRowName(cardKey, index); HealthRowBounds(band, rows, index, out var yMin, out var yMax); elements.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = $"0 {yMin:0.000}", AnchorMax = $"1 {yMax:0.000}" } }, HealthCardName(cardKey), name); elements.Add(new CuiLabel { Text = { Text = label, FontSize = 12, Align = TextAnchor.MiddleLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{HcLabelX0:0.000} 0", AnchorMax = $"{HcLabelX1:0.000} 1" } }, name); var vals = new[] { c0, c1, c2 }; var colors = new[] { ColorTextWhite, ColorTextWhite, c2Color ?? ColorHealthGreen }; for (int i = 0; i < 3; i++) { elements.Add(new CuiLabel { Text = { Text = vals[i] ?? "", FontSize = 12, Align = TextAnchor.MiddleLeft, Color = colors[i], Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{HcColX[i]:0.000} 0", AnchorMax = $"{HealthColXEnd(i):0.000} 1" } }, name); } } private void AddKvRow(CuiElementContainer elements, string cardKey, int band, int rows, int index, string label, string value, string valueColor) { var name = HealthRowName(cardKey, index); HealthRowBounds(band, rows, index, out var yMin, out var yMax); elements.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = $"0 {yMin:0.000}", AnchorMax = $"1 {yMax:0.000}" } }, HealthCardName(cardKey), name); elements.Add(new CuiLabel { Text = { Text = label, FontSize = 12, Align = TextAnchor.MiddleLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{HcLabelX0:0.000} 0", AnchorMax = $"{HcKvValueX0:0.000} 1" } }, name); elements.Add(new CuiLabel { Text = { Text = value, FontSize = 13, Align = TextAnchor.MiddleRight, Color = valueColor, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{HcKvValueX0:0.000} 0", AnchorMax = $"{HcKvValueX1:0.000} 1" } }, name); } private void AddDebugUiData(CuiElementContainer elements, ulong userId) { AddHealthPill(elements, 0, "Secret Valid", _secretValid); AddHealthPill(elements, 1, "Debug Log", _config.Debug); AddHealthPill(elements, 2, "Telemetry", _config.AutoReportLogs); AddTableRow(elements, "Timers", 0, 4, 0, "DELIVERABLE CHECK", $"{Math.Max(_nextCheckSeconds, 30)}s", FormatRelative(_lastQueueTickAt), FormatCountdown(_nextQueueTickAt), null); AddTableRow(elements, "Timers", 0, 4, 1, "JOIN/LEAVE EVENTS", $"{Math.Max(_config.JoinFlushSeconds, 15)}s", FormatRelative(_lastJoinFlushTickAt), FormatCountdown(_nextJoinFlushTickAt), null); AddTableRow(elements, "Timers", 0, 4, 2, "TELEMETRY FLUSH", $"{Math.Max(_config.TelemetryFlushSeconds, 30)}s", FormatRelative(_lastTelemetryTickAt), FormatCountdown(_nextTelemetryTickAt), null); AddTableRow(elements, "Timers", 0, 4, 3, "STORE REFRESH", $"{Math.Max(_config.ListingRefreshSeconds, 30)}s", FormatRelative(_lastListingTickAt), FormatCountdown(_nextListingTickAt), null); var hookOrder = new[] { "OnPlayerConnected", "OnPlayerDisconnected", "CmdTebex", "CmdBuy", "tebex.ui", "tebex.dbg", }; for (int hi = 0; hi < hookOrder.Length && hi < DebugHookRowCount; hi++) { var hookName = hookOrder[hi]; if (_hookStats.TryGetValue(hookName, out var hs) && hs.Count > 0) AddTableRow(elements, "Hooks", 0, DebugHookRowCount, hi, hookName.ToUpperInvariant(), $"{hs.Count}", $"{hs.AvgMs:0.0}ms", $"{hs.MaxMs:0.0}ms", hs.MaxMs > HookSlowThresholdMs ? ColorHealthOrange : ColorHealthGreen); else AddTableRow(elements, "Hooks", 0, DebugHookRowCount, hi, hookName.ToUpperInvariant(), "-", "-", "-", ColorHealthTextDim); } int errCount = 0, warnCount = 0, infoCount = 0; foreach (var ev in _telemetryIndex.Values) { if (string.Equals(ev.Level, "ERROR", StringComparison.OrdinalIgnoreCase)) errCount += ev.Count; else if (string.Equals(ev.Level, "WARN", StringComparison.OrdinalIgnoreCase)) warnCount += ev.Count; else infoCount += ev.Count; } AddKvRow(elements, "Telemetry", 1, 3, 0, "ERROR", errCount.ToString(), errCount > 0 ? ColorHealthRed : ColorTextWhite); AddKvRow(elements, "Telemetry", 1, 3, 1, "WARN", warnCount.ToString(), warnCount > 0 ? ColorHealthOrange : ColorTextWhite); AddKvRow(elements, "Telemetry", 1, 3, 2, "OTHER", infoCount.ToString(), ColorTextWhite); AddKvRow(elements, "Joins", 1, 3, 0, "PENDING JOINS", _pendingJoinEvents.Count.ToString(), ColorTextWhite); AddKvRow(elements, "Joins", 1, 3, 1, "IN-FLIGHT COMMANDS", _inflightCommandIds.Count.ToString(), ColorTextWhite); AddKvRow(elements, "Joins", 1, 3, 2, "DELAYED COMMANDS", _delayedCommands.Count.ToString(), ColorTextWhite); var feedback = _debugActionFeedback.TryGetValue(userId, out var fb) && !string.IsNullOrEmpty(fb) ? fb : string.Empty; elements.Add(new CuiLabel { Text = { Text = feedback, FontSize = 10, Align = TextAnchor.MiddleRight, Color = ColorHealthTextDim }, RectTransform = { AnchorMin = $"0.55 {HcFooterYMin:0.000}", AnchorMax = $"{1f - PanelPadX:0.000} {HcFooterYMax:0.000}" } }, DebugUiName + ".Panel", DebugUiName + ".ActionFeedback"); } private void RefreshDebugUiData(BasePlayer player) { if (player == null) return; for (int i = 0; i < HealthPillCount; i++) CuiHelper.DestroyUi(player, HealthPillName(i)); foreach (var spec in HealthCards) for (int i = 0; i < spec.Rows; i++) CuiHelper.DestroyUi(player, HealthRowName(spec.Key, i)); CuiHelper.DestroyUi(player, DebugUiName + ".ActionFeedback"); var elements = new CuiElementContainer(); AddDebugUiData(elements, player.userID); SendUi(player, elements); } private static readonly (string Section, string Syntax, string Description)[] CommandReferenceTable = { ("CONFIGURATION", "{admin} secret ", "Set the webstore secret key"), ("CONFIGURATION", "{admin} info", "Show server and account information"), ("CONFIGURATION", "{admin} reload", "Reload configuration and listings"), ("QUEUE & DELIVERY", "{admin} forcecheck", "Run a queue check right now"), ("QUEUE & DELIVERY", "{admin} forcecheck ", "Check online commands for one cached user"), ("QUEUE & DELIVERY", "{admin} replay ", "Re-apply tracked permissions after a wipe"), ("STORE & CHECKOUT", "{buy}", "Open the in-game store"), ("STORE & CHECKOUT", "{subs}", "Get a link to manage your active subscriptions"), ("STORE & CHECKOUT", "{admin} checkout ", "Get an instant checkout link for yourself"), ("STORE & CHECKOUT", "{admin} sendlink ", "Send a checkout link to a player"), ("STORE & CHECKOUT", "{admin} goals", "Show community goal progress"), ("USER MANAGEMENT", "{admin} ban [ip]", "Ban a player from the webstore"), ("USER MANAGEMENT", "{admin} lookup ", "Not available in this build"), ("DEBUG", "{admin} debug", "Open the Plugin Health Check page"), ("DEBUG", "{admin} debug ", "Turn verbose server logging on or off"), ("DEBUG", "{admin} selftest", "Run the permission ledger self-check"), ("DEBUG", "{admin} help", "Open this command reference"), }; private string FormatCommandSyntax(string syntax) => syntax.Replace("{admin}", "/" + _config.AdminCommand) .Replace("{buy}", "/" + _config.BuyCommand) .Replace("{subs}", "/" + _config.SubsCommand); private static List<(string Name, int Count)> CommandReferenceSections() { var result = new List<(string Name, int Count)>(); var seen = new Dictionary(); foreach (var row in CommandReferenceTable) { if (seen.TryGetValue(row.Section, out var at)) { result[at] = (row.Section, result[at].Count + 1); continue; } seen[row.Section] = result.Count; result.Add((row.Section, 1)); } return result; } private void OpenCommandReferenceUi(BasePlayer player) { if (player == null) return; DestroyDebugUi(player); DrawCommandReferenceUi(player); } private void DrawCommandReferenceUi(BasePlayer player) { CuiHelper.DestroyUi(player, CommandRefUiName); var elements = new CuiElementContainer(); elements.Add(new CuiPanel { Image = { Color = "0 0 0 0.85" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, UiOverlay, CommandRefUiName); var panel = CommandRefUiName + ".Panel"; elements.Add(new CuiPanel { Image = { Color = ColorHealthPanelBg }, RectTransform = { AnchorMin = HealthPanelAnchorMin, AnchorMax = HealthPanelAnchorMax } }, CommandRefUiName, panel); elements.Add(new CuiPanel { Image = { Color = ColorHealthAccent }, RectTransform = { AnchorMin = $"0 {HcAccentYMin:0.000}", AnchorMax = "1 1" } }, panel); elements.Add(new CuiLabel { Text = { Text = "TEBEX", FontSize = 25, Align = TextAnchor.MiddleLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{PanelPadX:0.000} 0.930", AnchorMax = $"0.70 {HcHeaderBtnYMax:0.000}" } }, panel); elements.Add(new CuiLabel { Text = { Text = "COMMAND REFERENCE", FontSize = 12, Align = TextAnchor.MiddleLeft, Color = ColorCmdRefSubtitle, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{PanelPadX:0.000} {HcSubtitleYMin:0.000}", AnchorMax = $"0.70 {HcSubtitleYMax:0.000}" } }, panel); elements.Add(new CuiButton { Button = { Color = ColorHealthAccent, Command = "tebex.dbg close" }, RectTransform = { AnchorMin = $"{1f - PanelPadX - HcHeaderBtnW:0.000} {HcHeaderBtnYMin:0.000}", AnchorMax = $"{1f - PanelPadX:0.000} {HcHeaderBtnYMax:0.000}" }, Text = { Text = "×", FontSize = 18, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, panel); elements.Add(new CuiPanel { Image = { Color = ColorHealthDivider }, RectTransform = { AnchorMin = $"0 {HcDividerYMin:0.000}", AnchorMax = $"1 {HcDividerYMax:0.000}" } }, panel); DrawCommandReferenceRows(elements, panel); elements.Add(new CuiLabel { Text = { Text = "Click a command to send it to your F1 console", FontSize = 10, Align = TextAnchor.MiddleLeft, Color = ColorCmdRefSubtitle }, RectTransform = { AnchorMin = $"{PanelPadX:0.000} {HcFooterYMin:0.000}", AnchorMax = $"0.55 {HcFooterYMax:0.000}" } }, panel); AddCommandReferenceFeedback(elements, player.userID); SendUi(player, elements); } private void DrawCommandReferenceRows(CuiElementContainer elements, string panel) { float viewportPxH = (CrBodyYMax - CrBodyYMin) * BuyPanelHFrac * CrRefH; var sections = CommandReferenceSections(); float contentPx = CrPadTopPx + CrPadBotPx; foreach (var s in sections) contentPx += CrSectionGapPx + CrSectionPx + s.Count * (CrRowPx + CrRowGapPx); float extra = Math.Max(0f, contentPx - viewportPxH); float H = viewportPxH + extra; const string scrollName = CommandRefUiName + ".List"; elements.Add(new CuiElement { Name = scrollName, Parent = panel, Components = { new CuiScrollViewComponent { Horizontal = false, Vertical = true, MovementType = UnityEngine.UI.ScrollRect.MovementType.Clamped, Inertia = true, DecelerationRate = 0.135f, ScrollSensitivity = 24f, ContentTransform = new CuiRectTransform { AnchorMin = "0 0", AnchorMax = "1 1", OffsetMin = $"0 {-extra:0}", OffsetMax = $"{-ScrollbarPx:0} 0" }, VerticalScrollbar = new CuiScrollbar { AutoHide = false, Size = ScrollbarPx, HandleColor = ColorScrollHandle, TrackColor = "0 0 0 0" } }, new CuiRectTransformComponent { AnchorMin = $"{PanelPadX:0.000} {CrBodyYMin:0.000}", AnchorMax = $"{1f - PanelPadX:0.000} {CrBodyYMax:0.000}" } } }); float topPx = CrPadTopPx; int index = 0; foreach (var section in sections) { topPx += CrSectionGapPx; var headerName = $"{scrollName}.Sec{index}"; elements.Add(new CuiPanel { Image = { Color = ColorCmdRefSection }, RectTransform = { AnchorMin = $"0 {1f - (topPx + CrSectionPx) / H:0.0000}", AnchorMax = $"1 {1f - topPx / H:0.0000}" } }, scrollName, headerName); elements.Add(new CuiLabel { Text = { Text = section.Name, FontSize = 11, Align = TextAnchor.MiddleLeft, Color = ColorCmdRefSectionTx, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{CrTextX0:0.000} 0", AnchorMax = "0.80 1" } }, headerName); elements.Add(new CuiLabel { Text = { Text = section.Count.ToString(), FontSize = 11, Align = TextAnchor.MiddleRight, Color = ColorHealthTextDim, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.80 0", AnchorMax = $"{CrTextX1:0.000} 1" } }, headerName); topPx += CrSectionPx; for (int i = 0; i < section.Count; i++, index++) { var row = CommandReferenceTable[index]; var rowName = $"{scrollName}.Row{index}"; elements.Add(new CuiPanel { Image = { Color = ColorCmdRefRow }, RectTransform = { AnchorMin = $"0 {1f - (topPx + CrRowPx) / H:0.0000}", AnchorMax = $"1 {1f - topPx / H:0.0000}" } }, scrollName, rowName); elements.Add(new CuiLabel { Text = { Text = FormatCommandSyntax(row.Syntax), FontSize = 13, Align = TextAnchor.MiddleLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{CrRowTextX0:0.000} 0", AnchorMax = $"{CrSyntaxX1:0.000} 1" } }, rowName); elements.Add(new CuiLabel { Text = { Text = row.Description, FontSize = 11, Align = TextAnchor.MiddleRight, Color = ColorHealthTextDim }, RectTransform = { AnchorMin = $"{CrSyntaxX1:0.000} 0", AnchorMax = $"{CrTextX1:0.000} 1" } }, rowName); elements.Add(new CuiButton { Button = { Color = "0 0 0 0", Command = $"tebex.dbg cmd {index}" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, Text = { Text = "" } }, rowName); topPx += CrRowPx + CrRowGapPx; } } } private void AddCommandReferenceFeedback(CuiElementContainer elements, ulong userId) { var text = _commandRefFeedback.TryGetValue(userId, out var fb) && !string.IsNullOrEmpty(fb) ? fb : string.Empty; elements.Add(new CuiLabel { Text = { Text = text, FontSize = 10, Align = TextAnchor.MiddleRight, Color = ColorHealthTextDim }, RectTransform = { AnchorMin = $"0.55 {HcFooterYMin:0.000}", AnchorMax = $"{1f - PanelPadX:0.000} {HcFooterYMax:0.000}" } }, CommandRefUiName + ".Panel", CommandRefUiName + ".Feedback"); } private void SendCommandReferenceRow(BasePlayer player, int index) { if (player == null) return; if (index < 0 || index >= CommandReferenceTable.Length) return; var syntax = FormatCommandSyntax(CommandReferenceTable[index].Syntax); player.ConsoleMessage(syntax); _commandRefFeedback[player.userID] = $"Sent {syntax} to your F1 console."; CuiHelper.DestroyUi(player, CommandRefUiName + ".Feedback"); var elements = new CuiElementContainer(); AddCommandReferenceFeedback(elements, player.userID); SendUi(player, elements); } private const int TelemetryUiMaxLinesPerSection = 12; private static readonly (string Name, float YMin, float YMax)[] TelemetrySectionBounds = { ("Queued", 0.62f, 0.92f), ("Sent", 0.32f, 0.61f), ("Joins", 0.02f, 0.31f), }; private string TelemetryDataName(string section) => $"{TelemetryUiName}.{section}Data"; private string TelemetryStampName() => TelemetryUiName + ".Stamp"; private void DrawTelemetryUi(BasePlayer player) { if (player == null) return; CuiHelper.DestroyUi(player, TelemetryUiName); var elements = new CuiElementContainer(); elements.Add(new CuiPanel { Image = { Color = "0 0 0 0.85" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, UiOverlay, TelemetryUiName); elements.Add(new CuiPanel { Image = { Color = "0.12 0.12 0.14 1" }, RectTransform = { AnchorMin = "0.10 0.08", AnchorMax = "0.90 0.92" } }, TelemetryUiName, TelemetryUiName + ".Panel"); elements.Add(new CuiPanel { Image = { Color = "0.18 0.18 0.22 1" }, RectTransform = { AnchorMin = "0 0.93", AnchorMax = "1 1" } }, TelemetryUiName + ".Panel", TelemetryUiName + ".Header"); elements.Add(new CuiLabel { Text = { Text = "Tebex - Telemetry & Joins", FontSize = 18, Align = TextAnchor.MiddleLeft, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.02 0", AnchorMax = "0.7 1" } }, TelemetryUiName + ".Header"); elements.Add(new CuiButton { Button = { Color = "0.55 0.55 0.2 1", Command = "tebex.dbg refresh" }, RectTransform = { AnchorMin = "0.72 0.15", AnchorMax = "0.81 0.85" }, Text = { Text = "Refresh", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" } }, TelemetryUiName + ".Header"); elements.Add(new CuiButton { Button = { Color = "0.55 0.55 0.2 1", Command = "tebex.dbg back" }, RectTransform = { AnchorMin = "0.83 0.15", AnchorMax = "0.92 0.85" }, Text = { Text = "< Back", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" } }, TelemetryUiName + ".Header"); elements.Add(new CuiButton { Button = { Color = "0.7 0.2 0.2 1", Command = "tebex.dbg close" }, RectTransform = { AnchorMin = "0.94 0.15", AnchorMax = "0.99 0.85" }, Text = { Text = "X", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" } }, TelemetryUiName + ".Header"); foreach (var s in TelemetrySectionBounds) { elements.Add(new CuiPanel { Image = { Color = "0.08 0.08 0.1 1" }, RectTransform = { AnchorMin = $"0 {s.YMin:0.000}", AnchorMax = $"1 {s.YMax:0.000}" } }, TelemetryUiName + ".Panel", TelemetryUiName + "." + s.Name); var titleText = s.Name switch { "Queued" => "Queued Telemetry", "Sent" => "Last 50 Sent Telemetry", "Joins" => "Pending Join Events", _ => s.Name }; elements.Add(new CuiLabel { Text = { Text = titleText, FontSize = 13, Align = TextAnchor.MiddleLeft, Color = "0.6 0.8 1 1" }, RectTransform = { AnchorMin = "0.02 0.86", AnchorMax = "0.98 0.99" } }, TelemetryUiName + "." + s.Name); } AddTelemetryUiData(elements); SendUi(player, elements); } private void AddTelemetryUiData(CuiElementContainer elements) { var queuedJson = FormatJsonList(_telemetryIndex.Values, _telemetryIndex.Count, TelemetryUiMaxLinesPerSection); var sentJson = FormatJsonList( _telemetrySentHistory.Reverse(), _telemetrySentHistory.Count, TelemetryUiMaxLinesPerSection); var joinsJson = FormatJsonList(_pendingJoinEvents, _pendingJoinEvents.Count, TelemetryUiMaxLinesPerSection); AddTelemetryDataLabel(elements, "Queued", queuedJson); AddTelemetryDataLabel(elements, "Sent", sentJson); AddTelemetryDataLabel(elements, "Joins", joinsJson); elements.Add(new CuiLabel { Text = { Text = $"UPDATED {DateTime.Now:HH:mm:ss}", FontSize = 10, Align = TextAnchor.MiddleRight, Color = "0.6 0.6 0.65 1" }, RectTransform = { AnchorMin = "0.40 0.93", AnchorMax = "0.70 1" } }, TelemetryUiName + ".Panel", TelemetryStampName()); } private void AddTelemetryDataLabel(CuiElementContainer elements, string section, string text) { float yMin = 0.02f, yMax = 0.92f; for (int i = 0; i < TelemetrySectionBounds.Length; i++) { if (TelemetrySectionBounds[i].Name == section) { yMin = TelemetrySectionBounds[i].YMin + 0.01f; yMax = TelemetrySectionBounds[i].YMax - 0.10f; break; } } elements.Add(new CuiLabel { Text = { Text = text, FontSize = 10, Align = TextAnchor.UpperLeft, Color = "0.85 0.92 1 1" }, RectTransform = { AnchorMin = $"0.02 {yMin:0.000}", AnchorMax = $"0.98 {yMax:0.000}" } }, TelemetryUiName + ".Panel", TelemetryDataName(section)); } private void RefreshTelemetryUiData(BasePlayer player) { if (player == null) return; foreach (var s in TelemetrySectionBounds) CuiHelper.DestroyUi(player, TelemetryDataName(s.Name)); CuiHelper.DestroyUi(player, TelemetryStampName()); var elements = new CuiElementContainer(); AddTelemetryUiData(elements); SendUi(player, elements); } private string FormatJsonList(IEnumerable items, int totalCount, int maxLines) { var listed = items.Take(maxLines) .Select(item => JsonConvert.SerializeObject(item, Formatting.None, JsonSettings)) .ToList(); if (listed.Count == 0) return "(empty)"; var sb = new StringBuilder(); for (int i = 0; i < listed.Count; i++) { if (i > 0) sb.Append('\n'); sb.Append(listed[i]); } if (totalCount > maxLines) { sb.Append('\n'); sb.Append($"... and {totalCount - maxLines} more"); } return sb.ToString(); } private enum HealthTileStyle { Primary, Neutral, Destructive } private void DrawDebugButton(CuiElementContainer elements, string label, string command, HealthTileStyle style, int row, int col, string subtitle = null) { float xMin = col == 0 ? HcCol0X0 : HcCol1X0; float xMax = col == 0 ? HcCol0X1 : HcCol1X1; float yMax = HcTilesTop - row * (HcTileH + HcTileGap); float yMin = yMax - HcTileH; string bg, bar; switch (style) { case HealthTileStyle.Neutral: bg = ColorHealthTileGreyBg; bar = ColorHealthTileGreyBar; break; case HealthTileStyle.Destructive: bg = ColorHealthTileRedBg; bar = ColorHealthTileRedBar; break; default: bg = ColorHealthTileBlueBg; bar = ColorHealthTileBlueBar; break; } elements.Add(new CuiPanel { Image = { Color = bar }, RectTransform = { AnchorMin = $"{xMin:0.000} {yMin:0.000}", AnchorMax = $"{xMin + HcTileBarW:0.000} {yMax:0.000}" } }, DebugUiName + ".Panel"); var tileName = $"{DebugUiName}.Tile{row}{col}"; elements.Add(new CuiButton { Button = { Color = bg, Command = command }, RectTransform = { AnchorMin = $"{xMin + HcTileBarW:0.000} {yMin:0.000}", AnchorMax = $"{xMax:0.000} {yMax:0.000}" }, Text = { Text = string.IsNullOrEmpty(subtitle) ? label.ToUpperInvariant() : "", FontSize = 13, Align = TextAnchor.MiddleCenter, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" } }, DebugUiName + ".Panel", tileName); if (string.IsNullOrEmpty(subtitle)) return; elements.Add(new CuiLabel { Text = { Text = label.ToUpperInvariant(), FontSize = 13, Align = TextAnchor.LowerLeft, Color = ColorTextWhite, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{HcTileTextX0:0.000} 0.46", AnchorMax = "0.98 0.88" } }, tileName); elements.Add(new CuiLabel { Text = { Text = subtitle.ToUpperInvariant(), FontSize = 10, Align = TextAnchor.UpperLeft, Color = ColorHealthTextDim, Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = $"{HcTileTextX0:0.000} 0.10", AnchorMax = "0.98 0.44" } }, tileName); } private static string FormatRelative(DateTime t) { if (t == DateTime.MinValue) return "never"; var d = DateTime.UtcNow - t; if (d.TotalSeconds < 0) return "future?"; if (d.TotalSeconds < 60) return $"{(int)d.TotalSeconds}s ago"; if (d.TotalMinutes < 60) return $"{(int)d.TotalMinutes}m {(int)(d.TotalSeconds % 60)}s ago"; if (d.TotalHours < 24) return $"{(int)d.TotalHours}h {(int)(d.TotalMinutes % 60)}m ago"; return $"{(int)d.TotalDays}d ago"; } private static string FormatCountdown(DateTime t) { if (t == DateTime.MinValue) return "-"; var d = t - DateTime.UtcNow; if (d.TotalSeconds <= 0) return "now"; if (d.TotalSeconds < 60) return $"in {(int)d.TotalSeconds}s"; if (d.TotalMinutes < 60) return $"in {(int)d.TotalMinutes}m {(int)(d.TotalSeconds % 60)}s"; if (d.TotalHours < 24) return $"in {(int)d.TotalHours}h {(int)(d.TotalMinutes % 60)}m"; return $"in {(int)d.TotalDays}d"; } [ConsoleCommand("tebex.dbg")] private void CcDbg(ConsoleSystem.Arg arg) => MeasureHook("tebex.dbg", () => { var player = arg.Player(); if (player == null) return; var ip = player.IPlayer; if (ip == null) return; if (!ip.IsAdmin && !permission.UserHasPermission(ip.Id, PermAdmin)) return; if (arg.Args == null || arg.Args.Length == 0) { OpenDebugUi(player); return; } var op = ((string)arg.Args[0]).ToLowerInvariant(); switch (op) { case "close": DestroyDebugUi(player); return; case "cmd": if (arg.Args.Length >= 2 && int.TryParse(arg.Args[1], out var cmdIndex)) SendCommandReferenceRow(player, cmdIndex); return; case "refresh": RefreshActiveDebugView(player); return; case "forcecheck": if (_secretValid) { ip.Reply("Forcing queue check..."); CheckQueue(n => ip.Reply($"Queue check complete. Executed {n} command(s).")); } else { ip.Reply("Secret key not set; cannot force queue check."); } _debugActionFeedback[player.userID] = $"Force Queue Check fired at {DateTime.Now:HH:mm:ss}, result in your chat (F1)."; RefreshDebugUiData(player); return; case "refreshlisting": RefreshListing(); FetchCommunityGoals(); _debugActionFeedback[player.userID] = "Store listing refreshed - package listing reloaded."; RefreshDebugUiData(player); return; case "cleartelemetry": case "flushtelemetry": FlushTelemetry(true); _debugActionFeedback[player.userID] = "Telemetry flushed - see View Telemetry page."; RefreshDebugUiData(player); return; case "clearjoins": case "flushjoins": FlushJoinEvents(true); _debugActionFeedback[player.userID] = "Join events flushed - see View Telemetry page."; RefreshDebugUiData(player); return; case "toggledebug": _config.Debug = !_config.Debug; SaveConfig(); ip.Reply($"Debug logging is now {(_config.Debug ? "ON" : "OFF")}."); _debugActionFeedback[player.userID] = $"Debug logging now {(_config.Debug ? "ON" : "OFF")}."; RefreshDebugUiData(player); return; case "viewtelemetry": _debugView[player.userID] = DebugView.Telemetry; CuiHelper.DestroyUi(player, DebugUiName); DrawTelemetryUi(player); return; case "back": _debugView[player.userID] = DebugView.Main; CuiHelper.DestroyUi(player, TelemetryUiName); DrawDebugUi(player); return; } RefreshActiveDebugView(player); }); private void RefreshActiveDebugView(BasePlayer player) { if (_debugView.TryGetValue(player.userID, out var view) && view == DebugView.Telemetry) RefreshTelemetryUiData(player); else RefreshDebugUiData(player); } [ConsoleCommand("tebex.ui")] private void CcUi(ConsoleSystem.Arg arg) => MeasureHook("tebex.ui", () => { var player = arg.Player(); if (player == null) return; if (arg.Args == null || arg.Args.Length == 0) return; if (!_uiStates.TryGetValue(player.userID, out var state)) { state = new BuyUiState(); _uiStates[player.userID] = state; } var ip = player.IPlayer; var op = ((string)arg.Args[0]).ToLowerInvariant(); switch (op) { case "close": DestroyUi(player); return; case "view": if (arg.Args.Length >= 2) { var which = ((string)arg.Args[1]).ToLowerInvariant(); if (which == "basket" && _config.EnableBasket) { state.View = BuyUiView.Basket; state.LastBasketError = string.Empty; if (!string.IsNullOrEmpty(state.BasketIdent)) { RefreshBasket(player, (ok, err) => { if (!ok) state.LastBasketError = err ?? ""; DrawBuyUi(player); }); return; } } else { state.View = BuyUiView.Store; state.OpenPackageId = null; } } break; case "cat": if (arg.Args.Length >= 2 && int.TryParse(arg.Args[1], out var cid)) { state.CategoryId = cid; state.Page = 0; state.OpenPackageId = null; } break; case "page": if (arg.Args.Length >= 2) { state.Page += arg.Args[1] == "next" ? 1 : -1; if (state.Page < 0) state.Page = 0; } break; case "pkg": if (arg.Args.Length >= 2 && int.TryParse(arg.Args[1], out var pid)) state.OpenPackageId = pid; break; case "back": state.OpenPackageId = null; break; case "add": if (arg.Args.Length >= 2 && int.TryParse(arg.Args[1], out var apid)) { if (state.InflightAdds.Contains(apid)) return; state.LastBasketError = string.Empty; AddToBasket(player, apid, (ok, err) => { if (!ok) state.LastBasketError = err ?? "Failed to add to basket."; DrawBuyUi(player); }); return; } break; case "remove": if (arg.Args.Length >= 2 && int.TryParse(arg.Args[1], out var rpid)) { state.LastBasketError = string.Empty; RemoveFromBasket(player, rpid, (ok, err) => { if (!ok) state.LastBasketError = err ?? "Failed to remove from basket."; DrawBuyUi(player); }); return; } break; case "checkout": { var pkgs = state.Basket?.packages; if (pkgs == null || pkgs.Count == 0) { ip?.Reply("Your basket is empty."); return; } var url = state.Basket?.links?.checkout; if (string.IsNullOrEmpty(url)) { ip?.Reply("Checkout URL not available. Try reopening the basket."); return; } DestroyUi(player); SendCheckoutLink(ip, url, 0); return; } case "webstore": { var webUrl = ResolveWebstoreUrl(); DestroyUi(player); player.ConsoleMessage(webUrl); player.ChatMessage("The webstore link has been sent to your F1 console. Press F1, then click the timestamp to copy the link."); return; } case "clearbasket": { state.BasketIdent = string.Empty; state.Basket = null; state.LastBasketError = string.Empty; state.InflightAdds.Clear(); state.View = BuyUiView.Store; state.OpenPackageId = null; DrawBuyUi(player); return; } case "buy": if (arg.Args.Length >= 2 && int.TryParse(arg.Args[1], out var bpid)) { DestroyUi(player); CreateCheckout(bpid, player.UserIDString, (ok, url, err) => { var ipInner = player.IPlayer; if (!ok) { ipInner?.Reply($"Checkout failed: {err}"); return; } SendCheckoutLink(ipInner, url, bpid); }); return; } break; } DrawBuyUi(player); }); private string FormatPrice(Tebex.Headless.Package p) { var sym = _serverInfo?.account?.currency?.symbol ?? "$"; if (p.discount > 0f && p.discount < p.base_price) { return $"{sym}{p.base_price:0.00} {sym}{p.total_price:0.00}"; } return $"{sym}{p.total_price:0.00}"; } private string ResolveWebstoreUrl() { var domain = _serverInfo?.account?.domain; if (string.IsNullOrEmpty(domain)) return "https://www.tebex.io"; return domain.StartsWith("http://") || domain.StartsWith("https://") ? domain : "https://" + domain; } private void SendCheckoutLink(IPlayer player, string url, int packageId) { if (player == null) return; var forPackage = packageId > 0 ? $" for package #{packageId}" : ""; var chatMsg = $"A checkout link{forPackage} has been sent to your F1 console. Click the timestamp to copy the link."; if (player.Object is BasePlayer bp) { bp.ConsoleMessage(url); bp.ChatMessage(chatMsg); } else { player.Reply(url); } } #endregion #region Self Test private void HandleSelfTest(IPlayer player) { if (!RequireAdmin(player)) return; var passed = 0; var failures = new List(); void Check(string name, bool ok, string detail = "") { if (ok) { passed++; return; } failures.Add(detail.Length == 0 ? name : $"{name}: {detail}"); } void Parse(string name, string command, bool expectMatch, bool expectGrant = false, string expectType = "", string expectValue = "") { var matched = TryParsePermissionGrant(command, out var grant, out var type, out var value); if (matched != expectMatch) { Check(name, false, $"matched={matched}, expected {expectMatch} [{command}]"); return; } if (!expectMatch) { Check(name, true); return; } Check(name, grant == expectGrant && type == expectType && value == expectValue, $"got grant={grant} type='{type}' value='{value}', " + $"expected grant={expectGrant} type='{expectType}' value='{expectValue}' [{command}]"); } Parse("grant user", "oxide.grant user 76561198000000001 vip", true, true, "permission", "vip"); Parse("revoke user", "oxide.revoke user 76561198000000001 vip", true, false, "permission", "vip"); Parse("o.grant alias", "o.grant user 76561198000000001 vip", true, true, "permission", "vip"); Parse("o.revoke alias", "o.revoke user 76561198000000001 vip", true, false, "permission", "vip"); Parse("usergroup add", "oxide.usergroup add 76561198000000001 vips", true, true, "group", "vips"); Parse("usergroup remove", "oxide.usergroup remove 76561198000000001 vips", true, false, "group", "vips"); Parse("o.usergroup alias", "o.usergroup add 76561198000000001 vips", true, true, "group", "vips"); Parse("carbon c.grant", "c.grant user 76561198000000001 vip", true, true, "permission", "vip"); Parse("carbon c.revoke", "c.revoke user 76561198000000001 vip", true, false, "permission", "vip"); Parse("carbon c.usergroup add", "c.usergroup add 76561198000000001 vips", true, true, "group", "vips"); Parse("carbon c.usergroup remove", "c.usergroup remove 76561198000000001 vips", true, false, "group", "vips"); Parse("carbon c.grant group ignored", "c.grant group vips someperm", false); Parse("uppercase verb", "OXIDE.GRANT USER 76561198000000001 VIP", true, true, "permission", "VIP"); Parse("extra whitespace", " oxide.grant user 76561198000000001 vip ", true, true, "permission", "vip"); Parse("tab separated", "oxide.grant\tuser\t76561198000000001\tvip", true, true, "permission", "vip"); Parse("grant group ignored", "oxide.grant group vips someperm", false); Parse("revoke group ignored", "oxide.revoke group vips someperm", false); Parse("usergroup set ignored", "oxide.usergroup set 76561198000000001 vips", false); Parse("group create ignored", "oxide.group add vips", false); Parse("too few tokens", "oxide.grant user 76561198000000001", false); Parse("unrelated command", "inventory.giveto 76561198000000001 rifle.ak 1", false); Parse("empty command", "", false); Parse("whitespace command", " ", false); Parse("null command", null!, false); Parse("quoted username", "oxide.grant user \"John Doe\" vip", true, true, "permission", "vip"); Parse("quoted group value", "oxide.usergroup add \"John Doe\" \"vip plus\"", true, true, "group", "vip plus"); Parse("trailing junk tolerated", "oxide.grant user 76561198000000001 vip 30d", true, true, "permission", "vip"); Check("tokenizer keeps quoted spaces", TokenizeCommand("a \"b c\" d").Count == 3 && TokenizeCommand("a \"b c\" d")[1] == "b c"); Check("LooksLikePermissionCommand positive", LooksLikePermissionCommand("oxide.grant user x y")); Check("LooksLikePermissionCommand carbon", LooksLikePermissionCommand("c.grant user x y")); Check("LooksLikePermissionCommand negative", !LooksLikePermissionCommand("inventory.giveto x y 1")); var customConfig = new List { new() { Label = "vip", GrantCommand = "addvip", RevokeCommand = "delvip" }, new() { Label = "elite", GrantCommand = "addelite", RevokeCommand = "" }, new() { Label = "gold", GrantCommand = "addgold {id}", RevokeCommand = "delgold {id}" }, new() { Label = "", GrantCommand = "addnothing", RevokeCommand = "delnothing" }, }; void Custom(string name, string command, string? expectLabel, bool expectGrant = false) { var match = MatchCustomCommand(customConfig, command, out var grant); if (expectLabel == null) { Check(name, match == null, $"expected no match, got '{match?.Label}' [{command}]"); return; } Check(name, match != null && match.Label == expectLabel && grant == expectGrant, $"got label='{match?.Label}' grant={grant}, expected label='{expectLabel}' grant={expectGrant} [{command}]"); } Custom("custom grant", "addvip 76561198000000001", "vip", true); Custom("custom revoke", "delvip 76561198000000001", "vip", false); Custom("custom case-insensitive", "ADDVIP 76561198000000001", "vip", true); Custom("custom grant-only pair", "addelite 76561198000000001", "elite", true); Custom("custom template config", "addgold 76561198000000001", "gold", true); Custom("custom template revoke", "delgold 76561198000000001", "gold", false); Custom("custom unlabelled skipped", "addnothing 76561198000000001", null); Custom("custom no match", "givekit 76561198000000001 starter", null); Custom("custom empty command", "", null); Check("custom null config tolerated", MatchCustomCommand(null!, "addvip 1", out _) == null); const string idA = "76561198000000001", idB = "76561198000000002"; var ledger = new Dictionary(); bool Apply(bool grant, string id, string user, string type, string value, string raw = "raw") => ApplyLedgerMutation(ledger, id, user, grant, type, value, 42, raw); Check("grant creates record", Apply(true, idA, "Alice", "permission", "vip") && ledger.Count == 1 && ledger[idA].Entries.Count == 1 && ledger[idA].Username == "Alice" && ledger[idA].Uuid == idA); Check("regrant dedups", Apply(true, idA, "Alice", "permission", "vip") && ledger[idA].Entries.Count == 1); Check("second perm appends", Apply(true, idA, "Alice", "permission", "kit.vip") && ledger[idA].Entries.Count == 2); Check("group is a distinct entry", Apply(true, idA, "Alice", "group", "vip") && ledger[idA].Entries.Count == 3); Check("revoke removes one", Apply(false, idA, "Alice", "permission", "kit.vip") && ledger[idA].Entries.Count == 2 && ledger.ContainsKey(idA)); Check("revoke is case-insensitive", Apply(false, idA, "Alice", "PERMISSION", "VIP") && ledger[idA].Entries.Count == 1); Check("revoke of last drops record", Apply(false, idA, "Alice", "group", "vip") && !ledger.ContainsKey(idA)); Check("revoke for unknown player is a no-op", !Apply(false, idB, "Bob", "permission", "vip") && !ledger.ContainsKey(idB)); Check("revoke of untracked perm reports no change", Apply(true, idB, "Bob", "permission", "vip") && !Apply(false, idB, "Bob", "permission", "never.granted") && ledger[idB].Entries.Count == 1); Check("username updates on later command", Apply(true, idB, "Bobby", "permission", "extra") && ledger[idB].Username == "Bobby"); Check("empty username does not clear a known one", Apply(true, idB, "", "permission", "another") && ledger[idB].Username == "Bobby"); Check("empty value rejected", !Apply(true, idB, "Bobby", "permission", "")); Check("empty uuid rejected", !Apply(true, "", "Bobby", "permission", "vip")); Check("null ledger tolerated", !ApplyLedgerMutation(null!, idA, "Alice", true, "permission", "vip", 0, "raw")); Check("custom label nets out", Apply(true, idB, "Bobby", "custom", "vip") && Apply(false, idB, "Bobby", "custom", "vip") && ledger[idB].Entries.All(e => !string.Equals(e.Type, "custom", StringComparison.OrdinalIgnoreCase))); Check("custom revoke leaves a same-named permission intact", ledger[idB].Entries.Any(e => e.Type == "permission" && e.Value == "vip")); var replayRec = new PermissionLedgerRecord { Uuid = idA, Username = "Alice" }; replayRec.Entries.Add(new PermissionLedgerEntry { Type = "permission", Value = "vip" }); replayRec.Entries.Add(new PermissionLedgerEntry { Type = "group", Value = "vips" }); replayRec.Entries.Add(new PermissionLedgerEntry { Type = "group", Value = "vip plus" }); replayRec.Entries.Add(new PermissionLedgerEntry { Type = "custom", Value = "vip", RawCommand = $"addvip {idA}" }); replayRec.Entries.Add(new PermissionLedgerEntry { Type = "custom", Value = "ghost", RawCommand = "" }); var replayCommands = BuildReplayCommands(replayRec); Check("replay emits one command per replayable entry", replayCommands.Count == 4, $"got {replayCommands.Count}: {string.Join(" | ", replayCommands)}"); Check("replay rebuilds permission grant", replayCommands.Contains($"oxide.grant user {idA} vip"), string.Join(" | ", replayCommands)); Check("replay rebuilds group add", replayCommands.Contains($"oxide.usergroup add {idA} vips"), string.Join(" | ", replayCommands)); Check("replay quotes a group name with spaces", replayCommands.Contains($"oxide.usergroup add {idA} \"vip plus\""), string.Join(" | ", replayCommands)); Check("replay reuses the stored custom command", replayCommands.Contains($"addvip {idA}"), string.Join(" | ", replayCommands)); var renamedRec = new PermissionLedgerRecord { Uuid = idA, Username = "Alice" }; renamedRec.Entries.Add(new PermissionLedgerEntry { Type = "permission", Value = "vip", RawCommand = "oxide.grant user OldNickname vip" }); Check("replay ignores RawCommand for oxide entries", BuildReplayCommands(renamedRec).SequenceEqual(new[] { $"oxide.grant user {idA} vip" })); var carbonCommands = BuildReplayCommands(replayRec, carbon: true); Check("carbon replay emits one command per replayable entry", carbonCommands.Count == 4, $"got {carbonCommands.Count}: {string.Join(" | ", carbonCommands)}"); Check("carbon replay rebuilds permission grant", carbonCommands.Contains($"c.grant user {idA} vip"), string.Join(" | ", carbonCommands)); Check("carbon replay rebuilds group add", carbonCommands.Contains($"c.usergroup add {idA} vips"), string.Join(" | ", carbonCommands)); Check("carbon replay quotes a group name with spaces", carbonCommands.Contains($"c.usergroup add {idA} \"vip plus\""), string.Join(" | ", carbonCommands)); Check("carbon replay leaves custom commands untouched", carbonCommands.Contains($"addvip {idA}"), string.Join(" | ", carbonCommands)); Check("carbon replay ignores an oxide RawCommand", BuildReplayCommands(renamedRec, carbon: true).SequenceEqual(new[] { $"c.grant user {idA} vip" })); Check("replay of an empty record emits nothing", BuildReplayCommands(new PermissionLedgerRecord { Uuid = idA }).Count == 0); Check("replay of a null record emits nothing", BuildReplayCommands(null!).Count == 0); var loaded = new Dictionary { [idA] = new() { Username = "Alice" }, [idB] = new() { Uuid = idB, Username = "Bob", Entries = null! }, ["76561198000000003"] = new() { Uuid = "76561198000000003" }, }; loaded[idA].Entries.Add(new PermissionLedgerEntry { Type = "permission", Value = "vip" }); loaded[idA].Entries.Add(new PermissionLedgerEntry { Type = "permission", Value = "" }); var repaired = NormalizeLedger(loaded); Check("normalise backfills Uuid from the key", loaded.ContainsKey(idA) && loaded[idA].Uuid == idA); Check("normalise makes the repaired record replayable", loaded.ContainsKey(idA) && BuildReplayCommands(loaded[idA]).SequenceEqual(new[] { $"oxide.grant user {idA} vip" })); Check("normalise drops empty records", !loaded.ContainsKey(idB) && !loaded.ContainsKey("76561198000000003")); Check("normalise reports repairs", repaired.Count > 0, $"repairs={repaired.Count}"); Check("normalise repairs name the player", repaired.All(r => r.Contains("7656")), string.Join(" | ", repaired)); Check("normalise reports the dropped entitlement", repaired.Any(r => r.Contains("unreplayable")), string.Join(" | ", repaired)); Check("normalise tolerates null", NormalizeLedger(null!).Count == 0); Check("normalise of a healthy ledger reports nothing", NormalizeLedger(new Dictionary { [idA] = new() { Uuid = idA, Username = "Alice", Entries = { new PermissionLedgerEntry { Type = "permission", Value = "vip" } }, }, }).Count == 0); var lookup = new Dictionary { [idA] = new() { Uuid = idA, Username = "Alice" }, }; Check("lookup by steamid", FindLedgerRecord(lookup, idA) != null); Check("lookup by username", FindLedgerRecord(lookup, "Alice") != null); Check("lookup by username ignores case", FindLedgerRecord(lookup, "alice") != null); Check("lookup miss returns null", FindLedgerRecord(lookup, "Nobody") == null); Check("lookup empty arg returns null", FindLedgerRecord(lookup, "") == null); Check("lookup null ledger returns null", FindLedgerRecord(null!, idA) == null); Check("br becomes a newline", StripHtml("Line one
Line two") == "Line one\nLine two"); Check("br self-closing becomes a newline", StripHtml("Line one
Line two") == "Line one\nLine two"); Check("closing p becomes a newline", StripHtml("

First para

Second para

") == "First para\nSecond para"); Check("li becomes a bullet on its own line", StripHtml("
  • One
  • Two
") == "• One\n• Two"); Check("other tags are dropped without a trace", StripHtml("Bold and italic") == "Bold and italic"); Check("html entities are decoded", StripHtml("Tom & Jerry — 50% off") == "Tom & Jerry — 50% off"); Check("runs of blank lines collapse to one", StripHtml("

A




B

") == "A\n\nB"); Check("leading and trailing whitespace is trimmed", StripHtml("

Padded

\n\n") == "Padded"); Check("plain text with no markup is unchanged", StripHtml("Just plain text.") == "Just plain text."); Check("empty and null input return empty string", StripHtml("") == "" && StripHtml(null!) == ""); Check("payload without a type omits the field", !JsonConvert.SerializeObject(new Tebex.Headless.AddPackagePayload(123), JsonSettings).Contains("\"type\"")); Check("payload with a type includes it", JsonConvert.SerializeObject(new Tebex.Headless.AddPackagePayload(123, type: "subscription"), JsonSettings) .Contains("\"type\":\"subscription\"")); var total = passed + failures.Count; if (failures.Count == 0) { player.Reply($"Permission ledger self-test PASSED, {passed}/{total} checks. Live ledger untouched."); Puts($"[Tebex] selftest PASSED: {passed}/{total} checks."); return; } player.Reply($"Permission ledger self-test FAILED, {failures.Count} of {total} checks failed:"); PrintWarning($"[Tebex] selftest FAILED: {failures.Count}/{total} checks."); foreach (var failure in failures) { player.Reply($" - {failure}"); PrintWarning($"[Tebex] selftest FAIL {failure}"); } } #endregion #region Localization private static readonly Dictionary LangDefaults = new() { ["NoPermission"] = "You do not have permission to use that command.", ["SecretMissing"] = "Tebex is not configured. An admin must run `tebex secret `.", ["StoreUnavailable"] = "The store is currently unavailable. Please try again later.", ["HelpHeader"] = "Tebex commands:" }; private string Lang(string key, string playerId = null) => lang.GetMessage(key, this, playerId); #endregion #region Utilities private static string GetAnonymizedPlayerIp(BasePlayer player) { const string unknown = "127.0.0.x"; if (player == null) return unknown; var ip = player.net?.connection?.ipaddress; if (ip == null || ip.Length == 0) return unknown; var a = ip.Split('.'); if (a.Length < 4) return unknown; return $"{a[0]}.{a[1]}.{a[2]}.x"; } private static string Truncate(string s, int len) { if (string.IsNullOrEmpty(s)) return ""; return s.Length <= len ? s : s.Substring(0, len) + "…"; } private static readonly Regex BlockTagRegex = new( @"||||", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex ListItemRegex = new(@"", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex BlankLineRegex = new(@"\n{3,}", RegexOptions.Compiled); private static string StripHtml(string s) { if (string.IsNullOrEmpty(s)) return ""; s = BlockTagRegex.Replace(s, "\n"); s = ListItemRegex.Replace(s, "• "); var sb = new StringBuilder(s.Length); bool inTag = false; foreach (var c in s) { if (c == '<') { inTag = true; continue; } if (c == '>') { inTag = false; continue; } if (!inTag) sb.Append(c); } var decoded = WebUtility.HtmlDecode(sb.ToString()); decoded = BlankLineRegex.Replace(decoded, "\n\n"); return decoded.Trim(); } #endregion } } namespace Tebex.QR { public sealed class QrCode { public byte[] Bytes; private QrCode(byte[] bytes) => Bytes = bytes; public bool[,] GetModules() { int version = Bytes[0]; int size = Bytes[2]; var modules = new bool[size, size]; int bitIndex = 0; for (int r = 0; r < size; r++) for (int c = 0; c < size; c++) { int byteIndex = 3 + (bitIndex >> 3); int bitInByte = 7 - (bitIndex & 7); bool black = ((Bytes[byteIndex] >> bitInByte) & 1) != 0; modules[r, c] = black; bitIndex++; } return modules; } public static QrCode Encode(string content) { if (content == null) throw new ArgumentNullException(nameof(content)); if (!content.StartsWith("https://")) { throw new ArgumentException("Content must be a web address beginning with https://, got " + content); } if (content.Contains("javascript:")) { throw new ArgumentException("Content must not contain Javascript"); } if (content.Contains("