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:
Erik 2026-08-15 15:20:03 +02:00
parent 397ccd62cd
commit 3a6b7e3115
3 changed files with 108 additions and 4 deletions

View file

@ -92,11 +92,25 @@ public sealed class LiveSessionControllerCharacterCreationTests
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
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(
WorldSession session,
uint characterGuid,
string accountName) =>
string accountName)
{
EnterWorldByGuidCalls.Add((characterGuid, accountName));
if (EnterWorldByGuidRejectionsRemaining > 0)
{
EnterWorldByGuidRejectionsRemaining--;
throw new CharacterSelectionRejectedException(
new CharacterError.Parsed(0x0000000Bu));
}
}
public void Tick(WorldSession session) { }
@ -252,6 +266,57 @@ public sealed class LiveSessionControllerCharacterCreationTests
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]
public void Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection()
{