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>
505 lines
23 KiB
C#
505 lines
23 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));
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|