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)); } // ── Options threading (Campaign CC slice CC4) ─────────────────────── // RuntimeCharacterCreationState.InstallOptions — the App-startup seam // ContentEffectsAudioCompositionPhase.Compose calls once portal.dat's // ChargenTableReader.Load result is available, mirroring // RuntimeCharacterState.InstallSpellMetadata's "install immutable DAT // metadata after construction" pattern. [Fact] public void InstallOptions_BeforeBegin_ReplacesTheOptionsLaterCommandsUse() { var state = new RuntimeCharacterCreationState(ChargenOptions.Empty); state.InstallOptions(RuntimeCharacterCreationStateFixture.Build()); state.Begin(new RuntimeGenerationToken(1)); Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); Assert.Equal( RuntimeCharacterCreationStateFixture.AluvianId, state.Snapshot.HeritageId); } [Fact] public void InstallOptions_WhileActive_ThrowsInsteadOfRacingLiveCommands() { RuntimeCharacterCreationState state = CreateActive(); Assert.Throws( () => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build())); } [Fact] public void InstallOptions_NullOptions_Throws() { var state = new RuntimeCharacterCreationState(ChargenOptions.Empty); Assert.Throws(() => state.InstallOptions(null!)); } [Fact] public void InstallOptions_AfterDispose_Throws() { var state = new RuntimeCharacterCreationState(ChargenOptions.Empty); state.Dispose(); Assert.Throws( () => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build())); } // ── 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); } /// /// F12 amendment (CC6b-MOUNT review fix round, filed as register /// AP-223): with AD-101 retired, a caller could otherwise reach Finish /// with heritage/gender still unset. Retail's own DoFinish never /// checks this because RandomizeCharacter guarantees it can't /// happen — this is acdream's own defensive backstop for any caller that /// bypasses the App layer's screen-open roll. /// [Fact] public void TryBeginFinish_HeritageUnset_IsRefused() { RuntimeCharacterCreationState state = CreateActive(); state.TrySetName("Adventurer"); bool accepted = state.TryBeginFinish( 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); Assert.False(accepted); Assert.True(refusal.HeritageOrGenderUnset); Assert.False(refusal.NoName); } [Fact] public void TryBeginFinish_GenderUnset_IsRefused() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySetName("Adventurer"); bool accepted = state.TryBeginFinish( 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); Assert.False(accepted); Assert.True(refusal.HeritageOrGenderUnset); } [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)] // CC5 review-fix round F2 (2026-08-16): Pending/Undef used to be // asserted as a SILENT reset producing no rejection at all // (ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection, // now deleted) — that assertion was wrong. Byte-decoded // gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030: // Pending is an explicit switch case landing on the SAME // "ID_Character_Err_NameDBDown" label as Corrupt/DatabaseDown, and // Undef falls through that function's own unsigned-underflow default // arm to the identical label — retail's dispatch has no silent branch. // Both now belong in this same "produces a rejection" theory. [InlineData(CharGenVerificationResponse.Code.Pending)] [InlineData(CharGenVerificationResponse.Code.Undef)] 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); } [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); } // ── Randomize (Campaign CC slice CC5, RandomizeCharacter port) ─────── /// /// Ports CharGenState::RandomizeCharacter @ 0x005c6d80: rolls a /// heritage id in [1,4] (the four HUMAN groups, never the other nine), /// a gender in [1,2], and freezes both non-Unset. A 200-iteration sweep /// with a fresh seeded RNG per iteration proves the heritage roll never /// escapes the 1-4 human-only range even though the fixture ALSO /// carries a non-human Olthoi heritage (id 12) and an out-of-range /// "Impoverished" heritage (id 90) that a broken roll could otherwise /// land on. /// [Fact] public void TryRandomizeCharacter_RollsOnlyTheFourHumanHeritagesAndAGender() { for (int seed = 0; seed < 200; seed++) { var state = new RuntimeCharacterCreationState( RuntimeCharacterCreationStateFixture.Build(), new Random(seed)); state.Begin(new RuntimeGenerationToken(1)); Assert.True(state.TryRandomizeCharacter()); RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; Assert.InRange(snapshot.HeritageId, 1u, 4u); Assert.True(snapshot.GenderKey is 1u or 2u); } } [Fact] public void TryRandomizeCharacter_RollsAppearanceClothingTemplateAndStartArea() { var state = new RuntimeCharacterCreationState( RuntimeCharacterCreationStateFixture.Build(), new Random(7)); state.Begin(new RuntimeGenerationToken(1)); Assert.True(state.TryRandomizeCharacter()); RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; // Every list in the fixture's shared gender record is non-empty, so // a full randomize must leave nothing Unset. Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairStyle); Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.EyesStrip); Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairColor); Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.ShirtStyle); Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.TrousersStyle); Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.FootwearStyle); Assert.NotEqual(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template); // Template is one of the PRESET rows (never index 0/Custom) — // RandomizeTemplate @ 0x005c6500's RandInt(count-1,...)+1 shape. Assert.NotEqual(0u, snapshot.Template); Assert.True(snapshot.StartArea is 0 or 1); // F8 (2026-08-16): every shade is RollShadeLocked's 32768-point // lattice on [0.0, 1.0] INCLUSIVE (rand() in [0, 32767] * (1/32767)), // never System.Random.NextDouble()'s continuous [0, 1). Assert.InRange(snapshot.Appearance.SkinShade, 0.0, 1.0); Assert.InRange(snapshot.Appearance.HairShade, 0.0, 1.0); Assert.InRange(snapshot.Appearance.HeadgearShade, 0.0, 1.0); Assert.InRange(snapshot.Appearance.ShirtShade, 0.0, 1.0); Assert.InRange(snapshot.Appearance.TrousersShade, 0.0, 1.0); Assert.InRange(snapshot.Appearance.FootwearShade, 0.0, 1.0); } /// /// F8 (2026-08-16): pins 's /// private RollShadeLocked lattice (rand() in [0,32767] * /// (1.0/32767.0)) deterministically via a fixed /// double that always returns its maxValue - 1, confirming the /// lattice's upper endpoint is EXACTLY reachable as 1.0 — a continuous /// -style roll ([0, 1)) could never /// produce that value. /// [Fact] public void TryRandomizeCharacter_ShadeLattice_ReachesExactlyOneAtRandomMax() { var state = new RuntimeCharacterCreationState( RuntimeCharacterCreationStateFixture.Build(), new MaxValueRandom()); state.Begin(new RuntimeGenerationToken(1)); Assert.True(state.TryRandomizeCharacter()); RuntimeCharacterCreationAppearance a = state.Snapshot.Appearance; Assert.Equal(1.0, a.SkinShade); Assert.Equal(1.0, a.HairShade); Assert.Equal(1.0, a.HeadgearShade); Assert.Equal(1.0, a.ShirtShade); Assert.Equal(1.0, a.TrousersShade); Assert.Equal(1.0, a.FootwearShade); } /// Always returns maxValue - 1 — the highest value /// 's contract permits for any bound, so /// every ordinary index pick in the randomize chain stays in-bounds /// while the shade rolls (Next(32768)) land on 32767, the shade /// lattice's top rung. private sealed class MaxValueRandom : Random { public override int Next(int maxValue) => maxValue - 1; } /// /// RandomizeTemplateLocked's own Olthoi/OlthoiAcid branch /// (mirroring CharGenState::RandomizeTemplate's force-to-template-0 /// arm) is UNREACHABLE through /// specifically — that caller's own heritage roll is always one of the /// four HUMAN ids (never Olthoi), matching retail's identical /// architecture (RandomizeCharacter's heritage roll and /// RandomizeTemplate's Olthoi branch are independent call paths; /// retail never composes them either, since a random CHARACTER is never /// Olthoi). The branch is not otherwise exposed as a standalone command /// this slice (out of CC5's named scope), so its OBSERVABLE behavior — /// selecting Olthoi always forces template 0 — is already covered by /// TrySelectHeritage_Olthoi.../ApplyTemplate coverage /// elsewhere in this file; this test only pins that a full /// TryRandomizeCharacter roll never lands on Olthoi in the first /// place, over enough iterations to catch a boundary-off-by-one. /// [Fact] public void TryRandomizeCharacter_NeverRollsANonHumanHeritage() { for (int seed = 0; seed < 200; seed++) { var state = new RuntimeCharacterCreationState( RuntimeCharacterCreationStateFixture.Build(), new Random(seed)); state.Begin(new RuntimeGenerationToken(1)); Assert.True(state.TryRandomizeCharacter()); Assert.NotEqual(RuntimeCharacterCreationStateFixture.OlthoiId, state.Snapshot.HeritageId); Assert.NotEqual(RuntimeCharacterCreationStateFixture.ImpoverishedId, state.Snapshot.HeritageId); } } [Fact] public void TryRandomizeCharacter_Inactive_IsRejected() { var state = new RuntimeCharacterCreationState( RuntimeCharacterCreationStateFixture.Build()); Assert.False(state.TryRandomizeCharacter()); } [Fact] public void TryRandomizeAppearance_RequiresHeritageAndGender() { RuntimeCharacterCreationState state = CreateActive(); Assert.False(state.TryRandomizeAppearance()); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); Assert.False(state.TryRandomizeAppearance()); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); Assert.True(state.TryRandomizeAppearance()); Assert.NotEqual( RuntimeCharacterCreationAppearance.Unset, state.Snapshot.Appearance.HairStyle); } [Fact] public void TryRandomizeClothing_RollsAllFourGearSlots() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); Assert.True(state.TryRandomizeClothing()); RuntimeCharacterCreationAppearance a = state.Snapshot.Appearance; // Headgear excludes-current with the +1 Unset-ring reindex — the // fixture's single headgear style means the roll can only land on // style 0 or Unset; either is a valid outcome of the ring, so this // just confirms the call actually touched the field (shirt/trousers/ // footwear below have no Unset ring and must land on their one // style). Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.ShirtStyle); Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.TrousersStyle); Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.FootwearStyle); } /// /// RandInt(int,int) @ 0x00684420's own decompiled shape: re-roll /// until the result differs from the excluded value, UNLESS there is /// only one possible outcome ( <= 1), which /// returns 0 immediately without ever comparing against exclude (the /// guard that keeps the loop from spinning forever). This drives /// /// enough times to statistically prove the shirt slot (the fixture's /// single-style list) never gets stuck — count<=1 must short-circuit. /// [Fact] public void TryRandomizeClothing_SingleOptionList_NeverHangs() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); for (int i = 0; i < 50; i++) Assert.True(state.TryRandomizeClothing()); Assert.Equal(0u, state.Snapshot.Appearance.ShirtStyle); } /// /// F12(a) (CC5 review-fix round, 2026-08-16): pins /// RandomizeIndexExcludingLocked's exclude-current re-roll /// property DETERMINISTICALLY. The fixture's HairStyles/ /// HairColors/EyeColors lists are all COUNT 2 /// ('s shared /// gender record), so excluding the current index leaves exactly ONE /// possible outcome — a second /// call must flip every one of these three fields to the OTHER index, /// regardless of which seed drives the roll. Two /// different seeds both proving the flip is what makes this a property /// pin rather than a single-seed coincidence. /// [Theory] [InlineData(1)] [InlineData(999)] public void TryRandomizeAppearance_ExcludeCurrent_OnCountTwoLists_AlwaysFlips(int seed) { var state = new RuntimeCharacterCreationState( RuntimeCharacterCreationStateFixture.Build(), new Random(seed)); state.Begin(new RuntimeGenerationToken(1)); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); Assert.True(state.TryRandomizeAppearance()); RuntimeCharacterCreationAppearance first = state.Snapshot.Appearance; Assert.True(state.TryRandomizeAppearance()); RuntimeCharacterCreationAppearance second = state.Snapshot.Appearance; Assert.True(first.HairStyle is 0u or 1u); Assert.True(first.HairColor is 0u or 1u); Assert.True(first.EyeColor is 0u or 1u); Assert.NotEqual(first.HairStyle, second.HairStyle); Assert.NotEqual(first.HairColor, second.HairColor); Assert.NotEqual(first.EyeColor, second.EyeColor); } /// /// F12(b) (CC5 review-fix round, 2026-08-16): pins /// RandomizeCharacterLocked's own ClearSessionState /// prologue (retail's own Reset() call) at the RUNTIME layer — /// the Summary page's Random button must clear a committed name, which /// is exactly the state transition CharacterCreationSummaryPage's /// F1 fix (the field-sync _suppressNextFieldEvent removal) has to /// coexist with correctly. /// [Fact] public void TryRandomizeCharacter_ClearsAPreviouslyCommittedName() { RuntimeCharacterCreationState state = CreateActive(); state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); Assert.True(state.TrySetName("Bob")); Assert.Equal("Bob", state.Snapshot.Name); Assert.True(state.TryRandomizeCharacter()); Assert.Equal(string.Empty, state.Snapshot.Name); } }