diff --git a/marketplace/Empostor.Plugin.Code.dll b/marketplace/Empostor.Plugin.Code.dll index d4f7779..9e7b6c1 100644 Binary files a/marketplace/Empostor.Plugin.Code.dll and b/marketplace/Empostor.Plugin.Code.dll differ diff --git a/marketplace/Empostor.Plugins.PlayerLog.dll b/marketplace/Empostor.Plugins.PlayerLog.dll index 9dba402..100cf16 100644 Binary files a/marketplace/Empostor.Plugins.PlayerLog.dll and b/marketplace/Empostor.Plugins.PlayerLog.dll differ diff --git a/src/Empostor.Api.Innersloth.Generator/packages.lock.json b/src/Empostor.Api.Innersloth.Generator/packages.lock.json index 5850ca3..fb68fad 100644 --- a/src/Empostor.Api.Innersloth.Generator/packages.lock.json +++ b/src/Empostor.Api.Innersloth.Generator/packages.lock.json @@ -150,7 +150,6 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } } - }, - ".NETStandard,Version=v2.0/win-x64": {} + } } } \ No newline at end of file diff --git a/src/Empostor.Api/Admin/IAdminExtension.cs b/src/Empostor.Api/Admin/IAdminExtension.cs index 77b73ae..ca907a7 100644 --- a/src/Empostor.Api/Admin/IAdminExtension.cs +++ b/src/Empostor.Api/Admin/IAdminExtension.cs @@ -17,6 +17,13 @@ public interface IAdminExtension /// Sidebar grouping label. Defaults to "Plugins". string Section => "Plugins"; + /// + /// Whether the panel should be re-fetched once per second while it is visible. Only panels + /// that mirror live server state (e.g. an in-game chat feed) need this; static panels keep + /// their DOM stable, which matters for widgets with in-progress typing or row editing. + /// + bool AutoRefresh => false; + /// Declare the panel's widgets and callbacks. void Build(AdminPanelBuilder builder); } diff --git a/src/Empostor.Api/Empostor.Api.csproj b/src/Empostor.Api/Empostor.Api.csproj index c00288b..fd4568f 100644 --- a/src/Empostor.Api/Empostor.Api.csproj +++ b/src/Empostor.Api/Empostor.Api.csproj @@ -6,7 +6,7 @@ enable true snupkg - 2.0.0 + 2.0.1 Empostor.Api Empostor.Api AeonLucid diff --git a/src/Empostor.Api/packages.lock.json b/src/Empostor.Api/packages.lock.json index 21be9d2..9734f25 100644 --- a/src/Empostor.Api/packages.lock.json +++ b/src/Empostor.Api/packages.lock.json @@ -144,7 +144,6 @@ "resolved": "4.5.0", "contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g==" } - }, - "net8.0/win-x64": {} + } } } \ No newline at end of file diff --git a/src/Empostor.Plugin.Code/CodeAdminExtension.cs b/src/Empostor.Plugin.Code/CodeAdminExtension.cs index 10e9cea..bc84aa9 100644 --- a/src/Empostor.Plugin.Code/CodeAdminExtension.cs +++ b/src/Empostor.Plugin.Code/CodeAdminExtension.cs @@ -1,14 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; using Empostor.Api.Admin; +using Empostor.Api.Games; +using Microsoft.Extensions.Logging; namespace Empostor.Plugin.Code; +/// +/// Admin panel page for the room-code pool: a searchable, editable list of every code +/// (available and in use), an add row, and a reload button for Boot.Codes word lists. +/// public sealed class CodeAdminExtension : IAdminExtension { private readonly IGameCodeManager _manager; + private readonly ILogger _logger; - public CodeAdminExtension(IGameCodeManager manager) + public CodeAdminExtension(IGameCodeManager manager, ILogger logger) { _manager = manager; + _logger = logger; } public string Id => "game-codes"; @@ -19,12 +32,128 @@ public sealed class CodeAdminExtension : IAdminExtension public void Build(AdminPanelBuilder b) { - b.RegisterText( - $"Loaded {_manager.SixCharCodes} six-character and {_manager.FourCharCodes} four-character code(s).", - "default"); - b.RegisterText($"Source directory: {_manager.Path}", "muted", monospace: true); - b.RegisterText( - "Codes are read from .txt files at startup. Add or edit files in the directory, then restart the server to reload.", - "muted"); + var entries = _manager.Entries; + var inUse = entries.Count(e => e.InUse); + + b.RegisterEntries(ent => + { + ent.Fields = new List + { + new() { Key = "code", Label = "Room Code", Monospace = true }, + }; + ent.Rows = entries + .Select(e => new Dictionary { ["code"] = e.Code.Code }) + .ToList(); + ent.SearchPlaceholder = $"Search {entries.Count} code(s)..."; + ent.AddLabel = "Add code"; + ent.OnAdd(OnAddEntry); + ent.OnEdit(OnEditEntry); + ent.OnRemove(OnRemoveEntry); + }); + + b.RegisterText(t => + { + t.Tone = "muted"; + t.Content = $"{_manager.FourCharCodes} four-char + {_manager.SixCharCodes} six-char code(s) loaded " + + $"({entries.Count} total: {entries.Count - inUse} available, {inUse} in use). " + + $"Source directory: {_manager.Path} — codes added here are also saved to 00-admin-added.txt."; + }); + + b.RegisterButton(btn => + { + btn.Label = "Reload from Boot.Codes"; + btn.Icon = "refresh"; + btn.Style = "secondary"; + btn.OnClick(ctx => + { + var count = _manager.Reload(); + return ValueTask.FromResult(AdminActionResult.Ok( + $"{count} code(s) loaded from Boot.Codes (codes in use were kept).")); + }); + }); + } + + private ValueTask OnAddEntry(AdminActionContext ctx) + { + var code = ReadCode(ctx); + if (code == null) + { + return ValueTask.FromResult(AdminActionResult.Fail("Enter a room code (4 or 6 letters).")); + } + + var (ok, message) = _manager.Add(code); + return ValueTask.FromResult(ok ? AdminActionResult.Ok(message) : AdminActionResult.Fail(message)); + } + + /// Editing a code = add the replacement, then drop the old one. + private ValueTask OnEditEntry(AdminActionContext ctx) + { + var newCode = ReadCode(ctx); + if (newCode == null) + { + return ValueTask.FromResult(AdminActionResult.Fail("Enter a room code (4 or 6 letters).")); + } + + var oldText = ctx.Value?.Trim().ToUpperInvariant(); + if (string.Equals(oldText, newCode, StringComparison.OrdinalIgnoreCase)) + { + return ValueTask.FromResult(AdminActionResult.Ok("Code unchanged.")); + } + + var (addOk, addMessage) = _manager.Add(newCode); + if (!addOk) + { + return ValueTask.FromResult(AdminActionResult.Fail(addMessage)); + } + + var note = string.Empty; + if (!string.IsNullOrEmpty(oldText)) + { + var oldCode = new GameCode(oldText); + if (!oldCode.IsInvalid) + { + var (_, removeMessage) = _manager.Remove(oldCode); + note = removeMessage; + } + } + + return ValueTask.FromResult(AdminActionResult.Ok($"Code changed to {newCode}." + note)); + } + + private ValueTask OnRemoveEntry(AdminActionContext ctx) + { + var text = ctx.Value?.Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(text)) + { + return ValueTask.FromResult(AdminActionResult.Fail("Missing room code.")); + } + + var code = new GameCode(text); + if (code.IsInvalid) + { + return ValueTask.FromResult(AdminActionResult.Fail("Not a valid room code.")); + } + + var (ok, message) = _manager.Remove(code); + return ValueTask.FromResult(ok ? AdminActionResult.Ok(message) : AdminActionResult.Fail(message)); + } + + private static string? ReadCode(AdminActionContext ctx) + { + if (ctx.Payload == null || ctx.Payload.Value.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (!ctx.Payload.Value.TryGetProperty("row", out var rowEl) + || rowEl.ValueKind != JsonValueKind.Object + || !rowEl.TryGetProperty("code", out var codeEl) + || codeEl.ValueKind != JsonValueKind.String) + { + return null; + } + + var code = codeEl.GetString()?.Trim(); + return string.IsNullOrWhiteSpace(code) ? null : code; } } diff --git a/src/Empostor.Plugin.Code/GameCodeManager.cs b/src/Empostor.Plugin.Code/GameCodeManager.cs index 4ea0c81..0a7267f 100644 --- a/src/Empostor.Plugin.Code/GameCodeManager.cs +++ b/src/Empostor.Plugin.Code/GameCodeManager.cs @@ -13,15 +13,18 @@ public sealed class GameCodeManager : IGameCodeManager private readonly ILogger _logger; private readonly IGameCodeFactory _codeFactory; - private readonly List _codes; - private readonly HashSet _inUse; private readonly object _sync = new(); + private readonly Dictionary _source = new(); + private List _codes = new(); + private HashSet _inUse = new(); + + private string AdminFilePath => System.IO.Path.Combine(Path, "00-admin-added.txt"); public string Path => System.IO.Path.GetFullPath("Boot.Codes"); - public int SixCharCodes { get; } + public int SixCharCodes { get; private set; } - public int FourCharCodes { get; } + public int FourCharCodes { get; private set; } public GameCodeManager(ILogger logger, IGameCodeFactory codeFactory) { @@ -57,6 +60,7 @@ public sealed class GameCodeManager : IGameCodeManager } var seen = new HashSet(); + _source.Clear(); foreach (var fileInfo in dirInfo.GetFiles()) { @@ -78,7 +82,10 @@ public sealed class GameCodeManager : IGameCodeManager var item = new GameCode(codeText); if (!item.IsInvalid) + { seen.Add(item); + _source[item] = fileInfo.Name; + } } } @@ -114,4 +121,119 @@ public sealed class GameCodeManager : IGameCodeManager _codes.Add(code); } } + + public IReadOnlyList Entries + { + get + { + lock (_sync) + { + var entries = new List(_codes.Count + _inUse.Count); + foreach (var code in _codes) entries.Add(new PoolEntry(code, false, SourceLabel(code))); + foreach (var code in _inUse) entries.Add(new PoolEntry(code, true, SourceLabel(code))); + entries.Sort((a, b) => string.Compare(a.Code.Code, b.Code.Code, StringComparison.OrdinalIgnoreCase)); + return entries; + } + } + } + + public (bool Ok, string Message) Add(string codeText) + { + var text = codeText.Trim().ToUpperInvariant(); + if (text.Length != 4 && text.Length != 6) + return (false, "Room code must be 4 or 6 letters."); + + if (!text.All(c => V2Chars.Contains(c))) + return (false, "Room code may only contain letters."); + + var code = new GameCode(text); + if (code.IsInvalid) + return (false, "Not a valid room code."); + + lock (_sync) + { + if (_codes.Contains(code) || _inUse.Contains(code)) + return (false, $"{text} is already in the pool."); + + _codes.Add(code); + _source[code] = AdminFilePath; + } + + // Persist so admin-added codes survive a restart (Read() merges this file back in). + try + { + File.AppendAllLines(AdminFilePath, new[] { text }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Code] Failed to append {Code} to {File}", text, AdminFilePath); + } + + return (true, $"Code {text} added to the pool."); + } + + public (bool Ok, string Message) Remove(GameCode code) + { + lock (_sync) + { + var fromAvailable = _codes.Remove(code); + var fromInUse = _inUse.Remove(code); + if (!fromAvailable && !fromInUse) + return (false, "That code is not in the pool."); + + var note = string.Empty; + if (_source.TryGetValue(code, out var source) && source != AdminFilePath) + note = $" It comes from {source} — remove it there too, or it will return on reload."; + + _source.Remove(code); + + try + { + RemoveFromAdminFile(code.Code); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Code] Failed to remove {Code} from {File}", code.Code, AdminFilePath); + } + + return (true, $"Code {code.Code} removed." + note); + } + } + + public int Reload() + { + lock (_sync) + { + var kept = new List(_inUse); + _source.Clear(); + + var list = Read(); + foreach (var code in kept) + { + if (!list.Contains(code)) list.Add(code); + if (!_source.ContainsKey(code)) _source[code] = "(in use)"; + } + + Extensions.Shuffle(list); + FourCharCodes = list.Count(c => c.Code.Length == 4); + SixCharCodes = list.Count(c => c.Code.Length == 6); + _codes = list; + _inUse = new HashSet(kept); + return _codes.Count; + } + } + + private string SourceLabel(GameCode code) + => _source.TryGetValue(code, out var source) ? source : string.Empty; + + private void RemoveFromAdminFile(string code) + { + var file = AdminFilePath; + if (!File.Exists(file)) return; + + var kept = File.ReadAllLines(file) + .Where(l => !l.Trim().Equals(code, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + File.WriteAllLines(file, kept); + } } diff --git a/src/Empostor.Plugin.Code/IGameCodeManager.cs b/src/Empostor.Plugin.Code/IGameCodeManager.cs index d836b82..777ba47 100644 --- a/src/Empostor.Plugin.Code/IGameCodeManager.cs +++ b/src/Empostor.Plugin.Code/IGameCodeManager.cs @@ -1,7 +1,11 @@ +using System.Collections.Generic; using Empostor.Api.Games; namespace Empostor.Plugin.Code; +/// One room code in the pool, for the admin panel's entry list. +public sealed record PoolEntry(GameCode Code, bool InUse, string Source); + public interface IGameCodeManager { int SixCharCodes { get; } @@ -13,4 +17,22 @@ public interface IGameCodeManager GameCode Get(); void Release(GameCode code); + + /// Every tracked code (available + in use), sorted by code text. + IReadOnlyList Entries { get; } + + /// + /// Adds a code to the available pool and appends it to Boot.Codes/00-admin-added.txt + /// so it survives a restart. Returns (true, confirmation) or (false, reason). + /// + (bool Ok, string Message) Add(string codeText); + + /// + /// Removes a code from the pool. Returns (true, message) — the message may carry a note + /// when the code comes from a hand-maintained word list file. + /// + (bool Ok, string Message) Remove(GameCode code); + + /// Re-reads Boot.Codes/*.txt. Codes currently in use are kept. + int Reload(); } diff --git a/src/Empostor.Plugins.PlayerLog/PlayerLogAdminExtension.cs b/src/Empostor.Plugins.PlayerLog/PlayerLogAdminExtension.cs index 61bbc58..b8f5dfb 100644 --- a/src/Empostor.Plugins.PlayerLog/PlayerLogAdminExtension.cs +++ b/src/Empostor.Plugins.PlayerLog/PlayerLogAdminExtension.cs @@ -35,6 +35,9 @@ public sealed class PlayerLogAdminExtension : IAdminExtension public string Section => "Server"; + /// The chat feed mirrors live server state, so keep the 1-second rebuild. + public bool AutoRefresh => true; + public void Build(AdminPanelBuilder b) { var players = _store.GetLoggedClientIds(); diff --git a/src/Empostor.Server/Http/AdminController.cs b/src/Empostor.Server/Http/AdminController.cs index c5d536a..7e77128 100644 --- a/src/Empostor.Server/Http/AdminController.cs +++ b/src/Empostor.Server/Http/AdminController.cs @@ -205,6 +205,7 @@ namespace Empostor.Server.Http title = e.Title, icon = e.Icon, section = e.Section, + autoRefresh = e.AutoRefresh, })); } diff --git a/src/Empostor.Server/Http/AdminTemplateDefaults.cs b/src/Empostor.Server/Http/AdminTemplateDefaults.cs index 3de5f51..36676af 100644 --- a/src/Empostor.Server/Http/AdminTemplateDefaults.cs +++ b/src/Empostor.Server/Http/AdminTemplateDefaults.cs @@ -1645,7 +1645,12 @@ internal static class AdminTemplateDefaults if (cur === 'bl') fBans(); if (cur === 'ge') fGamesEnd(); if (cur === 'hp') fHplp(); - if (cur.startsWith('ext-')) loadExtension(cur.slice(4)); + if (cur.startsWith('ext-')) { + // Only panels that declare themselves live are rebuilt each second; static panels + // keep their DOM stable so in-progress typing and row editing are never wiped. + const extId = cur.slice(4); + if (extId !== _extPaused && _extAutoRefresh[extId]) loadExtension(extId); + } } async function doBc() { @@ -1914,12 +1919,13 @@ internal static class AdminTemplateDefaults const box = el.closest('[data-entries]'); const row = collectEntryInputs(box, '.ent-new'); if (!row) return; + _extPaused = null; dispatch(extId, el.getAttribute('data-act'), null, { row: row }); return; } - else if (w === 'ent-edit') { startEntryEdit(el); return; } - else if (w === 'ent-save') { saveEntryEdit(el, extId); return; } - else if (w === 'ent-cancel') { loadExtension(extId); return; } + else if (w === 'ent-edit') { _extPaused = extId; startEntryEdit(el); return; } + else if (w === 'ent-save') { saveEntryEdit(el, extId); _extPaused = null; return; } + else if (w === 'ent-cancel') { _extPaused = null; loadExtension(extId); return; } else if (w === 'toggle') value = el.checked; else value = el.value; dispatch(extId, el.getAttribute('data-act'), value); @@ -1974,12 +1980,30 @@ internal static class AdminTemplateDefaults tr.querySelectorAll('input.ent-cell').forEach(function (inp) { row[inp.getAttribute('data-key')] = inp.value.trim(); }); await dispatch(extId, btn.getAttribute('data-act'), btn.getAttribute('data-row'), { row: row }); } + // While an entries widget has focus (search / add / inline edit), pause its 1-second + // rebuild so typing is not wiped. Cleared on blur and on save/cancel. + let _extPaused = null; + const _extAutoRefresh = {}; + document.addEventListener('focusin', function (ev) { + const box = ev.target.closest ? ev.target.closest('[data-entries]') : null; + if (!box) return; + const holder = box.closest('[data-ext]'); + if (holder) _extPaused = holder.getAttribute('data-ext'); + }); + document.addEventListener('focusout', function (ev) { + const el = ev.target; + if (!el.classList || !el.closest || !el.closest('[data-entries]')) return; + // Inline edit inputs keep the pause until Save/Cancel; search/add release on blur. + if (el.classList.contains('ent-cell')) return; + _extPaused = null; + }); async function initExtensions() { try { const { data } = await api('GET', '/api/admin/ext'); const navBox = document.getElementById('plugins-nav'); const ct = document.querySelector('ct'); (data || []).forEach(function (x) { + _extAutoRefresh[x.id] = x.autoRefresh === true; const ni = document.createElement('div'); ni.className = 'ni'; ni.innerHTML = iconSvg(x.icon, 16) + '' + e(x.title) + '';