feat(runtime): Campaign CC slice CC3 — RuntimeCharacterCreationState

Ports retail's CharGenState as the one Runtime-owned character-creation
state machine, mirroring RuntimeCharacterSelectionState's exact pattern
(snapshot/delta/event-stream, borrow-only view, generation-gated
commands, one mutable owner, no App types). Every command ports a named
retail function: SetHeritageGroup, SetGender, SetTemplate/ApplyTemplate
(Custom = template 0, Olthoi force-lock), the six attribute setters plus
GetAbsRemainingCredits/BalanceAttributes (retail's literal round-robin
order and fairness cursor), SetSkillLevel plus ResetSkillLevels' free-skill
baseline (reusing CC1's ChargenSkillCreditMath two-tier cost lookup
verbatim), RandomizeStartArea, and DoFinish's complete gate sequence
(empty name / unspent attribute credits / already-Pending / client-side
roster-vs-slotCount cap).

LiveSessionController gained a sibling IRuntimeCharacterCreationCommands
implementation, a CreateCharacter wire hook, and a response handler that
reuses existing machinery rather than inventing new paths: the Ok
identity is appended to the roster via RuntimeCharacterSelectionState's
own ApplyRoster, and the "log straight in" behavior reuses the private
EnterSelectedCore. ILiveSessionLifecycleHost gained two default-no-op
hooks (ApplyCharacterCreated/ApplyCreationFailed) so AcDream.App needs
zero changes to keep compiling; wiring them to the status stream is a
CC4 follow-up.

