acdream/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs
Erik 34e3a534be feat(chargen): Campaign CC slice CC5 — Summary page, Finish flow, RandomizeCharacter port
Fills TS-82's Summary placeholder with a faithful port of gmCGSummaryPage
(name field with NameInputFilter + the retail commit-on-focus-lost/submit
dispatch + the >32-char ID_CharGen_NameTooLong reject-and-revert path, the
REAL three-row-template listbox confirmed against the installed EoR dat
before writing any page code, and Summary's own independent gmCG3DView
preview instance wired through a second ChargenPreviewController pair
mirroring the Appearance page's exact composition shape).

Ports CharGenState::RandomizeCharacter and its six sub-primitives into
RuntimeCharacterCreationState — not approximated: the RandInt/RollDice
semantics are independently confirmed from both the decompiled RNG bodies
and the CharGenStateVtbl union struct in acclient.h. Three consumers:
the chargen screen's open-roll (retiring AP-214's honest-blank deviation
and reproducing the Appearance page's gender-flip-on-init quirk), the
Summary page's Random button (behind the retail randomize-warning
confirm), and the Appearance page's Random button (narrowing AP-212 to
just Heritage/Profession/Town's still-approximated rolls and Skills'
still-unported RandomizeSkills).

Wires the Finish button (previously ghosted) with retail's NoName/
CreditWarning dialog pair, adds the F12 amendment's HeritageOrGenderUnset
local refusal to TryBeginFinish (register AP-223) as a defensive backstop
now that the screen-open roll normally makes it unreachable, and wires
the four ID_Character_Err_* rejection dialogs for the 0xF643 response
codes CC3 already parsed but nothing displayed.

Register: TS-82 retired, AP-214 retired, AP-212 narrowed, AP-223/224/225
filed (heritage/gender Finish refusal, Summary's two-bucket skill-list
narrowing, the 32-vs-33 name-length threshold reconciliation).

Runtime 1722/0 (was 1713), App 5240/3 skips (was 5223/3), Headless 166/0
unchanged, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 00:01:07 +02:00

897 lines
42 KiB
C#

using AcDream.Core.CharGen;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.CharGen;
/// <summary>
/// Campaign CC slice CC3: <see cref="RuntimeCharacterCreationState"/> —
/// retail's <c>CharGenState</c> mirror. Every test cites the retail function
/// it is pinning; see the class's own doc comments for full addresses.
/// </summary>
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<InvalidOperationException>(
() => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build()));
}
[Fact]
public void InstallOptions_NullOptions_Throws()
{
var state = new RuntimeCharacterCreationState(ChargenOptions.Empty);
Assert.Throws<ArgumentNullException>(() => state.InstallOptions(null!));
}
[Fact]
public void InstallOptions_AfterDispose_Throws()
{
var state = new RuntimeCharacterCreationState(ChargenOptions.Empty);
state.Dispose();
Assert.Throws<ObjectDisposedException>(
() => 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);
}
/// <summary>
/// F4 acceptance gate: <c>CharGenState::ConstrainAllByHeritage @
/// 0x005C65CC</c> clamps a stale template index to <c>0xffffffff</c>
/// 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 <c>BuildRequestLocked</c>.
/// </summary>
[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);
}
/// <summary>
/// F7 acceptance gate: <c>CharGenState::BalanceAttributes @
/// 0x005C3DF0</c>'s persistent cursor (ported as the instance field
/// <c>_attributeBalanceCursor</c>) 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.
/// </summary>
[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);
}
/// <summary>
/// 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
/// <c>BalanceOrder</c>'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.
/// </summary>
[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);
}
/// <summary>
/// F3 acceptance gate: <c>gmCharGenMainUI::DoFinish(this, arg2) @
/// 0x004E9170</c>'s credit gate is <c>arg2 != 0 &amp;&amp;
/// remainingAtrbCredits &gt; 0</c> — retail does NOT force a full
/// spend. The ordinary click warns and refuses
/// (<c>arg2 = 1 @ 0x004E9579</c>, tested above); the credit-warning
/// dialog's own confirm handler re-invokes <c>DoFinish(this, 0)</c>
/// (@0x004E98BB), which skips the check entirely and sends with the
/// credits still unspent. <c>confirmedUnspentCredits: true</c> is that
/// <c>arg2 == 0</c> case.
/// </summary>
[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);
}
/// <summary>
/// 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 <c>DoFinish</c> never
/// checks this because <c>RandomizeCharacter</c> guarantees it can't
/// happen — this is acdream's own defensive backstop for any caller that
/// bypasses the App layer's screen-open roll.
/// </summary>
[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)]
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);
}
// ── Randomize (Campaign CC slice CC5, RandomizeCharacter port) ───────
/// <summary>
/// Ports <c>CharGenState::RandomizeCharacter @ 0x005c6d80</c>: 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.
/// </summary>
[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);
}
/// <summary>
/// <c>RandomizeTemplateLocked</c>'s own Olthoi/OlthoiAcid branch
/// (mirroring <c>CharGenState::RandomizeTemplate</c>'s force-to-template-0
/// arm) is UNREACHABLE through <see cref="RuntimeCharacterCreationState.TryRandomizeCharacter"/>
/// specifically — that caller's own heritage roll is always one of the
/// four HUMAN ids (never Olthoi), matching retail's identical
/// architecture (<c>RandomizeCharacter</c>'s heritage roll and
/// <c>RandomizeTemplate</c>'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
/// <c>TrySelectHeritage_Olthoi...</c>/<c>ApplyTemplate</c> coverage
/// elsewhere in this file; this test only pins that a full
/// <c>TryRandomizeCharacter</c> roll never lands on Olthoi in the first
/// place, over enough iterations to catch a boundary-off-by-one.
/// </summary>
[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);
}
/// <summary>
/// <c>RandInt(int,int) @ 0x00684420</c>'s own decompiled shape: re-roll
/// until the result differs from the excluded value, UNLESS there is
/// only one possible outcome (<paramref name="count"/> &lt;= 1), which
/// returns 0 immediately without ever comparing against exclude (the
/// guard that keeps the loop from spinning forever). This drives
/// <see cref="RuntimeCharacterCreationState.TryRandomizeClothing"/>
/// enough times to statistically prove the shirt slot (the fixture's
/// single-style list) never gets stuck — count&lt;=1 must short-circuit.
/// </summary>
[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);
}
}