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

@ -334,6 +334,53 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
selected);
}
/// <summary>
/// Campaign CC slice CC3 review-fix round (F2): appends ONE freshly
/// created character without re-deriving every entry's
/// <see cref="RuntimeCharacterSelectionEntry.ActiveIndex"/> from display
/// order the way <see cref="ApplyRoster"/> does. <c>ActiveIndex</c> is a
/// WIRE CONTRACT — <c>SendDeleteCharacter</c> sends it as the
/// CharacterSet slot and ACE indexes
/// <c>session.Characters[(int)characterSlot]</c>
/// (<c>references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:297</c>)
/// — so round-tripping the post-create roster through
/// <see cref="ApplyRoster"/>'s name-sort would silently retarget every
/// PRE-EXISTING character's delete slot to its alphabetical rank.
/// <paramref name="wireIndex"/> is the caller-supplied wire slot for the
/// NEW entry only (ACE appends to <c>session.Characters</c>, so the new
/// character's slot equals the wire roster's count BEFORE this create,
/// 0-based — the caller must read that from the cached wire source, not
/// from the sorted display mirror this class exposes). Every existing
/// entry's <see cref="RuntimeCharacterSelectionEntry.ActiveIndex"/> is
/// copied through untouched; only the array's DISPLAY order (name sort,
/// greyed-to-tail) is recomputed, exactly like <see cref="ApplyRoster"/>'s
/// own sort. Does not touch highlight — callers that want the new entry
/// selected still call <see cref="TryHighlight"/> afterward.
/// </summary>
internal void AppendCreatedCharacter(uint characterId, string name, int wireIndex)
{
ArgumentNullException.ThrowIfNull(name);
lock (_gate)
{
ThrowIfDisposed();
var appended = new RuntimeCharacterSelectionEntry[_entries.Length + 1];
Array.Copy(_entries, appended, _entries.Length);
appended[^1] = new RuntimeCharacterSelectionEntry(
wireIndex,
characterId,
name,
SecondsGreyedOut: 0u);
Array.Sort(
appended,
static (left, right) =>
string.CompareOrdinal(left.Name, right.Name));
_entries = StablePartitionGreyedToTail(appended);
_revision++;
}
Publish(RuntimeCharacterSelectionDeltaKind.RosterChanged, characterId);
}
/// <summary>
/// Campaign LA gate round 2 finding 3: retail's <c>UpdateWorldName</c>
/// (<c>0x004ec120</c>) / <c>RecvNotice_WorldName</c> (<c>0x004ec360</c>)