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

File diff suppressed because one or more lines are too long

View file

@ -230,7 +230,7 @@ the user gate.
|---|---|---|---|---|
| CC1 | REVIEW-CLOSED 2026-08-15 | `04450041`, `cb4703e8` | CLOSED (fix round + narrow re-review; every citation independently re-derived) | Core model (no Chorizite leak) + Content projector; 31 math units + 6 installed-DAT gates (13 heritages). FINDING for CC3: each human heritage's "Adventurer" template IS retail's Custom entry point — attributes at the 10-floor (60/330), a real TemplateCG row, not a UI special case. **Review fix round (`cb4703e8`):** F1 doc corrected — Custom IS template index 0 (the Adventurer row), per `gmCGProfessionPage::UpdateProfession @ 0x004821b0` (case 0 → button 0x100003d9 / `ID_CharGen_CustomText`) and `CharGenState::SetTemplate @ 0x005C5A60` (commits via `CharGenState::ApplyTemplate @ 0x005C5080`, i.e. selecting Custom resets sliders to the floor spread, it does not bypass templates); F2 two-tier skill-cost fallback implemented (`ChargenOptions.GlobalSkillCostsBySkillId` from portal.dat 0x0E000004, `ChargenSkillCreditMath` checks heritage list then global list) + installed-DAT completeness assertion recording reality: the global SkillTable prices 38/54 advancement skill ids, every one of the 13 heritages ships EXACTLY one heritage-specific override (always also present in the global table), and 16 skill ids are genuinely uncostable in both tiers (retail's -1 case) — see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`; F3 every `ChargenTableReader` collection is now frozen at projection (`ToFrozenDictionary`/`ToArray`, matching `MagicCatalog`'s pattern) including both `ChargenOptions.Empty` dictionaries; F4 a reflection guard test (`ChargenNoChoriziteLeakTests`) pins the no-Chorizite-leak contract by walking every public `AcDream.Core.CharGen` member; F5 `HasAnyAppearanceOptions`'s doc reworded to state precisely what it proves (an OR across eight lists, omitting the three color lists) + a new installed-DAT gate records per-list reality — found COMPLETE, every gender of every heritage has non-empty lists across all eight plus the three color lists, even the sparse Gear Knight/Olthoi variants; F6 `TryGetHeritage`/`TryGetStarterArea` annotated `[MaybeNullWhen(false)]` (matching the house `EmptyDatReaderWriter` pattern), all affected call sites (more than the originally estimated five) fixed across both test projects. Filed CC7 risk item 8: ACE's `PlayerFactory` heritage-override branch over-deducts skill credits when specializing a heritage-priced skill (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) — a retail-legal build may be rejected by local ACE at the CC7 connected gate; this is an ACE bug, not an acdream defect. **Narrow re-review CLOSED:** the reviewer retro-graded F2 to HIGH (under the base commit 37 of 38 costable skills were charged zero) and confirmed the SkillBase.SpecializedCost->PrimaryCost mapping dodged the UpgradeCostFromTrainedToSpecialized trap. Residuals: R1 retail refunds +1 credit on a both-tier miss (port charges 0; unreachable via retails own skills listbox — NOTE FOR CC3 if any path ever exposes the 16 uncostable ids); R2 list downcast-mutability and R3 field-walking in the leak guard CLOSED at the merge-closeout commit (Array.AsReadOnly at every projection seam; GetFields walk added). Decomp fact for CC4: ApplyTemplate force-sets template_=0 for heritage 0xc/0xd — both Olthoi variants are hard-locked to Custom/template 0. |
| CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL |
| CC3 | — | | | |
| CC3 | IMPLEMENTED 2026-08-15 (unreviewed — Opus dual-lens review owed per campaign process) | `(HEAD — see git log)` | — | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `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 (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. On Ok: appends the identity to the roster by REUSING `RuntimeCharacterSelectionState.ApplyRoster` (read current entries via `View.Visit`, append, re-apply — no new roster-mutation primitive) and logs straight in by REUSING the private `EnterSelectedCore` (no second enter route) — `gmCharGenMainUI::Update @ 0x004E8460`'s per-frame name-scan is deliberately not re-implemented since the SAME `0xF643` Ok reply already carries the exact guid/name (an equivalent, not divergent, substitution). `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change). Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (30 cases — every Finish gate, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (4 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting roster append + auto-enter + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate). Runtime 1701/0 (was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it. |
| CC4 | — | | | |
| CC5 | — | | | |
| CC6a | — | | | |

View file

@ -378,6 +378,82 @@ public interface IRuntimeAllegianceCommands
bool on);
}
// ── Character creation (Campaign CC slice CC3, 2026-08-15) ─────────────────
/// <summary>
/// Generation-gated character-creation (CharGenState) outbound actions —
/// lands beside <see cref="IGameRuntimeCommands.CharacterSelection"/>, the
/// same command family shape.
/// </summary>
public interface IRuntimeCharacterCreationCommands
{
RuntimeCommandResult SelectHeritage(
RuntimeGenerationToken expectedGeneration,
uint heritageId);
RuntimeCommandResult SelectGender(
RuntimeGenerationToken expectedGeneration,
uint genderKey);
RuntimeCommandResult SelectTemplate(
RuntimeGenerationToken expectedGeneration,
uint templateIndex);
RuntimeCommandResult SetAttribute(
RuntimeGenerationToken expectedGeneration,
Session.ChargenAttributeId attributeId,
int value);
RuntimeCommandResult SetAttributeLock(
RuntimeGenerationToken expectedGeneration,
Session.ChargenAttributeId attributeId,
bool locked);
RuntimeCommandResult TrainSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId);
RuntimeCommandResult SpecializeSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId);
RuntimeCommandResult UntrainSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId);
RuntimeCommandResult SetAppearanceIndex(
RuntimeGenerationToken expectedGeneration,
Session.ChargenAppearanceSlot slot,
uint index);
RuntimeCommandResult SetShade(
RuntimeGenerationToken expectedGeneration,
Session.ChargenShadeSlot slot,
double value);
RuntimeCommandResult SelectStartArea(
RuntimeGenerationToken expectedGeneration,
int startAreaIndex);
RuntimeCommandResult SetName(
RuntimeGenerationToken expectedGeneration,
string name);
RuntimeCommandResult SetSlot(
RuntimeGenerationToken expectedGeneration,
uint slot);
/// <summary>Retail's Finish button (<c>gmCharGenMainUI::DoFinish @
/// 0x004E9170</c>). On acceptance the request is already on the wire;
/// the Ok/rejection reply arrives asynchronously as a status delta —
/// see <see cref="Session.RuntimeCharacterCreationState"/>.</summary>
RuntimeCommandResult Finish(
RuntimeGenerationToken expectedGeneration);
RuntimeCommandResult AcknowledgeRejection(
RuntimeGenerationToken expectedGeneration);
}
public interface IGameRuntimeCommands
{
IRuntimeSessionCommands Session { get; }
@ -386,6 +462,10 @@ public interface IGameRuntimeCommands
throw new NotSupportedException(
"This command adapter does not project character selection.");
IRuntimeCharacterCreationCommands CharacterCreation =>
throw new NotSupportedException(
"This command adapter does not project character creation.");
IRuntimeSelectionCommands Selection { get; }
IRuntimeCombatCommands Combat { get; }

View file

@ -74,6 +74,8 @@ public sealed class DirectGameRuntimeCommandAdapter
public IRuntimeSessionCommands Session => this;
public IRuntimeCharacterSelectionCommands CharacterSelection =>
_runtime.Session;
public IRuntimeCharacterCreationCommands CharacterCreation =>
_runtime.Session;
public IRuntimeSelectionCommands Selection => this;
public IRuntimeCombatCommands Combat => this;
public IRuntimeMagicCommands Magic => this;

View file

@ -1,5 +1,6 @@
using System.Net;
using System.Net.Sockets;
using AcDream.Core.CharGen;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
@ -107,6 +108,22 @@ public interface ILiveSessionLifecycleHost
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
void ApplyEnteredWorld(LiveSessionCharacterSelection selection);
void DetachSession(WorldSession session);
/// <summary>
/// Campaign CC slice CC3: reported once per successful character
/// creation (the <c>0xF643</c> Ok identity), right before the roster is
/// re-reported with the new entry appended and the reused enter-selected
/// path runs — the same spot <see cref="ReportRoster"/> and
/// <see cref="ApplyEnteredWorld"/> already occupy. Default no-op: a host
/// that wants status-stream parity with
/// <c>SessionStatusWriter.CharacterCreated</c> overrides this.
/// </summary>
void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) { }
/// <summary>Campaign CC slice CC3: reported once per non-Ok <c>0xF643</c>
/// creation response. Default no-op — see <see cref="ApplyCharacterCreated"/>.
/// </summary>
void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) { }
}
/// <summary>
@ -192,6 +209,15 @@ public interface ILiveSessionOperations
session.SendDeleteCharacter(accountName, activeCharacterIndex);
void RestoreCharacter(WorldSession session, uint characterId) =>
session.SendRestoreCharacter(characterId);
/// <summary>Campaign CC slice CC3: retail's <c>Proto_UI::SendCharGenResult
/// @ 0x00546A70</c> outbound send, reached from <c>gmCharGenMainUI::DoFinish</c>.
/// </summary>
void CreateCharacter(
WorldSession session,
string accountName,
CharacterCreate.Request request,
ReadOnlySpan<uint> skillAdvancementClasses) =>
session.SendCharacterCreation(accountName, request, skillAdvancementClasses);
void Tick(WorldSession session);
void DisposeSession(WorldSession session);
}
@ -247,7 +273,8 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
public sealed class LiveSessionController
: IDisposable,
IRuntimeLiveSessionFramePhase,
IRuntimeCharacterSelectionCommands
IRuntimeCharacterSelectionCommands,
IRuntimeCharacterCreationCommands
{
private sealed class CharacterSelectionWireBinding : IDisposable
{
@ -257,6 +284,7 @@ public sealed class LiveSessionController
private readonly Action<CharacterRestore.Parsed> _restore;
private readonly Action<CharacterError.Parsed> _error;
private readonly Action<ServerName.Parsed> _worldName;
private readonly Action<CharGenVerificationResponse.Parsed> _created;
public CharacterSelectionWireBinding(
WorldSession session,
@ -264,7 +292,8 @@ public sealed class LiveSessionController
Action delete,
Action<CharacterRestore.Parsed> restore,
Action<CharacterError.Parsed> error,
Action<ServerName.Parsed> worldName)
Action<ServerName.Parsed> worldName,
Action<CharGenVerificationResponse.Parsed> created)
{
_session = session;
_roster = roster;
@ -272,11 +301,13 @@ public sealed class LiveSessionController
_restore = restore;
_error = error;
_worldName = worldName;
_created = created;
session.CharacterListReceived += roster;
session.CharacterDeleteAcknowledged += delete;
session.CharacterRestoreReceived += restore;
session.CharacterErrorReceived += error;
session.ServerNameReceived += worldName;
session.CharacterCreateResponseReceived += created;
}
public bool IsDisposed => _session is null;
@ -291,6 +322,7 @@ public sealed class LiveSessionController
session.CharacterRestoreReceived -= _restore;
session.CharacterErrorReceived -= _error;
session.ServerNameReceived -= _worldName;
session.CharacterCreateResponseReceived -= _created;
}
}
@ -397,11 +429,19 @@ public sealed class LiveSessionController
public LiveSessionController(
ILiveSessionOperations operations,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
ChargenOptions? chargenOptions = null)
{
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
CharacterSelectionState = new RuntimeCharacterSelectionState(
timeProvider);
// Campaign CC slice CC3: defaults to ChargenOptions.Empty (no
// heritages configured) — loading the installed DAT's chargen table
// and threading it through is a future App/GameRuntime wiring slice,
// not this one. A caller that never supplies real options simply
// gets an inert chargen surface (every heritage lookup misses).
CharacterCreationState = new RuntimeCharacterCreationState(
chargenOptions ?? ChargenOptions.Empty);
}
public RuntimeCharacterSelectionState CharacterSelectionState { get; }
@ -409,6 +449,11 @@ public sealed class LiveSessionController
public IRuntimeCharacterSelectionView CharacterSelection =>
CharacterSelectionState.View;
public RuntimeCharacterCreationState CharacterCreationState { get; }
public IRuntimeCharacterCreationView CharacterCreation =>
CharacterCreationState.View;
public WorldSession? CurrentSession
{
get { lock (_gate) return _scope?.Session; }
@ -684,6 +729,7 @@ public sealed class LiveSessionController
ulong generation = ++_generation;
RuntimeGenerationToken activeGeneration = new(generation);
CharacterSelectionState.Reset(activeGeneration);
CharacterCreationState.Reset(activeGeneration);
try
{
DrainRetiredScope();
@ -710,6 +756,7 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials);
CharacterSelectionState.Begin(activeGeneration);
CharacterCreationState.Begin(activeGeneration);
SessionScope? scope = null;
try
@ -921,6 +968,14 @@ public sealed class LiveSessionController
if (IsCurrent(scope, generation))
CharacterSelectionState.ApplyWorldName(worldName.WorldName);
}
},
created =>
{
lock (_gate)
{
if (IsCurrent(scope, generation))
HandleCharacterCreationResponse(scope, generation, created);
}
});
public RuntimeCommandResult Highlight(
@ -1149,6 +1204,350 @@ public sealed class LiveSessionController
new RuntimeGenerationToken(_generation),
characterId);
// ── Character creation (Campaign CC slice CC3) ─────────────────────
/// <summary>Collects <see cref="RuntimeCharacterSelectionEntry"/> rows
/// into <see cref="LiveSessionRosterEntry"/> form for
/// <see cref="HandleCharacterCreationResponse"/>'s roster-append
/// composition.</summary>
private sealed class RosterCollector(List<LiveSessionRosterEntry> entries)
: IRuntimeCharacterSelectionVisitor
{
public void Visit(in RuntimeCharacterSelectionEntry character) =>
entries.Add(new LiveSessionRosterEntry(
character.CharacterId,
character.Name,
character.SecondsGreyedOut));
}
/// <summary>
/// Ports the Ok half of <c>Handle_CharGenVerificationResponse @
/// 0x0055E8B0</c> case 1 (the PENDING/create branch —
/// <c>CharacterSet::AddIdentity</c>) composed with
/// <c>gmCharGenMainUI::Update @ 0x004E8460</c>'s per-frame roster scan
/// (which finds the freshly appended identity by name and calls
/// <c>CPlayerSystem::LogOnCharacter</c> directly): a fresh
/// <see cref="RuntimeCharacterCreationState"/> is disambiguated locally
/// (we already have the exact created guid/name from the SAME reply
/// that triggered the roster append, so there is no need to re-scan for
/// it the way retail's per-frame poll does — an equivalent, not a
/// divergent, substitution). Reuses <see cref="RuntimeCharacterSelectionState.ApplyRoster"/>
/// for the append (there is no single-entry append primitive to
/// duplicate) and <see cref="EnterSelectedCore"/> for the log-straight-in
/// (no second enter route). A non-Ok reply only needs the state-machine
/// update already performed by <see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>
/// — no roster/enter side effects.
/// </summary>
private void HandleCharacterCreationResponse(
SessionScope scope,
ulong generation,
CharGenVerificationResponse.Parsed response)
{
CharacterCreationState.ApplyCreationResponse(response);
RuntimeCharacterCreationSnapshot creation = CharacterCreationState.Snapshot;
if (creation.LastCreated is { } created)
scope.Host.ApplyCharacterCreated(created);
else if (creation.LastRejection is { } rejection)
scope.Host.ApplyCreationFailed(rejection);
if (creation.LastCreated is not { } identity)
return;
RuntimeCharacterSelectionSnapshot before = CharacterSelectionState.Snapshot;
var entries = new List<LiveSessionRosterEntry>(before.RosterCount + 1);
CharacterSelectionState.View.Visit(new RosterCollector(entries));
entries.Add(new LiveSessionRosterEntry(
identity.Guid,
identity.Name,
SecondsGreyedOut: 0u));
var report = new LiveSessionRosterReport(
before.AccountName,
before.SlotCount,
entries);
CharacterSelectionState.ApplyRoster(report);
scope.Host.ReportRoster(report);
if (!IsCurrent(scope, generation))
return;
if (!CharacterSelectionState.TryHighlight(identity.Guid))
return;
if (!IsCurrent(scope, generation))
return;
// Inline, not through the public Enter() command: we are already
// running inside Tick()'s top-level operation (this handler fires
// synchronously from _operations.Tick's inbound processing), exactly
// the same calling convention StartCore's own inline enter uses.
_ = EnterSelectedCore();
}
public RuntimeCommandResult SelectHeritage(
RuntimeGenerationToken expectedGeneration,
uint heritageId)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySelectHeritage(heritageId));
}
}
public RuntimeCommandResult SelectGender(
RuntimeGenerationToken expectedGeneration,
uint genderKey)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySelectGender(genderKey));
}
}
public RuntimeCommandResult SelectTemplate(
RuntimeGenerationToken expectedGeneration,
uint templateIndex)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySelectTemplate(templateIndex));
}
}
public RuntimeCommandResult SetAttribute(
RuntimeGenerationToken expectedGeneration,
ChargenAttributeId attributeId,
int value)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySetAttribute(attributeId, value));
}
}
public RuntimeCommandResult SetAttributeLock(
RuntimeGenerationToken expectedGeneration,
ChargenAttributeId attributeId,
bool locked)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySetAttributeLock(attributeId, locked));
}
}
public RuntimeCommandResult TrainSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TryTrainSkill(skillId));
}
}
public RuntimeCommandResult SpecializeSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TrySpecializeSkill(skillId));
}
}
public RuntimeCommandResult UntrainSkill(
RuntimeGenerationToken expectedGeneration,
uint skillId)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TryUntrainSkill(skillId));
}
}
public RuntimeCommandResult SetAppearanceIndex(
RuntimeGenerationToken expectedGeneration,
ChargenAppearanceSlot slot,
uint index)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySetAppearanceIndex(slot, index));
}
}
public RuntimeCommandResult SetShade(
RuntimeGenerationToken expectedGeneration,
ChargenShadeSlot slot,
double value)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TrySetShade(slot, value));
}
}
public RuntimeCommandResult SelectStartArea(
RuntimeGenerationToken expectedGeneration,
int startAreaIndex)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TrySelectStartArea(startAreaIndex));
}
}
public RuntimeCommandResult SetName(
RuntimeGenerationToken expectedGeneration,
string name)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TrySetName(name ?? string.Empty));
}
}
public RuntimeCommandResult SetSlot(
RuntimeGenerationToken expectedGeneration,
uint slot)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(CharacterCreationState.TrySetSlot(slot));
}
}
/// <summary>
/// Ports <c>gmCharGenMainUI::DoFinish @ 0x004E9170</c>'s send half: the
/// local gates live in <see cref="RuntimeCharacterCreationState.TryBeginFinish"/>;
/// this method supplies the roster/slot-cap inputs from
/// <see cref="CharacterSelectionState"/> and, on acceptance, sends the
/// wire request via <c>Proto_UI::SendCharGenResult</c>'s port
/// (<see cref="ILiveSessionOperations.CreateCharacter"/>). A transport
/// failure resets the verification latch the same way an unsolicited
/// Undef/Pending reply does (<see cref="RuntimeCharacterCreationState.ApplyCreationResponse"/>)
/// rather than leaving it stuck Pending forever.
/// </summary>
public RuntimeCommandResult Finish(RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot;
if (!CharacterCreationState.TryBeginFinish(
selection.RosterCount,
selection.SlotCount,
out CharacterCreate.Request request,
out uint[] skillAdvancementClasses,
out _))
{
return CharacterCreationResult(RuntimeCommandStatus.Rejected);
}
try
{
_operations.CreateCharacter(
_scope!.Session,
selection.AccountName,
request,
skillAdvancementClasses);
return CharacterCreationResult(RuntimeCommandStatus.Accepted);
}
catch
{
CharacterCreationState.ApplyCreationResponse(
new CharGenVerificationResponse.Parsed(
(uint)CharGenVerificationResponse.Code.Undef,
null,
null,
null));
return CharacterCreationResult(RuntimeCommandStatus.Rejected);
}
}
}
public RuntimeCommandResult AcknowledgeRejection(
RuntimeGenerationToken expectedGeneration)
{
lock (_gate)
{
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
if (gate != RuntimeCommandStatus.Accepted)
return CharacterCreationResult(gate);
return CharacterCreationResult(
CharacterCreationState.TryAcknowledgeRejection());
}
}
private RuntimeCommandStatus ValidateCharacterCreationCommand(
RuntimeGenerationToken expectedGeneration)
{
RuntimeGenerationToken current = new(_generation);
if (expectedGeneration != current)
return RuntimeCommandStatus.StaleGeneration;
if (_disposed || _disposeRequested || _scope is null || _inWorld)
return RuntimeCommandStatus.Inactive;
return CharacterSelectionState.Snapshot.Lifecycle
== RuntimeCharacterSelectionLifecycle.AwaitingSelection
? RuntimeCommandStatus.Accepted
: RuntimeCommandStatus.Inactive;
}
private RuntimeCommandResult CharacterCreationResult(bool accepted) =>
CharacterCreationResult(
accepted ? RuntimeCommandStatus.Accepted : RuntimeCommandStatus.Rejected);
private RuntimeCommandResult CharacterCreationResult(RuntimeCommandStatus status) =>
new(status, new RuntimeGenerationToken(_generation));
private void StopCore()
{
// MUST-FIX 1: TS-71's logout-flush half — retail's
@ -1165,6 +1564,7 @@ public sealed class LiveSessionController
_inWorld = false;
_activeSelection = null;
CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation));
CharacterCreationState.Reset(new RuntimeGenerationToken(_generation));
if (_scope is { } scope)
{
_scope = null;
@ -1306,6 +1706,7 @@ public sealed class LiveSessionController
{
StopCore();
CharacterSelectionState.Dispose();
CharacterCreationState.Dispose();
_disposed = true;
}

File diff suppressed because it is too large Load diff

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);
}
}

View file

@ -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;
}
}