feat(chargen): Campaign CC slice CC7 — end-to-end create flow + connected checklist
Create button un-ghosts: retail's exact gate (gmCharacterManagementUI:: UpdateButtons @0x004ec240, roster count < allowed slot count) ported into RuntimeCharacterSelectionButtons.CanCreate; the button's OnClick opens the chargen screen through the same CharacterCreationUiController.Open() seam the ACDREAM_OPEN_CHARGEN=1 dev path already used. Exit/Back confirm on chargen needed no new return-path code — character-management is never hidden while chargen is open on top of it — verified end-to-end by a new cross-controller test rather than left as an inspection claim. Full-flow test coverage: a new comprehensive test decodes every 0xF656 field (including the trailing checksum, recomputed via the production CharacterCreate.ComputeChecksum) against a fully populated creation (heritage/gender/all appearance slots/template/explicit skill command/ town/name); a new Theory drives the remaining six 0xF643 rejection codes through the real wire decode path, closing the gap between the already-covered isolated state-machine Theory and an actual WorldSession round trip. Launcher payload cycle: two new tests drive a real Runtime create/reject through the real SessionStatusWriter (wired exactly as LiveSessionRuntimeFactory/HeadlessSessionHost do in production) and read the result back with the real Launcher.Core StatusFileTailer/ StatusEventParser — closing the one gap CC2's own per-layer tests never reached. No gap was found in production wiring itself: GameWindow already constructs a real, non-null SessionStatusWriter for both hosts. Also fixes 4 pre-existing LiveSessionControllerTests assertions that compared a full RuntimeCharacterSelectionButtons record and would have failed once CanCreate started being computed; corrects register row AP-211 to reflect that its own predicted resolution (the Create-button gate landing) has now happened — both layers are intentionally kept as retail-matching enforcement plus defense-in-depth, not one superseding the other. Adds docs/research/2026-08-16-campaign-cc-test-script.md, the user's connected-gate script covering both the launcher and dev-shortcut launch paths, the six-page create flow, every Finish outcome, and the known cosmetic/behavioral divergences (AP-212/213/215/216/217/218/219/220/222/ 224/226/228) so they aren't mistaken for new bugs during the gate. Gates: full solution Release build green; Runtime 1735/0 (was 1726/0, +9), App 5256/3 skips (was 5254/3, +2), Headless 166/0 (unchanged), Launcher.Core 324/0, one full-solution pass across every project clean (no known flakes reproduced this run). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
bb22ee8bde
commit
9cf6c52283
10 changed files with 957 additions and 37 deletions
|
|
@ -61,8 +61,12 @@ public sealed class CharacterManagementUiControllerTests
|
|||
CharacterManagementUiController.RestoreElementId);
|
||||
|
||||
Assert.True(create.Visible);
|
||||
Assert.False(create.Enabled);
|
||||
Assert.Null(create.OnClick);
|
||||
// Campaign CC slice CC7: gmCharacterManagementUI::UpdateButtons @
|
||||
// 0x004ec240's Create gate — 3 characters against SlotCount 5.
|
||||
Assert.True(create.Enabled);
|
||||
Assert.NotNull(create.OnClick);
|
||||
create.OnClick!();
|
||||
Assert.Equal(1, environment.Runtime.RequestCreateCalls);
|
||||
Assert.True(enter.Enabled);
|
||||
Assert.True(delete.Visible);
|
||||
Assert.True(delete.Enabled);
|
||||
|
|
@ -99,6 +103,45 @@ public sealed class CharacterManagementUiControllerTests
|
|||
Assert.True(restore.Enabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC7: <c>gmCharacterManagementUI::UpdateButtons @
|
||||
/// 0x004ec240</c>'s Create gate (~0x004ec319-0x004ec32e) is purely
|
||||
/// <c>_charSet.set_.m_num < _charSet.numAllowedCharacters_</c> — a
|
||||
/// full roster (roster count == the allowed-slot ceiling) ghosts Create
|
||||
/// exactly like retail, and refilling below the ceiling un-ghosts it
|
||||
/// again on the next Tick.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
CharacterManagementUiController controller = environment.Controller;
|
||||
UiButton create = environment.Button(
|
||||
CharacterManagementUiController.CreateElementId);
|
||||
|
||||
// The fixture's SlotCount is 5 — five characters exactly fills it.
|
||||
RuntimeCharacterSelectionEntry[] full = Enumerable.Range(0, 5)
|
||||
.Select(index => new RuntimeCharacterSelectionEntry(
|
||||
index,
|
||||
(uint)(0x50000200 + index),
|
||||
$"Full {index:D2}",
|
||||
0u))
|
||||
.ToArray();
|
||||
environment.Runtime.ReplaceRoster(full, highlightedCharacterId: full[0].CharacterId);
|
||||
controller.Tick();
|
||||
|
||||
Assert.True(create.Visible);
|
||||
Assert.False(create.Enabled);
|
||||
|
||||
RuntimeCharacterSelectionEntry[] belowCeiling = full[..4];
|
||||
environment.Runtime.ReplaceRoster(
|
||||
belowCeiling,
|
||||
highlightedCharacterId: belowCeiling[0].CharacterId);
|
||||
controller.Tick();
|
||||
|
||||
Assert.True(create.Enabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120
|
||||
/// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName()
|
||||
|
|
@ -906,7 +949,8 @@ public sealed class CharacterManagementUiControllerTests
|
|||
ConfirmDelete,
|
||||
Restore,
|
||||
Cancel,
|
||||
RequestExit);
|
||||
RequestExit,
|
||||
RequestCreate);
|
||||
}
|
||||
|
||||
public FakeView View { get; } = new();
|
||||
|
|
@ -918,6 +962,14 @@ public sealed class CharacterManagementUiControllerTests
|
|||
public int CancelCalls { get; private set; }
|
||||
public int RestoreCalls { get; private set; }
|
||||
public int RequestExitCalls { get; private set; }
|
||||
public int RequestCreateCalls { get; private set; }
|
||||
|
||||
/// <summary>Campaign CC slice CC7: the fixture's fixed allowed-slot
|
||||
/// ceiling — mirrors <c>Snapshot</c>'s own hard-coded
|
||||
/// <c>SlotCount: 5</c> so <see cref="ButtonsFor"/> computes the SAME
|
||||
/// roster-vs-slot gate the real <c>RuntimeCharacterSelectionState.BuildButtons</c>
|
||||
/// does, instead of a fixture-only shortcut.</summary>
|
||||
private const int SlotCount = 5;
|
||||
public RuntimeCommandStatus RestoreStatus { get; set; } =
|
||||
RuntimeCommandStatus.Accepted;
|
||||
public bool ThrowOnRestore { get; set; }
|
||||
|
|
@ -929,7 +981,8 @@ public sealed class CharacterManagementUiControllerTests
|
|||
RuntimeCharacterSelectionButtons buttons = operation is
|
||||
RuntimeCharacterSelectionOperation.DeleteRequested
|
||||
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
||||
? RuntimeCharacterSelectionButtons.None
|
||||
? RuntimeCharacterSelectionButtons.None with
|
||||
{ CanCreate = View.Entries.Length < SlotCount }
|
||||
: ButtonsFor(View.Snapshot.HighlightedCharacterId);
|
||||
Update(snapshot => snapshot with
|
||||
{
|
||||
|
|
@ -1022,7 +1075,8 @@ public sealed class CharacterManagementUiControllerTests
|
|||
{
|
||||
PendingDeleteCharacterId = 0u,
|
||||
Operation = RuntimeCharacterSelectionOperation.DeleteRequested,
|
||||
Buttons = RuntimeCharacterSelectionButtons.None,
|
||||
Buttons = RuntimeCharacterSelectionButtons.None with
|
||||
{ CanCreate = View.Entries.Length < SlotCount },
|
||||
});
|
||||
return Result(RuntimeCommandStatus.Accepted, id);
|
||||
}
|
||||
|
|
@ -1045,7 +1099,8 @@ public sealed class CharacterManagementUiControllerTests
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
true),
|
||||
true,
|
||||
View.Entries.Length < SlotCount),
|
||||
});
|
||||
AfterRestoreProjection?.Invoke();
|
||||
return Result(RuntimeCommandStatus.Accepted, id);
|
||||
|
|
@ -1065,13 +1120,22 @@ public sealed class CharacterManagementUiControllerTests
|
|||
|
||||
private void RequestExit() => RequestExitCalls++;
|
||||
|
||||
private void RequestCreate() => RequestCreateCalls++;
|
||||
|
||||
/// <summary>Campaign CC slice CC7: mirrors
|
||||
/// <c>RuntimeCharacterSelectionState.BuildButtons</c>'s own
|
||||
/// unconditional <c>CanCreate</c> computation — roster length
|
||||
/// against <see cref="SlotCount"/> — so every branch below carries
|
||||
/// the SAME real gate the production state machine does, not a
|
||||
/// fixture-only shortcut.</summary>
|
||||
private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId)
|
||||
{
|
||||
bool canCreate = View.Entries.Length < SlotCount;
|
||||
RuntimeCharacterSelectionEntry? selected = View.Entries
|
||||
.Cast<RuntimeCharacterSelectionEntry?>()
|
||||
.FirstOrDefault(entry => entry?.CharacterId == characterId);
|
||||
if (selected is null)
|
||||
return RuntimeCharacterSelectionButtons.None;
|
||||
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
|
||||
if (selected.Value.IsPendingDelete)
|
||||
{
|
||||
return new RuntimeCharacterSelectionButtons(
|
||||
|
|
@ -1079,14 +1143,16 @@ public sealed class CharacterManagementUiControllerTests
|
|||
false,
|
||||
true,
|
||||
false,
|
||||
true);
|
||||
true,
|
||||
canCreate);
|
||||
}
|
||||
return new RuntimeCharacterSelectionButtons(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false);
|
||||
false,
|
||||
canCreate);
|
||||
}
|
||||
|
||||
private void Update(
|
||||
|
|
|
|||
|
|
@ -78,6 +78,48 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
Assert.Null(environment.Host.FixedCanvasSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC7 item 1: the real Create-button wire — retail's
|
||||
/// <c>gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0</c>
|
||||
/// case 3 -> <c>QueueUIMode(0x1000000b)</c> — and the return path on
|
||||
/// chargen Exit (<c>DoExit @ 0x004e8650</c> ->
|
||||
/// <c>QueueUIMode(0x1000000a)</c>). Character-management is never
|
||||
/// hidden by chargen opening on top of it (see the canvas-arbiter test
|
||||
/// above), so "return to character management" needs no separate
|
||||
/// Runtime action beyond chargen's own <c>Close()</c> — this proves
|
||||
/// that architecture claim end to end rather than by inspection alone.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement()
|
||||
{
|
||||
using var environment = new TwoControllerHarness();
|
||||
|
||||
// Character-management is active and visible before Create is ever
|
||||
// clicked (AttachAndTick already ran in its own harness ctor).
|
||||
Assert.True(environment.Management.Controller.Root.Visible);
|
||||
Assert.False(environment.Chargen.Controller.Root.Visible);
|
||||
|
||||
UiButton create = environment.Management.Button(
|
||||
CharacterManagementUiController.CreateElementId);
|
||||
Assert.True(create.Enabled);
|
||||
create.OnClick!();
|
||||
environment.Chargen.Controller.Tick();
|
||||
|
||||
Assert.True(environment.Chargen.Controller.Root.Visible);
|
||||
// Character-management stays active/visible underneath -- chargen
|
||||
// opening on top never deactivates or hides it.
|
||||
Assert.True(environment.Management.Controller.Root.Visible);
|
||||
|
||||
environment.Chargen.Button(CharacterCreationUiController.ExitElementId)
|
||||
.OnClick!();
|
||||
environment.Chargen.ConfirmActiveDialog(confirmed: true);
|
||||
|
||||
Assert.False(environment.Chargen.Controller.Root.Visible);
|
||||
// No separate "return" action was needed -- management was never
|
||||
// hidden, so it is simply what remains visible.
|
||||
Assert.True(environment.Management.Controller.Root.Visible);
|
||||
}
|
||||
|
||||
// ── Fixture: one shared UiRoot, both controllers ────────────────────
|
||||
|
||||
private sealed class TwoControllerHarness : IDisposable
|
||||
|
|
@ -85,8 +127,15 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
public TwoControllerHarness()
|
||||
{
|
||||
Host = new UiRoot { Width = 800f, Height = 600f };
|
||||
Management = new ManagementHarness(Host);
|
||||
// Campaign CC slice CC7: chargen must exist FIRST so
|
||||
// ManagementHarness can wire its Create button straight to the
|
||||
// real CharacterCreationUiController.Open() — the same shape
|
||||
// RetailUiRuntime.ConfigureCharacterManagement() uses in
|
||||
// production (a lazily-resolved lambda closing over the OTHER
|
||||
// controller, since bindings are always built before both
|
||||
// controllers exist).
|
||||
Chargen = new ChargenHarness(Host);
|
||||
Management = new ManagementHarness(Host, Chargen.Controller.Open);
|
||||
}
|
||||
|
||||
public UiRoot Host { get; }
|
||||
|
|
@ -104,17 +153,17 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
{
|
||||
private readonly RetailDialogFactory _dialogs;
|
||||
|
||||
public ManagementHarness(UiRoot host)
|
||||
public ManagementHarness(UiRoot host, Action requestCreate)
|
||||
{
|
||||
ImportedLayout screen = BuildManagementScreen();
|
||||
Runtime = new ManagementFakeRuntime();
|
||||
Screen = BuildManagementScreen();
|
||||
Runtime = new ManagementFakeRuntime(requestCreate);
|
||||
_dialogs = new RetailDialogFactory(
|
||||
host,
|
||||
type => RetailDialogFactoryTests.BuildDialogLayout(type));
|
||||
Controller = Assert.IsType<CharacterManagementUiController>(
|
||||
CharacterManagementUiController.Bind(
|
||||
host,
|
||||
screen,
|
||||
Screen,
|
||||
static (_, _) => BuildRow(),
|
||||
_dialogs,
|
||||
Runtime.Bindings,
|
||||
|
|
@ -126,9 +175,13 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
"Are you sure you want to leave?")));
|
||||
}
|
||||
|
||||
public ImportedLayout Screen { get; }
|
||||
public ManagementFakeRuntime Runtime { get; }
|
||||
public CharacterManagementUiController Controller { get; }
|
||||
|
||||
public UiButton Button(uint id) =>
|
||||
Assert.IsType<UiButton>(Screen.FindElement(id));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Controller.Dispose();
|
||||
|
|
@ -182,7 +235,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
private static readonly RuntimeGenerationToken Generation = new(11u);
|
||||
private readonly FakeManagementView _view = new();
|
||||
|
||||
public ManagementFakeRuntime()
|
||||
public ManagementFakeRuntime(Action requestCreate)
|
||||
{
|
||||
_view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)];
|
||||
_view.Snapshot = new RuntimeCharacterSelectionSnapshot(
|
||||
|
|
@ -199,7 +252,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
LastRestoreRequestedCharacterId: 0u,
|
||||
Operation: RuntimeCharacterSelectionOperation.None,
|
||||
Error: null,
|
||||
Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false));
|
||||
Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false, true));
|
||||
Bindings = new CharacterSelectionRuntimeBindings(
|
||||
View: () => _view,
|
||||
Highlight: _ => Result(),
|
||||
|
|
@ -208,7 +261,8 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
ConfirmDelete: Result,
|
||||
Restore: Result,
|
||||
Cancel: Result,
|
||||
RequestExit: () => { });
|
||||
RequestExit: () => { },
|
||||
RequestCreate: requestCreate);
|
||||
}
|
||||
|
||||
public CharacterSelectionRuntimeBindings Bindings { get; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue