fix(runtime): Campaign CC slice CC3 review-fix round — F1-F16

Opus dual-lens review of CC3's RuntimeCharacterCreationState passed on
retail fidelity but failed the controller integration: the post-create
log-straight-in indexed the CACHED wire CharacterList, which ACE never
resends after a create (it only appends server-side and replies Ok) —
with zero pre-existing characters this throws, with N it can silently
enter the WRONG character. The same stale-index problem corrupted every
pre-existing character's delete slot on roster re-sort. Fixes all four
blocking findings plus a credit-gate correctness bug (retail warns and
lets the user confirm through unspent credits; it does not force a full
spend) and eight lower-severity findings from the same review round.

F1 (blocking): WorldSession gained a guid-based EnterWorld(uint,string,
TimeSpan?) overload sharing EnterWorldCore with the index-based one;
ILiveSessionOperations gained a default EnterWorldByGuid method.
LiveSessionController factored EnterSelectedCore/the new
EnterCreatedCharacterCore through a shared EnterHighlightedCore so the
post-create enter sends by the exact guid the 0xF643 Ok reply carried,
never by a roster index.

F2 (blocking): RuntimeCharacterSelectionState gained a real
AppendCreatedCharacter primitive that preserves every existing entry's
ActiveIndex (a wire contract — SendDeleteCharacter sends it as the
CharacterSet slot) and assigns the new entry's from the pre-create wire
roster count, instead of round-tripping the post-create roster through
ApplyRoster's name-sort-and-renumber.

F3 (blocking): retail's DoFinish(this, arg2) gate is
"arg2 != 0 && remainingAtrbCredits > 0" — the ordinary click warns and
refuses, but the warning dialog's own confirm re-invokes DoFinish(this,
0), which sends anyway with credits unspent (ACE accepts this).
TryBeginFinish/Finish gained a confirmedUnspentCredits parameter; the
plan doc's "retail FORCES full spend" line is corrected in the same
commit.

F4 (blocking): a stale out-of-range template index surviving a heritage
switch to a heritage with fewer templates now clears to TemplateUnset,
matching ConstrainAllByHeritage's clamp.

F5/F9/F10: three register-row/doc citation corrections (AP-207's real
FitTemplateToCharacter call sites — a fourth one the original filing
also missed; the Slot field's real retail assignment source; AP-209's
classID branch table for Olthoi/OlthoiAcid). F6: ApplyCreationResponse
no longer publishes from inside the owner lock. F7: two new tests pin
BalanceAttributes' persistent donor cursor (successive-overspend
advance, Self-to-Strength wrap). F8: ResetSkillLevels' doc corrected to
retail's real both-costs->=0 gate. F11: the integration test fixture
captures guid-based enter calls and uses two pre-existing characters
whose wire order differs from alphabetical order, so the roster
assertion actually exercises F2 instead of coinciding with it by
accident. F12: filed register row AP-211 for the client-side RosterFull
slot-cap refusal (no retail DoFinish-layer counterpart). F13: narrowed
Finish's bare catch to InvalidOperationException/SocketException and
bound _scope to a local. F15: RandomizeStartAreaLocked leaves the start
area unchanged on an empty list instead of forcing -1, matching retail.

Runtime 1706/0 (was 1701), Core.Net unchanged at 994/0, full solution
Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 15:10:43 +02:00
parent 9a84230c4f
commit 397ccd62cd
9 changed files with 578 additions and 68 deletions

View file

