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) + '';