Filed four divergence-register rows for the corners deliberately not
ported: the FPU-unrecoverable FitTemplateToCharacter auto-detect (AP-207,
ACE only reads the field for title text), the per-style color-count
approximation (AP-208, CC1's model has no per-style palette data), the
classID DAT-DID placeholder (AP-209, ACE ignores the field), and
ApplyTemplate's atomic-vs-sequential attribute apply (AP-210).

34 new tests: full state-machine coverage (every Finish gate, every
rejection-code mapping, duplicate-NameInUse tolerance, Olthoi lock,
attribute balance/lock interaction, uncostable-skill rejection) plus a
LiveSessionController integration suite proving the wire send is exactly
55 skill slots (decoded from a real WorldSession + GameMessageCapture)
and the full Ok/rejection round trip through WorldSession.ProcessDatagram.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 14:26:10 +02:00
parent f3ef7baae2
commit 9a84230c4f
9 changed files with 3043 additions and 5 deletions

View file

@ -0,0 +1,206 @@
using System.Numerics;
using AcDream.Core.CharGen;
namespace AcDream.Runtime.Tests.CharGen;
/// <summary>
/// A small, hand-authored <see cref="ChargenOptions"/> fixture for
/// <see cref="RuntimeCharacterCreationState"/> tests — NOT installed-DAT data
/// (that is CC1's <c>ChargenTableReaderInstalledDatTests</c> concern). Shapes
/// mirror retail's field semantics closely enough to exercise every gate:
/// two heritages (a normal one and an Olthoi variant that must force template
/// 0), a "Custom"/floor template plus a str-heavy preset, a mix of costed
/// (including two FREE skills exercising every <c>ResetSkillLevels</c>
/// baseline branch) and deliberately uncostable skill ids, and one gender
/// with small appearance-option lists to exercise index bounds.
/// </summary>
internal static class RuntimeCharacterCreationStateFixture
{
public const uint AluvianId = 1u;
public const uint OlthoiId = (uint)ChargenHeritageGroup.Olthoi;
public const uint ImpoverishedId = 90u;
public const uint MaleGenderKey = 1u;
/// <summary>str=10 end=10 coord=10 quick=10 focus=10 self=10 — the
/// budget-66 heritage leaves 6 credits unspent after this template,
/// matching retail's "Custom sits at the floor" finding.</summary>
public const uint CustomTemplateIndex = 0u;
/// <summary>str=16, everything else at floor — sum 66 (fully spent).</summary>
public const uint PresetTemplateIndex = 1u;
/// <summary>Costed only by the heritage's own list — Normal 4 / Primary 12.</summary>
public const uint SkillTrainSpecialize = 1u;
/// <summary>The Preset template's Primary (specialized) skill.</summary>
public const uint SkillPresetPrimary = 2u;
/// <summary>FREE and pre-specialized by <c>ResetSkillLevels</c>'s baseline
/// (NormalCost=0, PrimaryCost&lt;=0).</summary>
public const uint SkillFreeSpecialized = 3u;
/// <summary>FREE to train, costs to specialize
/// (NormalCost=0, PrimaryCost&gt;0).</summary>
public const uint SkillFreeTrained = 4u;
/// <summary>Uncostable in BOTH tiers — must never be settable (the CC1
/// R1 binding fact: keep the both-miss refund path unreachable).</summary>
public const uint SkillUncostable = 5u;
/// <summary>The Custom template's authored Normal skill.</summary>
public const uint SkillCustomNormal = 24u;
public static ChargenOptions Build()
{
var starterAreas = new List<ChargenStarterArea>
{
new(0, "Holtburg", [new ChargenPosition(1u, Vector3.Zero, Quaternion.Identity)]),
new(1, "Yaraq", [new ChargenPosition(2u, Vector3.Zero, Quaternion.Identity)]),
};
var skillCosts = new Dictionary<uint, ChargenSkillCost>
{
[SkillTrainSpecialize] = new(SkillTrainSpecialize, NormalCost: 4, PrimaryCost: 12),
[SkillPresetPrimary] = new(SkillPresetPrimary, NormalCost: 3, PrimaryCost: 9),
[SkillFreeSpecialized] = new(SkillFreeSpecialized, NormalCost: 0, PrimaryCost: 0),
[SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 5),
[SkillCustomNormal] = new(SkillCustomNormal, NormalCost: 2, PrimaryCost: 6),
};
var gender = new ChargenGenderOptions(
GenderKey: (int)MaleGenderKey,
Name: "Male",
Scale: 1u,
SetupId: 0x2000054u,
SoundTableId: 0u,
IconId: 0u,
BasePaletteId: 0u,
SkinPalSetId: 0u,
PhysicsTableId: 0u,
MotionTableId: 0u,
CombatTableId: 0u,
BaseObjDesc: ChargenObjDesc.Empty,
HairColors: [100u, 101u],
HairStyles:
[
new ChargenHairStyle(1u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty),
new ChargenHairStyle(2u, Bald: true, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty),
],
EyeColors: [200u, 201u],
EyeStrips:
[
new ChargenEyeStrip(1u, 2u, ChargenObjDesc.Empty, ChargenObjDesc.Empty),
],
NoseStrips: [new ChargenFaceStrip(1u, ChargenObjDesc.Empty)],
MouthStrips: [new ChargenFaceStrip(1u, ChargenObjDesc.Empty)],
Headgears: [new ChargenGearOption("Cap", 1u, 300u)],
Shirts: [new ChargenGearOption("Shirt", 2u, 301u)],
Pants: [new ChargenGearOption("Pants", 3u, 302u)],
Footwear: [new ChargenGearOption("Boots", 4u, 303u)],
ClothingColors: [400u, 401u, 402u]);
var aluvianTemplates = new List<ChargenTemplate>
{
new(
"Custom",
IconId: 0u,
TitleStringId: 0u,
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
NormalSkills: [SkillCustomNormal],
PrimarySkills: []),
new(
"Preset",
IconId: 0u,
TitleStringId: 0u,
Attributes: new ChargenAttributeValues(16, 10, 10, 10, 10, 10),
NormalSkills: [SkillTrainSpecialize],
PrimarySkills: [SkillPresetPrimary]),
};
var aluvian = new ChargenHeritageOptions(
AluvianId,
"Aluvian",
IconId: 0u,
SetupId: 0x2000054u,
EnvironmentSetupId: 0u,
AttributeCredits: 66u,
SkillCredits: 50u,
PrimaryStartAreaIndices: [0, 1],
SecondaryStartAreaIndices: [],
SkillCostsBySkillId: skillCosts,
Templates: aluvianTemplates,
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)MaleGenderKey] = gender });
var olthoiTemplates = new List<ChargenTemplate>
{
new(
"Custom",
IconId: 0u,
TitleStringId: 0u,
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
NormalSkills: [],
PrimarySkills: []),
new(
"NeverChosen",
IconId: 0u,
TitleStringId: 0u,
Attributes: new ChargenAttributeValues(20, 20, 20, 20, 20, 20),
NormalSkills: [],
PrimarySkills: []),
};
var olthoi = new ChargenHeritageOptions(
OlthoiId,
"Olthoi",
IconId: 0u,
SetupId: 0x2000054u,
EnvironmentSetupId: 0u,
AttributeCredits: 60u,
SkillCredits: 0u,
PrimaryStartAreaIndices: [0],
SecondaryStartAreaIndices: [],
SkillCostsBySkillId: new Dictionary<uint, ChargenSkillCost>(),
Templates: olthoiTemplates,
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)MaleGenderKey] = gender });
// A deliberately impoverished heritage — just enough skill credits
// to train SkillTrainSpecialize but never specialize it — so a
// TrySpecializeSkill affordability refusal is directly testable
// without needing to hand-drain the richer Aluvian budget.
var impoverished = new ChargenHeritageOptions(
ImpoverishedId,
"Impoverished",
IconId: 0u,
SetupId: 0x2000054u,
EnvironmentSetupId: 0u,
AttributeCredits: 60u,
SkillCredits: 5u,
PrimaryStartAreaIndices: [0],
SecondaryStartAreaIndices: [],
SkillCostsBySkillId: new Dictionary<uint, ChargenSkillCost>
{
[SkillTrainSpecialize] = new(SkillTrainSpecialize, NormalCost: 4, PrimaryCost: 12),
},
Templates:
[
new ChargenTemplate(
"Custom",
IconId: 0u,
TitleStringId: 0u,
Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10),
NormalSkills: [],
PrimarySkills: []),
],
GendersByKey: new Dictionary<int, ChargenGenderOptions> { [(int)MaleGenderKey] = gender });
return new ChargenOptions(
starterAreas,
new Dictionary<uint, ChargenHeritageOptions>
{
[AluvianId] = aluvian,
[OlthoiId] = olthoi,
[ImpoverishedId] = impoverished,
},
new Dictionary<uint, ChargenSkillCost>());
}
}

