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:
parent
9a84230c4f
commit
397ccd62cd
9 changed files with 578 additions and 68 deletions
|
|
@ -202,6 +202,21 @@ public interface ILiveSessionOperations
|
|||
void StartCharacterSelectionReceive(WorldSession session) =>
|
||||
session.StartCharacterSelectionReceive();
|
||||
void EnterWorld(WorldSession session, int activeCharacterIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC3 review-fix round (F1): mirrors
|
||||
/// <see cref="EnterWorld(WorldSession,int)"/> but enters by the exact
|
||||
/// guid the <c>0xF643</c> Ok reply carried, bypassing the (by-design
|
||||
/// post-create-stale) cached roster — see
|
||||
/// <see cref="WorldSession.EnterWorld(uint,string,TimeSpan?)"/>'s doc
|
||||
/// comment for the full retail citation.
|
||||
/// </summary>
|
||||
void EnterWorldByGuid(
|
||||
WorldSession session,
|
||||
uint characterGuid,
|
||||
string accountName) =>
|
||||
session.EnterWorld(characterGuid, accountName);
|
||||
|
||||
void DeleteCharacter(
|
||||
WorldSession session,
|
||||
string accountName,
|
||||
|
|
@ -1118,7 +1133,33 @@ public sealed class LiveSessionController
|
|||
}
|
||||
}
|
||||
|
||||
private RuntimeCommandResult EnterSelectedCore()
|
||||
private RuntimeCommandResult EnterSelectedCore() =>
|
||||
EnterHighlightedCore(static (operations, session, character, _) =>
|
||||
operations.EnterWorld(session, character.ActiveIndex));
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC3 review-fix round (F1): the log-straight-in
|
||||
/// half of <see cref="HandleCharacterCreationResponse"/> — identical
|
||||
/// transaction shape to <see cref="EnterSelectedCore"/>, but sends the
|
||||
/// EnterWorld wire request by the exact created guid rather than by a
|
||||
/// roster index (see <see cref="ILiveSessionOperations.EnterWorldByGuid"/>).
|
||||
/// </summary>
|
||||
private RuntimeCommandResult EnterCreatedCharacterCore(
|
||||
RuntimeCharacterCreationIdentity identity) =>
|
||||
EnterHighlightedCore((operations, session, _, accountName) =>
|
||||
operations.EnterWorldByGuid(session, identity.Guid, accountName));
|
||||
|
||||
/// <summary>
|
||||
/// Shared transaction for "the highlighted character is about to enter
|
||||
/// the world": select it, send the caller-supplied EnterWorld wire
|
||||
/// request, activate commands, and publish the entered-world state.
|
||||
/// <paramref name="sendEnterWorld"/> is the only thing that differs
|
||||
/// between the ordinary index-based selection flow
|
||||
/// (<see cref="EnterSelectedCore"/>) and the post-create guid-based flow
|
||||
/// (<see cref="EnterCreatedCharacterCore"/>).
|
||||
/// </summary>
|
||||
private RuntimeCommandResult EnterHighlightedCore(
|
||||
Action<ILiveSessionOperations, WorldSession, RuntimeCharacterSelectionEntry, string> sendEnterWorld)
|
||||
{
|
||||
SessionScope scope = _scope!;
|
||||
ulong generation = _generation;
|
||||
|
|
@ -1141,7 +1182,7 @@ public sealed class LiveSessionController
|
|||
if (!IsCurrent(scope, generation))
|
||||
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
|
||||
|
||||
_operations.EnterWorld(scope.Session, character.ActiveIndex);
|
||||
sendEnterWorld(_operations, scope.Session, character, snapshot.AccountName);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
|
||||
|
||||
|
|
@ -1231,11 +1272,36 @@ public sealed class LiveSessionController
|
|||
/// (we already have the exact created guid/name from the SAME reply
|
||||
/// that triggered the roster append, so there is no need to re-scan for
|
||||
/// it the way retail's per-frame poll does — an equivalent, not a
|
||||
/// divergent, substitution). Reuses <see cref="RuntimeCharacterSelectionState.ApplyRoster"/>
|
||||
/// for the append (there is no single-entry append primitive to
|
||||
/// duplicate) and <see cref="EnterSelectedCore"/> for the log-straight-in
|
||||
/// (no second enter route). A non-Ok reply only needs the state-machine
|
||||
/// update already performed by <see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>
|
||||
/// divergent, substitution).
|
||||
///
|
||||
/// <para>
|
||||
/// Campaign CC slice CC3 review-fix round (F1/F2): the roster append no
|
||||
/// longer round-trips through <see cref="RuntimeCharacterSelectionState.ApplyRoster"/>
|
||||
/// — that re-derives EVERY entry's <c>ActiveIndex</c> from display
|
||||
/// (name-sorted) order, and <c>ActiveIndex</c> is a wire contract
|
||||
/// (<c>SendDeleteCharacter</c> sends it as the CharacterSet slot; ACE
|
||||
/// indexes <c>session.Characters[(int)characterSlot]</c> —
|
||||
/// <c>references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:297</c>),
|
||||
/// so a full re-sort would silently retarget every PRE-EXISTING
|
||||
/// character's delete slot to its alphabetical rank. Instead
|
||||
/// <see cref="RuntimeCharacterSelectionState.AppendCreatedCharacter"/>
|
||||
/// preserves every existing entry's <c>ActiveIndex</c> and assigns the
|
||||
/// new entry's from the wire count BEFORE this create (ACE appends to
|
||||
/// <c>session.Characters</c>, so the new character's slot equals that
|
||||
/// pre-create count, 0-based) — read from the cached wire list
|
||||
/// (<see cref="ILiveSessionOperations.GetCharacters"/>), the SAME source
|
||||
/// the ordinary index-enter path reads, not the sorted display mirror.
|
||||
/// The subsequent log-straight-in also no longer goes through
|
||||
/// <see cref="EnterSelectedCore"/>'s roster-index EnterWorld send — that
|
||||
/// cached wire list is BY DESIGN stale for the just-created character
|
||||
/// (ACE never resends CharacterList post-create), so it uses
|
||||
/// <see cref="EnterCreatedCharacterCore"/>'s guid-based send instead
|
||||
/// (see that method's and <see cref="ILiveSessionOperations.EnterWorldByGuid"/>'s
|
||||
/// doc comments).
|
||||
/// </para>
|
||||
///
|
||||
/// A non-Ok reply only needs the state-machine update already performed
|
||||
/// by <see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>
|
||||
/// — no roster/enter side effects.
|
||||
/// </summary>
|
||||
private void HandleCharacterCreationResponse(
|
||||
|
|
@ -1265,7 +1331,19 @@ public sealed class LiveSessionController
|
|||
before.AccountName,
|
||||
before.SlotCount,
|
||||
entries);
|
||||
CharacterSelectionState.ApplyRoster(report);
|
||||
|
||||
// F2: the new character's wire slot is the pre-create count of the
|
||||
// cached wire roster (ACE appends; that cached list is stale for
|
||||
// THIS character by design, but its COUNT is still exactly the
|
||||
// 0-based slot ACE assigned). Falls back to the display roster
|
||||
// count only if the cached wire list is unexpectedly unavailable.
|
||||
int wireIndex =
|
||||
_operations.GetCharacters(scope.Session)?.Characters.Count
|
||||
?? before.RosterCount;
|
||||
CharacterSelectionState.AppendCreatedCharacter(
|
||||
identity.Guid,
|
||||
identity.Name,
|
||||
wireIndex);
|
||||
scope.Host.ReportRoster(report);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return;
|
||||
|
|
@ -1279,7 +1357,7 @@ public sealed class LiveSessionController
|
|||
// running inside Tick()'s top-level operation (this handler fires
|
||||
// synchronously from _operations.Tick's inbound processing), exactly
|
||||
// the same calling convention StartCore's own inline enter uses.
|
||||
_ = EnterSelectedCore();
|
||||
_ = EnterCreatedCharacterCore(identity);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult SelectHeritage(
|
||||
|
|
@ -1463,17 +1541,25 @@ public sealed class LiveSessionController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>gmCharGenMainUI::DoFinish @ 0x004E9170</c>'s send half: the
|
||||
/// local gates live in <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>;
|
||||
/// this method supplies the roster/slot-cap inputs from
|
||||
/// Ports <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c>'s
|
||||
/// send half: the local gates live in
|
||||
/// <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>; this
|
||||
/// method supplies the roster/slot-cap inputs from
|
||||
/// <see cref="CharacterSelectionState"/> and, on acceptance, sends the
|
||||
/// wire request via <c>Proto_UI::SendCharGenResult</c>'s port
|
||||
/// (<see cref="ILiveSessionOperations.CreateCharacter"/>). A transport
|
||||
/// failure resets the verification latch the same way an unsolicited
|
||||
/// Undef/Pending reply does (<see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>)
|
||||
/// (<see cref="ILiveSessionOperations.CreateCharacter"/>).
|
||||
/// <paramref name="confirmUnspentCredits"/> is retail's <c>arg2 == 0</c>
|
||||
/// case — see <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>'s
|
||||
/// doc comment for the full credit-warning-dialog citation; the ordinary
|
||||
/// caller passes <c>false</c> (retail's <c>arg2 = 1</c> button click). A
|
||||
/// transport failure resets the verification latch the same way an
|
||||
/// unsolicited Undef/Pending reply does
|
||||
/// (<see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>)
|
||||
/// rather than leaving it stuck Pending forever.
|
||||
/// </summary>
|
||||
public RuntimeCommandResult Finish(RuntimeGenerationToken expectedGeneration)
|
||||
public RuntimeCommandResult Finish(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
bool confirmUnspentCredits = false)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
|
|
@ -1481,13 +1567,15 @@ public sealed class LiveSessionController
|
|||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return CharacterCreationResult(gate);
|
||||
|
||||
SessionScope scope = _scope!;
|
||||
RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot;
|
||||
if (!CharacterCreationState.TryBeginFinish(
|
||||
selection.RosterCount,
|
||||
selection.SlotCount,
|
||||
out CharacterCreate.Request request,
|
||||
out uint[] skillAdvancementClasses,
|
||||
out _))
|
||||
out _,
|
||||
confirmUnspentCredits))
|
||||
{
|
||||
return CharacterCreationResult(RuntimeCommandStatus.Rejected);
|
||||
}
|
||||
|
|
@ -1495,13 +1583,22 @@ public sealed class LiveSessionController
|
|||
try
|
||||
{
|
||||
_operations.CreateCharacter(
|
||||
_scope!.Session,
|
||||
scope.Session,
|
||||
selection.AccountName,
|
||||
request,
|
||||
skillAdvancementClasses);
|
||||
return CharacterCreationResult(RuntimeCommandStatus.Accepted);
|
||||
}
|
||||
catch
|
||||
// F13: narrowed to what SendCharacterCreation's send path
|
||||
// actually throws — WorldSession.SendGameMessage's own
|
||||
// InvalidOperationException (transport not yet negotiated) and
|
||||
// whatever the underlying UDP send raises (SocketException).
|
||||
// Anything else is a genuine bug, not a transport hiccup, and
|
||||
// should propagate rather than being silently swallowed into a
|
||||
// rejection.
|
||||
catch (Exception error) when (
|
||||
error is InvalidOperationException
|
||||
or System.Net.Sockets.SocketException)
|
||||
{
|
||||
CharacterCreationState.ApplyCreationResponse(
|
||||
new CharGenVerificationResponse.Parsed(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue