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>
371 lines
16 KiB
C#
371 lines
16 KiB
C#
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;
|
|
}
|
|
}
|