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

@ -18,5 +18,12 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Runtime\AcDream.Runtime.csproj" />
<!-- Campaign CC slice CC7: test-only cross-assembly reference for the
Runtime-state-transition -> SessionStatusWriter -> Launcher.Core
StatusFileTailer end-to-end assertion (mirrors the LA1+LA3
precedent of a cross-assembly test enforcing a shared contract).
AcDream.Runtime itself does not, and must not, reference
AcDream.Launcher.Core. -->
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -6,6 +6,7 @@ using AcDream.Core.CharGen;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Launcher.Core.Status;
using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.Runtime.Tests.CharGen;
@ -124,6 +125,17 @@ public sealed class LiveSessionControllerCharacterCreationTests
public List<RuntimeCharacterCreationIdentity> Created { get; } = [];
public List<RuntimeCharacterCreationRejection> Failed { get; } = [];
/// <summary>
/// Campaign CC slice CC7 item 3: when set, forwards exactly the way
/// production hosts do (<c>LiveSessionRuntimeFactory.Create</c>'s
/// own <c>CharacterCreated</c>/<c>CreationFailed</c> delegates,
/// <c>HeadlessSessionHost</c>'s identical pair) — the SAME real
/// <see cref="SessionStatusWriter"/> a launcher-composed session
/// would use, not a re-implemented shape.
/// </summary>
public SessionStatusWriter? Writer { get; set; }
public string SessionId { get; set; } = "s1";
public LiveSessionBinding BindSession(WorldSession session) =>
new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { });
public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { }
@ -134,10 +146,17 @@ public sealed class LiveSessionControllerCharacterCreationTests
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
EnteredWorld.Add(selection);
public void DetachSession(WorldSession session) { }
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) =>
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity)
{
Created.Add(identity);
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) =>
Writer?.CharacterCreated(SessionId, identity.Guid, identity.Name);
}
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection)
{
Failed.Add(rejection);
Writer?.CreationFailed(
SessionId, rejection.RawCode, rejection.Reason, rejection.AttemptedName);
}
}
private static LiveSessionConnectOptions LiveOptions() => new(
@ -399,6 +418,259 @@ public sealed class LiveSessionControllerCharacterCreationTests
Assert.Equal(10u, decoded.Strength); // Custom template sits at the floor — unspent, unchanged.
}
/// <summary>
/// Campaign CC slice CC7 item 2: the SEAMLESS end-to-end walk the
/// campaign closeout asks for — a fully populated creation (heritage,
/// gender, appearance across every one of the fourteen style/color
/// slots and all six shades, template, an EXPLICIT skill command beyond
/// what the template alone applies, an explicit town/start-area
/// selection, name) sent through a REAL <see cref="WorldSession"/>, then
/// decoded field-by-field — including the trailing checksum, which the
/// pre-existing <see cref="Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName"/>
/// test above never checked — against exactly the shape
/// <c>CharacterCreateInfo.Unpack</c>/<c>Appearance.Unpack</c> parse (see
/// <see cref="CharacterCreate"/>'s own doc comment for the ACE
/// cross-reference). The checksum is recomputed via the SAME production
/// <see cref="CharacterCreate.ComputeChecksum"/> formula rather than
/// re-deriving the sum a second time by hand in the test.
/// </summary>
[Fact]
public void Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape()
{
(LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) =
StartAwaitingSelection();
Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted);
Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyesStrip, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.NoseStrip, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.MouthStrip, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairStyle, 1u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairColor, 1u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyeColor, 1u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearStyle, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearColor, 2u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtStyle, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtColor, 1u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersStyle, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersColor, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearStyle, 0u).Accepted);
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearColor, 2u).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Skin, 0.25).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Hair, 0.5).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Headgear, 0.75).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Shirt, 0.1).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Trousers, 0.9).Accepted);
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Footwear, 0.6).Accepted);
Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted);
// Explicit skill command beyond the template's own Normal/Primary
// lists (SkillFreeTrained costs 0 to train — no credit-budget risk).
Assert.True(controller.TrainSkill(generation, RuntimeCharacterCreationStateFixture.SkillFreeTrained).Accepted);
// Town: the fixture's global starter-area list is [Holtburg(0), Yaraq(1)].
Assert.True(controller.SelectStartArea(generation, 1).Accepted);
Assert.True(controller.SetName(generation, "FullChar").Accepted);
WorldSession session = operations.Sessions[0];
byte[]? captured = null;
session.GameMessageCapture = (body, _) => captured = body;
Assert.True(controller.Finish(generation).Accepted);
Assert.NotNull(captured);
DecodedFullRequest decoded = DecodeCreateRequestFull(captured!);
Assert.Equal("testaccount", decoded.AccountName);
Assert.Equal(1u, decoded.Constant);
CharacterCreate.Request r = decoded.Request;
Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, r.Heritage);
Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, r.Gender);
Assert.Equal(0u, r.Appearance.EyesStrip);
Assert.Equal(0u, r.Appearance.NoseStrip);
Assert.Equal(0u, r.Appearance.MouthStrip);
Assert.Equal(1u, r.Appearance.HairColor);
Assert.Equal(1u, r.Appearance.EyeColor);
Assert.Equal(1u, r.Appearance.HairStyle);
Assert.Equal(0u, r.Appearance.HeadgearStyle);
Assert.Equal(2u, r.Appearance.HeadgearColor);
Assert.Equal(0u, r.Appearance.ShirtStyle);
Assert.Equal(1u, r.Appearance.ShirtColor);
Assert.Equal(0u, r.Appearance.TrousersStyle);
Assert.Equal(0u, r.Appearance.TrousersColor);
Assert.Equal(0u, r.Appearance.FootwearStyle);
Assert.Equal(2u, r.Appearance.FootwearColor);
Assert.Equal(0.25, r.Appearance.SkinShade);
Assert.Equal(0.5, r.Appearance.HairShade);
Assert.Equal(0.75, r.Appearance.HeadgearShade);
Assert.Equal(0.1, r.Appearance.ShirtShade);
Assert.Equal(0.9, r.Appearance.TrousersShade);
Assert.Equal(0.6, r.Appearance.FootwearShade);
Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, r.Template);
Assert.Equal(16u, r.Attributes.Strength);
Assert.Equal(10u, r.Attributes.Endurance);
Assert.Equal(10u, r.Attributes.Coordination);
Assert.Equal(10u, r.Attributes.Quickness);
Assert.Equal(10u, r.Attributes.Focus);
Assert.Equal(10u, r.Attributes.Self);
Assert.Equal(0u, r.Slot);
// classId: register AP-209's documented placeholder — ACE ignores
// this field (retail's DAT DID lookup has no Core equivalent).
Assert.Equal(0u, r.ClassId);
Assert.Equal(
(uint)CharacterCreate.SkillAdvancementClassCount,
(uint)decoded.SkillAdvancementClasses.Length);
Assert.Equal(
(uint)ChargenSkillAdvancementClass.Trained,
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]);
Assert.Equal(
(uint)ChargenSkillAdvancementClass.Specialized,
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]);
Assert.Equal(
(uint)ChargenSkillAdvancementClass.Trained,
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillFreeTrained]);
Assert.Equal("FullChar", r.Name);
Assert.Equal(1u, r.StartArea);
Assert.False(r.IsAdmin);
Assert.False(r.IsEnvoy);
// The trailing checksum (CG_Pack@0x005c74c3's final store) — never
// read by ACE, sent for byte fidelity with a genuine retail client.
Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum);
}
/// <summary>
/// Campaign CC slice CC7 item 2: the remaining <c>0xF643</c> rejection
/// codes beyond NameInUse (already covered by
/// <see cref="Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection"/>
/// above) — proves CC5's F2 fix (Pending/Undef produce a real rejection
/// instead of a silent reset) holds over the REAL wire decode path, not
/// just the isolated state-machine
/// <c>RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...</c>
/// theory.
/// </summary>
[Theory]
[InlineData(CharGenVerificationResponse.Code.Pending)]
[InlineData(CharGenVerificationResponse.Code.NameBanned)]
[InlineData(CharGenVerificationResponse.Code.Corrupt)]
[InlineData(CharGenVerificationResponse.Code.DatabaseDown)]
[InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)]
[InlineData(CharGenVerificationResponse.Code.Undef)]
public void Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect(
CharGenVerificationResponse.Code code)
{
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
StartAwaitingSelection();
BuildReadyCharacter(controller, generation);
WorldSession session = operations.Sessions[0];
session.GameMessageCapture = (_, _) => { };
Assert.True(controller.Finish(generation).Accepted);
InvokeProcessDatagram(session, BuildResponsePacket((uint)code, 0u, string.Empty));
Assert.Single(host.Failed);
Assert.Equal((uint)code, host.Failed[0].RawCode);
Assert.Equal(code, host.Failed[0].Code);
Assert.Equal(code.ToString(), host.Failed[0].Reason);
Assert.Equal("NewChar", host.Failed[0].AttemptedName);
Assert.Empty(host.Created);
Assert.False(controller.IsInWorld);
Assert.Empty(operations.EnterWorldByGuidCalls);
Assert.Empty(host.EnteredWorld);
Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar"));
}
/// <summary>
/// Campaign CC slice CC7 item 3: the launcher payload cycle end to end —
/// a REAL Runtime state transition (Finish -&gt; real
/// <see cref="WorldSession"/> -&gt; real inbound <c>0xF643</c> Ok reply)
/// through the REAL <see cref="SessionStatusWriter"/>, wired exactly the
/// way <c>LiveSessionRuntimeFactory.Create</c> (App host) and
/// <c>HeadlessSessionHost</c> wire it (see <see cref="TestHost"/>'s own
/// doc comment), to the REAL Launcher.Core
/// <see cref="StatusFileTailer"/>'s parsed event. This is the piece
/// CC2's own tests never reached: <c>SessionStatusWriterTests</c> calls
/// the writer directly and asserts raw JSON; <c>StatusEventParserTests</c>
/// parses a hand-written JSON literal; neither drives a create through
/// Runtime first, so a wiring gap between Runtime's own state machine
/// and the writer (or between the writer's bytes and the tailer's
/// parser) would not have been caught by either.
/// </summary>
[Fact]
public void Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer()
{
string path = Path.Combine(
Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl");
try
{
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
StartAwaitingSelection();
host.Writer = new SessionStatusWriter(path);
host.SessionId = "cc7-session";
BuildReadyCharacter(controller, generation);
WorldSession session = operations.Sessions[0];
session.GameMessageCapture = (_, _) => { };
Assert.True(controller.Finish(generation).Accepted);
InvokeProcessDatagram(session, BuildResponsePacket(
(uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar"));
// Runtime's own side of the contract already fired.
Assert.Single(host.Created);
var tailer = new StatusFileTailer(path);
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
CharacterCreatedStatusEvent created =
Assert.Single(events.OfType<CharacterCreatedStatusEvent>());
Assert.Equal("cc7-session", created.SessionId);
Assert.Equal(0x50001234u, created.Guid);
Assert.Equal("NewChar", created.Name);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
/// <summary>Sibling of the Ok test above for the non-Ok half of the
/// contract (<c>creationFailed{code,reason,name}</c>).</summary>
[Fact]
public void Finish_ThenNameInUseResponse_WritesCreationFailedEvent_ParsedByTheRealLauncherTailer()
{
string path = Path.Combine(
Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl");
try
{
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
StartAwaitingSelection();
host.Writer = new SessionStatusWriter(path);
host.SessionId = "cc7-session";
BuildReadyCharacter(controller, generation);
WorldSession session = operations.Sessions[0];
session.GameMessageCapture = (_, _) => { };
Assert.True(controller.Finish(generation).Accepted);
InvokeProcessDatagram(session, BuildResponsePacket(
(uint)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty));
Assert.Single(host.Failed);
var tailer = new StatusFileTailer(path);
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
CreationFailedStatusEvent failed =
Assert.Single(events.OfType<CreationFailedStatusEvent>());
Assert.Equal("cc7-session", failed.SessionId);
Assert.Equal((uint)CharGenVerificationResponse.Code.NameInUse, failed.Code);
Assert.Equal("NameInUse", failed.Reason);
Assert.Equal("NewChar", failed.Name);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
private static void InvokeProcessDatagram(WorldSession session, byte[] datagram)
{
MethodInfo method = typeof(WorldSession).GetMethod(
@ -490,6 +762,105 @@ public sealed class LiveSessionControllerCharacterCreationTests
accountName, heritage, gender, template, strength, name, numSkills, skills);
}
/// <summary>Campaign CC slice CC7: the FULL field set — every byte of
/// <see cref="CharacterCreate.BuildRequestBody"/>'s layout, unlike
/// <see cref="CapturedCreateRequest"/>/<see cref="DecodeCreateRequest"/>
/// above which only samples a handful of fields.</summary>
private readonly record struct DecodedFullRequest(
string AccountName,
uint Constant,
CharacterCreate.Request Request,
uint[] SkillAdvancementClasses,
uint Checksum);
private static DecodedFullRequest DecodeCreateRequestFull(ReadOnlySpan<byte> body)
{
int pos = 0;
uint opcode = ReadU32(body, ref pos);
Assert.Equal(CharacterCreate.Opcode, opcode);
string accountName = ReadString16L(body, ref pos);
uint constant = ReadU32(body, ref pos);
uint heritage = ReadU32(body, ref pos);
uint gender = ReadU32(body, ref pos);
uint eyesStrip = ReadU32(body, ref pos);
uint noseStrip = ReadU32(body, ref pos);
uint mouthStrip = ReadU32(body, ref pos);
uint hairColor = ReadU32(body, ref pos);
uint eyeColor = ReadU32(body, ref pos);
uint hairStyle = ReadU32(body, ref pos);
uint headgearStyle = ReadU32(body, ref pos);
uint headgearColor = ReadU32(body, ref pos);
uint shirtStyle = ReadU32(body, ref pos);
uint shirtColor = ReadU32(body, ref pos);
uint trousersStyle = ReadU32(body, ref pos);
uint trousersColor = ReadU32(body, ref pos);
uint footwearStyle = ReadU32(body, ref pos);
uint footwearColor = ReadU32(body, ref pos);
double skinShade = ReadF64(body, ref pos);
double hairShade = ReadF64(body, ref pos);
double headgearShade = ReadF64(body, ref pos);
double shirtShade = ReadF64(body, ref pos);
double trousersShade = ReadF64(body, ref pos);
double footwearShade = ReadF64(body, ref pos);
uint template = ReadU32(body, ref pos);
uint strength = ReadU32(body, ref pos);
uint endurance = ReadU32(body, ref pos);
uint coordination = ReadU32(body, ref pos);
uint quickness = ReadU32(body, ref pos);
uint focus = ReadU32(body, ref pos);
uint self = ReadU32(body, ref pos);
uint slot = ReadU32(body, ref pos);
uint classId = ReadU32(body, ref pos);
uint numSkills = ReadU32(body, ref pos);
var skills = new uint[numSkills];
for (int i = 0; i < numSkills; i++)
skills[i] = ReadU32(body, ref pos);
string name = ReadString16L(body, ref pos);
uint startArea = ReadU32(body, ref pos);
uint isAdmin = ReadU32(body, ref pos);
uint isEnvoy = ReadU32(body, ref pos);
uint checksum = ReadU32(body, ref pos);
// Nothing left over, nothing missing — the layout is exhaustive.
Assert.Equal(body.Length, pos);
var request = new CharacterCreate.Request(
heritage,
gender,
new CharacterCreate.Appearance(
eyesStrip,
noseStrip,
mouthStrip,
hairColor,
eyeColor,
hairStyle,
headgearStyle,
headgearColor,
shirtStyle,
shirtColor,
trousersStyle,
trousersColor,
footwearStyle,
footwearColor,
skinShade,
hairShade,
headgearShade,
shirtShade,
trousersShade,
footwearShade),
template,
new CharacterCreate.Attributes(
strength, endurance, coordination, quickness, focus, self),
slot,
classId,
name,
startArea,
isAdmin != 0u,
isEnvoy != 0u);
return new DecodedFullRequest(accountName, constant, request, skills, checksum);
}
private static uint ReadU32(ReadOnlySpan<byte> body, ref int pos)
{
uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));

View file

@ -588,8 +588,14 @@ public sealed class LiveSessionControllerTests
Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation);
// Campaign CC slice CC7: this fixture's roster (2 characters) is
// below its SlotCount (11), so retail's Create gate
// (gmCharacterManagementUI::UpdateButtons) stays enabled through
// the whole delete-request/acknowledge sequence below — CanCreate
// is independent of the delete-in-flight buttons this test is
// actually pinning.
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons);
if (acknowledgeBeforeCompletion)
@ -607,8 +613,10 @@ public sealed class LiveSessionControllerTests
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
: RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation);
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
// stays true independent of the delete-in-flight buttons.
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons);
Assert.True(controller.CharacterSelection.TryGet(
0x50000001u,
@ -625,8 +633,10 @@ public sealed class LiveSessionControllerTests
Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
controller.CharacterSelection.Snapshot.Operation);
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
// stays true independent of the delete-in-flight buttons.
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
}
@ -665,8 +675,10 @@ public sealed class LiveSessionControllerTests
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
: RuntimeCharacterSelectionOperation.DeleteRequested,
controller.CharacterSelection.Snapshot.Operation);
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
// stays true independent of the delete-in-flight buttons.
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal(
RuntimeCommandStatus.Rejected,
@ -679,8 +691,10 @@ public sealed class LiveSessionControllerTests
Assert.Equal(
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
controller.CharacterSelection.Snapshot.Operation);
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
// stays true independent of the delete-in-flight buttons.
Assert.Equal(
RuntimeCharacterSelectionButtons.None,
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
controller.CharacterSelection.Snapshot.Buttons);
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
}