acdream/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs
Erik 0e71d3b829 feat(app): Campaign CC slice CC4 — chargen screen shell + Heritage/Profession/Skills/Town pages
Mounts gmCharGenMainUI (enum 0x10000039, root 0x100003CC) via
CharacterCreationUiController/CharacterCreationUiMountCoordinator,
cloning CharacterManagementUiController's recipe. Master shell ports
SetProgressState @0x004e7a10 (Olthoi tab-hide + redirect) and
ListenToElementMessage @0x004e9450 (Back/Next/Finish/Help/Exit/Random
nav) verbatim, with free tab navigation over all six pages. Heritage,
Profession, Skills, and Town pages bind to CC3's
RuntimeCharacterCreationState commands; Appearance and Summary mount as
content-inert placeholders for CC6b/CC5.

Live-DAT probing (CharacterCreationLiveDatTests) found two widget-
mapping surprises the decomp's DynamicCast hints don't predict: the
Profession slider's value field imports as an editable UiField (wired
for direct numeric entry), and the avail/health/stamina/mana/credits
displays author as UIElement_Button hosts whose Type-12 value child is
swallowed by UiButton.ConsumesDatChildren — substituted with the
button's own Label. No new DatWidgetFactory widget types were needed.

Threads the installed DAT's real ChargenOptions into Runtime via the
new RuntimeCharacterCreationState.InstallOptions, called from
ContentEffectsAudioCompositionPhase.Compose (mirrors
InstallSpellMetadata's pattern); headless keeps ChargenOptions.Empty
unchanged. Wires CC3's F14 status-hook gap (ApplyCharacterCreated/
ApplyCreationFailed) to SessionStatusWriter for both graphical and
headless hosts, and adds the CharacterCreation view/command seam
through CurrentGameRuntimeAdapter and DeferredGameRuntimeStateCommands
alongside CharacterSelection's existing shape.

Register: AD-101/102/103, AP-212/213, TS-82 filed for the auto-gender-
select interim default, the omitted ToD-account gate, the button-Label
widget substitution, the Random-button approximation, the flat-listbox
Skills simplification, and the Appearance/Summary placeholders.

Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6), Headless
165/0 unaffected, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 17:45:51 +02:00

703 lines
33 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);
}
[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);
}
}