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); } // ── 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); } // ── 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); } [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); } }