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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -> real
|
||||
/// <see cref="WorldSession"/> -> 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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue