using AcDream.Core.CharGen; using AcDream.Core.Net.Messages; using AcDream.Runtime.Session; namespace AcDream.Runtime.Tests.CharGen; /// /// Campaign CC slice CC3: — /// retail's CharGenState mirror. Every test cites the retail function /// it is pinning; see the class's own doc comments for full addresses. /// public sealed class RuntimeCharacterCreationStateTests { private static RuntimeCharacterCreationState CreateActive() { var state = new RuntimeCharacterCreationState( RuntimeCharacterCreationStateFixture.Build(), new Random(1234)); state.Begin(new RuntimeGenerationToken(1)); return state; } // ── Lifecycle ─────────────────────────────────────────────────────── [Fact] public void Begin_StartsWithNoHeritageOrGenderSelected() { RuntimeCharacterCreationState state = CreateActive(); RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; Assert.True(snapshot.IsActive); Assert.Equal(0u, snapshot.HeritageId); Assert.Equal(0u, snapshot.GenderKey); Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template); Assert.Equal(-1, snapshot.StartArea); Assert.False(snapshot.VerificationPending); Assert.Equal(string.Empty, snapshot.Name); } [Fact] public void Reset_ClearsEveryFieldAndDeactivates() { RuntimeCharacterCreationState state = CreateActive(); Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); Assert.True(state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey)); Assert.True(state.TrySetName("Someone")); state.Reset(new RuntimeGenerationToken(2)); RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; Assert.False(snapshot.IsActive); Assert.Equal(0u, snapshot.HeritageId); Assert.Equal(0u, snapshot.GenderKey); Assert.Equal(string.Empty, snapshot.Name); Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template); // Commands are refused once inactive. Assert.False(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); } // ── Heritage / gender / template ──────────────────────────────────── [Fact] public void TrySelectHeritage_UnknownId_IsRejected() { RuntimeCharacterCreationState state = CreateActive(); Assert.False(state.TrySelectHeritage(0xDEADu)); Assert.Equal(0u, state.Snapshot.HeritageId); } [Fact] public void TrySelectHeritage_RecomputesBudgetsAndRollsARandomPrimaryStartArea() { RuntimeCharacterCreationState state = CreateActive(); Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, snapshot.HeritageId); Assert.Equal(66u, snapshot.TotalAttributeCredits); Assert.Equal(50u, snapshot.TotalSkillCredits); // No template chosen yet — ApplyTemplate's own guard leaves // attributes untouched (CharGenState::ApplyTemplate @ 0x005C5080). Assert.Equal(0, snapshot.Attributes.Total); Assert.Equal(66, snapshot.RemainingAttributeCredits); // RandomizeStartArea @ 0x005C59E0 only ever picks from // PrimaryStartAreaIndices, [0, 1] in the fixture. Assert.True(snapshot.StartArea is 0 or 1); } [Fact] public void TrySelectGender_RequiresHeritageFirst() { RuntimeCharacterCreationState state = CreateActive(); Assert.False(state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey)); } [Fact] public void TrySelectGender_UnknownKeyForHeritage_IsRejected() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); Assert.False(state.TrySelectGender(99u)); } [Fact] public void TrySelectTemplate_Custom_AppliesFloorAttributesAndLeavesCreditsUnspent() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex)); RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; Assert.Equal(0u, snapshot.Template); Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), snapshot.Attributes); // 66 budget - 60 floor spend = 6 unspent, matching CC1's "Custom // sits at the floor" finding. Assert.Equal(6, snapshot.RemainingAttributeCredits); Assert.Equal( ChargenSkillAdvancementClass.Trained, state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillCustomNormal)); } [Fact] public void TrySelectTemplate_Preset_TrainsNormalAndSpecializesPrimarySkills() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex)); RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; Assert.Equal(new ChargenAttributeValues(16, 10, 10, 10, 10, 10), snapshot.Attributes); Assert.Equal(0, snapshot.RemainingAttributeCredits); Assert.Equal( ChargenSkillAdvancementClass.Trained, state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); Assert.Equal( ChargenSkillAdvancementClass.Specialized, state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillPresetPrimary)); // ResetSkillLevels' baseline (0x005C43B0) still holds for skills the // template row doesn't mention. Assert.Equal( ChargenSkillAdvancementClass.Specialized, state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillFreeSpecialized)); Assert.Equal( ChargenSkillAdvancementClass.Trained, state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillFreeTrained)); } [Fact] public void TrySelectTemplate_OlthoiHeritage_AlwaysForcesTemplateZero() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.OlthoiId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); // CharGenState::ApplyTemplate @ 0x005C5080: mHeritageGroup == 0xc // force-sets template_ = 0 regardless of the requested index. Assert.True(state.TrySelectTemplate(1u)); Assert.Equal(0u, state.Snapshot.Template); Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), state.Snapshot.Attributes); } /// /// F4 acceptance gate: CharGenState::ConstrainAllByHeritage @ /// 0x005C65CC clamps a stale template index to 0xffffffff /// 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 BuildRequestLocked. /// [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 ────────────────────────────────────────────────────── [Fact] public void TrySetAttribute_ClampsToTheFloorAndCeiling() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 5)); Assert.Equal(10, state.Snapshot.Attributes.Strength); Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 999)); // Clamped further by the abs-remaining-credits check below 100. Assert.True(state.Snapshot.Attributes.Strength <= ChargenAttributeMath.AttributeMax); } [Fact] public void TrySetAttribute_RaisingOneAttributeRebalancesAnAboveFloorAttributeDownToTheFloor() { // CharGenState::BalanceAttributes @ 0x005C3DF0: starting from the // Preset template (Strength=16, everyone else at the 10 floor, fully // spent), raising Endurance consumes the "assumed floor" room // GetAbsRemainingCredits grants by pretending Strength could drop to // floor — BalanceAttributes then actually performs that drop. RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 16)); ChargenAttributeValues attrs = state.Snapshot.Attributes; Assert.Equal(16, attrs.Endurance); Assert.Equal(10, attrs.Strength); Assert.Equal(10, attrs.Coordination); Assert.Equal(10, attrs.Quickness); Assert.Equal(10, attrs.Focus); Assert.Equal(10, attrs.Self); Assert.Equal(66, attrs.Total); Assert.Equal(0, state.Snapshot.RemainingAttributeCredits); } [Fact] public void TrySetAttributeLock_PreventsThatAttributeFromAbsorbingABalance() { // CharGenState::LockAttribute @ 0x005C3BE0 + GetAbsRemainingCredits // @ 0x005C3B20's locked branch: a locked attribute contributes its // CURRENT value (not the floor) to the abs-remaining computation, so // no room is assumed available from it. RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Strength, true)); Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 16)); // No room was available (Strength locked at 16, everything else at // floor, budget already fully spent) — Endurance cannot rise. Assert.Equal(10, state.Snapshot.Attributes.Endurance); Assert.Equal(16, state.Snapshot.Attributes.Strength); } /// /// F7 acceptance gate: CharGenState::BalanceAttributes @ /// 0x005C3DF0's persistent cursor (ported as the instance field /// _attributeBalanceCursor) 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. /// [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); } /// /// 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 /// BalanceOrder'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. /// [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 ────────────────────────────────────────────────────────── [Fact] public void TrySpecializeSkill_UncostableSkill_IsAlwaysRejected() { // Binding fact from the CC1 review (R1): the 16 uncostable skill ids // must never be settable — retail's own listbox never lists them. RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillUncostable)); Assert.False(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillUncostable)); Assert.False(state.TryUntrainSkill(RuntimeCharacterCreationStateFixture.SkillUncostable)); Assert.Equal( ChargenSkillAdvancementClass.Inactive, state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillUncostable)); } [Fact] public void TrainThenSpecializeSkill_ChargesExactlyPrimaryCostNotBoth() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); int before = state.Snapshot.RemainingSkillCredits; Assert.True(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); Assert.Equal(before - 4, state.Snapshot.RemainingSkillCredits); Assert.True(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); // PrimaryCost (12) is the TOTAL, not an increment on NormalCost. Assert.Equal(before - 12, state.Snapshot.RemainingSkillCredits); Assert.True(state.TryUntrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); Assert.Equal(before, state.Snapshot.RemainingSkillCredits); } [Fact] public void TrySpecializeSkill_InsufficientCredits_IsRejectedAndLeavesStateUnchanged() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.ImpoverishedId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); Assert.Equal(5, state.Snapshot.RemainingSkillCredits); // PrimaryCost (12) exceeds the 5-credit budget outright. Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); Assert.Equal(5, state.Snapshot.RemainingSkillCredits); Assert.Equal( ChargenSkillAdvancementClass.Untrained, state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); // NormalCost (4) fits; the SAME skill Specialized still does not. Assert.True(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); Assert.Equal(1, state.Snapshot.RemainingSkillCredits); Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); Assert.Equal(1, state.Snapshot.RemainingSkillCredits); Assert.Equal( ChargenSkillAdvancementClass.Trained, state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); } // ── Finish gates ──────────────────────────────────────────────────── private static RuntimeCharacterCreationState ReadyToFinishState() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); // fully spent attrs state.TrySetName("Adventurer"); return state; } [Fact] public void TryBeginFinish_EmptyName_IsRefused() { RuntimeCharacterCreationState state = ReadyToFinishState(); state.TrySetName(" "); bool accepted = state.TryBeginFinish( rosterCount: 0, slotCount: 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); Assert.False(accepted); Assert.True(refusal.NoName); Assert.False(state.Snapshot.VerificationPending); } [Fact] public void TryBeginFinish_UnspentAttributeCredits_IsRefused() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); // 6 unspent state.TrySetName("Adventurer"); bool accepted = state.TryBeginFinish( 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); Assert.False(accepted); Assert.True(refusal.AttributeCreditsUnspent); } /// /// F3 acceptance gate: gmCharGenMainUI::DoFinish(this, arg2) @ /// 0x004E9170's credit gate is arg2 != 0 && /// remainingAtrbCredits > 0 — retail does NOT force a full /// spend. The ordinary click warns and refuses /// (arg2 = 1 @ 0x004E9579, tested above); the credit-warning /// dialog's own confirm handler re-invokes DoFinish(this, 0) /// (@0x004E98BB), which skips the check entirely and sends with the /// credits still unspent. confirmedUnspentCredits: true is that /// arg2 == 0 case. /// [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] public void TryBeginFinish_SecondCallWhilePending_IsRefused() { RuntimeCharacterCreationState state = ReadyToFinishState(); Assert.True(state.TryBeginFinish( 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal first)); Assert.False(first.Any); bool second = state.TryBeginFinish( 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); Assert.False(second); Assert.True(refusal.AlreadyPending); } [Fact] public void TryBeginFinish_RosterAtSlotCap_IsRefused() { RuntimeCharacterCreationState state = ReadyToFinishState(); bool accepted = state.TryBeginFinish( rosterCount: 11, slotCount: 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); Assert.False(accepted); Assert.True(refusal.RosterFull); } [Fact] public void TryBeginFinish_Accepted_TrimsNameAndProducesExactly55SkillSlots() { RuntimeCharacterCreationState state = ReadyToFinishState(); state.TrySetName(" Adventurer "); bool accepted = state.TryBeginFinish( rosterCount: 2, slotCount: 11, out CharacterCreate.Request request, out uint[] skillAdvancementClasses, out RuntimeCharacterCreationLocalRefusal refusal); Assert.True(accepted); Assert.False(refusal.Any); Assert.True(state.Snapshot.VerificationPending); Assert.Equal("Adventurer", request.Name); Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, request.Heritage); Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, request.Gender); Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, request.Template); Assert.Equal(16u, request.Attributes.Strength); Assert.Equal(CharacterCreate.SkillAdvancementClassCount, skillAdvancementClasses.Length); Assert.Equal( (uint)ChargenSkillAdvancementClass.Trained, skillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]); Assert.Equal( (uint)ChargenSkillAdvancementClass.Specialized, skillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]); } // ── Response handling ─────────────────────────────────────────────── private static RuntimeCharacterCreationState PendingState(out uint[] skills) { RuntimeCharacterCreationState state = ReadyToFinishState(); Assert.True(state.TryBeginFinish(0, 11, out _, out skills, out _)); return state; } [Fact] public void ApplyCreationResponse_Ok_RecordsCreatedIdentityAndClearsPending() { RuntimeCharacterCreationState state = PendingState(out _); state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( (uint)CharGenVerificationResponse.Code.Ok, 0x5000_1234u, "Adventurer", 0u)); RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; Assert.False(snapshot.VerificationPending); Assert.Equal( new RuntimeCharacterCreationIdentity(0x5000_1234u, "Adventurer"), snapshot.LastCreated); Assert.Null(snapshot.LastRejection); } [Theory] [InlineData(CharGenVerificationResponse.Code.NameInUse)] [InlineData(CharGenVerificationResponse.Code.NameBanned)] [InlineData(CharGenVerificationResponse.Code.Corrupt)] [InlineData(CharGenVerificationResponse.Code.DatabaseDown)] [InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)] public void ApplyCreationResponse_EachRejectionCode_RecordsTheMappingAndAttemptedName( CharGenVerificationResponse.Code code) { RuntimeCharacterCreationState state = PendingState(out _); state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( (uint)code, null, null, null)); Assert.NotNull(state.Snapshot.LastRejection); RuntimeCharacterCreationRejection rejection = state.Snapshot.LastRejection!.Value; Assert.Equal(code, rejection.Code); Assert.Equal(code.ToString(), rejection.Reason); Assert.Equal("Adventurer", rejection.AttemptedName); Assert.False(state.Snapshot.VerificationPending); Assert.Null(state.Snapshot.LastCreated); } [Theory] [InlineData(CharGenVerificationResponse.Code.Pending)] [InlineData(CharGenVerificationResponse.Code.Undef)] public void ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection( CharGenVerificationResponse.Code code) { // ACE sends Pending for a disabled-Olthoi rejection — retail shows // no dialog. Port as-is. RuntimeCharacterCreationState state = PendingState(out _); state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( (uint)code, null, null, null)); Assert.False(state.Snapshot.VerificationPending); Assert.Null(state.Snapshot.LastRejection); Assert.Null(state.Snapshot.LastCreated); } [Fact] public void ApplyCreationResponse_DuplicateReplyWhileNotPending_IsIgnored() { // ACE's own quirk (CharacterHandler.CharacterCreateEx calls // IsCharacterNameAvailable TWICE, producing two NameInUse replies // for one rejected create): the second reply must be a no-op, not a // second rejection event/state change. RuntimeCharacterCreationState state = PendingState(out _); state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( (uint)CharGenVerificationResponse.Code.NameInUse, null, null, null)); Assert.NotNull(state.Snapshot.LastRejection); // Acknowledge to clear, then feed a SECOND unsolicited reply — must // stay cleared (idempotent-tolerant, no crash, no new rejection). Assert.True(state.TryAcknowledgeRejection()); state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( (uint)CharGenVerificationResponse.Code.NameInUse, null, null, null)); Assert.Null(state.Snapshot.LastRejection); } [Fact] public void TryAcknowledgeRejection_ClearsTheSurfacedRejection() { RuntimeCharacterCreationState state = PendingState(out _); state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( (uint)CharGenVerificationResponse.Code.NameBanned, null, null, null)); Assert.NotNull(state.Snapshot.LastRejection); Assert.True(state.TryAcknowledgeRejection()); Assert.Null(state.Snapshot.LastRejection); } }