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

@ -1149,12 +1149,54 @@ public sealed class WorldSession : IDisposable
{
if (Characters is null || Characters.Characters.Count == 0)
throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList");
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
EnterWorldSelection selection = SelectCharacterForEnterWorld(
Characters,
characterIndex);
CharacterList.Character chosen = selection.Character;
_activeCharacterId = chosen.Id;
EnterWorldCore(selection.Character.Id, selection.EnterWorldBody, timeout);
}
/// <summary>
/// Send CharacterEnterWorldRequest and CharacterEnterWorld for the exact
/// (guid, accountName) identity the caller supplies, bypassing the
/// cached <see cref="Characters"/> roster entirely. Campaign CC slice
/// CC3 review-fix round (F1): the index-based overload above assumes
/// <paramref name="characterIndex"/> refers to a slot in
/// <see cref="Characters"/> — true for ordinary character-select entry,
/// but FALSE immediately after a character create. ACE never resends
/// <see cref="CharacterList"/> post-create (it only appends server-side
/// and replies with the <c>0xF643</c> Ok identity —
/// <c>references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:170-172</c>),
/// so entering the newly created character by a re-derived index can
/// throw (zero pre-existing characters) or silently enter the WRONG
/// character (N pre-existing characters, since the caller's display
/// order need not match the wire order). Retail's own
/// <c>CPlayerSystem::LogOnCharacter(gid)</c> is itself guid-based, so
/// this is a more direct port of the same entry point — not a
/// deviation from retail — for the one caller (enter-straight-in after
/// create) that has an exact identity in hand and no reliable index.
///
/// <para>
/// Retail's own fallback when the freshly created name never appears in
/// its per-frame roster poll (<c>gmCharGenMainUI::Update @
/// 0x004E8460</c>) bounces the UI back to character management
/// (<c>QueueUIMode(0x1000000a) @ 0x004E85D7</c>). acdream has no
/// analogous fallback here because this entry point is driven directly
/// by the identity carried on the SAME reply that confirms the create
/// succeeded — there is no polling step that could fail to find the
/// name, so there is nothing for a fallback to catch.
/// </para>
/// </summary>
public void EnterWorld(uint characterGuid, string accountName, TimeSpan? timeout = null)
{
ArgumentNullException.ThrowIfNull(accountName);
byte[] enterWorldBody = CharacterEnterWorld.BuildEnterWorldBody(characterGuid, accountName);
EnterWorldCore(characterGuid, enterWorldBody, timeout);
}
private void EnterWorldCore(uint characterGuid, byte[] enterWorldBody, TimeSpan? timeout)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
_activeCharacterId = characterGuid;
Transition(State.EnteringWorld);
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
@ -1220,7 +1262,7 @@ public sealed class WorldSession : IDisposable
// CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account
// populated by CharacterSet::UnPack, not the spelling supplied to the
// login form. ACE validates this canonical account value.
SendGameMessage(selection.EnterWorldBody);
SendGameMessage(enterWorldBody);
// LoginComplete is emitted by the host only after the accepted local
// Create has completed its canonical first placement. Sending it at