@ -49,6 +49,13 @@ public sealed class LiveSessionControllerCharacterCreationTests
public List<WorldSession> Sessions { get; } = [];
public int EnterWorldCount { get; private set; }
/// <summary>F1/F11: captures every guid-based enter call so the
/// post-create log-straight-in can be asserted against the EXACT
/// identity sent, instead of a bare counter that cannot tell an
/// index-based call from a guid-based one, or a right character
/// from a wrong one.</summary>
public List<(uint Guid, string AccountName)> EnterWorldByGuidCalls { get; } = [];
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
@ -63,9 +70,19 @@ public sealed class LiveSessionControllerCharacterCreationTests
public void StartCharacterSelectionReceive(WorldSession session) { }
/// <summary>Two pre-existing characters whose WIRE order (array
/// position — "Zed" slot 0, "Amy" slot 1) deliberately differs from
/// their ALPHABETICAL display order ("Amy" &lt; "Zed") — F2's
/// regression gate. A single-character roster cannot distinguish a
/// correct wire-index-preserving append from a buggy
/// re-sort-and-renumber, because with only one entry the two orders
/// coincide.</summary>
public CharacterList.Parsed? GetCharacters(WorldSession session) => new(
0u,
[new CharacterList.Character(0x50000001u, "Existing", 0u)],
[
new CharacterList.Character(0x50000002u, "Zed", 0u),
new CharacterList.Character(0x50000003u, "Amy", 0u),
],
[],
SlotCount: 11,
AccountName: "testaccount",
@ -75,6 +92,12 @@ public sealed class LiveSessionControllerCharacterCreationTests
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
EnterWorldCount++;
public void EnterWorldByGuid(
WorldSession session,
uint characterGuid,
string accountName) =>
EnterWorldByGuidCalls.Add((characterGuid, accountName));
public void Tick(WorldSession session) { }
public void DisposeSession(WorldSession session) { }
@ -195,17 +218,36 @@ public sealed class LiveSessionControllerCharacterCreationTests
Assert.Equal("NewChar", host.Created[0].Name);
Assert.Empty(host.Failed);
// The roster report following the Ok reply has BOTH the pre-existing
// The roster report following the Ok reply has EVERY pre-existing
// character and the newly created one.
LiveSessionRosterReport lastReport = host.Rosters[^1];
Assert.Contains(lastReport.Entries, e => e.Id == 0x50001234u && e.Name == "NewChar");
Assert.Contains(lastReport.Entries, e => e.Id == 0x50000001u);
Assert.Contains(lastReport.Entries, e => e.Id == 0x50000002u && e.Name == "Zed");
Assert.Contains(lastReport.Entries, e => e.Id == 0x50000003u && e.Name == "Amy");
// gmCharGenMainUI::Update @ 0x004E8460's log-straight-in, reused via
// EnterSelectedCore — the controller is now in-world as the new
// character, no second selection/EnterWorld call needed.
// F2 acceptance gate: the append preserves every PRE-EXISTING
// character's original wire ActiveIndex ("Zed" slot 0, "Amy" slot
// 1 — deliberately NOT alphabetical order) and assigns the NEW
// character the true wire count (2, 0-based, after Zed and Amy). A
// round-trip through ApplyRoster's name-sort-and-renumber would
// have swapped Zed/Amy to 1/0 instead.
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000002u, out RuntimeCharacterSelectionEntry zed));
Assert.Equal(0, zed.ActiveIndex);
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000003u, out RuntimeCharacterSelectionEntry amy));
Assert.Equal(1, amy.ActiveIndex);
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50001234u, out RuntimeCharacterSelectionEntry newChar));
Assert.Equal(2, newChar.ActiveIndex);
// F1 acceptance gate: gmCharGenMainUI::Update @ 0x004E8460's
// log-straight-in, reused via EnterCreatedCharacterCore — the
// controller is now in-world as the new character, entered by the
// EXACT guid the Ok reply carried (NOT the roster-index path, which
// the cached wire roster is by-design stale for post-create).
Assert.True(controller.IsInWorld);
Assert.Equal(1, operations.EnterWorldCount);
Assert.Equal(0, operations.EnterWorldCount);
Assert.Single(operations.EnterWorldByGuidCalls);
Assert.Equal(0x50001234u, operations.EnterWorldByGuidCalls[0].Guid);
Assert.Equal("testaccount", operations.EnterWorldByGuidCalls[0].AccountName);
Assert.Single(host.EnteredWorld);
Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId);
}
@ -230,6 +272,7 @@ public sealed class LiveSessionControllerCharacterCreationTests
Assert.Empty(host.Created);
Assert.False(controller.IsInWorld);
Assert.Equal(0, operations.EnterWorldCount);
Assert.Empty(operations.EnterWorldByGuidCalls);
Assert.Empty(host.EnteredWorld);
// No roster append on a rejection.
Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar"));
@ -254,6 +297,43 @@ public sealed class LiveSessionControllerCharacterCreationTests
Assert.False(sent);
}
/// <summary>
/// F3 acceptance gate: retail does NOT force a full attribute spend —
/// <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c> only warns
/// (<c>arg2 != 0 &amp;&amp; remainingAtrbCredits &gt; 0</c>) and the
/// credit-warning dialog's own confirm handler re-invokes
/// <c>DoFinish(this, 0)</c> (@0x004E98BB), which skips the check
/// entirely and sends. <see cref="LiveSessionController.Finish"/>'s
/// <c>confirmUnspentCredits</c> parameter is that <c>arg2 == 0</c> case.
/// </summary>
[Fact]
public void Finish_WithUnspentCreditsAndConfirmed_SendsAnyway()
{
(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.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.CustomTemplateIndex).Accepted);
Assert.True(controller.SetName(generation, "NewChar").Accepted);
WorldSession session = operations.Sessions[0];
byte[]? captured = null;
session.GameMessageCapture = (body, _) => captured = body;
// Unconfirmed: refused, matching the sibling test above — the
// warning-dialog gate.
Assert.False(controller.Finish(generation).Accepted);
Assert.Null(captured);
// Confirmed: sends anyway with the credits still unspent.
RuntimeCommandResult confirmed = controller.Finish(generation, confirmUnspentCredits: true);
Assert.True(confirmed.Accepted);
Assert.NotNull(captured);
CapturedCreateRequest decoded = DecodeCreateRequest(captured!);
Assert.Equal("NewChar", decoded.Name);
Assert.Equal(10u, decoded.Strength); // Custom template sits at the floor — unspent, unchanged.
}
private static void InvokeProcessDatagram(WorldSession session, byte[] datagram)
{
MethodInfo method = typeof(WorldSession).GetMethod(