fix(runtime)+docs: CC3 re-review CLOSED — R1 second-create wire slot, risk-8 measured LATENT
The CC3 narrow re-review returned CLOSED (both lenses PASS, merge recommended) with residual R1: the post-create wire-slot assignment read the cached wire CharacterList count, which ACE never refreshes after a create — correct for the first create, off by one for a second create in the same session (reachable via create Ok -> server-rejected guid enter -> ReturnToSelection -> create again), the same wire-contract failure class F2 fixed. Root fix now rather than carried: a creates-since-CharacterList counter (the equivalent of retail's own CharacterSet growing via AddIdentity per create), reset on every fresh wire CharacterList apply and at generation reset, applied only to the cached-wire branch since the display-roster fallback already contains prior appends. Regression test drives the full create->Ok->rejected-enter->create-again flow and pins wire slots 0/1/2/3. Docs: CC3 ledger row flipped to REVIEW-CLOSED with real shas (re-review R2); CC7 risk item 8 downgraded to LATENT with measured installed-DAT data (user-prompted): every heritage's single cost override is Arcane Lore at NormalCost=0/PrimaryCost=2 vs global 4/6, so ACE's over-deduction (= NormalCost = 0) cannot fire with end-of-retail data — the earlier "may be rejected" claim was inferred from code without measuring. Runtime 1707/0 Release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
397ccd62cd
commit
3a6b7e3115
3 changed files with 108 additions and 4 deletions
File diff suppressed because one or more lines are too long
|
|
@ -433,6 +433,16 @@ public sealed class LiveSessionController
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private ulong _generation;
|
private ulong _generation;
|
||||||
private RuntimeTeardownStage _lastTeardownStages;
|
private RuntimeTeardownStage _lastTeardownStages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CC3 re-review R1: creates accepted since the last wire
|
||||||
|
/// CharacterList was applied. ACE appends each created character to
|
||||||
|
/// its own session list but never resends the list, so the cached
|
||||||
|
/// wire count under-counts by exactly this number — the equivalent
|
||||||
|
/// of retail's own CharacterSet growing via AddIdentity per create.
|
||||||
|
/// Guarded by <c>_gate</c> like every sibling field.
|
||||||
|
/// </summary>
|
||||||
|
private int _createsSinceCharacterList;
|
||||||
private LiveSessionCharacterSelection? _activeSelection;
|
private LiveSessionCharacterSelection? _activeSelection;
|
||||||
private Action<WorldSession>? _autoSaveTickHook;
|
private Action<WorldSession>? _autoSaveTickHook;
|
||||||
private Action<WorldSession>? _preLogoffFlushHook;
|
private Action<WorldSession>? _preLogoffFlushHook;
|
||||||
|
|
@ -745,6 +755,7 @@ public sealed class LiveSessionController
|
||||||
RuntimeGenerationToken activeGeneration = new(generation);
|
RuntimeGenerationToken activeGeneration = new(generation);
|
||||||
CharacterSelectionState.Reset(activeGeneration);
|
CharacterSelectionState.Reset(activeGeneration);
|
||||||
CharacterCreationState.Reset(activeGeneration);
|
CharacterCreationState.Reset(activeGeneration);
|
||||||
|
_createsSinceCharacterList = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DrainRetiredScope();
|
DrainRetiredScope();
|
||||||
|
|
@ -818,6 +829,7 @@ public sealed class LiveSessionController
|
||||||
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
||||||
if (characters is not null)
|
if (characters is not null)
|
||||||
{
|
{
|
||||||
|
_createsSinceCharacterList = 0;
|
||||||
LiveSessionRosterReport roster = BuildRosterReport(characters);
|
LiveSessionRosterReport roster = BuildRosterReport(characters);
|
||||||
CharacterSelectionState.ApplyRoster(roster);
|
CharacterSelectionState.ApplyRoster(roster);
|
||||||
host.ReportRoster(roster);
|
host.ReportRoster(roster);
|
||||||
|
|
@ -947,6 +959,7 @@ public sealed class LiveSessionController
|
||||||
{
|
{
|
||||||
if (!IsCurrent(scope, generation))
|
if (!IsCurrent(scope, generation))
|
||||||
return;
|
return;
|
||||||
|
_createsSinceCharacterList = 0;
|
||||||
LiveSessionRosterReport report = BuildRosterReport(roster);
|
LiveSessionRosterReport report = BuildRosterReport(roster);
|
||||||
CharacterSelectionState.ApplyRoster(report);
|
CharacterSelectionState.ApplyRoster(report);
|
||||||
scope.Host.ReportRoster(report);
|
scope.Host.ReportRoster(report);
|
||||||
|
|
@ -1337,9 +1350,22 @@ public sealed class LiveSessionController
|
||||||
// THIS character by design, but its COUNT is still exactly the
|
// THIS character by design, but its COUNT is still exactly the
|
||||||
// 0-based slot ACE assigned). Falls back to the display roster
|
// 0-based slot ACE assigned). Falls back to the display roster
|
||||||
// count only if the cached wire list is unexpectedly unavailable.
|
// count only if the cached wire list is unexpectedly unavailable.
|
||||||
|
// CC3 re-review R1: the cached count is stale by the number of
|
||||||
|
// creates since the last CharacterList (ACE never resends one
|
||||||
|
// post-create), so a SECOND create in the same session must add
|
||||||
|
// the creates the cache hasn't seen — retail's own CharacterSet
|
||||||
|
// grows via AddIdentity per create, keeping GetSlot correct the
|
||||||
|
// same way. The counter applies ONLY to the cached-wire branch:
|
||||||
|
// the display-roster fallback already contains every prior
|
||||||
|
// create (AppendCreatedCharacter added them), so adding the
|
||||||
|
// counter there would double-count. Resets whenever a fresh wire
|
||||||
|
// CharacterList is applied and at generation reset.
|
||||||
int wireIndex =
|
int wireIndex =
|
||||||
_operations.GetCharacters(scope.Session)?.Characters.Count
|
_operations.GetCharacters(scope.Session)?.Characters.Count
|
||||||
?? before.RosterCount;
|
is int cachedWireCount
|
||||||
|
? cachedWireCount + _createsSinceCharacterList
|
||||||
|
: before.RosterCount;
|
||||||
|
_createsSinceCharacterList++;
|
||||||
CharacterSelectionState.AppendCreatedCharacter(
|
CharacterSelectionState.AppendCreatedCharacter(
|
||||||
identity.Guid,
|
identity.Guid,
|
||||||
identity.Name,
|
identity.Name,
|
||||||
|
|
@ -1662,6 +1688,7 @@ public sealed class LiveSessionController
|
||||||
_activeSelection = null;
|
_activeSelection = null;
|
||||||
CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation));
|
CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation));
|
||||||
CharacterCreationState.Reset(new RuntimeGenerationToken(_generation));
|
CharacterCreationState.Reset(new RuntimeGenerationToken(_generation));
|
||||||
|
_createsSinceCharacterList = 0;
|
||||||
if (_scope is { } scope)
|
if (_scope is { } scope)
|
||||||
{
|
{
|
||||||
_scope = null;
|
_scope = null;
|
||||||
|
|
|
||||||
|
|
@ -92,11 +92,25 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
||||||
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
||||||
EnterWorldCount++;
|
EnterWorldCount++;
|
||||||
|
|
||||||
|
/// <summary>R1: when positive, the next guid-enter throws retail's
|
||||||
|
/// server-rejection shape (the transport-valid path that returns
|
||||||
|
/// the controller to selection), decrementing per call — lets a
|
||||||
|
/// test reach the create-again-after-rejected-enter flow.</summary>
|
||||||
|
public int EnterWorldByGuidRejectionsRemaining { get; set; }
|
||||||
|
|
||||||
public void EnterWorldByGuid(
|
public void EnterWorldByGuid(
|
||||||
WorldSession session,
|
WorldSession session,
|
||||||
uint characterGuid,
|
uint characterGuid,
|
||||||
string accountName) =>
|
string accountName)
|
||||||
|
{
|
||||||
EnterWorldByGuidCalls.Add((characterGuid, accountName));
|
EnterWorldByGuidCalls.Add((characterGuid, accountName));
|
||||||
|
if (EnterWorldByGuidRejectionsRemaining > 0)
|
||||||
|
{
|
||||||
|
EnterWorldByGuidRejectionsRemaining--;
|
||||||
|
throw new CharacterSelectionRejectedException(
|
||||||
|
new CharacterError.Parsed(0x0000000Bu));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Tick(WorldSession session) { }
|
public void Tick(WorldSession session) { }
|
||||||
|
|
||||||
|
|
@ -252,6 +266,57 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
||||||
Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId);
|
Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CC3 re-review R1: a SECOND create in the same session must get wire
|
||||||
|
/// slot N+1, not N. ACE never resends CharacterList post-create, so the
|
||||||
|
/// cached wire count alone under-counts by the creates it hasn't seen;
|
||||||
|
/// the controller's creates-since-list counter (reset on every fresh
|
||||||
|
/// wire CharacterList) supplies the difference — the equivalent of
|
||||||
|
/// retail's own CharacterSet growing via AddIdentity per create. The
|
||||||
|
/// create-again path is reached exactly as the re-review described:
|
||||||
|
/// first create Ok, guid-enter rejected by the server
|
||||||
|
/// (CharacterSelectionRejectedException → ReturnToSelection), then a
|
||||||
|
/// second create.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot()
|
||||||
|
{
|
||||||
|
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
|
||||||
|
StartAwaitingSelection();
|
||||||
|
BuildReadyCharacter(controller, generation);
|
||||||
|
WorldSession session = operations.Sessions[0];
|
||||||
|
session.GameMessageCapture = (_, _) => { };
|
||||||
|
operations.EnterWorldByGuidRejectionsRemaining = 1;
|
||||||
|
|
||||||
|
Assert.True(controller.Finish(generation).Accepted);
|
||||||
|
InvokeProcessDatagram(session, BuildResponsePacket(
|
||||||
|
(uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar"));
|
||||||
|
|
||||||
|
// The rejected enter left us back at selection with the first
|
||||||
|
// created character appended at the true wire slot 2.
|
||||||
|
Assert.False(controller.IsInWorld);
|
||||||
|
Assert.Single(operations.EnterWorldByGuidCalls);
|
||||||
|
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50001234u, out RuntimeCharacterSelectionEntry firstCreated));
|
||||||
|
Assert.Equal(2, firstCreated.ActiveIndex);
|
||||||
|
|
||||||
|
// Second create in the same session: ACE's own list now holds
|
||||||
|
// Zed(0), Amy(1), NewChar(2) — the cached wire list still only
|
||||||
|
// holds Zed and Amy. The second character's slot must be 3.
|
||||||
|
Assert.True(controller.SetName(generation, "SecondChar").Accepted);
|
||||||
|
Assert.True(controller.Finish(generation).Accepted);
|
||||||
|
InvokeProcessDatagram(session, BuildResponsePacket(
|
||||||
|
(uint)CharGenVerificationResponse.Code.Ok, 0x50005678u, "SecondChar"));
|
||||||
|
|
||||||
|
Assert.True(controller.CharacterSelectionState.View.TryGet(0x50005678u, out RuntimeCharacterSelectionEntry secondCreated));
|
||||||
|
Assert.Equal(3, secondCreated.ActiveIndex);
|
||||||
|
// Pre-existing wire indices still intact after both appends.
|
||||||
|
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.Equal(2, host.Created.Count);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection()
|
public void Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue