mirror of
https://github.com/Empostor/Empostor.git
synced 2026-10-19 15:51:39 +00:00
fix autofresh
This commit is contained in:
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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -144,7 +144,6 @@
|
||||
"resolved": "4.5.0",
|
||||
"contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g=="
|
||||
}
|
||||
},
|
||||
"net8.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
|
||||
Reference in new issue
Block a user