fix(app,headless): Campaign CC slice CC4 re-review round — R1 arbiter + R2-R4
CC4 re-review returned NOT CLOSED: R1 (MEDIUM, blocking) is a new residual the F1 fix itself introduced, plus three LOW riders (R2, R3, R4). R1 — nulling UiRoot.FixedCanvasSize on chargen Close() stripped it from character-management, which stays active underneath and only sets the canvas on its own activation edge. Root cause (reviewer-named): two controllers writing one host-global with no owner. Fixed with the root-cause shape (reviewer's option (c)): UiRoot.DeclareFixedCanvas(owner, size)/RevokeFixedCanvas(owner), an owner-scoped arbiter — every declarer must agree on the canvas size (a mismatch throws instead of silently last-writer-wins), and the canvas nulls only once EVERY declarer has revoked. Both CharacterCreationUiController and CharacterManagementUi- Controller now declare/revoke instead of writing FixedCanvasSize directly; grepped for stragglers, none remain in production code (the raw setter stays public only for UiRootFixedCanvasTests' isolated scale-math coverage). New test (reviewer-specified): CharacterScreensFixedCanvasArbiterTests — two controllers sharing one UiRoot, proving the canvas stays set through chargen's Exit-confirm Close while char-management is still active, nulling only once char-management also deactivates, plus the original F1 defect's own covering case (both revoke together at world entry). R3 — HeadlessSessionHostTests.ContentLease_InstallsRealChargenOptions_ SelectHeritageIsAccepted proves F6's install actually opens the gate: a content lease carrying a real hand-built DatCharGen heritage (not ChargenOptions.Empty) is installed, and TrySelectHeritage for it succeeds. R2 — filed docs/ISSUES.md #402 for the pre-existing Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate full-suite flake (unrelated to Campaign CC). R4 — fixed "unchached" -> "uncached" typo in InteractionRetainedUiComposition.cs. Runtime 1713/0, App 5127/13 skips (+2), Headless 166/0 (+1), full solution Release build green. Live-DAT probes 7/7 under ACDREAM_PROBE_LIVE_MOUNT=1. The known #402 flake did not fire across 3 consecutive full-suite runs this session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ec854db045
commit
8add0667a5
9 changed files with 780 additions and 29 deletions
|
|
@ -24,6 +24,36 @@ What does NOT go here:
|
||||||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||||
|
|
||||||
|
## #402 — Flaky test: Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate
|
||||||
|
|
||||||
|
**Status:** OPEN (flake, not a regression)
|
||||||
|
**Severity:** LOW (test-infra noise; no known production defect)
|
||||||
|
**Filed:** 2026-08-15 (Campaign CC slice CC4 review fix round, R2 — noticed
|
||||||
|
while running the full App.Tests suite repeatedly for the F1/R1
|
||||||
|
FixedCanvasSize arbiter gate)
|
||||||
|
**Component:** `tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs`
|
||||||
|
|
||||||
|
`Build_UsesTheSuppliedSharedReaderGate` fails intermittently in full-suite
|
||||||
|
runs (observed roughly 2 of 5 runs) but passes reliably when run in
|
||||||
|
isolation (`--filter FullyQualifiedName~Build_UsesTheSuppliedSharedReaderGate`).
|
||||||
|
The test was last touched at `82f8d4f8` (2026-07-25, Slice I7's parsed-
|
||||||
|
collision-graph removal) — unrelated to any Campaign CC/CC4 chargen work,
|
||||||
|
which never touches streaming/collision code. Symptom pattern (passes
|
||||||
|
isolated, flakes under full-suite parallelism) points at shared mutable
|
||||||
|
state or a timing assumption racing another test class rather than the
|
||||||
|
factory logic itself; not yet root-caused.
|
||||||
|
|
||||||
|
**Fix direction:** re-run the full suite a few times to reproduce and
|
||||||
|
capture the failure's actual assertion/exception (not just "sometimes
|
||||||
|
red"), then check `LandblockBuildFactoryTests`'s fixture for anything
|
||||||
|
shared across test classes (static state, a shared reader/gate instance,
|
||||||
|
file-system paths) that a parallel xUnit collection could race.
|
||||||
|
|
||||||
|
**Acceptance:** the flake is reproduced with a captured failure detail,
|
||||||
|
root-caused, and fixed (or the test is isolated into its own collection if
|
||||||
|
the root cause is unavoidable cross-test parallelism); full-suite runs stop
|
||||||
|
intermittently failing on this test.
|
||||||
|
|
||||||
## #401 — RetailUi should default ON (opt-out), not per-path forced
|
## #401 — RetailUi should default ON (opt-out), not per-path forced
|
||||||
|
|
||||||
**Status:** OPEN (product-default decision)
|
**Status:** OPEN (product-default decision)
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -650,7 +650,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
// ResolveText several times per Refresh, and CharacterCreation-
|
// ResolveText several times per Refresh, and CharacterCreation-
|
||||||
// UiController.ApplyProgressState forces a full refresh on
|
// UiController.ApplyProgressState forces a full refresh on
|
||||||
// every page switch (`_lastRevision = long.MinValue`) — so an
|
// every page switch (`_lastRevision = long.MinValue`) — so an
|
||||||
// unchached resolver meant several fresh allocations + DatLock
|
// uncached resolver meant several fresh allocations + DatLock
|
||||||
// acquisitions per click. DatStringResolver's own constructor
|
// acquisitions per click. DatStringResolver's own constructor
|
||||||
// does no DAT I/O (only .Resolve reads), so building it here
|
// does no DAT I/O (only .Resolve reads), so building it here
|
||||||
// outside the lock matches this file's existing pattern
|
// outside the lock matches this file's existing pattern
|
||||||
|
|
|
||||||
|
|
@ -201,16 +201,15 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
Root.ClickThrough = false;
|
Root.ClickThrough = false;
|
||||||
Root.Visible = false;
|
Root.Visible = false;
|
||||||
// AD-98: the same authored 800x600 fixed-canvas treatment as the
|
// AD-98: the same authored 800x600 fixed-canvas treatment as the
|
||||||
// character-management screen. CharacterManagementUiController sets
|
// character-management screen. Both controllers DECLARE/REVOKE
|
||||||
// UiRoot.FixedCanvasSize ONCE on its own activation edge
|
// through UiRoot's owner-scoped arbiter (review fix round R1,
|
||||||
// (Tick's `if (!_active)` arm) and NULLS it in both Deactivate AND
|
// 2026-08-15) rather than writing UiRoot.FixedCanvasSize directly —
|
||||||
// Dispose — it is NOT a per-tick set, and this controller must be
|
// char-management can be simultaneously active underneath this
|
||||||
// symmetric with that exact shape (review fix round F1, 2026-08-15
|
// screen, and a raw write from either controller is a last-writer-
|
||||||
// — the earlier claim here that it was safe to leave the canvas
|
// wins race with no owner (the F1 fix's own Close() null wiped
|
||||||
// pinned forever was FALSE and left an 800x600-scaled canvas
|
// char-management's still-active canvas out from under it). See
|
||||||
// covering the in-world UI whenever this screen had been opened).
|
// Open/Close/Deactivate/Dispose below for the matching declare/
|
||||||
// See Open/Close/Deactivate/Dispose below for the matching set/null
|
// revoke pair.
|
||||||
// pair.
|
|
||||||
_authoredCanvas = new Vector2(
|
_authoredCanvas = new Vector2(
|
||||||
Root.Width > 0f ? Root.Width : 800f,
|
Root.Width > 0f ? Root.Width : 800f,
|
||||||
Root.Height > 0f ? Root.Height : 600f);
|
Root.Height > 0f ? Root.Height : 600f);
|
||||||
|
|
@ -380,17 +379,19 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
|
|
||||||
/// <summary>Opens the screen at retail's authored default page
|
/// <summary>Opens the screen at retail's authored default page
|
||||||
/// (<c>gmCharGenMainUI::gmCharGenMainUI</c>'s trailing
|
/// (<c>gmCharGenMainUI::gmCharGenMainUI</c>'s trailing
|
||||||
/// <c>SetProgressState(this, ECG_HERTAGE)</c>). Sets the fixed canvas
|
/// <c>SetProgressState(this, ECG_HERTAGE)</c>). Declares the fixed
|
||||||
/// on this exact activation edge — matching
|
/// canvas on this exact activation edge through <see cref="UiRoot"/>'s
|
||||||
/// <see cref="CharacterManagementUiController"/>'s own one-shot set —
|
/// arbiter — matching <see cref="CharacterManagementUiController"/>'s
|
||||||
/// not per-tick; <see cref="Close"/>/<see cref="Deactivate"/>/
|
/// own one-shot declare — not per-tick; <see cref="Close"/>/
|
||||||
/// <see cref="Dispose"/> null it back out symmetrically.</summary>
|
/// <see cref="Deactivate"/>/<see cref="Dispose"/> revoke it back out
|
||||||
|
/// symmetrically, and the canvas stays set for as long as ANY other
|
||||||
|
/// declarer (e.g. character-management underneath) remains active.</summary>
|
||||||
internal void Open()
|
internal void Open()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
return;
|
return;
|
||||||
_isOpen = true;
|
_isOpen = true;
|
||||||
_host.FixedCanvasSize = _authoredCanvas;
|
_host.DeclareFixedCanvas(this, _authoredCanvas);
|
||||||
ApplyProgressState(Page.Heritage);
|
ApplyProgressState(Page.Heritage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -400,7 +401,7 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
return;
|
return;
|
||||||
_isOpen = false;
|
_isOpen = false;
|
||||||
Root.Visible = false;
|
Root.Visible = false;
|
||||||
_host.FixedCanvasSize = null;
|
_host.RevokeFixedCanvas(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|
@ -415,9 +416,11 @@ internal sealed class CharacterCreationUiController : IDisposable
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// Matches CharacterManagementUiController.Dispose's own
|
// Matches CharacterManagementUiController.Dispose's own
|
||||||
// unconditional null — defends against disposing while _isOpen
|
// unconditional revoke — defends against disposing while
|
||||||
// (Close() is not otherwise called on this path).
|
// _isOpen (Close() is not otherwise called on this path). Idle
|
||||||
_host.FixedCanvasSize = null;
|
// if Close() already revoked (RevokeFixedCanvas is a no-op for
|
||||||
|
// an owner that already revoked).
|
||||||
|
_host.RevokeFixedCanvas(this);
|
||||||
_back.OnClick = null;
|
_back.OnClick = null;
|
||||||
_next.OnClick = null;
|
_next.OnClick = null;
|
||||||
_finish.OnClick = null;
|
_finish.OnClick = null;
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,13 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
// while this screen is active, the host stretches the ENTIRE canvas —
|
// while this screen is active, the host stretches the ENTIRE canvas —
|
||||||
// widgets, glyphs, and the painted background (which carries the
|
// widgets, glyphs, and the painted background (which carries the
|
||||||
// "World"/"Characters" captions as art) — as one unit via
|
// "World"/"Characters" captions as art) — as one unit via
|
||||||
// UiRoot.FixedCanvasSize. Resizing the root here instead is exactly the
|
// UiRoot.FixedCanvasSize, declared/revoked through the owner-scoped
|
||||||
// half-substitution that misaligned the widgets against the stretched
|
// arbiter (review fix round R1, 2026-08-15) rather than written
|
||||||
// art at the 2026-08-15 user gate.
|
// directly — character-creation can be simultaneously active on top
|
||||||
|
// of this screen, and a raw write from either controller is a last-
|
||||||
|
// writer-wins race with no owner. Resizing the root here instead of
|
||||||
|
// using the canvas is exactly the half-substitution that misaligned
|
||||||
|
// the widgets against the stretched art at the 2026-08-15 user gate.
|
||||||
_authoredCanvas = new Vector2(
|
_authoredCanvas = new Vector2(
|
||||||
Root.Width > 0f ? Root.Width : 800f,
|
Root.Width > 0f ? Root.Width : 800f,
|
||||||
Root.Height > 0f ? Root.Height : 600f);
|
Root.Height > 0f ? Root.Height : 600f);
|
||||||
|
|
@ -298,7 +302,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
{
|
{
|
||||||
_active = true;
|
_active = true;
|
||||||
Root.Visible = true;
|
Root.Visible = true;
|
||||||
_host.FixedCanvasSize = _authoredCanvas;
|
_host.DeclareFixedCanvas(this, _authoredCanvas);
|
||||||
_host.BringToFront(Root);
|
_host.BringToFront(Root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -368,7 +372,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_host.FixedCanvasSize = null;
|
_host.RevokeFixedCanvas(this);
|
||||||
_enter.OnClick = null;
|
_enter.OnClick = null;
|
||||||
_delete.OnClick = null;
|
_delete.OnClick = null;
|
||||||
_restore.OnClick = null;
|
_restore.OnClick = null;
|
||||||
|
|
@ -752,7 +756,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
{
|
{
|
||||||
_active = false;
|
_active = false;
|
||||||
Root.Visible = false;
|
Root.Visible = false;
|
||||||
_host.FixedCanvasSize = null;
|
_host.RevokeFixedCanvas(this);
|
||||||
}
|
}
|
||||||
foreach (UiButton row in _rows)
|
foreach (UiButton row in _rows)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
|
||||||
namespace AcDream.App.UI;
|
namespace AcDream.App.UI;
|
||||||
|
|
@ -41,9 +42,94 @@ public sealed class UiRoot : UiElement
|
||||||
/// renderer's quad chokepoint; the mouse entry points apply the inverse,
|
/// renderer's quad chokepoint; the mouse entry points apply the inverse,
|
||||||
/// so <see cref="MouseX"/>/<see cref="MouseY"/> and every hit test live
|
/// so <see cref="MouseX"/>/<see cref="MouseY"/> and every hit test live
|
||||||
/// in canvas space. Null (the in-world default) is native 1:1.
|
/// in canvas space. Null (the in-world default) is native 1:1.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Campaign CC slice CC4 review-fix round R1 (2026-08-15): this raw
|
||||||
|
/// setter remains public for tests that exercise the scale/mouse-
|
||||||
|
/// mapping math in isolation (<c>UiRootFixedCanvasTests</c>), but
|
||||||
|
/// PRODUCTION code must go through <see cref="DeclareFixedCanvas"/>/
|
||||||
|
/// <see cref="RevokeFixedCanvas"/> instead of writing this property
|
||||||
|
/// directly. Two fixed-canvas screens can be active at once
|
||||||
|
/// (character-management underneath character-creation) and a raw
|
||||||
|
/// write from either one is a last-writer-wins race with no owner —
|
||||||
|
/// the F1 fix's own <c>Close()</c> null wiped the OTHER screen's still-
|
||||||
|
/// active canvas out from under it (see AD-98).
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Vector2? FixedCanvasSize { get; set; }
|
public Vector2? FixedCanvasSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Screens currently declaring a fixed canvas, keyed by owner
|
||||||
|
/// (see <see cref="DeclareFixedCanvas"/>).</summary>
|
||||||
|
private readonly Dictionary<object, Vector2> _fixedCanvasDeclarations = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Declares that <paramref name="owner"/> wants the retained tree laid
|
||||||
|
/// out in <paramref name="size"/> while it is active. This is the single
|
||||||
|
/// arbiter for <see cref="FixedCanvasSize"/>: multiple owners may declare
|
||||||
|
/// concurrently (character-management stays declared while character-
|
||||||
|
/// creation is also open on top of it), and the effective
|
||||||
|
/// <see cref="FixedCanvasSize"/> is the shared declaration set's value.
|
||||||
|
/// Every current declarer must agree on the size — a mismatched second
|
||||||
|
/// declaration throws rather than silently overwriting the first
|
||||||
|
/// (Campaign CC CC4 review-fix round R1, 2026-08-15; see
|
||||||
|
/// <c>docs/architecture/retail-divergence-register.md</c> AD-98). Pair
|
||||||
|
/// every call with <see cref="RevokeFixedCanvas"/> on the SAME owner at
|
||||||
|
/// deactivate/close/dispose.
|
||||||
|
/// </summary>
|
||||||
|
public void DeclareFixedCanvas(object owner, Vector2 size)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(owner);
|
||||||
|
if (_fixedCanvasDeclarations.TryGetValue(owner, out Vector2 existing))
|
||||||
|
{
|
||||||
|
if (existing == size)
|
||||||
|
return; // idempotent re-declare (e.g. a re-ticked activation edge)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"UiRoot.DeclareFixedCanvas: owner {owner} re-declared a different " +
|
||||||
|
$"canvas ({existing} -> {size}) without revoking first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Vector2 declared in _fixedCanvasDeclarations.Values)
|
||||||
|
{
|
||||||
|
if (declared != size)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"UiRoot.DeclareFixedCanvas: owner {owner} declared {size} but " +
|
||||||
|
$"another active owner already declared {declared} — every " +
|
||||||
|
"concurrently-active fixed-canvas screen must author the SAME " +
|
||||||
|
"canvas size (see AD-98).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_fixedCanvasDeclarations[owner] = size;
|
||||||
|
FixedCanvasSize = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Revokes <paramref name="owner"/>'s declaration from
|
||||||
|
/// <see cref="DeclareFixedCanvas"/>. <see cref="FixedCanvasSize"/>
|
||||||
|
/// becomes null only once EVERY declarer has revoked; while another
|
||||||
|
/// owner is still declared, it stays set to that shared value. A
|
||||||
|
/// revoke from an owner that never declared (or already revoked) is a
|
||||||
|
/// no-op, matching the idempotent shutdown paths (<c>Deactivate</c>
|
||||||
|
/// AND <c>Dispose</c> can both revoke the same owner).</summary>
|
||||||
|
public void RevokeFixedCanvas(object owner)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(owner);
|
||||||
|
if (!_fixedCanvasDeclarations.Remove(owner))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_fixedCanvasDeclarations.Count == 0)
|
||||||
|
{
|
||||||
|
FixedCanvasSize = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Vector2 declared in _fixedCanvasDeclarations.Values)
|
||||||
|
{
|
||||||
|
FixedCanvasSize = declared;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The coordinate space the retained tree currently lays out in: the fixed
|
/// The coordinate space the retained tree currently lays out in: the fixed
|
||||||
/// authored canvas while one is active, else the window itself. Anything
|
/// authored canvas while one is active, else the window itself. Anything
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,430 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Core.CharGen;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign CC slice CC4 review-fix round R1 (2026-08-15):
|
||||||
|
/// <see cref="CharacterCreationUiController"/> and
|
||||||
|
/// <see cref="CharacterManagementUiController"/> can be SIMULTANEOUSLY
|
||||||
|
/// active against the SAME <see cref="UiRoot"/> — character-creation opens
|
||||||
|
/// on top of character-management, which keeps ticking underneath it. Both
|
||||||
|
/// controllers share <see cref="UiRoot.FixedCanvasSize"/>, and before this
|
||||||
|
/// fix each wrote it directly — a raw write from either screen was a last-
|
||||||
|
/// writer-wins race with no owner: the F1 fix's own chargen
|
||||||
|
/// <c>Close()</c> nulled the canvas out from under a STILL-ACTIVE
|
||||||
|
/// character-management screen underneath it (the exact AD-98 gate-round-2
|
||||||
|
/// defect resurfacing one layer up). These tests drive
|
||||||
|
/// <see cref="UiRoot.DeclareFixedCanvas"/>/<see cref="UiRoot.RevokeFixedCanvas"/>
|
||||||
|
/// across BOTH controllers on one shared host, per the reviewer's required
|
||||||
|
/// sequence.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CharacterScreensFixedCanvasArbiterTests
|
||||||
|
{
|
||||||
|
private static readonly Vector2 AuthoredCanvas = new(800f, 600f);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanvasStaysSetWhileEitherScreenIsActive_AndNullsOnlyWhenBothRevoke()
|
||||||
|
{
|
||||||
|
using var environment = new TwoControllerHarness();
|
||||||
|
|
||||||
|
// char-mgmt alone: declared on its own activation edge (Tick's
|
||||||
|
// `if (!_active)` arm).
|
||||||
|
Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize);
|
||||||
|
|
||||||
|
// chargen opens ON TOP of the still-active char-mgmt screen.
|
||||||
|
environment.Chargen.Controller.Open();
|
||||||
|
Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize);
|
||||||
|
|
||||||
|
// chargen Exit-confirms and closes -- char-mgmt is STILL ACTIVE, so
|
||||||
|
// the canvas must stay set. This is R1's regression: the pre-fix
|
||||||
|
// Close() nulled UiRoot.FixedCanvasSize unconditionally here,
|
||||||
|
// stripping it from char-management underneath.
|
||||||
|
environment.Chargen.Button(CharacterCreationUiController.ExitElementId)
|
||||||
|
.OnClick!();
|
||||||
|
environment.Chargen.ConfirmActiveDialog(confirmed: true);
|
||||||
|
Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize);
|
||||||
|
Assert.False(environment.Chargen.Controller.Root.Visible);
|
||||||
|
|
||||||
|
// char-mgmt deactivates (e.g. entering the world) -- now NEITHER
|
||||||
|
// screen declares, so the canvas nulls.
|
||||||
|
environment.Management.Runtime.SetLifecycle(
|
||||||
|
RuntimeCharacterSelectionLifecycle.InWorld);
|
||||||
|
environment.Management.Controller.Tick();
|
||||||
|
Assert.Null(environment.Host.FixedCanvasSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The original F1 defect's own covering case: both screens
|
||||||
|
/// revoke together (world entry while chargen was ALSO still open)
|
||||||
|
/// still nulls the canvas -- not just "one revokes while the other
|
||||||
|
/// holds," the scenario above.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void CanvasNulls_WhenBothScreensRevokeAtWorldEntry()
|
||||||
|
{
|
||||||
|
using var environment = new TwoControllerHarness();
|
||||||
|
environment.Chargen.Controller.Open();
|
||||||
|
Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize);
|
||||||
|
|
||||||
|
environment.Management.Runtime.SetLifecycle(
|
||||||
|
RuntimeCharacterSelectionLifecycle.InWorld);
|
||||||
|
environment.Management.Controller.Tick();
|
||||||
|
environment.Chargen.Runtime.ProvideView = false;
|
||||||
|
environment.Chargen.Controller.Tick();
|
||||||
|
|
||||||
|
Assert.Null(environment.Host.FixedCanvasSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fixture: one shared UiRoot, both controllers ────────────────────
|
||||||
|
|
||||||
|
private sealed class TwoControllerHarness : IDisposable
|
||||||
|
{
|
||||||
|
public TwoControllerHarness()
|
||||||
|
{
|
||||||
|
Host = new UiRoot { Width = 800f, Height = 600f };
|
||||||
|
Management = new ManagementHarness(Host);
|
||||||
|
Chargen = new ChargenHarness(Host);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UiRoot Host { get; }
|
||||||
|
public ManagementHarness Management { get; }
|
||||||
|
public ChargenHarness Chargen { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Chargen.Dispose();
|
||||||
|
Management.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ManagementHarness : IDisposable
|
||||||
|
{
|
||||||
|
private readonly RetailDialogFactory _dialogs;
|
||||||
|
|
||||||
|
public ManagementHarness(UiRoot host)
|
||||||
|
{
|
||||||
|
ImportedLayout screen = BuildManagementScreen();
|
||||||
|
Runtime = new ManagementFakeRuntime();
|
||||||
|
_dialogs = new RetailDialogFactory(
|
||||||
|
host,
|
||||||
|
type => RetailDialogFactoryTests.BuildDialogLayout(type));
|
||||||
|
Controller = Assert.IsType<CharacterManagementUiController>(
|
||||||
|
CharacterManagementUiController.Bind(
|
||||||
|
host,
|
||||||
|
screen,
|
||||||
|
static (_, _) => BuildRow(),
|
||||||
|
_dialogs,
|
||||||
|
Runtime.Bindings,
|
||||||
|
new CharacterManagementUiController.DialogStrings(
|
||||||
|
name => $"WARNING! {name}",
|
||||||
|
"DELETE",
|
||||||
|
"Please Wait",
|
||||||
|
"Entering World",
|
||||||
|
"Are you sure you want to leave?")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ManagementFakeRuntime Runtime { get; }
|
||||||
|
public CharacterManagementUiController Controller { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Controller.Dispose();
|
||||||
|
_dialogs.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UiElement BuildRow() => LayoutImporter.Build(
|
||||||
|
new ElementInfo { Id = 0x100003A5u, Type = 1u, Width = 160f, Height = 16f },
|
||||||
|
_ => (0u, 0, 0),
|
||||||
|
null).Root;
|
||||||
|
|
||||||
|
private static ImportedLayout BuildManagementScreen()
|
||||||
|
{
|
||||||
|
var root = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = CharacterManagementUiController.RootElementId,
|
||||||
|
Type = 3u,
|
||||||
|
Width = 800f,
|
||||||
|
Height = 600f,
|
||||||
|
};
|
||||||
|
var list = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = CharacterManagementUiController.ListElementId,
|
||||||
|
Type = 5u,
|
||||||
|
X = 42f,
|
||||||
|
Y = 212f,
|
||||||
|
Width = 160f,
|
||||||
|
Height = 320f,
|
||||||
|
};
|
||||||
|
list.TemplateList.Add(new UiTemplateListEntry(0x21000004u, 0x100003A5u));
|
||||||
|
root.Children.Add(list);
|
||||||
|
root.Children.Add(new ElementInfo
|
||||||
|
{
|
||||||
|
Id = CharacterManagementUiController.WorldTextElementId,
|
||||||
|
Type = 12u,
|
||||||
|
Width = 193f,
|
||||||
|
Height = 110f,
|
||||||
|
});
|
||||||
|
root.Children.Add(ButtonInfo(CharacterManagementUiController.CreateElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterManagementUiController.EnterElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterManagementUiController.DeleteElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterManagementUiController.RestoreElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterManagementUiController.CreditsElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterManagementUiController.ExitElementId));
|
||||||
|
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ManagementFakeRuntime
|
||||||
|
{
|
||||||
|
private static readonly RuntimeGenerationToken Generation = new(11u);
|
||||||
|
private readonly FakeManagementView _view = new();
|
||||||
|
|
||||||
|
public ManagementFakeRuntime()
|
||||||
|
{
|
||||||
|
_view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)];
|
||||||
|
_view.Snapshot = new RuntimeCharacterSelectionSnapshot(
|
||||||
|
Generation,
|
||||||
|
RuntimeCharacterSelectionLifecycle.AwaitingSelection,
|
||||||
|
Revision: 1,
|
||||||
|
AccountName: "account",
|
||||||
|
SlotCount: 5,
|
||||||
|
RosterCount: _view.Entries.Length,
|
||||||
|
WorldName: "sawato",
|
||||||
|
HighlightedCharacterId: 0x50000001u,
|
||||||
|
HighlightedDisplayIndex: 0,
|
||||||
|
PendingDeleteCharacterId: 0u,
|
||||||
|
LastRestoreRequestedCharacterId: 0u,
|
||||||
|
Operation: RuntimeCharacterSelectionOperation.None,
|
||||||
|
Error: null,
|
||||||
|
Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false));
|
||||||
|
Bindings = new CharacterSelectionRuntimeBindings(
|
||||||
|
View: () => _view,
|
||||||
|
Highlight: _ => Result(),
|
||||||
|
Enter: Result,
|
||||||
|
RequestDelete: Result,
|
||||||
|
ConfirmDelete: Result,
|
||||||
|
Restore: Result,
|
||||||
|
Cancel: Result,
|
||||||
|
RequestExit: () => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
public CharacterSelectionRuntimeBindings Bindings { get; }
|
||||||
|
|
||||||
|
private static RuntimeCommandResult Result() =>
|
||||||
|
new(RuntimeCommandStatus.Accepted, Generation);
|
||||||
|
|
||||||
|
public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle)
|
||||||
|
{
|
||||||
|
RuntimeCharacterSelectionSnapshot current = _view.Snapshot;
|
||||||
|
_view.Snapshot = current with { Lifecycle = lifecycle, Revision = current.Revision + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeManagementView : IRuntimeCharacterSelectionView
|
||||||
|
{
|
||||||
|
public RuntimeCharacterSelectionEntry[] Entries { get; set; } = [];
|
||||||
|
public RuntimeCharacterSelectionSnapshot Snapshot { get; set; }
|
||||||
|
|
||||||
|
public bool TryGetAt(int displayIndex, out RuntimeCharacterSelectionEntry character)
|
||||||
|
{
|
||||||
|
if ((uint)displayIndex >= (uint)Entries.Length)
|
||||||
|
{
|
||||||
|
character = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
character = Entries[displayIndex];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGet(uint characterId, out RuntimeCharacterSelectionEntry character)
|
||||||
|
{
|
||||||
|
int index = Array.FindIndex(Entries, entry => entry.CharacterId == characterId);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
character = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
character = Entries[index];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Visit(IRuntimeCharacterSelectionVisitor visitor)
|
||||||
|
{
|
||||||
|
foreach (RuntimeCharacterSelectionEntry character in Entries)
|
||||||
|
visitor.Visit(in character);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) =>
|
||||||
|
NullSubscription.Instance;
|
||||||
|
|
||||||
|
private sealed class NullSubscription : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NullSubscription Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ChargenHarness : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<ImportedLayout> _dialogLayouts = [];
|
||||||
|
private readonly RetailDialogFactory _dialogs;
|
||||||
|
|
||||||
|
public ChargenHarness(UiRoot host)
|
||||||
|
{
|
||||||
|
Screen = BuildChargenScreen();
|
||||||
|
Runtime = new ChargenFakeRuntime();
|
||||||
|
_dialogs = new RetailDialogFactory(host, type =>
|
||||||
|
{
|
||||||
|
ImportedLayout layout = RetailDialogFactoryTests.BuildDialogLayout(type);
|
||||||
|
_dialogLayouts.Add(layout);
|
||||||
|
return layout;
|
||||||
|
});
|
||||||
|
Controller = Assert.IsType<CharacterCreationUiController>(
|
||||||
|
CharacterCreationUiController.CreateDetached(
|
||||||
|
host,
|
||||||
|
Screen,
|
||||||
|
static (_, _) => null,
|
||||||
|
_dialogs,
|
||||||
|
Runtime.Bindings,
|
||||||
|
new CharacterCreationUiController.DialogStrings(
|
||||||
|
"Are you sure you want to leave?")));
|
||||||
|
Controller.AttachAndTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImportedLayout Screen { get; }
|
||||||
|
public ChargenFakeRuntime Runtime { get; }
|
||||||
|
public CharacterCreationUiController Controller { get; }
|
||||||
|
|
||||||
|
public UiButton Button(uint id) =>
|
||||||
|
Assert.IsType<UiButton>(Screen.FindElement(id));
|
||||||
|
|
||||||
|
public void ConfirmActiveDialog(bool confirmed)
|
||||||
|
{
|
||||||
|
ImportedLayout dialog = _dialogLayouts[^1];
|
||||||
|
uint buttonId = confirmed
|
||||||
|
? RetailConfirmationDialogView.AcceptButtonId
|
||||||
|
: RetailConfirmationDialogView.RejectButtonId;
|
||||||
|
UiButton button = Assert.IsType<UiButton>(dialog.FindElement(buttonId));
|
||||||
|
button.OnClick!();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Controller.Dispose();
|
||||||
|
_dialogs.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImportedLayout BuildChargenScreen()
|
||||||
|
{
|
||||||
|
var root = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = CharacterCreationUiController.RootElementId,
|
||||||
|
Type = 3u,
|
||||||
|
Width = 800f,
|
||||||
|
Height = 600f,
|
||||||
|
};
|
||||||
|
root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId));
|
||||||
|
root.Children.Add(ContainerInfo(CharacterCreationUiController.MasterPageElementId));
|
||||||
|
root.Children.Add(ContainerInfo(CharacterCreationUiController.HeritagePageElementId));
|
||||||
|
root.Children.Add(ContainerInfo(CharacterCreationUiController.ProfessionPageElementId));
|
||||||
|
root.Children.Add(ContainerInfo(CharacterCreationUiController.SkillsPageElementId));
|
||||||
|
root.Children.Add(ContainerInfo(CharacterCreationUiController.AppearancePageElementId));
|
||||||
|
root.Children.Add(ContainerInfo(CharacterCreationUiController.TownPageElementId));
|
||||||
|
root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.SkillsTabElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.AppearanceTabElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.TownTabElementId));
|
||||||
|
root.Children.Add(ButtonInfo(CharacterCreationUiController.SummaryTabElementId));
|
||||||
|
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElementInfo ContainerInfo(uint id) =>
|
||||||
|
new() { Id = id, Type = 3u, Width = 200f, Height = 60f };
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ChargenFakeRuntime
|
||||||
|
{
|
||||||
|
private static readonly RuntimeGenerationToken Generation = new(13u);
|
||||||
|
|
||||||
|
public ChargenFakeRuntime()
|
||||||
|
{
|
||||||
|
View = new FakeChargenView();
|
||||||
|
Bindings = new CharacterCreationRuntimeBindings(
|
||||||
|
() => ProvideView ? View : null,
|
||||||
|
_ => Result(),
|
||||||
|
_ => Result(),
|
||||||
|
_ => Result(),
|
||||||
|
(_, _) => Result(),
|
||||||
|
(_, _) => Result(),
|
||||||
|
_ => Result(),
|
||||||
|
_ => Result(),
|
||||||
|
_ => Result(),
|
||||||
|
_ => Result(),
|
||||||
|
_ => Result(),
|
||||||
|
RequestExit: () => { },
|
||||||
|
ResolveText: _ => null,
|
||||||
|
OpenOnStart: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FakeChargenView View { get; }
|
||||||
|
public CharacterCreationRuntimeBindings Bindings { get; }
|
||||||
|
public bool ProvideView { get; set; } = true;
|
||||||
|
|
||||||
|
private static RuntimeCommandResult Result() =>
|
||||||
|
new(RuntimeCommandStatus.Accepted, Generation);
|
||||||
|
|
||||||
|
public sealed class FakeChargenView : IRuntimeCharacterCreationView
|
||||||
|
{
|
||||||
|
public RuntimeCharacterCreationSnapshot Snapshot { get; set; } =
|
||||||
|
new(
|
||||||
|
Generation,
|
||||||
|
IsActive: true,
|
||||||
|
Revision: 1,
|
||||||
|
HeritageId: 0u,
|
||||||
|
GenderKey: 0u,
|
||||||
|
Appearance: RuntimeCharacterCreationAppearance.Default,
|
||||||
|
Template: RuntimeCharacterCreationSnapshot.TemplateUnset,
|
||||||
|
Attributes: default,
|
||||||
|
AttributeLockMask: 0u,
|
||||||
|
TotalAttributeCredits: 0u,
|
||||||
|
RemainingAttributeCredits: 0,
|
||||||
|
TotalSkillCredits: 0u,
|
||||||
|
RemainingSkillCredits: 0,
|
||||||
|
Name: string.Empty,
|
||||||
|
StartArea: -1,
|
||||||
|
Slot: 0u,
|
||||||
|
VerificationPending: false,
|
||||||
|
LastLocalRefusal: default,
|
||||||
|
LastRejection: null,
|
||||||
|
LastCreated: null);
|
||||||
|
|
||||||
|
public ChargenOptions Options => ChargenOptions.Empty;
|
||||||
|
|
||||||
|
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) =>
|
||||||
|
ChargenSkillAdvancementClass.Inactive;
|
||||||
|
|
||||||
|
public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) =>
|
||||||
|
NullSubscription.Instance;
|
||||||
|
|
||||||
|
private sealed class NullSubscription : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NullSubscription Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElementInfo ButtonInfo(uint id) =>
|
||||||
|
new() { Id = id, Type = 1u, Width = 100f, Height = 30f };
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using AcDream.Content;
|
||||||
|
using AcDream.Content.CharGen;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
|
|
@ -19,11 +24,68 @@ using AcDream.Runtime.Gameplay;
|
||||||
using AcDream.Runtime.Physics;
|
using AcDream.Runtime.Physics;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.Runtime.World;
|
using AcDream.Runtime.World;
|
||||||
|
using DatCharGen = DatReaderWriter.DBObjs.CharGen;
|
||||||
|
using DatHeritageGroupCG = DatReaderWriter.Types.HeritageGroupCG;
|
||||||
|
using DatIDBObj = DatReaderWriter.Lib.IO.IDBObj;
|
||||||
|
using DatDatabaseImpl = DatReaderWriter.DatDatabase;
|
||||||
|
using DatPStringBaseByte = DatReaderWriter.Types.PStringBase<byte>;
|
||||||
|
|
||||||
namespace AcDream.Headless.Tests;
|
namespace AcDream.Headless.Tests;
|
||||||
|
|
||||||
public sealed class HeadlessSessionHostTests
|
public sealed class HeadlessSessionHostTests
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Review fix round R3 (2026-08-15): proves the F6 fix actually opens
|
||||||
|
/// the gate, not just that <c>InstallOptions</c> was called. A content
|
||||||
|
/// lease carrying a REAL chargen table (one heritage that does NOT
|
||||||
|
/// exist in <see cref="AcDream.Core.CharGen.ChargenOptions.Empty"/>) is
|
||||||
|
/// installed by the host constructor; selecting that heritage — refused
|
||||||
|
/// against the pre-F6 empty default — is ACCEPTED once the session
|
||||||
|
/// reaches character-creation-active. <c>Begin</c>/<c>TrySelectHeritage</c>
|
||||||
|
/// are called directly (both <c>internal</c>, reachable via this
|
||||||
|
/// project's InternalsVisibleTo on <c>AcDream.Runtime</c>) to isolate the
|
||||||
|
/// F6 wiring from the unrelated real-network handshake that would
|
||||||
|
/// otherwise be needed to reach the same session state.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted()
|
||||||
|
{
|
||||||
|
var factory = new ChargenFixtureContentFactory();
|
||||||
|
using var owner = new HeadlessProcessContentOwner(
|
||||||
|
ContentDescriptor(),
|
||||||
|
_ => { },
|
||||||
|
factory);
|
||||||
|
HeadlessProcessContentOwner.HeadlessProcessContentLease lease =
|
||||||
|
owner.AcquireLease("chargen-fixture");
|
||||||
|
using var credential = new HeadlessCredentialSecret(
|
||||||
|
"fixture",
|
||||||
|
"password");
|
||||||
|
using var diagnosticsOutput = new StringWriter();
|
||||||
|
using var host = new HeadlessSessionHost(
|
||||||
|
Descriptor(),
|
||||||
|
credential,
|
||||||
|
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||||
|
new FixtureSessionOperations(),
|
||||||
|
contentLease: lease);
|
||||||
|
|
||||||
|
RuntimeCharacterCreationState creation =
|
||||||
|
host.Runtime.Session.CharacterCreationState;
|
||||||
|
|
||||||
|
// F6 proof #1: the fixture heritage (absent from ChargenOptions.Empty)
|
||||||
|
// is actually installed off the content lease's real Dats.
|
||||||
|
Assert.True(creation.Options.HeritagesById.ContainsKey(
|
||||||
|
ChargenFixtureContentFactory.HeritageId));
|
||||||
|
|
||||||
|
// F6 proof #2: with real options installed, selecting that heritage
|
||||||
|
// is accepted once character-creation is active (Begin mirrors the
|
||||||
|
// real wire trigger this unit test bypasses).
|
||||||
|
creation.Begin(host.Runtime.Generation);
|
||||||
|
Assert.True(creation.TrySelectHeritage(ChargenFixtureContentFactory.HeritageId));
|
||||||
|
Assert.Equal(
|
||||||
|
ChargenFixtureContentFactory.HeritageId,
|
||||||
|
creation.View.Snapshot.HeritageId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LoginCommandsUseTheHeadlessLiveBusAndPreserveWireOrder()
|
public void LoginCommandsUseTheHeadlessLiveBusAndPreserveWireOrder()
|
||||||
{
|
{
|
||||||
|
|
@ -3685,4 +3747,140 @@ public sealed class HeadlessSessionHostTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static HeadlessContentDescriptor ContentDescriptor() => new()
|
||||||
|
{
|
||||||
|
DatDirectory = "fixture-dats",
|
||||||
|
PreparedAssetPath = "fixture.pak",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Review fix round R3 (2026-08-15): a content factory whose
|
||||||
|
/// <c>Dats</c> is a REAL (hand-built, not DispatchProxy-stubbed)
|
||||||
|
/// <see cref="IDatReaderWriter"/> serving one heritage off
|
||||||
|
/// <see cref="ChargenTableReader.ChargenTableDid"/> — the existing
|
||||||
|
/// <c>TestResourceProxy</c> pattern elsewhere in this project always
|
||||||
|
/// returns null/default and can't serve typed record data.</summary>
|
||||||
|
private sealed class ChargenFixtureContentFactory : IHeadlessProcessContentFactory
|
||||||
|
{
|
||||||
|
internal const uint HeritageId = 1u;
|
||||||
|
|
||||||
|
public HeadlessOpenedProcessContent Open(
|
||||||
|
HeadlessContentDescriptor descriptor,
|
||||||
|
Action<string> diagnostic) =>
|
||||||
|
new(
|
||||||
|
new FixtureChargenDatReaderWriter(),
|
||||||
|
DispatchProxy.Create<ITestPreparedSource, TestResourceProxy>(),
|
||||||
|
MagicCatalog.Empty,
|
||||||
|
ImmutableArray.CreateRange(new float[256]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal <see cref="IDatReaderWriter"/> mirroring
|
||||||
|
/// <c>ChargenTableReaderTests.EmptyDatReaderWriter</c>'s shape, except
|
||||||
|
/// <see cref="Get{T}"/> serves a real <see cref="DatCharGen"/> for
|
||||||
|
/// <see cref="ChargenTableReader.ChargenTableDid"/> — everything else
|
||||||
|
/// still misses, matching the missing-table tolerance
|
||||||
|
/// <see cref="ChargenTableReader.Load"/> already has tests for.</summary>
|
||||||
|
private sealed class FixtureChargenDatReaderWriter : IDatReaderWriter
|
||||||
|
{
|
||||||
|
private readonly StubDatabase _db = new();
|
||||||
|
private readonly DatCharGen _chargenTable = BuildChargenTable();
|
||||||
|
|
||||||
|
public string SourceDirectory => string.Empty;
|
||||||
|
public IDatDatabase Portal => _db;
|
||||||
|
public IDatDatabase Cell => _db;
|
||||||
|
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
|
||||||
|
new(new Dictionary<uint, IDatDatabase>());
|
||||||
|
public IDatDatabase HighRes => _db;
|
||||||
|
public IDatDatabase Language => _db;
|
||||||
|
public IDatDatabase Local => _db;
|
||||||
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
||||||
|
new(new Dictionary<uint, uint>());
|
||||||
|
public int PortalIteration => 0;
|
||||||
|
public int CellIteration => 0;
|
||||||
|
public int HighResIteration => 0;
|
||||||
|
public int LanguageIteration => 0;
|
||||||
|
|
||||||
|
public bool TryGetFileBytes(
|
||||||
|
uint regionId,
|
||||||
|
uint fileId,
|
||||||
|
ref byte[] bytes,
|
||||||
|
out int bytesRead)
|
||||||
|
{
|
||||||
|
bytesRead = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : DatIDBObj =>
|
||||||
|
Array.Empty<uint>();
|
||||||
|
|
||||||
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
||||||
|
Array.Empty<IDatReaderWriter.IdResolution>();
|
||||||
|
|
||||||
|
public bool TrySave<T>(T obj, int iteration = 0) where T : DatIDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : DatIDBObj =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
[return: MaybeNull]
|
||||||
|
public T Get<T>(uint fileId) where T : DatIDBObj =>
|
||||||
|
fileId == ChargenTableReader.ChargenTableDid
|
||||||
|
&& typeof(T) == typeof(DatCharGen)
|
||||||
|
? (T)(object)_chargenTable
|
||||||
|
: default;
|
||||||
|
|
||||||
|
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value)
|
||||||
|
where T : DatIDBObj
|
||||||
|
{
|
||||||
|
value = Get<T>(fileId);
|
||||||
|
return value is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
|
||||||
|
private static DatCharGen BuildChargenTable()
|
||||||
|
{
|
||||||
|
var table = new DatCharGen();
|
||||||
|
var heritage = new DatHeritageGroupCG
|
||||||
|
{
|
||||||
|
Name = Str("FixtureHeritage"),
|
||||||
|
AttributeCredits = 60u,
|
||||||
|
SkillCredits = 50u,
|
||||||
|
};
|
||||||
|
table.HeritageGroups.Add(ChargenFixtureContentFactory.HeritageId, heritage);
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DatPStringBaseByte Str(string value)
|
||||||
|
{
|
||||||
|
var s = new DatPStringBaseByte();
|
||||||
|
s.Value = value;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubDatabase : IDatDatabase
|
||||||
|
{
|
||||||
|
public DatDatabaseImpl Db => null!;
|
||||||
|
public int Iteration => 0;
|
||||||
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : DatIDBObj =>
|
||||||
|
Array.Empty<uint>();
|
||||||
|
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value)
|
||||||
|
where T : DatIDBObj
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value)
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead)
|
||||||
|
{
|
||||||
|
bytesRead = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
public bool TrySave<T>(T obj, int iteration = 0) where T : DatIDBObj => false;
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue