merge(vt): plain <menu> popup from the campaign branch into the slice-7 panel work (ledger union)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
dbdde0783d
30 changed files with 2530 additions and 83 deletions
|
|
@ -1170,9 +1170,12 @@ internal sealed class AppAutomationSurface
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routed to retail's ClientLocal log type (0x1A) — the channel the client
|
||||
/// uses for its own notices. Nothing reaches the server, so a plugin cannot
|
||||
/// accidentally speak in the player's name.
|
||||
/// Owner direction 2026-09-07 (register row AD-124): plugin-originated
|
||||
/// text now lands in the chat window (retail <c>Default</c>/0x00),
|
||||
/// matching Decal's own <c>AddChatText</c> behavior — not retail's
|
||||
/// ClientLocal (0x1A) SpewBox-only channel this previously used.
|
||||
/// Nothing reaches the server, so a plugin cannot accidentally speak in
|
||||
/// the player's name.
|
||||
/// </summary>
|
||||
public void PostSystemMessage(string text)
|
||||
{
|
||||
|
|
@ -1181,7 +1184,7 @@ internal sealed class AppAutomationSurface
|
|||
RuntimeCommunicationState? communication;
|
||||
lock (_gate)
|
||||
communication = _communication;
|
||||
communication?.AddText(text, RetailLogTextType.ClientLocal);
|
||||
communication?.AddText(text, RetailLogTextType.Default);
|
||||
}
|
||||
|
||||
public bool Submit(string text)
|
||||
|
|
|
|||
|
|
@ -160,6 +160,22 @@ public sealed class UiMenu : UiElement
|
|||
private bool _draggingPopupThumb;
|
||||
private float _popupThumbDragOffset;
|
||||
|
||||
/// <summary>Index into <see cref="Items"/> of the row under the pointer while
|
||||
/// the plain popup is open, or -1. Presentation-only (see
|
||||
/// <see cref="PlainHoverColor"/>'s doc) — retail's sprite popup has no
|
||||
/// equivalent hover concept, so this never affects the retail draw path.</summary>
|
||||
private int _hoveredPopupIndex = -1;
|
||||
|
||||
/// <summary>Test seam, same rationale as <see cref="CurrentFaceSpriteForTest"/>.</summary>
|
||||
internal int HoveredPopupIndexForTest => _hoveredPopupIndex;
|
||||
|
||||
/// <summary>
|
||||
/// The plain popup needs continuous MouseMove while open to keep its hover
|
||||
/// highlight tracking the cursor (retail's sprite popup has no such state, so
|
||||
/// this only matters when <see cref="RetailButtonArt"/> is false).
|
||||
/// </summary>
|
||||
public override bool ReceivesHoverMouseMove => _open && !RetailButtonArt;
|
||||
|
||||
private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px)
|
||||
// The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px
|
||||
// square; the label starts just past it (box width + small gap) so text aligns with
|
||||
|
|
@ -339,6 +355,28 @@ public sealed class UiMenu : UiElement
|
|||
/// with the list rows beneath it.</summary>
|
||||
public const float PlainPadding = 3f;
|
||||
|
||||
// ── Plain OPEN-popup chrome (RetailButtonArt = false). Owner live-client
|
||||
// report 2026-09-07 ("Drop down menus look horrible, there is also a
|
||||
// checkmark on the text there"): the S7 fix above only replaced the
|
||||
// CLOSED-state button face — opening the dropdown still drew retail's
|
||||
// tan/orange gradient panel (PopupBgSprite), the row-highlight sprites
|
||||
// (whose art bakes a checkbox/checkmark glyph into the leftmost ~17px —
|
||||
// see TextIndent's doc comment), and the ornate scrollbar chrome. VTank's
|
||||
// own open combo (VVS HudCombo, docs/research/vtank-kb/08-ui-views.md §2)
|
||||
// is a plain dark list — no gradient, no baked checkmark — so the plain
|
||||
// popup below reuses UiMarkupList's own list palette (same rationale as
|
||||
// PlainBackgroundColor/PlainBorderColor above) rather than inventing a
|
||||
// third color scheme.
|
||||
/// <summary>The current entry's row fill — identical value to
|
||||
/// <see cref="UiMarkupList.SelectedColor"/> so a plugin's open dropdown
|
||||
/// reads as the same widget family as its lists.</summary>
|
||||
public Vector4 PlainSelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
|
||||
/// <summary>A slightly lighter fill for the row under the pointer (no
|
||||
/// separate glyph or sprite swap — fills only, mirroring
|
||||
/// <see cref="PlainOpenBorderColor"/>'s "tint, never a sprite swap" rule
|
||||
/// for the closed state).</summary>
|
||||
public Vector4 PlainHoverColor { get; set; } = new(0.40f, 0.33f, 0.14f, 0.95f);
|
||||
|
||||
private bool _open;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -377,6 +415,7 @@ public sealed class UiMenu : UiElement
|
|||
OnOpen?.Invoke();
|
||||
}
|
||||
_open = value;
|
||||
_hoveredPopupIndex = -1; // stale hover from the last time this popup was open
|
||||
if (FindRoot() is not { } root) return;
|
||||
if (value) root.SetActivePopup(this, () => SetOpen(false));
|
||||
else root.ClearActivePopup(this);
|
||||
|
|
@ -617,8 +656,29 @@ public sealed class UiMenu : UiElement
|
|||
/// pass) greys out the part of the popup that overlaps it.</summary>
|
||||
protected override void OnDrawOverlay(UiRenderContext ctx)
|
||||
{
|
||||
if (!_open) return;
|
||||
|
||||
// Owner live-client report 2026-09-07: the S7 closed-state fix left the
|
||||
// OPEN popup drawing retail's gradient/checkmark art regardless of
|
||||
// RetailButtonArt. Plain mode needs no SpriteResolve at all — it draws
|
||||
// only untextured fills/outlines (see DrawGridPopupPlain/
|
||||
// DrawScrollablePopupPlain's own doc comments).
|
||||
if (!RetailButtonArt)
|
||||
{
|
||||
ctx.PushAlphaAbsolute(1f);
|
||||
try
|
||||
{
|
||||
if (Scrollable)
|
||||
DrawScrollablePopupPlain(ctx);
|
||||
else
|
||||
DrawGridPopupPlain(ctx);
|
||||
}
|
||||
finally { ctx.PopAlpha(); }
|
||||
return;
|
||||
}
|
||||
|
||||
var resolve = SpriteResolve;
|
||||
if (!_open || resolve is null) return;
|
||||
if (resolve is null) return;
|
||||
|
||||
// Force OPAQUE (a menu reads solid even though the chat window is translucent).
|
||||
// Draw bevel → panel fill → row sprites → labels, all through the sprite bucket
|
||||
|
|
@ -772,6 +832,152 @@ public sealed class UiMenu : UiElement
|
|||
}
|
||||
}
|
||||
|
||||
// ── Plain OPEN-popup drawing (RetailButtonArt = false) ──────────────────
|
||||
//
|
||||
// Owner live-client report 2026-09-07: no DAT art at all — a flat fill
|
||||
// background, a 1px border, one row per entry in the list text color, the
|
||||
// current entry filled like a list selection, the hovered entry a slightly
|
||||
// lighter fill, and NO checkmark (retail's row-highlight sprites bake a
|
||||
// checkbox/checkmark glyph into their leftmost ~17px — see TextIndent's
|
||||
// doc comment — which a flat DrawFill simply cannot draw, so plain mode
|
||||
// has none by construction). These mirror DrawGridPopup/DrawScrollablePopup's
|
||||
// shape exactly (same column/row math, same VisibleTopRow/EnabledProvider
|
||||
// rules) so hit-testing (OnHitTest/OnEvent, unchanged) stays byte-identical
|
||||
// to what it already computes for the retail path.
|
||||
|
||||
/// <summary>Plain counterpart of <see cref="DrawGridPopup"/> — flat fill +
|
||||
/// 1px outline instead of the bevel/panel sprites, per-row selected/hover
|
||||
/// fills instead of highlight sprites, <see cref="PlainTextColor"/>/
|
||||
/// <see cref="TextColorGhosted"/> labels left-aligned at
|
||||
/// <see cref="PlainPadding"/> instead of the authored <see cref="TextIndent"/>/
|
||||
/// <see cref="ItemTextCentered"/> justification (plain mode has no baked
|
||||
/// checkbox glyph to align past, and no authored per-menu justification
|
||||
/// convention — VTank's own list rows are always left-aligned).</summary>
|
||||
private void DrawGridPopupPlain(UiRenderContext ctx)
|
||||
{
|
||||
float outerTop = PopupTop;
|
||||
float inX = Border, inY = outerTop + Border;
|
||||
|
||||
ctx.DrawFill(0f, outerTop, OuterW, OuterH, PlainBackgroundColor);
|
||||
ctx.DrawRectOutline(0f, outerTop, OuterW, OuterH, PlainBorderColor, 1f);
|
||||
|
||||
for (int i = 0; i < Items.Count; i++)
|
||||
{
|
||||
int col = i / RowsPerColumn, row = i % RowsPerColumn;
|
||||
float x = inX + col * ColumnWidth, y = inY + row * RowHeight;
|
||||
bool selected = Equals(Items[i].Payload, Selected);
|
||||
if (selected)
|
||||
ctx.DrawFill(x, y, ColumnWidth, RowHeight, PlainSelectedColor);
|
||||
else if (i == _hoveredPopupIndex)
|
||||
ctx.DrawFill(x, y, ColumnWidth, RowHeight, PlainHoverColor);
|
||||
}
|
||||
|
||||
float textY = (RowHeight - LineH()) * 0.5f;
|
||||
for (int i = 0; i < Items.Count; i++)
|
||||
{
|
||||
int col = i / RowsPerColumn, row = i % RowsPerColumn;
|
||||
bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true;
|
||||
DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + PlainPadding,
|
||||
inY + row * RowHeight + textY,
|
||||
avail ? PlainTextColor : TextColorGhosted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Plain counterpart of <see cref="DrawScrollablePopup"/> — same
|
||||
/// <see cref="VisibleTopRow"/>-sliced single column, plain
|
||||
/// selected/hover row fills, and a plain scrollbar
|
||||
/// (<see cref="DrawPopupScrollbarPlain"/>) instead of the sprite chrome.</summary>
|
||||
private void DrawScrollablePopupPlain(UiRenderContext ctx)
|
||||
{
|
||||
ConfigurePopupScroll();
|
||||
|
||||
float outerTop = PopupTop;
|
||||
float inX = Border, inY = outerTop + Border;
|
||||
|
||||
ctx.DrawFill(0f, outerTop, OuterW, OuterH, PlainBackgroundColor);
|
||||
ctx.DrawRectOutline(0f, outerTop, OuterW, OuterH, PlainBorderColor, 1f);
|
||||
|
||||
int start = VisibleTopRow;
|
||||
int count = System.Math.Min(EffectiveVisibleRows, Items.Count - start);
|
||||
float textY = (RowHeight - LineH()) * 0.5f;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
int idx = start + i;
|
||||
float y = inY + i * RowHeight;
|
||||
bool selected = Equals(Items[idx].Payload, Selected);
|
||||
if (selected)
|
||||
ctx.DrawFill(inX, y, ColumnWidth, RowHeight, PlainSelectedColor);
|
||||
else if (idx == _hoveredPopupIndex)
|
||||
ctx.DrawFill(inX, y, ColumnWidth, RowHeight, PlainHoverColor);
|
||||
}
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
int idx = start + i;
|
||||
bool avail = EnabledProvider?.Invoke(Items[idx].Payload) ?? true;
|
||||
DrawLabel(ctx, Items[idx].Label, inX + PlainPadding, inY + i * RowHeight + textY,
|
||||
avail ? PlainTextColor : TextColorGhosted);
|
||||
}
|
||||
|
||||
DrawPopupScrollbarPlain(ctx, inX + ColumnWidth, inY);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plain counterpart of <see cref="DrawPopupScrollbar"/>: a 1px-bordered
|
||||
/// track and a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
|
||||
/// thumb/track/arrow-button art at all. Shares the exact same
|
||||
/// <see cref="UiScrollbar.ThumbRect"/> geometry (so the thumb's drawn
|
||||
/// position matches <see cref="HandleScrollablePopupMouseDown"/>'s hit-test
|
||||
/// math), but draws no separate up/down button glyphs — plain mode has no
|
||||
/// art for them and the click regions already work through geometry alone
|
||||
/// (<see cref="HandleScrollablePopupMouseDown"/> is unchanged).
|
||||
/// </summary>
|
||||
private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y)
|
||||
{
|
||||
if (!IsPopupScrollbarPresentationVisible) return;
|
||||
|
||||
ctx.DrawFill(x, y, ScrollbarWidth, InteriorH, PlainBackgroundColor);
|
||||
ctx.DrawRectOutline(x, y, ScrollbarWidth, InteriorH, PlainBorderColor, 1f);
|
||||
|
||||
if (!PopupScroll.HasOverflow) return;
|
||||
|
||||
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
|
||||
float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent);
|
||||
float trackTop = decExtent;
|
||||
float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent);
|
||||
var (ty, th) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen);
|
||||
ctx.DrawFill(x + 1f, y + ty, MathF.Max(0f, ScrollbarWidth - 2f), th, PlainBorderColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes the hovered popup row from a MouseMove's local (lx,ly) —
|
||||
/// same convention <see cref="OnEvent"/>'s MouseDown handling already uses
|
||||
/// (<see cref="PopupTop"/>/<see cref="Border"/>-relative). Plain-mode-only:
|
||||
/// see <see cref="ReceivesHoverMouseMove"/>'s doc comment for why this is
|
||||
/// never invoked on the retail sprite-popup path.
|
||||
/// </summary>
|
||||
private void UpdatePlainPopupHover(float lx, float ly)
|
||||
{
|
||||
float ix = lx - Border, iy = ly - (PopupTop + Border);
|
||||
_hoveredPopupIndex = Scrollable ? HoveredScrollableIndex(ix, iy) : HoveredGridIndex(ix, iy);
|
||||
}
|
||||
|
||||
private int HoveredGridIndex(float ix, float iy)
|
||||
{
|
||||
if (ix < 0 || ix >= InteriorW || iy < 0 || iy >= InteriorH) return -1;
|
||||
int col = (int)(ix / ColumnWidth);
|
||||
int row = (int)(iy / RowHeight);
|
||||
int idx = col * RowsPerColumn + row;
|
||||
return row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count ? idx : -1;
|
||||
}
|
||||
|
||||
private int HoveredScrollableIndex(float ix, float iy)
|
||||
{
|
||||
if (ix < 0 || ix >= ColumnWidth || iy < 0 || iy >= InteriorH) return -1;
|
||||
int row = (int)(iy / RowHeight);
|
||||
int idx = VisibleTopRow + row;
|
||||
return row >= 0 && row < EffectiveVisibleRows && idx >= 0 && idx < Items.Count ? idx : -1;
|
||||
}
|
||||
|
||||
/// <summary>Draw the universal 8-piece retail window bevel (corners + tiled edges +
|
||||
/// tiled centre fill) framing the rect (<paramref name="x"/>,<paramref name="y"/>,
|
||||
/// <paramref name="w"/>,<paramref name="h"/>). Reuses the same geometry +
|
||||
|
|
@ -846,11 +1052,25 @@ public sealed class UiMenu : UiElement
|
|||
}
|
||||
}
|
||||
|
||||
// Plain-mode hover tracking (see ReceivesHoverMouseMove's doc comment):
|
||||
// continuous MouseMove while the plain popup is open recomputes the
|
||||
// hovered row for DrawGridPopupPlain/DrawScrollablePopupPlain. Checked
|
||||
// BEFORE the MouseUp/HoverLeave/MouseDown-only gates below since, like
|
||||
// the Scrollable drag block above, it spans an event type none of them
|
||||
// handle.
|
||||
if (!RetailButtonArt && _open && e.Type == UiEventType.MouseMove)
|
||||
{
|
||||
UpdatePlainPopupHover(e.Data1, e.Data2);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.Type is UiEventType.MouseUp
|
||||
or UiEventType.HoverLeave
|
||||
or UiEventType.CaptureChanged)
|
||||
{
|
||||
_facePressed = false; // the momentary face flick ends here
|
||||
if (e.Type == UiEventType.HoverLeave)
|
||||
_hoveredPopupIndex = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ internal sealed record HeadlessCommandLine(
|
|||
string Command,
|
||||
string ConfigurationPath,
|
||||
HeadlessPathOverrides Paths,
|
||||
HeadlessDirectCredentials? DirectCredentials)
|
||||
HeadlessDirectCredentials? DirectCredentials,
|
||||
bool ConsoleEnabled = false)
|
||||
{
|
||||
internal static HeadlessCommandLine Parse(
|
||||
IReadOnlyList<string> arguments)
|
||||
|
|
@ -23,15 +24,26 @@ internal sealed record HeadlessCommandLine(
|
|||
string? cacheDirectory = null;
|
||||
string? user = null;
|
||||
string? password = null;
|
||||
for (int index = 1; index < arguments.Count; index += 2)
|
||||
bool console = false;
|
||||
int index = 1;
|
||||
while (index < arguments.Count)
|
||||
{
|
||||
string name = arguments[index];
|
||||
// --console is a bare flag (no value token) — the interactive
|
||||
// console for the run command (see HeadlessConsoleOptions).
|
||||
if (name == "--console")
|
||||
{
|
||||
console = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 >= arguments.Count)
|
||||
{
|
||||
throw new HeadlessCommandLineException(
|
||||
"Every command option requires a value.");
|
||||
}
|
||||
|
||||
string name = arguments[index];
|
||||
string value = arguments[index + 1];
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
|
|
@ -65,6 +77,7 @@ internal sealed record HeadlessCommandLine(
|
|||
throw new HeadlessCommandLineException(
|
||||
"Unknown command option.");
|
||||
}
|
||||
index += 2;
|
||||
}
|
||||
|
||||
if (configurationPath is null)
|
||||
|
|
@ -82,6 +95,14 @@ internal sealed record HeadlessCommandLine(
|
|||
throw new HeadlessCommandLineException(
|
||||
"Direct credentials are valid only for run mode.");
|
||||
}
|
||||
// N3: reject rather than silently ignore --console for validate mode
|
||||
// — validate never starts a session, so there is nothing for the
|
||||
// console to attach to.
|
||||
if (console && command != "run")
|
||||
{
|
||||
throw new HeadlessCommandLineException(
|
||||
"--console is valid only for run mode.");
|
||||
}
|
||||
|
||||
return new HeadlessCommandLine(
|
||||
command,
|
||||
|
|
@ -92,7 +113,8 @@ internal sealed record HeadlessCommandLine(
|
|||
cacheDirectory),
|
||||
user is null
|
||||
? null
|
||||
: new HeadlessDirectCredentials(user, password!));
|
||||
: new HeadlessDirectCredentials(user, password!),
|
||||
console);
|
||||
}
|
||||
|
||||
private static void SetOnce(ref string? destination, string value)
|
||||
|
|
|
|||
53
src/AcDream.Headless/Configuration/HeadlessConsoleOptions.cs
Normal file
53
src/AcDream.Headless/Configuration/HeadlessConsoleOptions.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
namespace AcDream.Headless.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Typed resolution for the headless interactive console (docs/plans/
|
||||
/// 2026-09-07-headless-console.md). Three inputs, first match wins:
|
||||
/// the <c>--console</c> command-line flag, the
|
||||
/// <c>ACDREAM_HEADLESS_CONSOLE</c> environment variable, and finally a
|
||||
/// terminal-shaped default — on when stdin is a real console (an operator
|
||||
/// typing at a keyboard), off when it is redirected (a script, CI runner, or
|
||||
/// piped fixture, where a background reader thread blocked on
|
||||
/// <c>ReadLine</c> would never see input and would just sit idle). See
|
||||
/// docs/launch-options.md for the documented row this owns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// S1 fix (2026-09-07 review round): the environment variable is a
|
||||
/// default-on override once it is SET at all, not a bare "equals 1" test —
|
||||
/// <c>ACDREAM_HEADLESS_CONSOLE=0</c> must disable the console even when
|
||||
/// stdin is a real terminal, matching the
|
||||
/// <c>ACDREAM_RETAIL_CLOSE_DEGRADES</c> / <c>ACDREAM_RETAIL_UI</c>
|
||||
/// convention (any value other than the literal string <c>"0"</c> enables).
|
||||
/// An UNSET variable still falls through to the terminal-shaped default —
|
||||
/// this flag's "default on" is conditional on stdin, unlike those two, but
|
||||
/// once set at all it behaves identically.
|
||||
/// </remarks>
|
||||
internal static class HeadlessConsoleOptions
|
||||
{
|
||||
internal const string EnvironmentVariable = "ACDREAM_HEADLESS_CONSOLE";
|
||||
|
||||
internal static bool Resolve(
|
||||
bool commandLineFlag,
|
||||
bool standardInputIsTerminal) =>
|
||||
Resolve(
|
||||
commandLineFlag,
|
||||
Environment.GetEnvironmentVariable,
|
||||
standardInputIsTerminal);
|
||||
|
||||
internal static bool Resolve(
|
||||
bool commandLineFlag,
|
||||
Func<string, string?> env,
|
||||
bool standardInputIsTerminal)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(env);
|
||||
if (commandLineFlag)
|
||||
return true;
|
||||
if (env(EnvironmentVariable) is null)
|
||||
return standardInputIsTerminal;
|
||||
// Default-on once the flag is set at all: any value other than the
|
||||
// literal string "0" enables the console — the same
|
||||
// ACDREAM_RETAIL_CLOSE_DEGRADES / ACDREAM_RETAIL_UI idiom.
|
||||
return !string.Equals(
|
||||
env("ACDREAM_HEADLESS_CONSOLE"), "0", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,9 @@ internal static class HeadlessEntryPoint
|
|||
TextReader standardInput,
|
||||
TextWriter output,
|
||||
TextWriter error,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
bool standardInputIsTerminal = false,
|
||||
bool standardOutputIsTerminal = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(arguments);
|
||||
ArgumentNullException.ThrowIfNull(standardInput);
|
||||
|
|
@ -74,13 +76,18 @@ internal static class HeadlessEntryPoint
|
|||
configuredPaths.Merge(commandLine.Paths));
|
||||
if (commandLine.Command == "run")
|
||||
{
|
||||
bool consoleEnabled = HeadlessConsoleOptions.Resolve(
|
||||
commandLine.ConsoleEnabled,
|
||||
standardInputIsTerminal);
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
paths,
|
||||
standardInput,
|
||||
output,
|
||||
directCredentials:
|
||||
commandLine.DirectCredentials);
|
||||
commandLine.DirectCredentials,
|
||||
consoleEnabled: consoleEnabled,
|
||||
standardOutputIsTerminal: standardOutputIsTerminal);
|
||||
return (int)host.RunAsync(cancellationToken)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
|
|
|||
58
src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs
Normal file
58
src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation for the console's rendered chat lines. A distinct, terminal-
|
||||
/// shaped format from the graphical <c>ChatVM.FormatEntry</c> retail prose
|
||||
/// (Headless cannot reference <c>AcDream.UI.Abstractions</c> — see the
|
||||
/// dependency-boundary test — and a script piping console output wants a
|
||||
/// stable, greppable "[Label] Sender: text" shape more than retail's exact
|
||||
/// sentence). It uses the SAME channel-name strings the graphical SpewBox
|
||||
/// shows (<see cref="RuntimeChatEntry.ChannelName"/>, "Tell", "Local") per
|
||||
/// the plan's requirement, just not the same sentence template.
|
||||
/// </summary>
|
||||
internal static class HeadlessConsoleChatFormatter
|
||||
{
|
||||
/// <summary>Formats one chat event for the console, or
|
||||
/// <see langword="null"/> when this kind renders nothing (there are
|
||||
/// none today — kept for forward compatibility with a future silent
|
||||
/// kind).</summary>
|
||||
internal static string? Format(in RuntimeChatEntry entry)
|
||||
{
|
||||
var kind = (ChatKind)entry.Kind;
|
||||
return kind switch
|
||||
{
|
||||
ChatKind.LocalSpeech or ChatKind.RangedSpeech =>
|
||||
$"[Local] {SpeakerLabel(entry.Sender)}: {entry.Text}",
|
||||
ChatKind.Channel =>
|
||||
$"[{ChannelLabel(entry)}] {SpeakerLabel(entry.Sender)}: {entry.Text}",
|
||||
ChatKind.Tell => FormatTell(entry),
|
||||
ChatKind.Emote or ChatKind.SoulEmote =>
|
||||
$"* {entry.Sender} {entry.Text}",
|
||||
ChatKind.Popup => $"[Popup] {entry.Text}",
|
||||
// System/Combat lines arrive pre-formatted (system messages,
|
||||
// combat translator output) — render bare, matching retail's own
|
||||
// no-prefix system-chat convention (Campaign CH user-gate round
|
||||
// 1, item B).
|
||||
_ => entry.Text,
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatTell(in RuntimeChatEntry entry) =>
|
||||
// SenderGuid != 0 is an incoming whisper (see ChatLog.OnTellReceived);
|
||||
// == 0 is our own outbound echo, where Sender carries the target
|
||||
// name (ChatLog.OnSelfSent). Both directions get the "[Tell]" label
|
||||
// the plan asks for; the "You -> " marker is what disambiguates an
|
||||
// outgoing tell from an incoming one in the bracket-label shape.
|
||||
entry.SenderGuid != 0
|
||||
? $"[Tell] {entry.Sender}: {entry.Text}"
|
||||
: $"[Tell] You -> {entry.Sender}: {entry.Text}";
|
||||
|
||||
private static string SpeakerLabel(string sender) =>
|
||||
string.IsNullOrEmpty(sender) || sender == "You" ? "You" : sender;
|
||||
|
||||
private static string ChannelLabel(in RuntimeChatEntry entry) =>
|
||||
string.IsNullOrEmpty(entry.ChannelName) ? "Channel" : entry.ChannelName;
|
||||
}
|
||||
116
src/AcDream.Headless/Hosting/HeadlessConsoleController.cs
Normal file
116
src/AcDream.Headless/Hosting/HeadlessConsoleController.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using AcDream.Runtime.Chat;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// The console's own orchestration: owns the background reader
|
||||
/// (<see cref="HeadlessConsoleInputReader"/>) and, once per session tick
|
||||
/// (<see cref="DrainDue"/>), drains every line queued since the last call
|
||||
/// and dispatches each one IN ORDER, on the calling thread — never the
|
||||
/// reader thread (Slice K's monotonic scheduler contract; see
|
||||
/// <see cref="HeadlessConsoleInputReader"/>'s own doc).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>/quit</c> and <c>/status</c> are console-only controls (the plan's
|
||||
/// "Control" section) — they never reach <see cref="ChatCommandRouter"/>,
|
||||
/// matching retail's own client-local commands. Every other line goes
|
||||
/// through <paramref name="submit"/>, which a production caller binds to
|
||||
/// <c>HeadlessSessionHost.SubmitConsoleLine</c> — the exact
|
||||
/// <see cref="ChatCommandRouter.Submit"/> pipeline (retail's client-command
|
||||
/// catalog first, then local <c>/help</c>, then the plugin-verb registry,
|
||||
/// then the retail unregistered-channel-tag fallback, then an explicit
|
||||
/// server command, then plain chat) <c>LoginCommandSequence</c> and the
|
||||
/// graphical chat box both already use.
|
||||
/// </remarks>
|
||||
internal sealed class HeadlessConsoleController : IDisposable
|
||||
{
|
||||
private readonly HeadlessConsoleInputReader _reader;
|
||||
private readonly TextWriter _output;
|
||||
private readonly Func<string, SubmitOutcome> _submit;
|
||||
private readonly Func<string> _statusText;
|
||||
private readonly CancellationTokenSource _quitRequested;
|
||||
|
||||
internal HeadlessConsoleController(
|
||||
TextReader input,
|
||||
TextWriter output,
|
||||
Func<string, SubmitOutcome> submit,
|
||||
Func<string> statusText,
|
||||
CancellationTokenSource quitRequested)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
_submit = submit ?? throw new ArgumentNullException(nameof(submit));
|
||||
_statusText = statusText ?? throw new ArgumentNullException(nameof(statusText));
|
||||
_quitRequested = quitRequested
|
||||
?? throw new ArgumentNullException(nameof(quitRequested));
|
||||
_reader = new HeadlessConsoleInputReader(input);
|
||||
}
|
||||
|
||||
/// <summary>Number of lines handled by the most recent
|
||||
/// <see cref="DrainDue"/> call — a test seam for the reader-thread
|
||||
/// ordering assertion.</summary>
|
||||
internal int LastDrainCount { get; private set; }
|
||||
|
||||
/// <summary>Test seam: lets a bounded-fixture test wait for the
|
||||
/// background reader thread to reach EOF before calling
|
||||
/// <see cref="DrainDue"/>, instead of sleeping or polling.</summary>
|
||||
internal HeadlessConsoleInputReader Reader => _reader;
|
||||
|
||||
internal void DrainDue()
|
||||
{
|
||||
int count = 0;
|
||||
while (_reader.TryDequeue(out string line))
|
||||
{
|
||||
Handle(line);
|
||||
count++;
|
||||
}
|
||||
LastDrainCount = count;
|
||||
}
|
||||
|
||||
private void Handle(string rawLine)
|
||||
{
|
||||
string trimmed = rawLine.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
return;
|
||||
|
||||
if (trimmed.Equals("/quit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WriteLine("quitting (graceful logout)");
|
||||
_quitRequested.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.Equals("/status", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WriteLine(_statusText());
|
||||
return;
|
||||
}
|
||||
|
||||
// S4 (2026-09-07 review round): mirrors
|
||||
// LoginCommandSequence.DrainDue's own try/catch and
|
||||
// UnknownCommand/Dropped reporting — a console typo (a bad line, a
|
||||
// downstream bug in a plugin verb handler) must never escape to the
|
||||
// scheduler's per-session quarantine catch and fault the whole
|
||||
// session, and the operator deserves the same "this line did
|
||||
// nothing" signal LoginCommandSequence already gives a login-line
|
||||
// failure.
|
||||
try
|
||||
{
|
||||
SubmitOutcome outcome = _submit(rawLine);
|
||||
if (outcome is SubmitOutcome.UnknownCommand or SubmitOutcome.Dropped)
|
||||
WriteLine($"not handled ({outcome}): {rawLine}");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
WriteLine($"command failed: {error.GetBaseException().Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteLine(string text)
|
||||
{
|
||||
_output.WriteLine(text);
|
||||
_output.Flush();
|
||||
}
|
||||
|
||||
public void Dispose() => _reader.Dispose();
|
||||
}
|
||||
94
src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs
Normal file
94
src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
using System.Collections.Concurrent;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Reads lines from a <see cref="TextReader"/> on one dedicated background
|
||||
/// thread and hands them to whoever drains <see cref="TryDequeue"/>. Slice K's
|
||||
/// scheduler contract binds every mutating call to one thread for a session's
|
||||
/// whole lifetime (#368 — collision generations refuse migration), so console
|
||||
/// input can never be executed from this thread: it only ever enqueues, and
|
||||
/// the session tick is the sole reader of <see cref="TryDequeue"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="TextReader.ReadLine"/> has no cancellable overload, so a real
|
||||
/// <c>Console.In</c> reader can be blocked on it when the process wants to
|
||||
/// exit. The thread is a background thread (does not keep the process alive)
|
||||
/// and <see cref="Dispose"/> only requests the loop stop at its next
|
||||
/// opportunity — it does not abort a pending read. A closed/EOF input (a
|
||||
/// piped fixture reaching its last line, or the real console's stdin handle
|
||||
/// closing) ends the loop on its own; <see cref="EndOfInput"/> lets a test
|
||||
/// wait for that deterministically instead of polling or sleeping.
|
||||
/// </remarks>
|
||||
internal sealed class HeadlessConsoleInputReader : IDisposable
|
||||
{
|
||||
private readonly TextReader _input;
|
||||
private readonly ConcurrentQueue<string> _queue = new();
|
||||
private readonly Thread _thread;
|
||||
private volatile bool _stopRequested;
|
||||
|
||||
internal HeadlessConsoleInputReader(TextReader input)
|
||||
{
|
||||
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||
_thread = new Thread(ReadLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "acdream-headless-console-reader",
|
||||
};
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
/// <summary>Set once the reader loop has returned (EOF or stop request).
|
||||
/// Tests wait on this instead of sleeping/polling for a deterministic
|
||||
/// "every line the fixture will ever produce has been enqueued" signal.
|
||||
/// </summary>
|
||||
internal ManualResetEventSlim EndOfInput { get; } = new(initialState: false);
|
||||
|
||||
/// <summary>Dequeues the next queued line in FIFO order, or returns
|
||||
/// <see langword="false"/> if none is queued yet. Never blocks.</summary>
|
||||
internal bool TryDequeue(out string line) => _queue.TryDequeue(out line!);
|
||||
|
||||
private void ReadLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_stopRequested)
|
||||
{
|
||||
string? line = _input.ReadLine();
|
||||
if (line is null)
|
||||
return;
|
||||
_queue.Enqueue(line);
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// The input was disposed out from under a pending read (process
|
||||
// teardown racing the reader thread) — end the loop quietly,
|
||||
// same as EOF.
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A redirected stream can fail mid-read (e.g. a broken pipe).
|
||||
// Treat it the same as EOF rather than crashing the process.
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndOfInput.Set();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Requests the read loop stop at its next opportunity. Does
|
||||
/// not abort a <see cref="TextReader.ReadLine"/> already in progress —
|
||||
/// the thread is background, so it cannot block process exit.
|
||||
/// Deliberately does NOT dispose <see cref="EndOfInput"/>: the read
|
||||
/// loop's own <c>finally</c> sets it from the reader thread, and racing
|
||||
/// that against a Dispose() here (an unhandled
|
||||
/// <see cref="ObjectDisposedException"/> on a background thread
|
||||
/// terminates the process) is worse than leaking one small
|
||||
/// synchronization handle for the process's remaining lifetime.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_stopRequested = true;
|
||||
}
|
||||
}
|
||||
111
src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs
Normal file
111
src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// One presentation over the K2 bot event stream
|
||||
/// (<see cref="IRuntimeEventObserver"/>) — the SAME typed events a headless
|
||||
/// bot policy observes (<c>HeadlessBotPolicy.cs</c>) — rendered as plain
|
||||
/// lines. Every write goes through <see cref="WriteLine"/>, so a test can
|
||||
/// assert on exactly what a real console would have printed without a
|
||||
/// terminal.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessConsoleRenderer : IRuntimeEventObserver
|
||||
{
|
||||
private const string Reset = "[0m";
|
||||
private const string Dim = "[2m";
|
||||
|
||||
private readonly TextWriter _output;
|
||||
private readonly bool _useColor;
|
||||
|
||||
internal HeadlessConsoleRenderer(TextWriter output, bool useColor)
|
||||
{
|
||||
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
_useColor = useColor;
|
||||
}
|
||||
|
||||
public void OnChat(in RuntimeChatDelta delta)
|
||||
{
|
||||
string? line = HeadlessConsoleChatFormatter.Format(delta.Entry);
|
||||
if (!string.IsNullOrEmpty(line))
|
||||
WriteLine(line, dim: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's transient "interface text" (SpewBox, <c>ClientLocal</c>
|
||||
/// type) never touches <see cref="RuntimeCommunicationState.Chat"/> —
|
||||
/// see <c>RuntimeCommunicationState.AddText</c> — so it never reaches
|
||||
/// <see cref="OnChat"/>. <c>HeadlessConsoleSpewBoxPump</c> calls this
|
||||
/// directly, once per console tick, for whatever text is newly visible
|
||||
/// in the polled <see cref="AcDream.Core.Chat.SpewBoxState"/> — the
|
||||
/// SAME seam the graphical overlay's own SpewBox controller reads, so
|
||||
/// server- and plugin-driven interface text prints here too, not only
|
||||
/// the console's own submissions. Default weight (N5) — this is
|
||||
/// player-visible interface text, not scheduling noise.
|
||||
/// </summary>
|
||||
internal void WriteInterfaceText(string text) => WriteLine(text, dim: false);
|
||||
|
||||
public void OnLifecycle(in RuntimeLifecycleDelta delta)
|
||||
{
|
||||
switch (delta.Current)
|
||||
{
|
||||
case RuntimeLifecycleState.InWorld:
|
||||
WriteLine("entered world", dim: true);
|
||||
break;
|
||||
case RuntimeLifecycleState.Stopping:
|
||||
WriteLine("disconnecting", dim: true);
|
||||
break;
|
||||
case RuntimeLifecycleState.Faulted:
|
||||
WriteLine("session faulted", dim: true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnCommand(in RuntimeCommandDelta delta)
|
||||
{
|
||||
if (delta.Status == RuntimeCommandStatus.Rejected)
|
||||
{
|
||||
WriteLine(
|
||||
$"command rejected: {delta.Domain} {delta.Text}".TrimEnd(),
|
||||
dim: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnPortal(in RuntimePortalDelta delta)
|
||||
{
|
||||
if (delta.Portal.IsMaterialized)
|
||||
{
|
||||
WriteLine(
|
||||
$"portal -> cell 0x{delta.Portal.DestinationCell:X8}",
|
||||
dim: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnEntity(in RuntimeEntityDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnInventory(in RuntimeInventoryDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnMovement(in RuntimeMovementDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnCombat(in RuntimeCombatDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N5 (2026-09-07 review round): only lifecycle/command/portal lines are
|
||||
/// dimmed — scheduling and session-status noise, not player-visible
|
||||
/// content. Chat and interface text print at the terminal's default
|
||||
/// weight.
|
||||
/// </summary>
|
||||
private void WriteLine(string text, bool dim)
|
||||
{
|
||||
_output.WriteLine(_useColor && dim ? Dim + text + Reset : text);
|
||||
_output.Flush();
|
||||
}
|
||||
}
|
||||
63
src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs
Normal file
63
src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// S5 (2026-09-07 review round, docs/plans/2026-09-07-headless-console.md):
|
||||
/// polls <see cref="SpewBoxState"/> on the console's own per-tick pump — the
|
||||
/// SAME seam <c>AcDream.App.UI.SpewBoxController.Tick</c> drives for the
|
||||
/// graphical overlay. Retail's transient "interface text"
|
||||
/// (<see cref="RetailLogTextType.ClientLocal"/>, routed by
|
||||
/// <c>RuntimeCommunicationState.AddText</c>) never touches
|
||||
/// <c>RuntimeCommunicationState.Chat</c>/<c>RuntimeChatDelta</c>, so it is
|
||||
/// otherwise invisible to a console that only observes the chat event
|
||||
/// stream — this is true for EVERY producer of that text (a bad-args
|
||||
/// refusal from the console's own submit, but also a server-driven refusal
|
||||
/// or a plugin's own interface-text write), not just the console's own
|
||||
/// submissions. This replaces the earlier per-call
|
||||
/// <c>HeadlessConsoleChatFeedback</c> decorator, which only ever saw text
|
||||
/// produced by the console's own <c>SubmitConsoleLine</c> calls.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessConsoleSpewBoxPump
|
||||
{
|
||||
private readonly SpewBoxState _spewBox;
|
||||
private readonly Func<double> _nowSeconds;
|
||||
private readonly Action<string> _writeInterfaceText;
|
||||
private SpewBoxEntry[] _lastSeen = [];
|
||||
|
||||
internal HeadlessConsoleSpewBoxPump(
|
||||
SpewBoxState spewBox,
|
||||
Func<double> nowSeconds,
|
||||
Action<string> writeInterfaceText)
|
||||
{
|
||||
_spewBox = spewBox ?? throw new ArgumentNullException(nameof(spewBox));
|
||||
_nowSeconds = nowSeconds
|
||||
?? throw new ArgumentNullException(nameof(nowSeconds));
|
||||
_writeInterfaceText = writeInterfaceText
|
||||
?? throw new ArgumentNullException(nameof(writeInterfaceText));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains any pending SpewBox text into the visible set (exactly
|
||||
/// <see cref="SpewBoxState.Tick"/>'s contract — the same drain
|
||||
/// <c>SpewBoxVM.Lines</c> performs for the graphical overlay) and prints
|
||||
/// any entry that was not part of the previous call's visible snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="SpewBoxState.Snapshot"/> is newest-first
|
||||
/// (retail's <c>InsertItem(item, 0)</c>); this walks it back-to-front so
|
||||
/// newly-visible entries print in the order they were actually
|
||||
/// enqueued, not newest-first.
|
||||
/// </remarks>
|
||||
internal void Pump()
|
||||
{
|
||||
_spewBox.Tick(_nowSeconds());
|
||||
SpewBoxEntry[] current = _spewBox.Snapshot();
|
||||
for (int i = current.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (Array.IndexOf(_lastSeen, current[i]) < 0)
|
||||
_writeInterfaceText(current[i].Text);
|
||||
}
|
||||
_lastSeen = current;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,16 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||
private readonly HeadlessProcessContentOwner? _content;
|
||||
private readonly HeadlessProcessResourceSampler _resources;
|
||||
/// <summary>
|
||||
/// Headless console (docs/plans/2026-09-07-headless-console.md): always
|
||||
/// created, cancelled only by <c>/quit</c> — linking it into the
|
||||
/// scheduler's run token below costs nothing when the console is
|
||||
/// disabled (it simply never fires) and keeps <see cref="RunOnUpdateThread"/>
|
||||
/// free of a console-shaped branch.
|
||||
/// </summary>
|
||||
private readonly CancellationTokenSource _consoleQuitRequested = new();
|
||||
private readonly HeadlessConsoleController? _console;
|
||||
private readonly IDisposable? _consoleRendererSubscription;
|
||||
private int _disposeIndex;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -26,7 +36,9 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
ILiveSessionOperations? sessionOperations = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
IHeadlessProcessContentFactory? contentFactory = null,
|
||||
HeadlessDirectCredentials? directCredentials = null)
|
||||
HeadlessDirectCredentials? directCredentials = null,
|
||||
bool consoleEnabled = false,
|
||||
bool standardOutputIsTerminal = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
|
@ -65,6 +77,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
paths.VtankProfilesDirectory);
|
||||
HeadlessProcessContentOwner? content = null;
|
||||
HeadlessProcessResourceSampler? resources = null;
|
||||
HeadlessConsoleController? console = null;
|
||||
IDisposable? consoleRendererSubscription = null;
|
||||
// FA6: constructed unconditionally — cheap, and every non-gate
|
||||
// session simply never reads or writes it (see the coordinator's
|
||||
// own class doc).
|
||||
|
|
@ -137,9 +151,62 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
_resources = resources;
|
||||
_content = content;
|
||||
_disposeIndex = _sessions.Length - 1;
|
||||
|
||||
// Headless console (docs/plans/2026-09-07-headless-console.md):
|
||||
// "Multi-session. Out of scope for the first cut" — attach only
|
||||
// to a single-session process. Constructed AFTER every
|
||||
// session's credential resolution above (which may itself read
|
||||
// a line from standardInput for a StandardInput-provider
|
||||
// credential) so the console's own reader thread never races a
|
||||
// password prompt for the same stream.
|
||||
if (consoleEnabled && _sessions.Length == 1)
|
||||
{
|
||||
HeadlessSessionHost session = _sessions[0];
|
||||
var renderer = new HeadlessConsoleRenderer(
|
||||
diagnostics,
|
||||
useColor: standardOutputIsTerminal);
|
||||
consoleRendererSubscription =
|
||||
session.Runtime.Subscribe(renderer);
|
||||
HeadlessConsoleController controller = new(
|
||||
standardInput,
|
||||
diagnostics,
|
||||
session.SubmitConsoleLine,
|
||||
() => BuildStatusText(session),
|
||||
_consoleQuitRequested);
|
||||
// S5 (2026-09-07 review round): poll the SAME SpewBoxState
|
||||
// seam the graphical overlay's SpewBoxController.Tick reads
|
||||
// (RuntimeCommunicationState.AddText's ClientLocal branch —
|
||||
// it never touches Chat/RuntimeChatDelta) so server- and
|
||||
// plugin-driven interface text prints too, not only the
|
||||
// console's own submissions. Replaces the earlier per-call
|
||||
// HeadlessConsoleChatFeedback decorator, which only saw text
|
||||
// produced by THIS console's own SubmitConsoleLine calls.
|
||||
var spewPump = new HeadlessConsoleSpewBoxPump(
|
||||
session.Runtime.CommunicationOwner.SpewBox,
|
||||
() => session.Runtime.Clock.SimulationTimeSeconds,
|
||||
renderer.WriteInterfaceText);
|
||||
session.ConsolePump = () =>
|
||||
{
|
||||
controller.DrainDue();
|
||||
spewPump.Pump();
|
||||
};
|
||||
console = controller;
|
||||
}
|
||||
else if (consoleEnabled)
|
||||
{
|
||||
// S7 (2026-09-07 review round): a silent skip here read as
|
||||
// "--console worked" to an operator with no way to tell
|
||||
// otherwise — the launcher's multi-session mode is a
|
||||
// legitimate, common configuration, so say so explicitly.
|
||||
_diagnostics.Message("console", "single-session only");
|
||||
}
|
||||
_console = console;
|
||||
_consoleRendererSubscription = consoleRendererSubscription;
|
||||
}
|
||||
catch
|
||||
{
|
||||
console?.Dispose();
|
||||
consoleRendererSubscription?.Dispose();
|
||||
resources?.Dispose();
|
||||
for (int index = sessions.Count - 1; index >= 0; index--)
|
||||
sessions[index].Dispose();
|
||||
|
|
@ -148,6 +215,29 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>/status</c>: generation, position (or "unknown" without a live
|
||||
/// movement controller — a content-less host, or before the first
|
||||
/// accepted placement), and the plugin-visible macro state this host
|
||||
/// can actually observe today (loaded-plugin count — no plugin
|
||||
/// currently reports a richer status string; see the plan's "if the
|
||||
/// plugin reports one").
|
||||
/// </summary>
|
||||
private static string BuildStatusText(HeadlessSessionHost session)
|
||||
{
|
||||
RuntimeMovementSnapshot movement =
|
||||
session.Runtime.MovementOwner.Snapshot;
|
||||
string position = movement.HasController
|
||||
? $"cell=0x{movement.Position.ObjCellId:X8} "
|
||||
+ $"local=({movement.Position.Frame.Origin.X:F2},"
|
||||
+ $"{movement.Position.Frame.Origin.Y:F2},"
|
||||
+ $"{movement.Position.Frame.Origin.Z:F2})"
|
||||
: "unknown";
|
||||
return $"generation={session.Runtime.Generation.Value} "
|
||||
+ $"position={position} "
|
||||
+ $"plugins={session.Plugins.LoadedCount} loaded";
|
||||
}
|
||||
|
||||
internal HeadlessSessionHost Session => _sessions.Length == 1
|
||||
? _sessions[0]
|
||||
: throw new InvalidOperationException(
|
||||
|
|
@ -249,12 +339,21 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
_scheduler.CaptureSnapshot(),
|
||||
_content);
|
||||
|
||||
// Headless console: /quit cancels _consoleQuitRequested, which this
|
||||
// linked token propagates into the scheduler's own wait loop —
|
||||
// Run() returns normally (its loop condition simply goes false),
|
||||
// the SAME graceful-exit path an external Ctrl+C/SIGTERM already
|
||||
// takes. Linking costs nothing when the console never fires.
|
||||
using CancellationTokenSource linkedQuit =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
_consoleQuitRequested.Token);
|
||||
try
|
||||
{
|
||||
_scheduler.Run(cancellationToken);
|
||||
_scheduler.Run(linkedQuit.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
when (cancellationToken.IsCancellationRequested)
|
||||
when (linkedQuit.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception error)
|
||||
|
|
@ -277,6 +376,9 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_console?.Dispose();
|
||||
_consoleRendererSubscription?.Dispose();
|
||||
_consoleQuitRequested.Dispose();
|
||||
while (_disposeIndex >= 0)
|
||||
{
|
||||
_sessions[_disposeIndex].Dispose();
|
||||
|
|
|
|||
|
|
@ -183,6 +183,25 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private readonly IHeadlessBotPolicy _policy;
|
||||
private readonly IDisposable _policySubscription;
|
||||
private readonly HeadlessPluginSession _pluginSession;
|
||||
/// <summary>
|
||||
/// Headless console (docs/plans/2026-09-07-headless-console.md): the
|
||||
/// SAME plugin-verb registry <see cref="_chatCommandSurface"/>'s bus
|
||||
/// forwards to (via <c>TryHandlePluginCommand</c>) and
|
||||
/// <see cref="HeadlessPluginSession.Create"/> hands to every loaded
|
||||
/// plugin. Exposed only so a test can register a verb directly without
|
||||
/// loading a real plugin assembly — production callers reach it
|
||||
/// exclusively through <see cref="SubmitConsoleLine"/> /
|
||||
/// <see cref="LoginCommandSequence"/>, never this field.
|
||||
/// </summary>
|
||||
private readonly AcDream.Core.Plugins.PluginCommandRegistry _pluginCommands;
|
||||
/// <summary>
|
||||
/// Headless console: the SAME retained bus <c>LoginCommandSequence</c>
|
||||
/// submits through — see <see cref="SubmitConsoleLine"/>. One instance
|
||||
/// for the host's whole lifetime; <see cref="CreateEventRoute"/>
|
||||
/// attaches/detaches a fresh <see cref="LiveChatCommandRoute"/> to it on
|
||||
/// every (re)connect, exactly as it does today for login commands.
|
||||
/// </summary>
|
||||
private readonly LiveChatCommandSurface _chatCommandSurface;
|
||||
private readonly LiveSessionHost _liveSession;
|
||||
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
|
||||
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
||||
|
|
@ -453,6 +472,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
Runtime = runtime;
|
||||
Commands = commands;
|
||||
_liveSession = liveSession;
|
||||
_pluginCommands = pluginCommands;
|
||||
_chatCommandSurface = chatCommandSurface;
|
||||
_statusWriter = statusWriter;
|
||||
_localPlayerFrame =
|
||||
runtime.CreateLocalPlayerFrameController(
|
||||
|
|
@ -521,7 +542,20 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
/// </summary>
|
||||
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
|
||||
internal HeadlessPluginSession Plugins => _pluginSession;
|
||||
/// <summary>Test seam (mirrors <see cref="OptionsSeeder"/>'s own
|
||||
/// pattern): registers a plugin verb directly against the SAME registry
|
||||
/// a real loaded plugin would use, without loading a plugin assembly.
|
||||
/// </summary>
|
||||
internal AcDream.Core.Plugins.PluginCommandRegistry PluginCommands =>
|
||||
_pluginCommands;
|
||||
internal string SessionId => _descriptor.Id;
|
||||
/// <summary>
|
||||
/// Headless console: invoked at the end of every <see cref="Tick"/> so
|
||||
/// console input drains ON the session tick, in order, never on the
|
||||
/// reader thread. <see langword="null"/> (every non-console host) costs
|
||||
/// nothing extra per tick.
|
||||
/// </summary>
|
||||
internal Action? ConsolePump { get; set; }
|
||||
internal string ActiveCharacterName { get; private set; } =
|
||||
string.Empty;
|
||||
internal bool IsPolicyComplete =>
|
||||
|
|
@ -563,6 +597,31 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_pendingConfirmation = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The headless console's ONE entry point for a typed line — the exact
|
||||
/// pipeline <see cref="LoginCommandSequence"/> already submits through:
|
||||
/// <see cref="ChatCommandRouter.Submit"/> against this host's retained
|
||||
/// <see cref="_chatCommandSurface"/>. Dispatch order (matching
|
||||
/// <see cref="ChatCommandRouter"/>'s own class doc): retail's client-
|
||||
/// command catalog first, then the local <c>/help</c> presentation
|
||||
/// command, then the plugin-verb registry, then the retail unregistered-
|
||||
/// channel-tag fallback, then an explicit server command, then plain
|
||||
/// chat. Retail's transient interface text (bad-args refusals, unknown-
|
||||
/// command text — never routed through
|
||||
/// <see cref="AcDream.Runtime.RuntimeChatDelta"/>, see
|
||||
/// <c>RuntimeCommunicationState.AddText</c>'s <c>ClientLocal</c> branch)
|
||||
/// lands in the shared <see cref="RuntimeCommunicationState.SpewBox"/>
|
||||
/// exactly like every other producer of that text; the console's own
|
||||
/// per-tick pump polls it (see <c>HeadlessConsoleSpewBoxPump</c>)
|
||||
/// instead of this call decorating its own feedback.
|
||||
/// </summary>
|
||||
internal SubmitOutcome SubmitConsoleLine(string line) =>
|
||||
ChatCommandRouter.Submit(
|
||||
line,
|
||||
new RuntimeChatCommandFeedback(Runtime.CommunicationOwner),
|
||||
_chatCommandSurface,
|
||||
ChatChannelKind.Say);
|
||||
|
||||
internal RuntimeSessionStartResult Start()
|
||||
{
|
||||
// Campaign LA slice LA1: "started" = session host start — the
|
||||
|
|
@ -607,6 +666,10 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_localPlayerFrame.RunPostNetworkCommandPhase();
|
||||
Runtime.ActionOwner.CombatAttack.Tick();
|
||||
_policy.Tick(Runtime, Commands);
|
||||
// Headless console: drain any input queued by the background reader
|
||||
// thread since the last tick, in order, on THIS thread — never the
|
||||
// reader thread (see HeadlessConsoleInputReader's own doc).
|
||||
ConsolePump?.Invoke();
|
||||
}
|
||||
|
||||
internal RuntimeTeardownAcknowledgement Stop(string reason = "stopped")
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ try
|
|||
Console.In,
|
||||
Console.Out,
|
||||
Console.Error,
|
||||
cancellation.Token);
|
||||
cancellation.Token,
|
||||
standardInputIsTerminal: !Console.IsInputRedirected,
|
||||
standardOutputIsTerminal: !Console.IsOutputRedirected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -302,10 +302,20 @@ public interface IPluginChat
|
|||
Array.Empty<PluginChatMessage>();
|
||||
|
||||
/// <summary>
|
||||
/// Post a client-local system line, the channel retail uses for the
|
||||
/// client's own notices. It is local to this client: nothing is sent to the
|
||||
/// server and no other player sees it.
|
||||
/// Post a plugin-originated system line into the chat window. It is
|
||||
/// local to this client: nothing is sent to the server and no other
|
||||
/// player sees it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Owner direction 2026-09-07 (register row AD-124): this used to route
|
||||
/// through retail's <c>ClientLocal</c> (0x1A) channel — the SpewBox
|
||||
/// overlay every <c>ChatInterface</c> window's default filter excludes.
|
||||
/// The owner explicitly overrode that for plugin text, matching Decal's
|
||||
/// own <c>AddChatText</c> behavior: plugin output now lands in the chat
|
||||
/// transcript (retail <c>Default</c>/0x00) so it is actually visible and
|
||||
/// scrolls back, never the transient overlay. See
|
||||
/// <c>AppAutomationSurface.PostSystemMessage</c> for the implementation.
|
||||
/// </remarks>
|
||||
void PostSystemMessage(string text);
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -120,13 +120,19 @@ public static class ChatCommandRouter
|
|||
// Command-shaped but no letter verb ("/", "//shrug", "@ x"):
|
||||
// refuse locally rather than putting junk on the wire or in speech.
|
||||
// #363/#367: this is one of retail's DoHelp-family "Unknown
|
||||
// command" fallbacks (0x1A ClientLocal, SpewBox-only) — routed
|
||||
// through the interface-text seam now that one exists, instead of
|
||||
// the chat scroll.
|
||||
// command" fallbacks — retail itself types it 0x1A ClientLocal
|
||||
// (SpewBox-only). Owner-directed override 2026-09-07 (register row
|
||||
// AD-124): unknown-command refusals specifically must reach the
|
||||
// chat window instead, so ShowSystemMessage (chat scroll, retail
|
||||
// Default/0x00) replaces ShowInterfaceText (SpewBox) HERE ONLY —
|
||||
// do not "fix" this back to ShowInterfaceText; that would silently
|
||||
// re-hide the refusal the owner asked to keep visible. Real
|
||||
// retail-command bad-argument refusals (AP-183) are UNCHANGED and
|
||||
// still use ShowInterfaceText/SpewBox elsewhere in this file.
|
||||
if (trimmed[0] is '/' or '@'
|
||||
&& (trimmed.Length == 1 || !char.IsLetter(trimmed[1])))
|
||||
{
|
||||
feedback.ShowInterfaceText(
|
||||
feedback.ShowSystemMessage(
|
||||
$"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands.");
|
||||
return SubmitOutcome.UnknownCommand;
|
||||
}
|
||||
|
|
@ -345,7 +351,11 @@ public static class ChatCommandRouter
|
|||
// SAME fallback an unregistered verb gets — DoHelp's help-
|
||||
// pointer-null guard skips its callback branch entirely. See
|
||||
// RetailCommandHelpTable.CatalogVerbsWithNoRetailHelp's remarks.
|
||||
feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
|
||||
// Owner-directed override 2026-09-07 (register row AD-124):
|
||||
// this is an "Unknown command" refusal, so ShowSystemMessage
|
||||
// (chat scroll) replaces ShowInterfaceText (SpewBox) here —
|
||||
// do not revert.
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.UnknownCommand);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -370,10 +380,14 @@ public static class ChatCommandRouter
|
|||
return;
|
||||
}
|
||||
|
||||
// Retail types this 0x1A (ClientLocal) -> SpewBox-only. #363/#367:
|
||||
// now routed through IChatCommandFeedback.ShowInterfaceText instead
|
||||
// of the chat scroll — see RetailCommandHelpTable.UnknownCommand.
|
||||
feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
|
||||
// Retail types this 0x1A (ClientLocal) -> SpewBox-only. #363/#367
|
||||
// originally routed it through IChatCommandFeedback.ShowInterfaceText
|
||||
// for exactly that reason. Owner-directed override 2026-09-07
|
||||
// (register row AD-124): "Unknown command" refusals must reach the
|
||||
// chat window instead, so ShowSystemMessage replaces
|
||||
// ShowInterfaceText here — see RetailCommandHelpTable.UnknownCommand
|
||||
// and do not revert this to ShowInterfaceText.
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.UnknownCommand);
|
||||
}
|
||||
|
||||
private static bool EqAny(string value, params string[] options)
|
||||
|
|
|
|||
|
|
@ -210,15 +210,29 @@ namespace AcDream.Runtime.Chat;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Issue #363 (2026-08-10):</b> <c>ChatCommandRouter</c> now routes this
|
||||
/// <b>Issue #363 (2026-08-10):</b> <c>ChatCommandRouter</c> routed this
|
||||
/// fallback (and every other <c>0x1A</c> command-refusal call site) through
|
||||
/// <c>IChatCommandFeedback.ShowInterfaceText</c> — an optional hook the host
|
||||
/// wires to <c>RuntimeCommunicationState.AddText</c>, the same SpewBox
|
||||
/// chokepoint every other producer of interface text uses. The retained
|
||||
/// <c>ChatVM</c> implements this four-member feedback seam without entering
|
||||
/// command-routing code. Closes ISSUES.md #367 and retires register row
|
||||
/// command-routing code. Closed ISSUES.md #367 and retired register row
|
||||
/// AP-186.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Owner-directed override 2026-09-07 (register row AD-124):</b> the
|
||||
/// paragraph above still describes retail's own behavior faithfully, but
|
||||
/// acdream no longer matches it for exactly this <see cref="UnknownCommand"/>
|
||||
/// text (both its call sites in <c>ChatCommandRouter.EmitVerbHelp</c>) and
|
||||
/// the sibling "Unknown command: {verb}." refusal in
|
||||
/// <c>ChatCommandRouter.Submit</c>'s own body: those three sites now call
|
||||
/// <c>IChatCommandFeedback.ShowSystemMessage</c> (the chat scroll, retail
|
||||
/// <c>Default</c>/0x00) instead of <c>ShowInterfaceText</c> (SpewBox), so an
|
||||
/// unknown command is actually visible and stays in the transcript. Every
|
||||
/// OTHER <c>0x1A</c> refusal this class documents (bad-args, AP-183) is
|
||||
/// unchanged and still SpewBox-only.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailCommandHelpTable
|
||||
{
|
||||
|
|
@ -266,9 +280,16 @@ public static class RetailCommandHelpTable
|
|||
// acclient_2013_pseudo_c.txt:395052 (u"Unknown command", UTF-16LE) --
|
||||
// DoHelp's fallback when the verb hash lookup fails, or resolves to an
|
||||
// entry with no registered help callback. Retail types this 0x1A
|
||||
// (ClientLocal) -- SpewBox-only; see the class remarks' routing note --
|
||||
// ChatCommandRouter routes it through IChatCommandFeedback.ShowInterfaceText
|
||||
// (issue #363), closing #367.
|
||||
// (ClientLocal) -- SpewBox-only; see the class remarks' routing note.
|
||||
// Owner-directed override 2026-09-07 (register row AD-124): acdream
|
||||
// now routes THIS text (and the sibling "Unknown command: {verb}."
|
||||
// refusal in ChatCommandRouter.Submit's own body) through
|
||||
// IChatCommandFeedback.ShowSystemMessage (chat scroll) instead of
|
||||
// ShowInterfaceText (SpewBox) — a deliberate deviation from retail's
|
||||
// own 0x1A typing, scoped to unknown-command text only. Do not revert
|
||||
// this to ShowInterfaceText without a fresh owner direction; every
|
||||
// other 0x1A refusal in ChatCommandRouter (bad-args, AP-183) is
|
||||
// unaffected and still uses ShowInterfaceText/SpewBox.
|
||||
public const string UnknownCommand = "Unknown command";
|
||||
|
||||
// @mr/@pr are registered with a NULL function pointer in the 2013
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue