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:
Erik 2026-08-16 02:35:21 +02:00
parent bb22ee8bde
commit 9cf6c52283
10 changed files with 957 additions and 37 deletions

View file

@ -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 &lt; _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(