Opus dual-lens review of 34e3a534+a975efd1 returned architectural PASS-with-items / retail-fidelity FAIL. Every finding fixed: - F1 (BLOCKER): deleted CharacterCreationSummaryPage's dead _suppressNextFieldEvent latch. UiField.SetText never raises OnFocusLost/OnSubmit, so the latch never had anything genuine to suppress — it stayed armed until the player's own next real commit and silently ate their typed name. - F2: byte-re-derived gmCharGenMainUI::RecvNotice_ CharGenVerificationResponse @0x004e9030's jump table — Pending is an explicit switch case landing on the SAME NameDBDown label as Corrupt/DatabaseDown, and Undef/out-of-range falls through the function's own unsigned-underflow default arm to that identical label. Retail's dispatch has NO silent branch. ApplyCreationResponse now produces a real rejection for Pending/Undef instead of a silent reset; ReconcileDialogs maps them to NameDBDown. Corrects the wrong "retail swallows Pending" claim everywhere it was repeated (plan doc, Core.Net doc comment, Runtime doc comments). - F3: skill rows now use the key/value template with CharGenState::GetSkillScore @0x005C4B50 as the value (ported via the new RetailSkillFormula.CalculateChargenScore / ChargenSkillScoreResolver, wired through a new GetSkillScore binding), not template 0/name-only; bucket headers are unconditional. Writing this fix's own regression test surfaced a second, more severe bug: CharacterCreationSummaryPage never wired _list.TemplateResolver at all, so RebuildListbox has been a silent no-op since CC5 shipped — fixed by threading templateResolver through the page's constructor, matching every sibling UiTemplateListBox owner. - F4: added the missing _errorMessageDialogContext one-outstanding guard to the 0xF643 rejection dialog, matching MakeErrorMessageDialog's own guard @0x004e8cc4 and the other four sibling dialogs' shape (registered in CloseAllDialogs, suppress- callback checked). - F5: the Summary preview camera now seeds/re-derives retail's zoomed-OUT eye (byte-decoded (0,-2.5,0.95) at gmCGSummaryPage:: InitializePage ~0x0047bd14-0x0047bd44) instead of Appearance's zoomed-in default, via a new ChargenPreviewController useZoomedOutEye flag. - F6: retired AP-225 outright — re-derived the ListenToElementMessage length gate is NUL-inclusive, so MaxNameLength=32 was always byte-correct, not merely internally consistent. - F7: amended AP-221 to cover the Summary preview's duplicate one-shot-composition binding gap (CC5 duplicated the pattern instead of closing it). - F8: byte-decoded GetRandomReal @0x00563940's fmul operand at 0x007cd650 — an 8-byte double, not a 4-byte float — is EXACTLY 1.0/32767.0, not 1/32768. Added RollShadeLocked (_random.Next(32768) * (1.0/32767.0)) and switched all six shade rolls onto it. - F9: evaluated porting retail's exact empty-name-commit no-op (NUL-inclusive length==1 skips SetName entirely) and rejected it — it would fight the F1 field-sync model by spontaneously reverting an emptied field on the next unrelated revision bump. Kept the clear, documented the tradeoff, filed AP-227. - F11: filed AP-226 documenting retail's static pcProfessions/pcGender/ pcHeritage/pcTown label tables versus acdream's DAT-sourced labels, including the non-human-heritage-renders-bare-"Heritage:" retail quirk. - F12: added exclude-current determinism (count-2 lists), Random- clears-name, repeat-identical-rejection-reshows, and RebuildListbox content tests (the last one found F3's TemplateResolver bug). - F13: threaded an optional Random through GameRuntimeDependencies -> LiveSessionController -> RuntimeCharacterCreationState, matching the existing TimeProvider injection shape, closing the Slice-K determinism hazard on a bot-reachable Randomize* command family. - F14: RandomizeCharacterLocked now assigns _heritageId unconditionally before the TryGetHeritage gate, matching retail's SetHeritageGroup @0x005C67A0 (mHeritageGroup written before the DAT lookup). Gates: Runtime 1726/0 (was 1722/0), App 5242/3 skips (was 5240/3), Headless 166/0, Core.Net 993/994 (the one failure, NakEmissionTests LossSoak, is a known pre-existing flake — passes standalone), full solution Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1001 lines
47 KiB
C#
1001 lines
47 KiB
C#
using AcDream.Core.CharGen;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.Runtime.Tests.CharGen;
|
|
|
|
/// <summary>
|
|
/// Campaign CC slice CC3: <see cref="RuntimeCharacterCreationState"/> —
|
|
/// retail's <c>CharGenState</c> mirror. Every test cites the retail function
|
|
/// it is pinning; see the class's own doc comments for full addresses.
|
|
/// </summary>
|
|
public sealed class RuntimeCharacterCreationStateTests
|
|
{
|
|
private static RuntimeCharacterCreationState CreateActive()
|
|
{
|
|
var state = new RuntimeCharacterCreationState(
|
|
RuntimeCharacterCreationStateFixture.Build(),
|
|
new Random(1234));
|
|
state.Begin(new RuntimeGenerationToken(1));
|
|
return state;
|
|
}
|
|
|
|
// ── Lifecycle ───────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Begin_StartsWithNoHeritageOrGenderSelected()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
|
|
|
|
Assert.True(snapshot.IsActive);
|
|
Assert.Equal(0u, snapshot.HeritageId);
|
|
Assert.Equal(0u, snapshot.GenderKey);
|
|
Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template);
|
|
Assert.Equal(-1, snapshot.StartArea);
|
|
Assert.False(snapshot.VerificationPending);
|
|
Assert.Equal(string.Empty, snapshot.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_ClearsEveryFieldAndDeactivates()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId));
|
|
Assert.True(state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey));
|
|
Assert.True(state.TrySetName("Someone"));
|
|
|
|
state.Reset(new RuntimeGenerationToken(2));
|
|
|
|
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
|
|
Assert.False(snapshot.IsActive);
|
|
Assert.Equal(0u, snapshot.HeritageId);
|
|
Assert.Equal(0u, snapshot.GenderKey);
|
|
Assert.Equal(string.Empty, snapshot.Name);
|
|
Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template);
|
|
// Commands are refused once inactive.
|
|
Assert.False(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId));
|
|
}
|
|
|
|
// ── Options threading (Campaign CC slice CC4) ───────────────────────
|
|
// RuntimeCharacterCreationState.InstallOptions — the App-startup seam
|
|
// ContentEffectsAudioCompositionPhase.Compose calls once portal.dat's
|
|
// ChargenTableReader.Load result is available, mirroring
|
|
// RuntimeCharacterState.InstallSpellMetadata's "install immutable DAT
|
|
// metadata after construction" pattern.
|
|
|
|
[Fact]
|
|
public void InstallOptions_BeforeBegin_ReplacesTheOptionsLaterCommandsUse()
|
|
{
|
|
var state = new RuntimeCharacterCreationState(ChargenOptions.Empty);
|
|
|
|
state.InstallOptions(RuntimeCharacterCreationStateFixture.Build());
|
|
state.Begin(new RuntimeGenerationToken(1));
|
|
|
|
Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId));
|
|
Assert.Equal(
|
|
RuntimeCharacterCreationStateFixture.AluvianId,
|
|
state.Snapshot.HeritageId);
|
|
}
|
|
|
|
[Fact]
|
|
public void InstallOptions_WhileActive_ThrowsInsteadOfRacingLiveCommands()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build()));
|
|
}
|
|
|
|
[Fact]
|
|
public void InstallOptions_NullOptions_Throws()
|
|
{
|
|
var state = new RuntimeCharacterCreationState(ChargenOptions.Empty);
|
|
|
|
Assert.Throws<ArgumentNullException>(() => state.InstallOptions(null!));
|
|
}
|
|
|
|
[Fact]
|
|
public void InstallOptions_AfterDispose_Throws()
|
|
{
|
|
var state = new RuntimeCharacterCreationState(ChargenOptions.Empty);
|
|
state.Dispose();
|
|
|
|
Assert.Throws<ObjectDisposedException>(
|
|
() => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build()));
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F4 acceptance gate: <c>CharGenState::ConstrainAllByHeritage @
|
|
/// 0x005C65CC</c> clamps a stale template index to <c>0xffffffff</c>
|
|
/// when it no longer fits the newly selected heritage's template list.
|
|
/// Without this, a high template index chosen against a
|
|
/// many-templates heritage would survive a switch to a heritage with
|
|
/// fewer templates and reach the wire via <c>BuildRequestLocked</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TrySelectHeritage_TemplateOutOfRangeForNewHeritage_ClearsToUnset()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
|
// Index 1 — valid for Aluvian's two templates (Custom=0, Preset=1).
|
|
Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex));
|
|
Assert.Equal(1u, state.Snapshot.Template);
|
|
|
|
// Impoverished has only ONE template (index 0) — index 1 no longer
|
|
// fits; gender (Male) stays valid for Impoverished too, so the
|
|
// clamp branch (not the "no gender yet" no-op branch) is the one
|
|
// under test.
|
|
Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.ImpoverishedId));
|
|
|
|
Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, state.Snapshot.Template);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F7 acceptance gate: <c>CharGenState::BalanceAttributes @
|
|
/// 0x005C3DF0</c>'s persistent cursor (ported as the instance field
|
|
/// <c>_attributeBalanceCursor</c>) advances past whichever attribute
|
|
/// last absorbed an overspend, so a SECOND overspend in a LATER call
|
|
/// does not re-drain the SAME donor the first call already emptied.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TrySetAttribute_SuccessiveOverspends_AbsorbFromDifferentAttributes()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
|
// Str=16, everyone else at the 10 floor, fully spent (66/66) — the
|
|
// fixture's only above-floor attribute at the start.
|
|
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex);
|
|
|
|
// First overspend: raising Endurance by 1 forces a 1-point
|
|
// donation. The cursor starts at Strength (the only above-floor
|
|
// attribute), so Strength donates.
|
|
Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 11));
|
|
Assert.Equal(15, state.Snapshot.Attributes.Strength);
|
|
Assert.Equal(11, state.Snapshot.Attributes.Endurance);
|
|
|
|
// Second overspend: raising Coordination by 1 forces another
|
|
// 1-point donation. If the cursor had reset to Strength, Strength
|
|
// (still above floor at 15) would donate again — it doesn't: the
|
|
// cursor advanced past Strength after the first call, so THIS
|
|
// donation comes from Endurance (the attribute the FIRST call just
|
|
// raised) instead.
|
|
Assert.True(state.TrySetAttribute(ChargenAttributeId.Coordination, 11));
|
|
Assert.Equal(15, state.Snapshot.Attributes.Strength); // untouched this time
|
|
Assert.Equal(10, state.Snapshot.Attributes.Endurance); // donated
|
|
Assert.Equal(11, state.Snapshot.Attributes.Coordination);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F7 acceptance gate, the wrap case: when the donor found in one pass
|
|
/// is the LAST entry in the fixed round-robin order (Self — see
|
|
/// <c>BalanceOrder</c>'s own doc comment: Strength, Endurance,
|
|
/// Coordination, Quickness, Focus, Self), the cursor wraps back to the
|
|
/// FIRST entry (Strength) rather than falling off the end.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TrySetAttribute_BalanceCursor_WrapsFromSelfBackToStrength()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
|
// Str=16, everyone else at the 10 floor, fully spent (66/66).
|
|
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex);
|
|
|
|
// Lock every attribute except Strength and Self: they stay in the
|
|
// budget total but are excluded from donation, isolating the wrap
|
|
// behavior to exactly the two attributes under test.
|
|
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Endurance, true));
|
|
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Coordination, true));
|
|
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Quickness, true));
|
|
Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Focus, true));
|
|
|
|
// Move all 6 spare points from Strength to Self — Strength is the
|
|
// sole eligible donor (everything else is locked or is the raise
|
|
// target), so it donates all 6. The cursor lands just past
|
|
// Strength (index 0 → Endurance).
|
|
Assert.True(state.TrySetAttribute(ChargenAttributeId.Self, 16));
|
|
Assert.Equal(10, state.Snapshot.Attributes.Strength);
|
|
Assert.Equal(16, state.Snapshot.Attributes.Self);
|
|
|
|
// Raise Strength by 1: every locked attribute is skipped, so the
|
|
// search reaches Self (the only remaining eligible donor). Self is
|
|
// the LAST entry in the round-robin order, so this absorption
|
|
// wraps the cursor back to Strength (the FIRST entry) afterward.
|
|
Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 11));
|
|
Assert.Equal(11, state.Snapshot.Attributes.Strength);
|
|
Assert.Equal(15, state.Snapshot.Attributes.Self);
|
|
|
|
// Raise the (locked) Endurance attribute by 1 — locking only
|
|
// excludes an attribute from AUTOMATIC donation, not from being set
|
|
// directly. If the cursor wrapped correctly, the donor search
|
|
// starts at Strength again and Strength (still above floor at 11)
|
|
// donates FIRST — not Self (also still above floor at 15), which is
|
|
// what an un-wrapped cursor stuck past Self would have picked
|
|
// instead.
|
|
Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 11));
|
|
Assert.Equal(10, state.Snapshot.Attributes.Strength);
|
|
Assert.Equal(15, state.Snapshot.Attributes.Self); // unchanged — proves the wrap
|
|
Assert.Equal(11, state.Snapshot.Attributes.Endurance);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F3 acceptance gate: <c>gmCharGenMainUI::DoFinish(this, arg2) @
|
|
/// 0x004E9170</c>'s credit gate is <c>arg2 != 0 &&
|
|
/// remainingAtrbCredits > 0</c> — retail does NOT force a full
|
|
/// spend. The ordinary click warns and refuses
|
|
/// (<c>arg2 = 1 @ 0x004E9579</c>, tested above); the credit-warning
|
|
/// dialog's own confirm handler re-invokes <c>DoFinish(this, 0)</c>
|
|
/// (@0x004E98BB), which skips the check entirely and sends with the
|
|
/// credits still unspent. <c>confirmedUnspentCredits: true</c> is that
|
|
/// <c>arg2 == 0</c> case.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TryBeginFinish_UnspentAttributeCreditsConfirmed_IsAccepted()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
|
state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); // 6 unspent
|
|
state.TrySetName("Adventurer");
|
|
Assert.Equal(6, state.Snapshot.RemainingAttributeCredits);
|
|
|
|
bool accepted = state.TryBeginFinish(
|
|
0, 11, out CharacterCreate.Request request, out _,
|
|
out RuntimeCharacterCreationLocalRefusal refusal,
|
|
confirmedUnspentCredits: true);
|
|
|
|
Assert.True(accepted);
|
|
Assert.False(refusal.Any);
|
|
Assert.True(state.Snapshot.VerificationPending);
|
|
// The wire request carries the credits AS UNSPENT — confirming does
|
|
// not force-spend them, it only skips the local refusal.
|
|
Assert.Equal(10u, request.Attributes.Strength);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F12 amendment (CC6b-MOUNT review fix round, filed as register
|
|
/// AP-223): with AD-101 retired, a caller could otherwise reach Finish
|
|
/// with heritage/gender still unset. Retail's own <c>DoFinish</c> never
|
|
/// checks this because <c>RandomizeCharacter</c> guarantees it can't
|
|
/// happen — this is acdream's own defensive backstop for any caller that
|
|
/// bypasses the App layer's screen-open roll.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TryBeginFinish_HeritageUnset_IsRefused()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
state.TrySetName("Adventurer");
|
|
|
|
bool accepted = state.TryBeginFinish(
|
|
0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal);
|
|
|
|
Assert.False(accepted);
|
|
Assert.True(refusal.HeritageOrGenderUnset);
|
|
Assert.False(refusal.NoName);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryBeginFinish_GenderUnset_IsRefused()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
|
state.TrySetName("Adventurer");
|
|
|
|
bool accepted = state.TryBeginFinish(
|
|
0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal);
|
|
|
|
Assert.False(accepted);
|
|
Assert.True(refusal.HeritageOrGenderUnset);
|
|
}
|
|
|
|
[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)]
|
|
// CC5 review-fix round F2 (2026-08-16): Pending/Undef used to be
|
|
// asserted as a SILENT reset producing no rejection at all
|
|
// (ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection,
|
|
// now deleted) — that assertion was wrong. Byte-decoded
|
|
// gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030:
|
|
// Pending is an explicit switch case landing on the SAME
|
|
// "ID_Character_Err_NameDBDown" label as Corrupt/DatabaseDown, and
|
|
// Undef falls through that function's own unsigned-underflow default
|
|
// arm to the identical label — retail's dispatch has no silent branch.
|
|
// Both now belong in this same "produces a rejection" theory.
|
|
[InlineData(CharGenVerificationResponse.Code.Pending)]
|
|
[InlineData(CharGenVerificationResponse.Code.Undef)]
|
|
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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
// ── Randomize (Campaign CC slice CC5, RandomizeCharacter port) ───────
|
|
|
|
/// <summary>
|
|
/// Ports <c>CharGenState::RandomizeCharacter @ 0x005c6d80</c>: rolls a
|
|
/// heritage id in [1,4] (the four HUMAN groups, never the other nine),
|
|
/// a gender in [1,2], and freezes both non-Unset. A 200-iteration sweep
|
|
/// with a fresh seeded RNG per iteration proves the heritage roll never
|
|
/// escapes the 1-4 human-only range even though the fixture ALSO
|
|
/// carries a non-human Olthoi heritage (id 12) and an out-of-range
|
|
/// "Impoverished" heritage (id 90) that a broken roll could otherwise
|
|
/// land on.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TryRandomizeCharacter_RollsOnlyTheFourHumanHeritagesAndAGender()
|
|
{
|
|
for (int seed = 0; seed < 200; seed++)
|
|
{
|
|
var state = new RuntimeCharacterCreationState(
|
|
RuntimeCharacterCreationStateFixture.Build(),
|
|
new Random(seed));
|
|
state.Begin(new RuntimeGenerationToken(1));
|
|
|
|
Assert.True(state.TryRandomizeCharacter());
|
|
|
|
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
|
|
Assert.InRange(snapshot.HeritageId, 1u, 4u);
|
|
Assert.True(snapshot.GenderKey is 1u or 2u);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TryRandomizeCharacter_RollsAppearanceClothingTemplateAndStartArea()
|
|
{
|
|
var state = new RuntimeCharacterCreationState(
|
|
RuntimeCharacterCreationStateFixture.Build(),
|
|
new Random(7));
|
|
state.Begin(new RuntimeGenerationToken(1));
|
|
|
|
Assert.True(state.TryRandomizeCharacter());
|
|
|
|
RuntimeCharacterCreationSnapshot snapshot = state.Snapshot;
|
|
// Every list in the fixture's shared gender record is non-empty, so
|
|
// a full randomize must leave nothing Unset.
|
|
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairStyle);
|
|
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.EyesStrip);
|
|
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairColor);
|
|
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.ShirtStyle);
|
|
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.TrousersStyle);
|
|
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.FootwearStyle);
|
|
Assert.NotEqual(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template);
|
|
// Template is one of the PRESET rows (never index 0/Custom) —
|
|
// RandomizeTemplate @ 0x005c6500's RandInt(count-1,...)+1 shape.
|
|
Assert.NotEqual(0u, snapshot.Template);
|
|
Assert.True(snapshot.StartArea is 0 or 1);
|
|
|
|
// F8 (2026-08-16): every shade is RollShadeLocked's 32768-point
|
|
// lattice on [0.0, 1.0] INCLUSIVE (rand() in [0, 32767] * (1/32767)),
|
|
// never System.Random.NextDouble()'s continuous [0, 1).
|
|
Assert.InRange(snapshot.Appearance.SkinShade, 0.0, 1.0);
|
|
Assert.InRange(snapshot.Appearance.HairShade, 0.0, 1.0);
|
|
Assert.InRange(snapshot.Appearance.HeadgearShade, 0.0, 1.0);
|
|
Assert.InRange(snapshot.Appearance.ShirtShade, 0.0, 1.0);
|
|
Assert.InRange(snapshot.Appearance.TrousersShade, 0.0, 1.0);
|
|
Assert.InRange(snapshot.Appearance.FootwearShade, 0.0, 1.0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F8 (2026-08-16): pins <see cref="RuntimeCharacterCreationState"/>'s
|
|
/// private <c>RollShadeLocked</c> lattice (<c>rand() in [0,32767] *
|
|
/// (1.0/32767.0)</c>) deterministically via a fixed <see cref="Random"/>
|
|
/// double that always returns its <c>maxValue - 1</c>, confirming the
|
|
/// lattice's upper endpoint is EXACTLY reachable as 1.0 — a continuous
|
|
/// <see cref="Random.NextDouble"/>-style roll ([0, 1)) could never
|
|
/// produce that value.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TryRandomizeCharacter_ShadeLattice_ReachesExactlyOneAtRandomMax()
|
|
{
|
|
var state = new RuntimeCharacterCreationState(
|
|
RuntimeCharacterCreationStateFixture.Build(),
|
|
new MaxValueRandom());
|
|
state.Begin(new RuntimeGenerationToken(1));
|
|
|
|
Assert.True(state.TryRandomizeCharacter());
|
|
|
|
RuntimeCharacterCreationAppearance a = state.Snapshot.Appearance;
|
|
Assert.Equal(1.0, a.SkinShade);
|
|
Assert.Equal(1.0, a.HairShade);
|
|
Assert.Equal(1.0, a.HeadgearShade);
|
|
Assert.Equal(1.0, a.ShirtShade);
|
|
Assert.Equal(1.0, a.TrousersShade);
|
|
Assert.Equal(1.0, a.FootwearShade);
|
|
}
|
|
|
|
/// <summary>Always returns <c>maxValue - 1</c> — the highest value
|
|
/// <see cref="Random.Next(int)"/>'s contract permits for any bound, so
|
|
/// every ordinary index pick in the randomize chain stays in-bounds
|
|
/// while the shade rolls (<c>Next(32768)</c>) land on 32767, the shade
|
|
/// lattice's top rung.</summary>
|
|
private sealed class MaxValueRandom : Random
|
|
{
|
|
public override int Next(int maxValue) => maxValue - 1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>RandomizeTemplateLocked</c>'s own Olthoi/OlthoiAcid branch
|
|
/// (mirroring <c>CharGenState::RandomizeTemplate</c>'s force-to-template-0
|
|
/// arm) is UNREACHABLE through <see cref="RuntimeCharacterCreationState.TryRandomizeCharacter"/>
|
|
/// specifically — that caller's own heritage roll is always one of the
|
|
/// four HUMAN ids (never Olthoi), matching retail's identical
|
|
/// architecture (<c>RandomizeCharacter</c>'s heritage roll and
|
|
/// <c>RandomizeTemplate</c>'s Olthoi branch are independent call paths;
|
|
/// retail never composes them either, since a random CHARACTER is never
|
|
/// Olthoi). The branch is not otherwise exposed as a standalone command
|
|
/// this slice (out of CC5's named scope), so its OBSERVABLE behavior —
|
|
/// selecting Olthoi always forces template 0 — is already covered by
|
|
/// <c>TrySelectHeritage_Olthoi...</c>/<c>ApplyTemplate</c> coverage
|
|
/// elsewhere in this file; this test only pins that a full
|
|
/// <c>TryRandomizeCharacter</c> roll never lands on Olthoi in the first
|
|
/// place, over enough iterations to catch a boundary-off-by-one.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TryRandomizeCharacter_NeverRollsANonHumanHeritage()
|
|
{
|
|
for (int seed = 0; seed < 200; seed++)
|
|
{
|
|
var state = new RuntimeCharacterCreationState(
|
|
RuntimeCharacterCreationStateFixture.Build(),
|
|
new Random(seed));
|
|
state.Begin(new RuntimeGenerationToken(1));
|
|
|
|
Assert.True(state.TryRandomizeCharacter());
|
|
|
|
Assert.NotEqual(RuntimeCharacterCreationStateFixture.OlthoiId, state.Snapshot.HeritageId);
|
|
Assert.NotEqual(RuntimeCharacterCreationStateFixture.ImpoverishedId, state.Snapshot.HeritageId);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TryRandomizeCharacter_Inactive_IsRejected()
|
|
{
|
|
var state = new RuntimeCharacterCreationState(
|
|
RuntimeCharacterCreationStateFixture.Build());
|
|
Assert.False(state.TryRandomizeCharacter());
|
|
}
|
|
|
|
[Fact]
|
|
public void TryRandomizeAppearance_RequiresHeritageAndGender()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
Assert.False(state.TryRandomizeAppearance());
|
|
|
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
|
Assert.False(state.TryRandomizeAppearance());
|
|
|
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
|
Assert.True(state.TryRandomizeAppearance());
|
|
Assert.NotEqual(
|
|
RuntimeCharacterCreationAppearance.Unset,
|
|
state.Snapshot.Appearance.HairStyle);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryRandomizeClothing_RollsAllFourGearSlots()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
|
|
|
Assert.True(state.TryRandomizeClothing());
|
|
|
|
RuntimeCharacterCreationAppearance a = state.Snapshot.Appearance;
|
|
// Headgear excludes-current with the +1 Unset-ring reindex — the
|
|
// fixture's single headgear style means the roll can only land on
|
|
// style 0 or Unset; either is a valid outcome of the ring, so this
|
|
// just confirms the call actually touched the field (shirt/trousers/
|
|
// footwear below have no Unset ring and must land on their one
|
|
// style).
|
|
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.ShirtStyle);
|
|
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.TrousersStyle);
|
|
Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.FootwearStyle);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>RandInt(int,int) @ 0x00684420</c>'s own decompiled shape: re-roll
|
|
/// until the result differs from the excluded value, UNLESS there is
|
|
/// only one possible outcome (<paramref name="count"/> <= 1), which
|
|
/// returns 0 immediately without ever comparing against exclude (the
|
|
/// guard that keeps the loop from spinning forever). This drives
|
|
/// <see cref="RuntimeCharacterCreationState.TryRandomizeClothing"/>
|
|
/// enough times to statistically prove the shirt slot (the fixture's
|
|
/// single-style list) never gets stuck — count<=1 must short-circuit.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TryRandomizeClothing_SingleOptionList_NeverHangs()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
|
|
|
for (int i = 0; i < 50; i++)
|
|
Assert.True(state.TryRandomizeClothing());
|
|
|
|
Assert.Equal(0u, state.Snapshot.Appearance.ShirtStyle);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F12(a) (CC5 review-fix round, 2026-08-16): pins
|
|
/// <c>RandomizeIndexExcludingLocked</c>'s exclude-current re-roll
|
|
/// property DETERMINISTICALLY. The fixture's <c>HairStyles</c>/
|
|
/// <c>HairColors</c>/<c>EyeColors</c> lists are all COUNT 2
|
|
/// (<see cref="RuntimeCharacterCreationStateFixture.Build"/>'s shared
|
|
/// gender record), so excluding the current index leaves exactly ONE
|
|
/// possible outcome — a second <see cref="RuntimeCharacterCreationState.TryRandomizeAppearance"/>
|
|
/// call must flip every one of these three fields to the OTHER index,
|
|
/// regardless of which <see cref="Random"/> seed drives the roll. Two
|
|
/// different seeds both proving the flip is what makes this a property
|
|
/// pin rather than a single-seed coincidence.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(999)]
|
|
public void TryRandomizeAppearance_ExcludeCurrent_OnCountTwoLists_AlwaysFlips(int seed)
|
|
{
|
|
var state = new RuntimeCharacterCreationState(
|
|
RuntimeCharacterCreationStateFixture.Build(),
|
|
new Random(seed));
|
|
state.Begin(new RuntimeGenerationToken(1));
|
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
|
|
|
Assert.True(state.TryRandomizeAppearance());
|
|
RuntimeCharacterCreationAppearance first = state.Snapshot.Appearance;
|
|
|
|
Assert.True(state.TryRandomizeAppearance());
|
|
RuntimeCharacterCreationAppearance second = state.Snapshot.Appearance;
|
|
|
|
Assert.True(first.HairStyle is 0u or 1u);
|
|
Assert.True(first.HairColor is 0u or 1u);
|
|
Assert.True(first.EyeColor is 0u or 1u);
|
|
Assert.NotEqual(first.HairStyle, second.HairStyle);
|
|
Assert.NotEqual(first.HairColor, second.HairColor);
|
|
Assert.NotEqual(first.EyeColor, second.EyeColor);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F12(b) (CC5 review-fix round, 2026-08-16): pins
|
|
/// <c>RandomizeCharacterLocked</c>'s own <c>ClearSessionState</c>
|
|
/// prologue (retail's own <c>Reset()</c> call) at the RUNTIME layer —
|
|
/// the Summary page's Random button must clear a committed name, which
|
|
/// is exactly the state transition <c>CharacterCreationSummaryPage</c>'s
|
|
/// F1 fix (the field-sync <c>_suppressNextFieldEvent</c> removal) has to
|
|
/// coexist with correctly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TryRandomizeCharacter_ClearsAPreviouslyCommittedName()
|
|
{
|
|
RuntimeCharacterCreationState state = CreateActive();
|
|
state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId);
|
|
state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey);
|
|
Assert.True(state.TrySetName("Bob"));
|
|
Assert.Equal("Bob", state.Snapshot.Name);
|
|
|
|
Assert.True(state.TryRandomizeCharacter());
|
|
|
|
Assert.Equal(string.Empty, state.Snapshot.Name);
|
|
}
|
|
}
|