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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1149,12 +1149,54 @@ public sealed class WorldSession : IDisposable
|
||||||
{
|
{
|
||||||
if (Characters is null || Characters.Characters.Count == 0)
|
if (Characters is null || Characters.Characters.Count == 0)
|
||||||
throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList");
|
throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList");
|
||||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
|
|
||||||
EnterWorldSelection selection = SelectCharacterForEnterWorld(
|
EnterWorldSelection selection = SelectCharacterForEnterWorld(
|
||||||
Characters,
|
Characters,
|
||||||
characterIndex);
|
characterIndex);
|
||||||
CharacterList.Character chosen = selection.Character;
|
EnterWorldCore(selection.Character.Id, selection.EnterWorldBody, timeout);
|
||||||
_activeCharacterId = chosen.Id;
|
}
|
||||||
|
|
||||||
|
/// <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);
|
Transition(State.EnteringWorld);
|
||||||
|
|
||||||
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
|
SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody());
|
||||||
|
|
@ -1220,7 +1262,7 @@ public sealed class WorldSession : IDisposable
|
||||||
// CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account
|
// CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account
|
||||||
// populated by CharacterSet::UnPack, not the spelling supplied to the
|
// populated by CharacterSet::UnPack, not the spelling supplied to the
|
||||||
// login form. ACE validates this canonical account value.
|
// login form. ACE validates this canonical account value.
|
||||||
SendGameMessage(selection.EnterWorldBody);
|
SendGameMessage(enterWorldBody);
|
||||||
|
|
||||||
// LoginComplete is emitted by the host only after the accepted local
|
// LoginComplete is emitted by the host only after the accepted local
|
||||||
// Create has completed its canonical first placement. Sending it at
|
// Create has completed its canonical first placement. Sending it at
|
||||||
|
|
|
||||||
|
|
@ -446,9 +446,17 @@ public interface IRuntimeCharacterCreationCommands
|
||||||
/// <summary>Retail's Finish button (<c>gmCharGenMainUI::DoFinish @
|
/// <summary>Retail's Finish button (<c>gmCharGenMainUI::DoFinish @
|
||||||
/// 0x004E9170</c>). On acceptance the request is already on the wire;
|
/// 0x004E9170</c>). On acceptance the request is already on the wire;
|
||||||
/// the Ok/rejection reply arrives asynchronously as a status delta —
|
/// the Ok/rejection reply arrives asynchronously as a status delta —
|
||||||
/// see <see cref="Session.RuntimeCharacterCreationState"/>.</summary>
|
/// see <see cref="Session.RuntimeCharacterCreationState"/>.
|
||||||
|
/// <paramref name="confirmUnspentCredits"/> is retail's <c>arg2 == 0</c>
|
||||||
|
/// — the credit-warning dialog's own confirm click — skipping the
|
||||||
|
/// unspent-attribute-credits gate; the ordinary caller passes
|
||||||
|
/// <c>false</c> (retail's <c>arg2 = 1</c> button click), which shows
|
||||||
|
/// that warning instead of sending when credits remain. See
|
||||||
|
/// <see cref="Session.RuntimeCharacterCreationState.TryBeginFinish"/>'s
|
||||||
|
/// doc comment for the full citation.</summary>
|
||||||
RuntimeCommandResult Finish(
|
RuntimeCommandResult Finish(
|
||||||
RuntimeGenerationToken expectedGeneration);
|
RuntimeGenerationToken expectedGeneration,
|
||||||
|
bool confirmUnspentCredits = false);
|
||||||
|
|
||||||
RuntimeCommandResult AcknowledgeRejection(
|
RuntimeCommandResult AcknowledgeRejection(
|
||||||
RuntimeGenerationToken expectedGeneration);
|
RuntimeGenerationToken expectedGeneration);
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,21 @@ public interface ILiveSessionOperations
|
||||||
void StartCharacterSelectionReceive(WorldSession session) =>
|
void StartCharacterSelectionReceive(WorldSession session) =>
|
||||||
session.StartCharacterSelectionReceive();
|
session.StartCharacterSelectionReceive();
|
||||||
void EnterWorld(WorldSession session, int activeCharacterIndex);
|
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(
|
void DeleteCharacter(
|
||||||
WorldSession session,
|
WorldSession session,
|
||||||
string accountName,
|
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!;
|
SessionScope scope = _scope!;
|
||||||
ulong generation = _generation;
|
ulong generation = _generation;
|
||||||
|
|
@ -1141,7 +1182,7 @@ public sealed class LiveSessionController
|
||||||
if (!IsCurrent(scope, generation))
|
if (!IsCurrent(scope, generation))
|
||||||
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
|
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
|
||||||
|
|
||||||
_operations.EnterWorld(scope.Session, character.ActiveIndex);
|
sendEnterWorld(_operations, scope.Session, character, snapshot.AccountName);
|
||||||
if (!IsCurrent(scope, generation))
|
if (!IsCurrent(scope, generation))
|
||||||
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
|
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
|
||||||
|
|
||||||
|
|
@ -1231,11 +1272,36 @@ public sealed class LiveSessionController
|
||||||
/// (we already have the exact created guid/name from the SAME reply
|
/// (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
|
/// 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
|
/// it the way retail's per-frame poll does — an equivalent, not a
|
||||||
/// divergent, substitution). Reuses <see cref="RuntimeCharacterSelectionState.ApplyRoster"/>
|
/// divergent, substitution).
|
||||||
/// for the append (there is no single-entry append primitive to
|
///
|
||||||
/// duplicate) and <see cref="EnterSelectedCore"/> for the log-straight-in
|
/// <para>
|
||||||
/// (no second enter route). A non-Ok reply only needs the state-machine
|
/// Campaign CC slice CC3 review-fix round (F1/F2): the roster append no
|
||||||
/// update already performed by <see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>
|
/// 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.
|
/// — no roster/enter side effects.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleCharacterCreationResponse(
|
private void HandleCharacterCreationResponse(
|
||||||
|
|
@ -1265,7 +1331,19 @@ public sealed class LiveSessionController
|
||||||
before.AccountName,
|
before.AccountName,
|
||||||
before.SlotCount,
|
before.SlotCount,
|
||||||
entries);
|
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);
|
scope.Host.ReportRoster(report);
|
||||||
if (!IsCurrent(scope, generation))
|
if (!IsCurrent(scope, generation))
|
||||||
return;
|
return;
|
||||||
|
|
@ -1279,7 +1357,7 @@ public sealed class LiveSessionController
|
||||||
// running inside Tick()'s top-level operation (this handler fires
|
// running inside Tick()'s top-level operation (this handler fires
|
||||||
// synchronously from _operations.Tick's inbound processing), exactly
|
// synchronously from _operations.Tick's inbound processing), exactly
|
||||||
// the same calling convention StartCore's own inline enter uses.
|
// the same calling convention StartCore's own inline enter uses.
|
||||||
_ = EnterSelectedCore();
|
_ = EnterCreatedCharacterCore(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public RuntimeCommandResult SelectHeritage(
|
public RuntimeCommandResult SelectHeritage(
|
||||||
|
|
@ -1463,17 +1541,25 @@ public sealed class LiveSessionController
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ports <c>gmCharGenMainUI::DoFinish @ 0x004E9170</c>'s send half: the
|
/// Ports <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c>'s
|
||||||
/// local gates live in <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>;
|
/// send half: the local gates live in
|
||||||
/// this method supplies the roster/slot-cap inputs from
|
/// <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>; this
|
||||||
|
/// method supplies the roster/slot-cap inputs from
|
||||||
/// <see cref="CharacterSelectionState"/> and, on acceptance, sends the
|
/// <see cref="CharacterSelectionState"/> and, on acceptance, sends the
|
||||||
/// wire request via <c>Proto_UI::SendCharGenResult</c>'s port
|
/// wire request via <c>Proto_UI::SendCharGenResult</c>'s port
|
||||||
/// (<see cref="ILiveSessionOperations.CreateCharacter"/>). A transport
|
/// (<see cref="ILiveSessionOperations.CreateCharacter"/>).
|
||||||
/// failure resets the verification latch the same way an unsolicited
|
/// <paramref name="confirmUnspentCredits"/> is retail's <c>arg2 == 0</c>
|
||||||
/// Undef/Pending reply does (<see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>)
|
/// 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.
|
/// rather than leaving it stuck Pending forever.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RuntimeCommandResult Finish(RuntimeGenerationToken expectedGeneration)
|
public RuntimeCommandResult Finish(
|
||||||
|
RuntimeGenerationToken expectedGeneration,
|
||||||
|
bool confirmUnspentCredits = false)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
|
|
@ -1481,13 +1567,15 @@ public sealed class LiveSessionController
|
||||||
if (gate != RuntimeCommandStatus.Accepted)
|
if (gate != RuntimeCommandStatus.Accepted)
|
||||||
return CharacterCreationResult(gate);
|
return CharacterCreationResult(gate);
|
||||||
|
|
||||||
|
SessionScope scope = _scope!;
|
||||||
RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot;
|
RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot;
|
||||||
if (!CharacterCreationState.TryBeginFinish(
|
if (!CharacterCreationState.TryBeginFinish(
|
||||||
selection.RosterCount,
|
selection.RosterCount,
|
||||||
selection.SlotCount,
|
selection.SlotCount,
|
||||||
out CharacterCreate.Request request,
|
out CharacterCreate.Request request,
|
||||||
out uint[] skillAdvancementClasses,
|
out uint[] skillAdvancementClasses,
|
||||||
out _))
|
out _,
|
||||||
|
confirmUnspentCredits))
|
||||||
{
|
{
|
||||||
return CharacterCreationResult(RuntimeCommandStatus.Rejected);
|
return CharacterCreationResult(RuntimeCommandStatus.Rejected);
|
||||||
}
|
}
|
||||||
|
|
@ -1495,13 +1583,22 @@ public sealed class LiveSessionController
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_operations.CreateCharacter(
|
_operations.CreateCharacter(
|
||||||
_scope!.Session,
|
scope.Session,
|
||||||
selection.AccountName,
|
selection.AccountName,
|
||||||
request,
|
request,
|
||||||
skillAdvancementClasses);
|
skillAdvancementClasses);
|
||||||
return CharacterCreationResult(RuntimeCommandStatus.Accepted);
|
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(
|
CharacterCreationState.ApplyCreationResponse(
|
||||||
new CharGenVerificationResponse.Parsed(
|
new CharGenVerificationResponse.Parsed(
|
||||||
|
|
|
||||||
|
|
@ -506,6 +506,20 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
/// row's six attributes verbatim and re-derives the skill array (baseline
|
/// row's six attributes verbatim and re-derives the skill array (baseline
|
||||||
/// reset, then the row's Normal skills trained and Primary skills
|
/// reset, then the row's Normal skills trained and Primary skills
|
||||||
/// specialized).
|
/// specialized).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Campaign CC slice CC3 review-fix round (F4): when the currently
|
||||||
|
/// selected <see cref="_template"/> index is out of range for THIS
|
||||||
|
/// heritage's template list (a heritage switch left a stale index from
|
||||||
|
/// a previous, richer heritage), this clears it to
|
||||||
|
/// <see cref="RuntimeCharacterCreationSnapshot.TemplateUnset"/> instead
|
||||||
|
/// of merely returning — mirroring the clamp
|
||||||
|
/// <c>CharGenState::ConstrainAllByHeritage @ 0x005C65CC</c> performs
|
||||||
|
/// right after <c>ApplyTemplate</c> in <c>SetHeritageGroup</c>
|
||||||
|
/// (<c>if (template_ >= count) template_ = 0xffffffff;</c>). Without
|
||||||
|
/// this, the stale out-of-range index would survive unchanged and reach
|
||||||
|
/// the wire via <see cref="BuildRequestLocked"/>.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ApplyTemplateLocked(ChargenHeritageOptions heritage)
|
private void ApplyTemplateLocked(ChargenHeritageOptions heritage)
|
||||||
{
|
{
|
||||||
|
|
@ -519,7 +533,10 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
if (_heritageId == 0 || _genderKey == 0 || _template == RuntimeCharacterCreationSnapshot.TemplateUnset)
|
if (_heritageId == 0 || _genderKey == 0 || _template == RuntimeCharacterCreationSnapshot.TemplateUnset)
|
||||||
return;
|
return;
|
||||||
if (_template >= (uint)heritage.Templates.Count)
|
if (_template >= (uint)heritage.Templates.Count)
|
||||||
|
{
|
||||||
|
_template = RuntimeCharacterCreationSnapshot.TemplateUnset;
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ChargenTemplate row = heritage.Templates[(int)_template];
|
ChargenTemplate row = heritage.Templates[(int)_template];
|
||||||
_attributes = row.Attributes;
|
_attributes = row.Attributes;
|
||||||
|
|
@ -580,13 +597,34 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ports <c>CharGenState::ResetSkillLevels @ 0x005C43B0</c>'s baseline
|
/// Ports <c>CharGenState::ResetSkillLevels @ 0x005C43B0</c>'s baseline
|
||||||
/// derivation: resets the credit counter to the full budget, then for
|
/// derivation: resets the credit counter to the full budget, then for
|
||||||
/// every skill id costable in EITHER tier picks the state that costs
|
/// every skill id classifies TrainedCost>0 → Untrained (must be paid
|
||||||
/// nothing yet — TrainedCost>0 → Untrained (must be paid for);
|
/// for); TrainedCost==0 && SpecializedCost<=0 → Specialized
|
||||||
/// TrainedCost==0 && SpecializedCost<=0 → Specialized (free and
|
/// (free and pre-specialized, e.g. an innate skill); TrainedCost==0
|
||||||
/// pre-specialized, e.g. an innate skill); TrainedCost==0 &&
|
/// && SpecializedCost>0 → Trained (free to train, costs to
|
||||||
/// SpecializedCost>0 → Trained (free to train, costs to specialize). A
|
/// specialize). A skill uncostable in both tiers is left untouched
|
||||||
/// skill uncostable in both tiers is left untouched (stays Inactive on a
|
/// (stays Inactive on a fresh set).
|
||||||
/// fresh set).
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Campaign CC slice CC3 review-fix round (F8): retail's real gate at
|
||||||
|
/// <c>0x005C4487</c> is <c>if (trainedCost >= 0 &&
|
||||||
|
/// specializedCost >= 0)</c> — BOTH tiers non-negative, not "either
|
||||||
|
/// tier costable" as this comment previously said. This port's
|
||||||
|
/// <see cref="TryGetSkillCost"/> instead gates on DICTIONARY PRESENCE
|
||||||
|
/// (found in the heritage's own list or the global SkillTable), which
|
||||||
|
/// is equivalent ONLY because of a CC1-established, installed-DAT-gated
|
||||||
|
/// invariant: costs are stored VERBATIM (never filtered), and for every
|
||||||
|
/// entry the reader ever produces, both <c>NormalCost</c> and
|
||||||
|
/// <c>PrimaryCost</c> are non-negative
|
||||||
|
/// (<c>ChargenTableReaderInstalledDatTests</c>'s
|
||||||
|
/// <c>NormalCost >= 0</c>/<c>PrimaryCost >= 0</c> assertions); a
|
||||||
|
/// skill uncostable in either tier is simply ABSENT from both the
|
||||||
|
/// heritage and global dictionaries in the installed DAT (retail's -1
|
||||||
|
/// case), not present with a negative value. If that installed-DAT
|
||||||
|
/// shape ever changed (a partially-negative entry, one tier <0 and
|
||||||
|
/// the other >=0), this dictionary-presence gate would diverge from
|
||||||
|
/// retail's real per-value check — the regression test above is what
|
||||||
|
/// would catch that drift.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ResetSkillLevelsLocked(ChargenHeritageOptions heritage)
|
private void ResetSkillLevelsLocked(ChargenHeritageOptions heritage)
|
||||||
{
|
{
|
||||||
|
|
@ -644,14 +682,17 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
/// picks a uniformly random entry from the heritage's
|
/// picks a uniformly random entry from the heritage's
|
||||||
/// <c>PrimaryStartAreaIndices</c> (never <c>SecondaryStartAreaIndices</c>)
|
/// <c>PrimaryStartAreaIndices</c> (never <c>SecondaryStartAreaIndices</c>)
|
||||||
/// and adopts it as the default starting area, bounds-checked against
|
/// and adopts it as the default starting area, bounds-checked against
|
||||||
/// the shared starter-area list.</summary>
|
/// the shared starter-area list. Campaign CC slice CC3 review-fix round
|
||||||
|
/// (F15): retail (<c>0x005C5A0A</c>, the <c>if (var_9c > 0)</c>
|
||||||
|
/// guard) touches <c>startArea</c> ONLY inside that branch — an empty
|
||||||
|
/// <c>PrimaryStartAreaIndices</c> leaves the field COMPLETELY
|
||||||
|
/// UNTOUCHED, not reset to <c>-1</c>. Unreachable through the installed
|
||||||
|
/// DAT (every heritage ships a non-empty primary list), but aligned
|
||||||
|
/// here for exactness.</summary>
|
||||||
private void RandomizeStartAreaLocked(ChargenHeritageOptions heritage)
|
private void RandomizeStartAreaLocked(ChargenHeritageOptions heritage)
|
||||||
{
|
{
|
||||||
if (heritage.PrimaryStartAreaIndices.Count == 0)
|
if (heritage.PrimaryStartAreaIndices.Count == 0)
|
||||||
{
|
|
||||||
_startArea = -1;
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
int candidate = heritage.PrimaryStartAreaIndices[
|
int candidate = heritage.PrimaryStartAreaIndices[
|
||||||
_random.Next(heritage.PrimaryStartAreaIndices.Count)];
|
_random.Next(heritage.PrimaryStartAreaIndices.Count)];
|
||||||
_startArea = candidate >= 0 && candidate < _options.StarterAreas.Count
|
_startArea = candidate >= 0 && candidate < _options.StarterAreas.Count
|
||||||
|
|
@ -1097,13 +1138,22 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The CharacterSet slot this create targets. Retail resets
|
/// <summary>The CharacterSet slot this create targets. Retail DOES
|
||||||
/// this to <c>0xFFFFFFFF</c> on every rejection
|
/// assign it — but only as a side effect of char-select, not chargen:
|
||||||
/// (<c>Handle_CharGenVerificationResponse</c>'s case 3/4/5/6/7) and the
|
/// <c>gmCharacterManagementUI::SelectCharacter @ 0x004EC160</c> calls
|
||||||
/// decomp does not show which caller assigns a real value before the
|
/// <c>CharGenState::SetSlot(CharacterSet::GetSlot(...)) @
|
||||||
/// first Finish — ACE itself never reads the field
|
/// 0x004EC22A</c> with the slot of whichever EXISTING character the
|
||||||
/// (<c>PlayerFactory.cs:154</c>, commented out), so 0 is a safe
|
/// player last clicked in the character-select list, reset to
|
||||||
/// placeholder until a slot-aware caller (CC4/CC7) sets one.</summary>
|
/// <c>0xFFFFFFFF</c> on entering chargen (<c>0x004EC074</c>,
|
||||||
|
/// <c>0x0055EA9A</c>) and again on every rejection
|
||||||
|
/// (<c>Handle_CharGenVerificationResponse</c>'s case 3/4/5/6/7). The
|
||||||
|
/// value retail actually sends on Finish is therefore semantically
|
||||||
|
/// STALE — the last-selected PRE-EXISTING character's own slot, not
|
||||||
|
/// anything about the character being created — and ACE never reads
|
||||||
|
/// the field regardless (<c>PlayerFactory.cs:154</c>, commented out).
|
||||||
|
/// <c>0</c> is a safe placeholder for the same reason it is safe in
|
||||||
|
/// retail: it is exactly as meaningless to ACE as retail's own stale
|
||||||
|
/// value.</summary>
|
||||||
internal bool TrySetSlot(uint slot)
|
internal bool TrySetSlot(uint slot)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
|
|
@ -1120,10 +1170,25 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
// ── Finish / response ───────────────────────────────────────────────
|
// ── Finish / response ───────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ports <c>gmCharGenMainUI::DoFinish @ 0x004E9170</c>'s complete gate
|
/// Ports <c>gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170</c>'s
|
||||||
/// sequence: trim+commit the name (empty → refuse), require
|
/// complete gate sequence: trim+commit the name (empty → refuse), then
|
||||||
/// <c>remainingAtrbCredits == 0</c> (retail forces a full attribute
|
/// — ONLY when <paramref name="confirmedUnspentCredits"/> is false,
|
||||||
/// spend), require <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/>
|
/// mirroring retail's <c>arg2 != 0</c> half of
|
||||||
|
/// <c>arg2 != 0 && remainingAtrbCredits > 0</c> — require
|
||||||
|
/// <c>remainingAtrbCredits == 0</c>. Retail does NOT force a full
|
||||||
|
/// attribute spend: the ordinary Finish-button click passes
|
||||||
|
/// <c>arg2 = 1</c> (<c>0x004E9579</c>) and, on unspent credits, shows a
|
||||||
|
/// WARNING dialog and returns WITHOUT sending
|
||||||
|
/// (<c>MakeCreditWarningDialog @ 0x004E91F6</c>); that dialog's own
|
||||||
|
/// confirm handler re-invokes <c>DoFinish(this, 0)</c>
|
||||||
|
/// (<c>0x004E98BB</c>), which skips the credit check entirely and
|
||||||
|
/// sends with the credits still unspent. ACE accepts this
|
||||||
|
/// (<c>ValidateAttributeCredits</c> only rejects a total that EXCEEDS
|
||||||
|
/// the max, never an under-spend). <paramref name="confirmedUnspentCredits"/>
|
||||||
|
/// is retail's <c>arg2 == 0</c> case: pass <c>true</c> only from the
|
||||||
|
/// warning dialog's own confirm path (CC4) or an equivalent headless
|
||||||
|
/// caller that has already decided to proceed with unspent credits.
|
||||||
|
/// Then requires <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/>
|
||||||
/// to be false (no double submit), then the campaign's client-side slot
|
/// to be false (no double submit), then the campaign's client-side slot
|
||||||
/// cap (risk item 3 — retail's char-select UI, not <c>DoFinish</c>
|
/// cap (risk item 3 — retail's char-select UI, not <c>DoFinish</c>
|
||||||
/// itself, refuses when the roster is already full; ACE never checks
|
/// itself, refuses when the roster is already full; ACE never checks
|
||||||
|
|
@ -1139,7 +1204,8 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
int slotCount,
|
int slotCount,
|
||||||
out CharacterCreate.Request request,
|
out CharacterCreate.Request request,
|
||||||
out uint[] skillAdvancementClasses,
|
out uint[] skillAdvancementClasses,
|
||||||
out RuntimeCharacterCreationLocalRefusal refusal)
|
out RuntimeCharacterCreationLocalRefusal refusal,
|
||||||
|
bool confirmedUnspentCredits = false)
|
||||||
{
|
{
|
||||||
request = default;
|
request = default;
|
||||||
skillAdvancementClasses = [];
|
skillAdvancementClasses = [];
|
||||||
|
|
@ -1158,7 +1224,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
refusal = trimmed.Length == 0
|
refusal = trimmed.Length == 0
|
||||||
? new RuntimeCharacterCreationLocalRefusal(
|
? new RuntimeCharacterCreationLocalRefusal(
|
||||||
NoName: true, false, false, false)
|
NoName: true, false, false, false)
|
||||||
: _remainingAttributeCredits > 0
|
: !confirmedUnspentCredits && _remainingAttributeCredits > 0
|
||||||
? new RuntimeCharacterCreationLocalRefusal(
|
? new RuntimeCharacterCreationLocalRefusal(
|
||||||
false, AttributeCreditsUnspent: true, false, false)
|
false, AttributeCreditsUnspent: true, false, false)
|
||||||
: _verificationPending
|
: _verificationPending
|
||||||
|
|
@ -1245,6 +1311,19 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
/// review F3) — a call that arrives while
|
/// review F3) — a call that arrives while
|
||||||
/// <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/> is
|
/// <see cref="RuntimeCharacterCreationSnapshot.VerificationPending"/> is
|
||||||
/// already false is a no-op rather than a second event.
|
/// already false is a no-op rather than a second event.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Campaign CC slice CC3 review-fix round (F6): every branch below only
|
||||||
|
/// SETS <c>kind</c> inside <c>lock (_gate)</c>; the single
|
||||||
|
/// <see cref="Publish"/> call happens once, after the lock releases —
|
||||||
|
/// matching every other public method in this class. The Pending/Undef
|
||||||
|
/// branch previously published from inside the lock (harmless on its
|
||||||
|
/// own — <see cref="Publish"/>'s own <c>lock (_gate)</c> is reentrant on
|
||||||
|
/// the same thread — but inconsistent with the rest of the class and a
|
||||||
|
/// lock-ordering risk once an observer callback reaches back into
|
||||||
|
/// caller-held locks, e.g. <c>LiveSessionController._gate</c>, while
|
||||||
|
/// still inside this one).
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response)
|
internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response)
|
||||||
{
|
{
|
||||||
|
|
@ -1268,9 +1347,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
||||||
{
|
{
|
||||||
// Silent state reset — retail shows no dialog (ACE sends
|
// Silent state reset — retail shows no dialog (ACE sends
|
||||||
// Pending for a disabled-Olthoi rejection; port as-is).
|
// Pending for a disabled-Olthoi rejection; port as-is).
|
||||||
_revision++;
|
kind = RuntimeCharacterCreationDeltaKind.StateChanged;
|
||||||
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,53 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
selected);
|
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>
|
/// <summary>
|
||||||
/// Campaign LA gate round 2 finding 3: retail's <c>UpdateWorldName</c>
|
/// Campaign LA gate round 2 finding 3: retail's <c>UpdateWorldName</c>
|
||||||
/// (<c>0x004ec120</c>) / <c>RecvNotice_WorldName</c> (<c>0x004ec360</c>)
|
/// (<c>0x004ec120</c>) / <c>RecvNotice_WorldName</c> (<c>0x004ec360</c>)
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,33 @@ public sealed class RuntimeCharacterCreationStateTests
|
||||||
Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), state.Snapshot.Attributes);
|
Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), state.Snapshot.Attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F4 acceptance gate: <c>CharGenState::ConstrainAllByHeritage @
|
||||||
|
/// 0x005C65CC</c> clamps a stale template index to <c>0xffffffff</c>
|
||||||
|
/// when it no longer fits the newly selected heritage's template list.
|
||||||
|
/// Without this, a high template index chosen against a
|
||||||
|
/// many-templates heritage would survive a switch to a heritage with
|
||||||
|
/// fewer templates and reach the wire via <c>BuildRequestLocked</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TrySelectHeritage_TemplateOutOfRangeForNewHeritage_ClearsToUnset()
|
||||||
|
{
|
||||||
|
RuntimeCharacterCreationState state = CreateActive();
|
||||||
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||||
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||||
|
// Index 1 — valid for Aluvian's two templates (Custom=0, Preset=1).
|
||||||
|
Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex));
|
||||||
|
Assert.Equal(1u, state.Snapshot.Template);
|
||||||
|
|
||||||
|
// Impoverished has only ONE template (index 0) — index 1 no longer
|
||||||
|
// fits; gender (Male) stays valid for Impoverished too, so the
|
||||||
|
// clamp branch (not the "no gender yet" no-op branch) is the one
|
||||||
|
// under test.
|
||||||
|
Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.ImpoverishedId));
|
||||||
|
|
||||||
|
Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, state.Snapshot.Template);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Attributes ──────────────────────────────────────────────────────
|
// ── Attributes ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -230,6 +257,95 @@ public sealed class RuntimeCharacterCreationStateTests
|
||||||
Assert.Equal(16, state.Snapshot.Attributes.Strength);
|
Assert.Equal(16, state.Snapshot.Attributes.Strength);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F7 acceptance gate: <c>CharGenState::BalanceAttributes @
|
||||||
|
/// 0x005C3DF0</c>'s persistent cursor (ported as the instance field
|
||||||
|
/// <c>_attributeBalanceCursor</c>) advances past whichever attribute
|
||||||
|
/// last absorbed an overspend, so a SECOND overspend in a LATER call
|
||||||
|
/// does not re-drain the SAME donor the first call already emptied.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TrySetAttribute_SuccessiveOverspends_AbsorbFromDifferentAttributes()
|
||||||
|
{
|
||||||
|
RuntimeCharacterCreationState state = CreateActive();
|
||||||
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||||
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||||
|
// Str=16, everyone else at the 10 floor, fully spent (66/66) — the
|
||||||
|
// fixture's only above-floor attribute at the start.
|
||||||
|
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex);
|
||||||
|
|
||||||
|
// First overspend: raising Endurance by 1 forces a 1-point
|
||||||
|
// donation. The cursor starts at Strength (the only above-floor
|
||||||
|
// attribute), so Strength donates.
|
||||||
|
Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 11));
|
||||||
|
Assert.Equal(15, state.Snapshot.Attributes.Strength);
|
||||||
|
Assert.Equal(11, state.Snapshot.Attributes.Endurance);
|
||||||
|
|
||||||
|
// Second overspend: raising Coordination by 1 forces another
|
||||||
|
// 1-point donation. If the cursor had reset to Strength, Strength
|
||||||
|
// (still above floor at 15) would donate again — it doesn't: the
|
||||||
|
// cursor advanced past Strength after the first call, so THIS
|
||||||
|
// donation comes from Endurance (the attribute the FIRST call just
|
||||||
|
// raised) instead.
|
||||||
|
Assert.True(state.TrySetAttribute(ChargenAttributeId.Coordination, 11));
|
||||||
|
Assert.Equal(15, state.Snapshot.Attributes.Strength); // untouched this time
|
||||||
|
Assert.Equal(10, state.Snapshot.Attributes.Endurance); // donated
|
||||||
|
Assert.Equal(11, state.Snapshot.Attributes.Coordination);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F7 acceptance gate, the wrap case: when the donor found in one pass
|
||||||
|
/// is the LAST entry in the fixed round-robin order (Self — see
|
||||||
|
/// <c>BalanceOrder</c>'s own doc comment: Strength, Endurance,
|
||||||
|
/// Coordination, Quickness, Focus, Self), the cursor wraps back to the
|
||||||
|
/// FIRST entry (Strength) rather than falling off the end.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TrySetAttribute_BalanceCursor_WrapsFromSelfBackToStrength()
|
||||||
|
{
|
||||||
|
RuntimeCharacterCreationState state = CreateActive();
|
||||||
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||||
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||||
|
// Str=16, everyone else at the 10 floor, fully spent (66/66).
|
||||||
|
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex);
|
||||||
|
|
||||||
|
// Lock every attribute except Strength and Self: they stay in the
|
||||||
|
// budget total but are excluded from donation, isolating the wrap
|
||||||
|
// behavior to exactly the two attributes under test.
|
||||||
|
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Endurance, true));
|
||||||
|
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Coordination, true));
|
||||||
|
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Quickness, true));
|
||||||
|
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Focus, true));
|
||||||
|
|
||||||
|
// Move all 6 spare points from Strength to Self — Strength is the
|
||||||
|
// sole eligible donor (everything else is locked or is the raise
|
||||||
|
// target), so it donates all 6. The cursor lands just past
|
||||||
|
// Strength (index 0 → Endurance).
|
||||||
|
Assert.True(state.TrySetAttribute(ChargenAttributeId.Self, 16));
|
||||||
|
Assert.Equal(10, state.Snapshot.Attributes.Strength);
|
||||||
|
Assert.Equal(16, state.Snapshot.Attributes.Self);
|
||||||
|
|
||||||
|
// Raise Strength by 1: every locked attribute is skipped, so the
|
||||||
|
// search reaches Self (the only remaining eligible donor). Self is
|
||||||
|
// the LAST entry in the round-robin order, so this absorption
|
||||||
|
// wraps the cursor back to Strength (the FIRST entry) afterward.
|
||||||
|
Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 11));
|
||||||
|
Assert.Equal(11, state.Snapshot.Attributes.Strength);
|
||||||
|
Assert.Equal(15, state.Snapshot.Attributes.Self);
|
||||||
|
|
||||||
|
// Raise the (locked) Endurance attribute by 1 — locking only
|
||||||
|
// excludes an attribute from AUTOMATIC donation, not from being set
|
||||||
|
// directly. If the cursor wrapped correctly, the donor search
|
||||||
|
// starts at Strength again and Strength (still above floor at 11)
|
||||||
|
// donates FIRST — not Self (also still above floor at 15), which is
|
||||||
|
// what an un-wrapped cursor stuck past Self would have picked
|
||||||
|
// instead.
|
||||||
|
Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 11));
|
||||||
|
Assert.Equal(10, state.Snapshot.Attributes.Strength);
|
||||||
|
Assert.Equal(15, state.Snapshot.Attributes.Self); // unchanged — proves the wrap
|
||||||
|
Assert.Equal(11, state.Snapshot.Attributes.Endurance);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Skills ──────────────────────────────────────────────────────────
|
// ── Skills ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -342,6 +458,40 @@ public sealed class RuntimeCharacterCreationStateTests
|
||||||
Assert.True(refusal.AttributeCreditsUnspent);
|
Assert.True(refusal.AttributeCreditsUnspent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F3 acceptance gate: <c>gmCharGenMainUI::DoFinish(this, arg2) @
|
||||||
|
/// 0x004E9170</c>'s credit gate is <c>arg2 != 0 &&
|
||||||
|
/// remainingAtrbCredits > 0</c> — retail does NOT force a full
|
||||||
|
/// spend. The ordinary click warns and refuses
|
||||||
|
/// (<c>arg2 = 1 @ 0x004E9579</c>, tested above); the credit-warning
|
||||||
|
/// dialog's own confirm handler re-invokes <c>DoFinish(this, 0)</c>
|
||||||
|
/// (@0x004E98BB), which skips the check entirely and sends with the
|
||||||
|
/// credits still unspent. <c>confirmedUnspentCredits: true</c> is that
|
||||||
|
/// <c>arg2 == 0</c> case.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void TryBeginFinish_UnspentAttributeCreditsConfirmed_IsAccepted()
|
||||||
|
{
|
||||||
|
RuntimeCharacterCreationState state = CreateActive();
|
||||||
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
||||||
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
||||||
|
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); // 6 unspent
|
||||||
|
state.TrySetName("Adventurer");
|
||||||
|
Assert.Equal(6, state.Snapshot.RemainingAttributeCredits);
|
||||||
|
|
||||||
|
bool accepted = state.TryBeginFinish(
|
||||||
|
0, 11, out CharacterCreate.Request request, out _,
|
||||||
|
out RuntimeCharacterCreationLocalRefusal refusal,
|
||||||
|
confirmedUnspentCredits: true);
|
||||||
|
|
||||||
|
Assert.True(accepted);
|
||||||
|
Assert.False(refusal.Any);
|
||||||
|
Assert.True(state.Snapshot.VerificationPending);
|
||||||
|
// The wire request carries the credits AS UNSPENT — confirming does
|
||||||
|
// not force-spend them, it only skips the local refusal.
|
||||||
|
Assert.Equal(10u, request.Attributes.Strength);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryBeginFinish_SecondCallWhilePending_IsRefused()
|
public void TryBeginFinish_SecondCallWhilePending_IsRefused()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
||||||
public List<WorldSession> Sessions { get; } = [];
|
public List<WorldSession> Sessions { get; } = [];
|
||||||
public int EnterWorldCount { get; private set; }
|
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) =>
|
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||||
new(IPAddress.Loopback, port);
|
new(IPAddress.Loopback, port);
|
||||||
|
|
||||||
|
|
@ -63,9 +70,19 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
||||||
|
|
||||||
public void StartCharacterSelectionReceive(WorldSession session) { }
|
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" < "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(
|
public CharacterList.Parsed? GetCharacters(WorldSession session) => new(
|
||||||
0u,
|
0u,
|
||||||
[new CharacterList.Character(0x50000001u, "Existing", 0u)],
|
[
|
||||||
|
new CharacterList.Character(0x50000002u, "Zed", 0u),
|
||||||
|
new CharacterList.Character(0x50000003u, "Amy", 0u),
|
||||||
|
],
|
||||||
[],
|
[],
|
||||||
SlotCount: 11,
|
SlotCount: 11,
|
||||||
AccountName: "testaccount",
|
AccountName: "testaccount",
|
||||||
|
|
@ -75,6 +92,12 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
||||||
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
||||||
EnterWorldCount++;
|
EnterWorldCount++;
|
||||||
|
|
||||||
|
public void EnterWorldByGuid(
|
||||||
|
WorldSession session,
|
||||||
|
uint characterGuid,
|
||||||
|
string accountName) =>
|
||||||
|
EnterWorldByGuidCalls.Add((characterGuid, accountName));
|
||||||
|
|
||||||
public void Tick(WorldSession session) { }
|
public void Tick(WorldSession session) { }
|
||||||
|
|
||||||
public void DisposeSession(WorldSession session) { }
|
public void DisposeSession(WorldSession session) { }
|
||||||
|
|
@ -195,17 +218,36 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
||||||
Assert.Equal("NewChar", host.Created[0].Name);
|
Assert.Equal("NewChar", host.Created[0].Name);
|
||||||
Assert.Empty(host.Failed);
|
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.
|
// character and the newly created one.
|
||||||
LiveSessionRosterReport lastReport = host.Rosters[^1];
|
LiveSessionRosterReport lastReport = host.Rosters[^1];
|
||||||
Assert.Contains(lastReport.Entries, e => e.Id == 0x50001234u && e.Name == "NewChar");
|
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
|
// F2 acceptance gate: the append preserves every PRE-EXISTING
|
||||||
// EnterSelectedCore — the controller is now in-world as the new
|
// character's original wire ActiveIndex ("Zed" slot 0, "Amy" slot
|
||||||
// character, no second selection/EnterWorld call needed.
|
// 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.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.Single(host.EnteredWorld);
|
||||||
Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId);
|
Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId);
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +272,7 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
||||||
Assert.Empty(host.Created);
|
Assert.Empty(host.Created);
|
||||||
Assert.False(controller.IsInWorld);
|
Assert.False(controller.IsInWorld);
|
||||||
Assert.Equal(0, operations.EnterWorldCount);
|
Assert.Equal(0, operations.EnterWorldCount);
|
||||||
|
Assert.Empty(operations.EnterWorldByGuidCalls);
|
||||||
Assert.Empty(host.EnteredWorld);
|
Assert.Empty(host.EnteredWorld);
|
||||||
// No roster append on a rejection.
|
// No roster append on a rejection.
|
||||||
Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar"));
|
Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar"));
|
||||||
|
|
@ -254,6 +297,43 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
||||||
Assert.False(sent);
|
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 && remainingAtrbCredits > 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)
|
private static void InvokeProcessDatagram(WorldSession session, byte[] datagram)
|
||||||
{
|
{
|
||||||
MethodInfo method = typeof(WorldSession).GetMethod(
|
MethodInfo method = typeof(WorldSession).GetMethod(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue