fix autofresh

This commit is contained in:
linmeideli committed 2026-10-01 18:03:41 +08:00
1 parent 8883529f0c
commit 2c6c51724a
12 files changed
+327 -21

No files matched your search

Binary file not shown.
Binary file not shown.
@@ -150,7 +150,6 @@
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
}
}
},
".NETStandard,Version=v2.0/win-x64": {}
}
}
}
@@ -17,6 +17,13 @@ public interface IAdminExtension
/// <summary>Sidebar grouping label. Defaults to "Plugins".</summary>
string Section => "Plugins";
/// <summary>
/// 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.
/// </summary>
bool AutoRefresh => false;
/// <summary>Declare the panel's widgets and callbacks.</summary>
void Build(AdminPanelBuilder builder);
}
+1 -1
View File
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<VersionPrefix>2.0.0</VersionPrefix>
<VersionPrefix>2.0.1</VersionPrefix>
<AssemblyName>Empostor.Api</AssemblyName>
<AssemblyTitle>Empostor.Api</AssemblyTitle>
<Authors>AeonLucid</Authors>
+1 -2
View File
@@ -144,7 +144,6 @@
"resolved": "4.5.0",
"contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g=="
}
},
"net8.0/win-x64": {}
}
}
}
+137 -8
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class CodeAdminExtension : IAdminExtension
{
private readonly IGameCodeManager _manager;
private readonly ILogger<CodeAdminExtension> _logger;
public CodeAdminExtension(IGameCodeManager manager)
public CodeAdminExtension(IGameCodeManager manager, ILogger<CodeAdminExtension> 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<AdminEntryField>
{
new() { Key = "code", Label = "Room Code", Monospace = true },
};
ent.Rows = entries
.Select(e => new Dictionary<string, string> { ["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<AdminActionResult> 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));
}
/// <summary>Editing a code = add the replacement, then drop the old one.</summary>
private ValueTask<AdminActionResult> 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<AdminActionResult> 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;
}
}
+126 -4
View File
@@ -13,15 +13,18 @@ public sealed class GameCodeManager : IGameCodeManager
private readonly ILogger<GameCodeManager> _logger;
private readonly IGameCodeFactory _codeFactory;
private readonly List<GameCode> _codes;
private readonly HashSet<GameCode> _inUse;
private readonly object _sync = new();
private readonly Dictionary<GameCode, string> _source = new();
private List<GameCode> _codes = new();
private HashSet<GameCode> _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<GameCodeManager> logger, IGameCodeFactory codeFactory)
{
@@ -57,6 +60,7 @@ public sealed class GameCodeManager : IGameCodeManager
}
var seen = new HashSet<GameCode>();
_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<PoolEntry> Entries
{
get
{
lock (_sync)
{
var entries = new List<PoolEntry>(_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<GameCode>(_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<GameCode>(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);
}
}
@@ -1,7 +1,11 @@
using System.Collections.Generic;
using Empostor.Api.Games;
namespace Empostor.Plugin.Code;
/// <summary>One room code in the pool, for the admin panel's entry list.</summary>
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);
/// <summary>Every tracked code (available + in use), sorted by code text.</summary>
IReadOnlyList<PoolEntry> Entries { get; }
/// <summary>
/// Adds a code to the available pool and appends it to <c>Boot.Codes/00-admin-added.txt</c>
/// so it survives a restart. Returns (true, confirmation) or (false, reason).
/// </summary>
(bool Ok, string Message) Add(string codeText);
/// <summary>
/// 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.
/// </summary>
(bool Ok, string Message) Remove(GameCode code);
/// <summary>Re-reads <c>Boot.Codes/*.txt</c>. Codes currently in use are kept.</summary>
int Reload();
}
@@ -35,6 +35,9 @@ public sealed class PlayerLogAdminExtension : IAdminExtension
public string Section => "Server";
/// <summary>The chat feed mirrors live server state, so keep the 1-second rebuild.</summary>
public bool AutoRefresh => true;
public void Build(AdminPanelBuilder b)
{
var players = _store.GetLoggedClientIds();
@@ -205,6 +205,7 @@ namespace Empostor.Server.Http
title = e.Title,
icon = e.Icon,
section = e.Section,
autoRefresh = e.AutoRefresh,
}));
}
@@ -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) + '<span>' + e(x.title) + '</span>';