View file

@ -0,0 +1,505 @@
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));
}
// ── Heritage / gender / template ────────────────────────────────────
[Fact]
public void TrySelectHeritage_UnknownId_IsRejected()
{
RuntimeCharacterCreationState state = CreateActive();
Assert.False(state.TrySelectHeritage(0xDEADu));
Assert.Equal(0u, state.Snapshot.HeritageId);
}
[Fact]
public void TrySelectHeritage_RecomputesBudgetsAndRollsARandomPrimaryStartArea()
{
RuntimeCharacterCreationState state = CreateActive();
Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId));
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, snapshot.HeritageId);
Assert.Equal(66u, snapshot.TotalAttributeCredits);
Assert.Equal(50u, snapshot.TotalSkillCredits);
// No template chosen yet — ApplyTemplate's own guard leaves
// attributes untouched (CharGenState::ApplyTemplate @ 0x005C5080).
Assert.Equal(0, snapshot.Attributes.Total);
Assert.Equal(66, snapshot.RemainingAttributeCredits);
// RandomizeStartArea @ 0x005C59E0 only ever picks from
// PrimaryStartAreaIndices, [0, 1] in the fixture.
Assert.True(snapshot.StartArea is 0 or 1);
}
[Fact]
public void TrySelectGender_RequiresHeritageFirst()
{
RuntimeCharacterCreationState state = CreateActive();
Assert.False(state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey));
}
[Fact]
public void TrySelectGender_UnknownKeyForHeritage_IsRejected()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
Assert.False(state.TrySelectGender(99u));
}
[Fact]
public void TrySelectTemplate_Custom_AppliesFloorAttributesAndLeavesCreditsUnspent()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex));
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
Assert.Equal(0u, snapshot.Template);
Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), snapshot.Attributes);
// 66 budget - 60 floor spend = 6 unspent, matching CC1's "Custom
// sits at the floor" finding.
Assert.Equal(6, snapshot.RemainingAttributeCredits);
Assert.Equal(
ChargenSkillAdvancementClass.Trained,
state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillCustomNormal));
}
[Fact]
public void TrySelectTemplate_Preset_TrainsNormalAndSpecializesPrimarySkills()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex));
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
Assert.Equal(new ChargenAttributeValues(16, 10, 10, 10, 10, 10), snapshot.Attributes);
Assert.Equal(0, snapshot.RemainingAttributeCredits);
Assert.Equal(
ChargenSkillAdvancementClass.Trained,
state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize));
Assert.Equal(
ChargenSkillAdvancementClass.Specialized,
state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillPresetPrimary));
// ResetSkillLevels' baseline (0x005C43B0) still holds for skills the
// template row doesn't mention.
Assert.Equal(
ChargenSkillAdvancementClass.Specialized,
state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillFreeSpecialized));
Assert.Equal(
ChargenSkillAdvancementClass.Trained,
state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillFreeTrained));
}
[Fact]
public void TrySelectTemplate_OlthoiHeritage_AlwaysForcesTemplateZero()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.OlthoiId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
// CharGenState::ApplyTemplate @ 0x005C5080: mHeritageGroup == 0xc
// force-sets template_ = 0 regardless of the requested index.
Assert.True(state.TrySelectTemplate(1u));
Assert.Equal(0u, state.Snapshot.Template);
Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), state.Snapshot.Attributes);
}
// ── Attributes ──────────────────────────────────────────────────────
[Fact]
public void TrySetAttribute_ClampsToTheFloorAndCeiling()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex);
Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 5));
Assert.Equal(10, state.Snapshot.Attributes.Strength);
Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 999));
// Clamped further by the abs-remaining-credits check below 100.
Assert.True(state.Snapshot.Attributes.Strength <= ChargenAttributeMath.AttributeMax);
}
[Fact]
public void TrySetAttribute_RaisingOneAttributeRebalancesAnAboveFloorAttributeDownToTheFloor()
{
// CharGenState::BalanceAttributes @ 0x005C3DF0: starting from the
// Preset template (Strength=16, everyone else at the 10 floor, fully
// spent), raising Endurance consumes the "assumed floor" room
// GetAbsRemainingCredits grants by pretending Strength could drop to
// floor — BalanceAttributes then actually performs that drop.
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex);
Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 16));
ChargenAttributeValues attrs = state.Snapshot.Attributes;
Assert.Equal(16, attrs.Endurance);
Assert.Equal(10, attrs.Strength);
Assert.Equal(10, attrs.Coordination);
Assert.Equal(10, attrs.Quickness);
Assert.Equal(10, attrs.Focus);
Assert.Equal(10, attrs.Self);
Assert.Equal(66, attrs.Total);
Assert.Equal(0, state.Snapshot.RemainingAttributeCredits);
}
[Fact]
public void TrySetAttributeLock_PreventsThatAttributeFromAbsorbingABalance()
{
// CharGenState::LockAttribute @ 0x005C3BE0 + GetAbsRemainingCredits
// @ 0x005C3B20's locked branch: a locked attribute contributes its
// CURRENT value (not the floor) to the abs-remaining computation, so
// no room is assumed available from it.
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex);
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Strength, true));
Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 16));
// No room was available (Strength locked at 16, everything else at
// floor, budget already fully spent) — Endurance cannot rise.
Assert.Equal(10, state.Snapshot.Attributes.Endurance);
Assert.Equal(16, state.Snapshot.Attributes.Strength);
}
// ── Skills ──────────────────────────────────────────────────────────
[Fact]
public void TrySpecializeSkill_UncostableSkill_IsAlwaysRejected()
{
// Binding fact from the CC1 review (R1): the 16 uncostable skill ids
// must never be settable — retail's own listbox never lists them.
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex);
Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillUncostable));
Assert.False(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillUncostable));
Assert.False(state.TryUntrainSkill(RuntimeCharacterCreationStateFixture.SkillUncostable));
Assert.Equal(
ChargenSkillAdvancementClass.Inactive,
state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillUncostable));
}
[Fact]
public void TrainThenSpecializeSkill_ChargesExactlyPrimaryCostNotBoth()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex);
int before = state.Snapshot.RemainingSkillCredits;
Assert.True(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize));
Assert.Equal(before - 4, state.Snapshot.RemainingSkillCredits);
Assert.True(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize));
// PrimaryCost (12) is the TOTAL, not an increment on NormalCost.
Assert.Equal(before - 12, state.Snapshot.RemainingSkillCredits);
Assert.True(state.TryUntrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize));
Assert.Equal(before, state.Snapshot.RemainingSkillCredits);
}
[Fact]
public void TrySpecializeSkill_InsufficientCredits_IsRejectedAndLeavesStateUnchanged()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.ImpoverishedId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex);
Assert.Equal(5, state.Snapshot.RemainingSkillCredits);
// PrimaryCost (12) exceeds the 5-credit budget outright.
Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize));
Assert.Equal(5, state.Snapshot.RemainingSkillCredits);
Assert.Equal(
ChargenSkillAdvancementClass.Untrained,
state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize));
// NormalCost (4) fits; the SAME skill Specialized still does not.
Assert.True(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize));
Assert.Equal(1, state.Snapshot.RemainingSkillCredits);
Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize));
Assert.Equal(1, state.Snapshot.RemainingSkillCredits);
Assert.Equal(
ChargenSkillAdvancementClass.Trained,
state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize));
}
// ── Finish gates ────────────────────────────────────────────────────
private static RuntimeCharacterCreationState ReadyToFinishState()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); // fully spent attrs
state.TrySetName("Adventurer");
return state;
}
[Fact]
public void TryBeginFinish_EmptyName_IsRefused()
{
RuntimeCharacterCreationState state = ReadyToFinishState();
state.TrySetName(" ");
bool accepted = state.TryBeginFinish(
rosterCount: 0,
slotCount: 11,
out _,
out _,
out RuntimeCharacterCreationLocalRefusal refusal);
Assert.False(accepted);
Assert.True(refusal.NoName);
Assert.False(state.Snapshot.VerificationPending);
}
[Fact]
public void TryBeginFinish_UnspentAttributeCredits_IsRefused()
{
RuntimeCharacterCreationState state = CreateActive();
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); // 6 unspent
state.TrySetName("Adventurer");
bool accepted = state.TryBeginFinish(
0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal);
Assert.False(accepted);
Assert.True(refusal.AttributeCreditsUnspent);
}
[Fact]
public void TryBeginFinish_SecondCallWhilePending_IsRefused()
{
RuntimeCharacterCreationState state = ReadyToFinishState();
Assert.True(state.TryBeginFinish(
0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal first));
Assert.False(first.Any);
bool second = state.TryBeginFinish(
0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal);
Assert.False(second);
Assert.True(refusal.AlreadyPending);
}
[Fact]
public void TryBeginFinish_RosterAtSlotCap_IsRefused()
{
RuntimeCharacterCreationState state = ReadyToFinishState();
bool accepted = state.TryBeginFinish(
rosterCount: 11,
slotCount: 11,
out _,
out _,
out RuntimeCharacterCreationLocalRefusal refusal);
Assert.False(accepted);
Assert.True(refusal.RosterFull);
}
[Fact]
public void TryBeginFinish_Accepted_TrimsNameAndProducesExactly55SkillSlots()
{
RuntimeCharacterCreationState state = ReadyToFinishState();
state.TrySetName(" Adventurer ");
bool accepted = state.TryBeginFinish(
rosterCount: 2,
slotCount: 11,
out CharacterCreate.Request request,
out uint[] skillAdvancementClasses,
out RuntimeCharacterCreationLocalRefusal refusal);
Assert.True(accepted);
Assert.False(refusal.Any);
Assert.True(state.Snapshot.VerificationPending);
Assert.Equal("Adventurer", request.Name);
Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, request.Heritage);
Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, request.Gender);
Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, request.Template);
Assert.Equal(16u, request.Attributes.Strength);
Assert.Equal(CharacterCreate.SkillAdvancementClassCount, skillAdvancementClasses.Length);
Assert.Equal(
(uint)ChargenSkillAdvancementClass.Trained,
skillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]);
Assert.Equal(
(uint)ChargenSkillAdvancementClass.Specialized,
skillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]);
}
// ── Response handling ───────────────────────────────────────────────
private static RuntimeCharacterCreationState PendingState(out uint[] skills)
{
RuntimeCharacterCreationState state = ReadyToFinishState();
Assert.True(state.TryBeginFinish(0, 11, out _, out skills, out _));
return state;
}
[Fact]
public void ApplyCreationResponse_Ok_RecordsCreatedIdentityAndClearsPending()
{
RuntimeCharacterCreationState state = PendingState(out _);
state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed(
(uint)CharGenVerificationResponse.Code.Ok, 0x5000_1234u, "Adventurer", 0u));
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
Assert.False(snapshot.VerificationPending);
Assert.Equal(
new RuntimeCharacterCreationIdentity(0x5000_1234u, "Adventurer"),
snapshot.LastCreated);
Assert.Null(snapshot.LastRejection);
}
[Theory]
[InlineData(CharGenVerificationResponse.Code.NameInUse)]
[InlineData(CharGenVerificationResponse.Code.NameBanned)]
[InlineData(CharGenVerificationResponse.Code.Corrupt)]
[InlineData(CharGenVerificationResponse.Code.DatabaseDown)]
[InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)]
public void ApplyCreationResponse_EachRejectionCode_RecordsTheMappingAndAttemptedName(
CharGenVerificationResponse.Code code)
{
RuntimeCharacterCreationState state = PendingState(out _);
state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed(
(uint)code, null, null, null));
Assert.NotNull(state.Snapshot.LastRejection);
RuntimeCharacterCreationRejection rejection = state.Snapshot.LastRejection!.Value;
Assert.Equal(code, rejection.Code);
Assert.Equal(code.ToString(), rejection.Reason);
Assert.Equal("Adventurer", rejection.AttemptedName);
Assert.False(state.Snapshot.VerificationPending);
Assert.Null(state.Snapshot.LastCreated);
}
[Theory]
[InlineData(CharGenVerificationResponse.Code.Pending)]
[InlineData(CharGenVerificationResponse.Code.Undef)]
public void ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection(
CharGenVerificationResponse.Code code)
{
// ACE sends Pending for a disabled-Olthoi rejection — retail shows
// no dialog. Port as-is.
RuntimeCharacterCreationState state = PendingState(out _);
state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed(
(uint)code, null, null, null));
Assert.False(state.Snapshot.VerificationPending);
Assert.Null(state.Snapshot.LastRejection);
Assert.Null(state.Snapshot.LastCreated);
}
[Fact]
public void ApplyCreationResponse_DuplicateReplyWhileNotPending_IsIgnored()
{
// ACE's own quirk (CharacterHandler.CharacterCreateEx calls
// IsCharacterNameAvailable TWICE, producing two NameInUse replies
// for one rejected create): the second reply must be a no-op, not a
// second rejection event/state change.
RuntimeCharacterCreationState state = PendingState(out _);
state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed(
(uint)CharGenVerificationResponse.Code.NameInUse, null, null, null));
Assert.NotNull(state.Snapshot.LastRejection);
// Acknowledge to clear, then feed a SECOND unsolicited reply — must
// stay cleared (idempotent-tolerant, no crash, no new rejection).
Assert.True(state.TryAcknowledgeRejection());
state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed(
(uint)CharGenVerificationResponse.Code.NameInUse, null, null, null));
Assert.Null(state.Snapshot.LastRejection);
}
[Fact]
public void TryAcknowledgeRejection_ClearsTheSurfacedRejection()
{
RuntimeCharacterCreationState state = PendingState(out _);
state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed(
(uint)CharGenVerificationResponse.Code.NameBanned, null, null, null));
Assert.NotNull(state.Snapshot.LastRejection);
Assert.True(state.TryAcknowledgeRejection());
Assert.Null(state.Snapshot.LastRejection);
}
}