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:
parent
f3ef7baae2
commit
9a84230c4f
9 changed files with 3043 additions and 5 deletions
|
|
@ -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<=0).</summary>
|
||||
public const uint SkillFreeSpecialized = 3u;
|
||||
|
||||
/// <summary>FREE to train, costs to specialize
|
||||
/// (NormalCost=0, PrimaryCost>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>());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using AcDream.Core.CharGen;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Net.Packets;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.Tests.CharGen;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC3: <see cref="LiveSessionController"/>'s
|
||||
/// character-creation integration — the wire send (via a REAL
|
||||
/// <see cref="WorldSession"/> + <c>GameMessageCapture</c>, the same seam
|
||||
/// <c>WorldSessionCharacterCreationTests</c> uses in Core.Net) and the
|
||||
/// Ok-response round trip (roster append reusing
|
||||
/// <see cref="RuntimeCharacterSelectionState.ApplyRoster"/>, the reused
|
||||
/// <c>EnterSelectedCore</c> log-straight-in, and the new
|
||||
/// <see cref="ILiveSessionLifecycleHost.ApplyCharacterCreated"/>/
|
||||
/// <see cref="ILiveSessionLifecycleHost.ApplyCreationFailed"/> hooks) via a
|
||||
/// real inbound <c>0xF643</c> packet through <c>WorldSession.ProcessDatagram</c>
|
||||
/// (reflection — the same private test seam Core.Net's own creation tests
|
||||
/// use).
|
||||
/// </summary>
|
||||
public sealed class LiveSessionControllerCharacterCreationTests
|
||||
{
|
||||
private sealed class TestTransport : IWorldSessionTransport
|
||||
{
|
||||
public void Send(ReadOnlySpan<byte> datagram) { }
|
||||
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
|
||||
public int Receive(Span<byte> destination, TimeSpan timeout, out IPEndPoint? from)
|
||||
{
|
||||
from = null;
|
||||
return -1;
|
||||
}
|
||||
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||||
Memory<byte> destination,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class TestOperations : ILiveSessionOperations
|
||||
{
|
||||
public List<WorldSession> Sessions { get; } = [];
|
||||
public int EnterWorldCount { get; private set; }
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||
new(IPAddress.Loopback, port);
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint)
|
||||
{
|
||||
var session = new WorldSession(endpoint, new TestTransport());
|
||||
Sessions.Add(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
public void Connect(WorldSession session, string user, string password) { }
|
||||
|
||||
public void StartCharacterSelectionReceive(WorldSession session) { }
|
||||
|
||||
public CharacterList.Parsed? GetCharacters(WorldSession session) => new(
|
||||
0u,
|
||||
[new CharacterList.Character(0x50000001u, "Existing", 0u)],
|
||||
[],
|
||||
SlotCount: 11,
|
||||
AccountName: "testaccount",
|
||||
true,
|
||||
true);
|
||||
|
||||
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
||||
EnterWorldCount++;
|
||||
|
||||
public void Tick(WorldSession session) { }
|
||||
|
||||
public void DisposeSession(WorldSession session) { }
|
||||
}
|
||||
|
||||
private sealed class TestHost : ILiveSessionLifecycleHost
|
||||
{
|
||||
public List<LiveSessionRosterReport> Rosters { get; } = [];
|
||||
public List<LiveSessionCharacterSelection> EnteredWorld { get; } = [];
|
||||
public List<RuntimeCharacterCreationIdentity> Created { get; } = [];
|
||||
public List<RuntimeCharacterCreationRejection> Failed { get; } = [];
|
||||
|
||||
public LiveSessionBinding BindSession(WorldSession session) =>
|
||||
new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { });
|
||||
public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { }
|
||||
public void ReportConnecting(string host, int port, string user) { }
|
||||
public void ReportConnected() { }
|
||||
public void ReportRoster(LiveSessionRosterReport roster) => Rosters.Add(roster);
|
||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
|
||||
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
|
||||
EnteredWorld.Add(selection);
|
||||
public void DetachSession(WorldSession session) { }
|
||||
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) =>
|
||||
Created.Add(identity);
|
||||
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) =>
|
||||
Failed.Add(rejection);
|
||||
}
|
||||
|
||||
private static LiveSessionConnectOptions LiveOptions() => new(
|
||||
Enabled: true,
|
||||
"127.0.0.1",
|
||||
9000,
|
||||
"testaccount",
|
||||
"password",
|
||||
Character: null,
|
||||
Probe: false,
|
||||
AwaitCharacterSelection: true);
|
||||
|
||||
private static (LiveSessionController Controller, TestOperations Operations, TestHost Host, RuntimeGenerationToken Generation)
|
||||
StartAwaitingSelection()
|
||||
{
|
||||
var operations = new TestOperations();
|
||||
var host = new TestHost();
|
||||
var controller = new LiveSessionController(
|
||||
operations,
|
||||
timeProvider: null,
|
||||
RuntimeCharacterCreationStateFixture.Build());
|
||||
|
||||
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
||||
Assert.Equal(LiveSessionStartStatus.AwaitingCharacterSelection, result.Status);
|
||||
|
||||
return (controller, operations, host, controller.Generation);
|
||||
}
|
||||
|
||||
private static void BuildReadyCharacter(LiveSessionController controller, RuntimeGenerationToken generation)
|
||||
{
|
||||
Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted);
|
||||
Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted);
|
||||
Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted);
|
||||
Assert.True(controller.SetName(generation, "NewChar").Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName()
|
||||
{
|
||||
(LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) =
|
||||
StartAwaitingSelection();
|
||||
BuildReadyCharacter(controller, generation);
|
||||
|
||||
WorldSession session = operations.Sessions[0];
|
||||
byte[]? captured = null;
|
||||
GameMessageGroup? capturedGroup = null;
|
||||
session.GameMessageCapture = (body, group) =>
|
||||
{
|
||||
captured = body;
|
||||
capturedGroup = group;
|
||||
};
|
||||
|
||||
RuntimeCommandResult result = controller.Finish(generation);
|
||||
|
||||
Assert.True(result.Accepted);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(GameMessageGroup.LoginQueue, capturedGroup);
|
||||
|
||||
CapturedCreateRequest decoded = DecodeCreateRequest(captured!);
|
||||
Assert.Equal("testaccount", decoded.AccountName);
|
||||
Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, decoded.Heritage);
|
||||
Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, decoded.Gender);
|
||||
Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, decoded.Template);
|
||||
Assert.Equal(16u, decoded.Strength);
|
||||
Assert.Equal("NewChar", decoded.Name);
|
||||
Assert.Equal((uint)CharacterCreate.SkillAdvancementClassCount, decoded.NumSkills);
|
||||
Assert.Equal(CharacterCreate.SkillAdvancementClassCount, decoded.SkillAdvancementClasses.Length);
|
||||
Assert.Equal(
|
||||
(uint)ChargenSkillAdvancementClass.Trained,
|
||||
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]);
|
||||
Assert.Equal(
|
||||
(uint)ChargenSkillAdvancementClass.Specialized,
|
||||
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finish_ThenOkResponse_AppendsToRosterAndLogsStraightIn()
|
||||
{
|
||||
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
|
||||
StartAwaitingSelection();
|
||||
BuildReadyCharacter(controller, generation);
|
||||
WorldSession session = operations.Sessions[0];
|
||||
session.GameMessageCapture = (_, _) => { };
|
||||
|
||||
Assert.True(controller.Finish(generation).Accepted);
|
||||
|
||||
InvokeProcessDatagram(session, BuildResponsePacket(
|
||||
(uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar"));
|
||||
|
||||
Assert.Single(host.Created);
|
||||
Assert.Equal(0x50001234u, host.Created[0].Guid);
|
||||
Assert.Equal("NewChar", host.Created[0].Name);
|
||||
Assert.Empty(host.Failed);
|
||||
|
||||
// The roster report following the Ok reply has BOTH the pre-existing
|
||||
// character and the newly created one.
|
||||
LiveSessionRosterReport lastReport = host.Rosters[^1];
|
||||
Assert.Contains(lastReport.Entries, e => e.Id == 0x50001234u && e.Name == "NewChar");
|
||||
Assert.Contains(lastReport.Entries, e => e.Id == 0x50000001u);
|
||||
|
||||
// gmCharGenMainUI::Update @ 0x004E8460's log-straight-in, reused via
|
||||
// EnterSelectedCore — the controller is now in-world as the new
|
||||
// character, no second selection/EnterWorld call needed.
|
||||
Assert.True(controller.IsInWorld);
|
||||
Assert.Equal(1, operations.EnterWorldCount);
|
||||
Assert.Single(host.EnteredWorld);
|
||||
Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection()
|
||||
{
|
||||
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
|
||||
StartAwaitingSelection();
|
||||
BuildReadyCharacter(controller, generation);
|
||||
WorldSession session = operations.Sessions[0];
|
||||
session.GameMessageCapture = (_, _) => { };
|
||||
|
||||
Assert.True(controller.Finish(generation).Accepted);
|
||||
|
||||
InvokeProcessDatagram(session, BuildResponsePacket(
|
||||
(uint)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty));
|
||||
|
||||
Assert.Single(host.Failed);
|
||||
Assert.Equal(CharGenVerificationResponse.Code.NameInUse, host.Failed[0].Code);
|
||||
Assert.Equal("NewChar", host.Failed[0].AttemptedName);
|
||||
Assert.Empty(host.Created);
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Equal(0, operations.EnterWorldCount);
|
||||
Assert.Empty(host.EnteredWorld);
|
||||
// No roster append on a rejection.
|
||||
Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finish_RefusedLocallyWithUnspentAttributeCredits_NeverTouchesTheWire()
|
||||
{
|
||||
(LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) =
|
||||
StartAwaitingSelection();
|
||||
Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted);
|
||||
Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted);
|
||||
Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.CustomTemplateIndex).Accepted);
|
||||
Assert.True(controller.SetName(generation, "NewChar").Accepted);
|
||||
WorldSession session = operations.Sessions[0];
|
||||
bool sent = false;
|
||||
session.GameMessageCapture = (_, _) => sent = true;
|
||||
|
||||
RuntimeCommandResult result = controller.Finish(generation);
|
||||
|
||||
Assert.False(result.Accepted);
|
||||
Assert.False(sent);
|
||||
}
|
||||
|
||||
private static void InvokeProcessDatagram(WorldSession session, byte[] datagram)
|
||||
{
|
||||
MethodInfo method = typeof(WorldSession).GetMethod(
|
||||
"ProcessDatagram",
|
||||
BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
method.Invoke(session, [new ReadOnlyMemory<byte>(datagram), null, true]);
|
||||
}
|
||||
|
||||
private static byte[] BuildResponseBody(uint code, uint guid, string name)
|
||||
{
|
||||
var w = new PacketWriter();
|
||||
w.WriteUInt32(CharGenVerificationResponse.ResponseOpcode);
|
||||
w.WriteUInt32(code);
|
||||
if (code == (uint)CharGenVerificationResponse.Code.Ok)
|
||||
{
|
||||
w.WriteUInt32(guid);
|
||||
w.WriteString16L(name);
|
||||
w.WriteUInt32(0u);
|
||||
}
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] BuildResponsePacket(uint code, uint guid, string name)
|
||||
{
|
||||
byte[] message = BuildResponseBody(code, guid, name);
|
||||
var fragments = new byte[MessageFragmentHeader.Size + message.Length];
|
||||
GameMessageFragment.WriteSingleFragment(fragments.AsSpan(), fragmentSequence: 1u, GameMessageGroup.UIQueue, message);
|
||||
return PacketCodec.Encode(
|
||||
new PacketHeader { Sequence = 1u, Flags = PacketHeaderFlags.BlobFragments },
|
||||
fragments,
|
||||
outboundIsaac: null);
|
||||
}
|
||||
|
||||
private readonly record struct CapturedCreateRequest(
|
||||
string AccountName,
|
||||
uint Heritage,
|
||||
uint Gender,
|
||||
uint Template,
|
||||
uint Strength,
|
||||
string Name,
|
||||
uint NumSkills,
|
||||
uint[] SkillAdvancementClasses);
|
||||
|
||||
/// <summary>Manual mirror of <see cref="CharacterCreate.BuildRequestBody"/>'s
|
||||
/// exact field order — see that class's doc comment for the full
|
||||
/// layout.</summary>
|
||||
private static CapturedCreateRequest DecodeCreateRequest(ReadOnlySpan<byte> body)
|
||||
{
|
||||
int pos = 0;
|
||||
uint opcode = ReadU32(body, ref pos);
|
||||
Assert.Equal(CharacterCreate.Opcode, opcode);
|
||||
string accountName = ReadString16L(body, ref pos);
|
||||
uint constant = ReadU32(body, ref pos);
|
||||
Assert.Equal(1u, constant);
|
||||
uint heritage = ReadU32(body, ref pos);
|
||||
uint gender = ReadU32(body, ref pos);
|
||||
_ = ReadU32(body, ref pos); // eyesStrip
|
||||
_ = ReadU32(body, ref pos); // noseStrip
|
||||
_ = ReadU32(body, ref pos); // mouthStrip
|
||||
_ = ReadU32(body, ref pos); // hairColor
|
||||
_ = ReadU32(body, ref pos); // eyeColor
|
||||
_ = ReadU32(body, ref pos); // hairStyle
|
||||
_ = ReadU32(body, ref pos); // headgearStyle
|
||||
_ = ReadU32(body, ref pos); // headgearColor
|
||||
_ = ReadU32(body, ref pos); // shirtStyle
|
||||
_ = ReadU32(body, ref pos); // shirtColor
|
||||
_ = ReadU32(body, ref pos); // trousersStyle
|
||||
_ = ReadU32(body, ref pos); // trousersColor
|
||||
_ = ReadU32(body, ref pos); // footwearStyle
|
||||
_ = ReadU32(body, ref pos); // footwearColor
|
||||
for (int i = 0; i < 6; i++)
|
||||
_ = ReadF64(body, ref pos); // six shades
|
||||
uint template = ReadU32(body, ref pos);
|
||||
uint strength = ReadU32(body, ref pos);
|
||||
_ = ReadU32(body, ref pos); // endurance
|
||||
_ = ReadU32(body, ref pos); // coordination
|
||||
_ = ReadU32(body, ref pos); // quickness
|
||||
_ = ReadU32(body, ref pos); // focus
|
||||
_ = ReadU32(body, ref pos); // self
|
||||
_ = ReadU32(body, ref pos); // slot
|
||||
_ = ReadU32(body, ref pos); // classId
|
||||
uint numSkills = ReadU32(body, ref pos);
|
||||
var skills = new uint[numSkills];
|
||||
for (int i = 0; i < numSkills; i++)
|
||||
skills[i] = ReadU32(body, ref pos);
|
||||
string name = ReadString16L(body, ref pos);
|
||||
// startArea, isAdmin, isEnvoy, checksum follow — not needed here.
|
||||
return new CapturedCreateRequest(
|
||||
accountName, heritage, gender, template, strength, name, numSkills, skills);
|
||||
}
|
||||
|
||||
private static uint ReadU32(ReadOnlySpan<byte> body, ref int pos)
|
||||
{
|
||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static double ReadF64(ReadOnlySpan<byte> body, ref int pos)
|
||||
{
|
||||
double value = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos));
|
||||
pos += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string ReadString16L(ReadOnlySpan<byte> body, ref int pos)
|
||||
{
|
||||
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
string value = Encoding.ASCII.GetString(body.Slice(pos + 2, len));
|
||||
int recordSize = 2 + len;
|
||||
int padding = (4 - (recordSize & 3)) & 3;
|
||||
pos += recordSize + padding;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue