feat(chargen): Campaign CC slice CC5 — Summary page, Finish flow, RandomizeCharacter port
Fills TS-82's Summary placeholder with a faithful port of gmCGSummaryPage (name field with NameInputFilter + the retail commit-on-focus-lost/submit dispatch + the >32-char ID_CharGen_NameTooLong reject-and-revert path, the REAL three-row-template listbox confirmed against the installed EoR dat before writing any page code, and Summary's own independent gmCG3DView preview instance wired through a second ChargenPreviewController pair mirroring the Appearance page's exact composition shape). Ports CharGenState::RandomizeCharacter and its six sub-primitives into RuntimeCharacterCreationState — not approximated: the RandInt/RollDice semantics are independently confirmed from both the decompiled RNG bodies and the CharGenStateVtbl union struct in acclient.h. Three consumers: the chargen screen's open-roll (retiring AP-214's honest-blank deviation and reproducing the Appearance page's gender-flip-on-init quirk), the Summary page's Random button (behind the retail randomize-warning confirm), and the Appearance page's Random button (narrowing AP-212 to just Heritage/Profession/Town's still-approximated rolls and Skills' still-unported RandomizeSkills). Wires the Finish button (previously ghosted) with retail's NoName/ CreditWarning dialog pair, adds the F12 amendment's HeritageOrGenderUnset local refusal to TryBeginFinish (register AP-223) as a defensive backstop now that the screen-open roll normally makes it unreachable, and wires the four ID_Character_Err_* rejection dialogs for the 0xF643 response codes CC3 already parsed but nothing displayed. Register: TS-82 retired, AP-214 retired, AP-212 narrowed, AP-223/224/225 filed (heritage/gender Finish refusal, Summary's two-bucket skill-list narrowing, the 32-vs-33 name-length threshold reconciliation). Runtime 1722/0 (was 1713), App 5240/3 skips (was 5223/3), Headless 166/0 unchanged, full solution Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6114b2dda2
commit
34e3a534be
22 changed files with 2217 additions and 79 deletions
|
|
@ -460,6 +460,30 @@ public interface IRuntimeCharacterCreationCommands
|
|||
|
||||
RuntimeCommandResult AcknowledgeRejection(
|
||||
RuntimeGenerationToken expectedGeneration);
|
||||
|
||||
// ── Campaign CC slice CC5: RandomizeCharacter port ──────────────────
|
||||
|
||||
/// <summary>Retail's ctor-time <c>CharGenState::RandomizeCharacter</c>
|
||||
/// roll (mirrored at the App layer's screen-open edge) and the Summary
|
||||
/// page's Random button (<c>gmCharGenMainUI::DoRandom</c> case 5, behind
|
||||
/// the caller's own <c>ID_CharGen_RandomizeWarning</c> confirmation) —
|
||||
/// see <see cref="Session.RuntimeCharacterCreationState.TryRandomizeCharacter"/>.</summary>
|
||||
RuntimeCommandResult RandomizeCharacter(
|
||||
RuntimeGenerationToken expectedGeneration);
|
||||
|
||||
/// <summary>The Appearance page's Random button while its Face sub-tab
|
||||
/// is showing (<c>gmCharGenMainUI::DoRandom</c> case 3's <c>else</c>
|
||||
/// arm) — see
|
||||
/// <see cref="Session.RuntimeCharacterCreationState.TryRandomizeAppearance"/>.</summary>
|
||||
RuntimeCommandResult RandomizeAppearance(
|
||||
RuntimeGenerationToken expectedGeneration);
|
||||
|
||||
/// <summary>The Appearance page's Random button while its Clothes
|
||||
/// sub-tab is showing (<c>gmCharGenMainUI::DoRandom</c> case 3's
|
||||
/// <c>RandomizeClothing(state, 1)</c> arm) — see
|
||||
/// <see cref="Session.RuntimeCharacterCreationState.TryRandomizeClothing"/>.</summary>
|
||||
RuntimeCommandResult RandomizeClothing(
|
||||
RuntimeGenerationToken expectedGeneration);
|
||||
}
|
||||
|
||||
public interface IGameRuntimeCommands
|
||||
|
|
|
|||
|
|
@ -1657,6 +1657,45 @@ public sealed class LiveSessionController
|
|||
}
|
||||
}
|
||||
|
||||
public RuntimeCommandResult RandomizeCharacter(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return CharacterCreationResult(gate);
|
||||
return CharacterCreationResult(
|
||||
CharacterCreationState.TryRandomizeCharacter());
|
||||
}
|
||||
}
|
||||
|
||||
public RuntimeCommandResult RandomizeAppearance(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return CharacterCreationResult(gate);
|
||||
return CharacterCreationResult(
|
||||
CharacterCreationState.TryRandomizeAppearance());
|
||||
}
|
||||
}
|
||||
|
||||
public RuntimeCommandResult RandomizeClothing(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return CharacterCreationResult(gate);
|
||||
return CharacterCreationResult(
|
||||
CharacterCreationState.TryRandomizeClothing());
|
||||
}
|
||||
}
|
||||
|
||||
private RuntimeCommandStatus ValidateCharacterCreationCommand(
|
||||
RuntimeGenerationToken expectedGeneration)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -124,16 +124,32 @@ public readonly record struct RuntimeCharacterCreationAppearance(
|
|||
/// evaluates before a Finish click is allowed to reach the wire, plus the
|
||||
/// campaign's client-side slot cap (risk item 3 — retail's UI, not
|
||||
/// <c>DoFinish</c> itself, refuses when the roster is already full versus
|
||||
/// <c>CharacterList.slotCount</c>; ACE never checks this server-side).
|
||||
/// <c>CharacterList.slotCount</c>; ACE never checks this server-side), plus
|
||||
/// (Campaign CC slice CC5, the CC6b-MOUNT review's F12 amendment)
|
||||
/// <see cref="HeritageOrGenderUnset"/> — an acdream-ONLY addition with no
|
||||
/// direct <c>DoFinish</c> citation (register AP-223): retail's own
|
||||
/// <c>DoFinish</c> never checks heritage/gender because it can't reach a
|
||||
/// state where either is unset — <c>gmCharGenMainUI</c>'s constructor calls
|
||||
/// <c>CharGenState::RandomizeCharacter</c> before any page (including
|
||||
/// Summary/Finish) exists, so a real heritage+gender selection is an
|
||||
/// ARCHITECTURAL guarantee by the time Finish is clickable at all. Once
|
||||
/// <see cref="RuntimeCharacterCreationState.TryRandomizeCharacter"/> is
|
||||
/// wired at the App layer's screen-open edge (mirroring that same ctor
|
||||
/// call), this refusal is normally unreachable through the ordinary UI —
|
||||
/// it exists as a defensive backstop for any caller (a headless bot, a
|
||||
/// future direct command) that can reach <c>Finish</c> without that
|
||||
/// open-edge roll ever having run.
|
||||
/// </summary>
|
||||
public readonly record struct RuntimeCharacterCreationLocalRefusal(
|
||||
bool NoName,
|
||||
bool AttributeCreditsUnspent,
|
||||
bool AlreadyPending,
|
||||
bool RosterFull)
|
||||
bool RosterFull,
|
||||
bool HeritageOrGenderUnset = false)
|
||||
{
|
||||
public bool Any =>
|
||||
NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull;
|
||||
NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull
|
||||
|| HeritageOrGenderUnset;
|
||||
|
||||
public static RuntimeCharacterCreationLocalRefusal None { get; } = default;
|
||||
}
|
||||
|
|
@ -487,27 +503,38 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
if (_disposed || !_active)
|
||||
return false;
|
||||
|
||||
_heritageId = heritageId;
|
||||
_totalAttributeCredits = heritage.AttributeCredits;
|
||||
_totalSkillCredits = heritage.SkillCredits;
|
||||
_remainingSkillCredits = checked((int)heritage.SkillCredits);
|
||||
|
||||
ApplyTemplateLocked(heritage);
|
||||
RandomizeStartAreaLocked(heritage);
|
||||
ConstrainAppearanceByGenderLocked();
|
||||
RecomputeRemainingAttributeCreditsLocked();
|
||||
// ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive
|
||||
// re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through
|
||||
// our own gated skill commands, but kept for parity with a
|
||||
// heritage switch that leaves stale skill picks over-budget.
|
||||
if (RecomputeSkillSpendLocked(heritage) < 0)
|
||||
ResetSkillLevelsLocked(heritage);
|
||||
SetHeritageGroupLocked(heritageId, heritage);
|
||||
_revision++;
|
||||
}
|
||||
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The locked body of <c>CharGenState::SetHeritageGroup @ 0x005C67A0</c> —
|
||||
/// factored out of <see cref="TrySelectHeritage"/> (Campaign CC slice
|
||||
/// CC5) so <see cref="RandomizeCharacterLocked"/>'s own heritage roll
|
||||
/// can reuse it without re-entering <see cref="_gate"/>.
|
||||
/// </summary>
|
||||
private void SetHeritageGroupLocked(uint heritageId, ChargenHeritageOptions heritage)
|
||||
{
|
||||
_heritageId = heritageId;
|
||||
_totalAttributeCredits = heritage.AttributeCredits;
|
||||
_totalSkillCredits = heritage.SkillCredits;
|
||||
_remainingSkillCredits = checked((int)heritage.SkillCredits);
|
||||
|
||||
ApplyTemplateLocked(heritage);
|
||||
RandomizeStartAreaLocked(heritage);
|
||||
ConstrainAppearanceByGenderLocked();
|
||||
RecomputeRemainingAttributeCreditsLocked();
|
||||
// ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive
|
||||
// re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through
|
||||
// our own gated skill commands, but kept for parity with a
|
||||
// heritage switch that leaves stale skill picks over-budget.
|
||||
if (RecomputeSkillSpendLocked(heritage) < 0)
|
||||
ResetSkillLevelsLocked(heritage);
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::SetGender @ 0x005C64A0</c>: clamps
|
||||
/// every appearance index into the new gender's option-list bounds. The
|
||||
/// four <c>SetXStyle(this, this->XStyle)</c> re-invocations retail
|
||||
|
|
@ -527,14 +554,23 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
return false;
|
||||
}
|
||||
|
||||
_genderKey = genderKey;
|
||||
ConstrainAppearanceByGenderLocked();
|
||||
SetGenderLocked(genderKey);
|
||||
_revision++;
|
||||
}
|
||||
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The locked body of <c>CharGenState::SetGender @
|
||||
/// 0x005C64A0</c> — factored out of <see cref="TrySelectGender"/>
|
||||
/// (Campaign CC slice CC5) so <see cref="RandomizeCharacterLocked"/>'s
|
||||
/// own gender roll can reuse it.</summary>
|
||||
private void SetGenderLocked(uint genderKey)
|
||||
{
|
||||
_genderKey = genderKey;
|
||||
ConstrainAppearanceByGenderLocked();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ports the seven Profession-page buttons, each of which calls
|
||||
/// <c>CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60</c> — template
|
||||
|
|
@ -1164,6 +1200,362 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
: value;
|
||||
}
|
||||
|
||||
// ── Randomize (Campaign CC slice CC5) ───────────────────────────────
|
||||
// Ports CharGenState::RandomizeCharacter @ 0x005c6d80 and its six named
|
||||
// sub-primitives (RandomizeHeritageGroup/RandomizeAppearance/
|
||||
// RandomizeHeadgear/RandomizeShirt/RandomizeTrousers/RandomizeFootwear/
|
||||
// RandomizeTemplate/RandomizeStartArea — register AP-212's own citation
|
||||
// list). The RNG primitive both retail's own RandInt(int) and
|
||||
// RandInt(int,int) overloads reduce to is decompiled verbatim at
|
||||
// 0x00684400/0x00684420: RandInt(count) is a uniform pick in [0,count);
|
||||
// RandInt(count,exclude) loops the same roll until it differs from
|
||||
// exclude (a no-op when count<=1, matching retail's own early-return —
|
||||
// ported as RandomizeIndexExcludingLocked below). CharGenState's own
|
||||
// vtable (acclient.h's $A0F97670E669114D706A75D718F5A366 union —
|
||||
// "GetRandomInt(this,int,int)"/"Grandom Int(this,int)") confirms
|
||||
// RandomizeAppearance's vtable-indirected calls are this SAME RandInt
|
||||
// pair, not a distinct algorithm.
|
||||
|
||||
/// <summary>Ports <c>Random::RollDice(int,int) @ 0x0042c5c0</c>: returns
|
||||
/// <paramref name="min"/> unchanged when the two bounds are equal
|
||||
/// (matching retail's own <c>arg2==arg1</c> fast path), otherwise a
|
||||
/// uniform pick over the INCLUSIVE range
|
||||
/// <c>[min(min,max), max(min,max)]</c>.</summary>
|
||||
private int RollDiceLocked(int min, int max)
|
||||
{
|
||||
if (min == max)
|
||||
return min;
|
||||
int lo = Math.Min(min, max);
|
||||
int hi = Math.Max(min, max);
|
||||
return lo + _random.Next(hi - lo + 1);
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>RandInt(int,int) @ 0x00684420</c> exactly: for
|
||||
/// <paramref name="count"/> <= 1 there is only one possible outcome,
|
||||
/// so retail returns 0 immediately WITHOUT ever comparing against
|
||||
/// <paramref name="exclude"/> (the guard that keeps the do/while loop
|
||||
/// below from spinning forever); otherwise re-rolls uniformly in
|
||||
/// <c>[0,count)</c> until the result differs from
|
||||
/// <paramref name="exclude"/> — an <paramref name="exclude"/> outside
|
||||
/// <c>[0,count)</c> (e.g. <see cref="RuntimeCharacterCreationAppearance.Unset"/>
|
||||
/// on a freshly-<see cref="ClearSessionState"/>'d field) can never match,
|
||||
/// so the loop always exits on its first iteration and this degrades to
|
||||
/// a plain uniform pick.</summary>
|
||||
private uint RandomizeIndexExcludingLocked(int count, uint exclude)
|
||||
{
|
||||
if (count <= 1)
|
||||
return 0u;
|
||||
uint result;
|
||||
do
|
||||
{
|
||||
result = (uint)_random.Next(count);
|
||||
} while (result == exclude);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::RandomizeAppearance(this, 0) @
|
||||
/// 0x005c4f10</c> — every real call site in the retail binary passes
|
||||
/// <c>arg2 == 0</c> (an exhaustive grep of every <c>RandomizeAppearance</c>
|
||||
/// call found none with <c>arg2 != 0</c>), so the <c>arg2 != 0</c> arm
|
||||
/// (a hard-coded vtable-index-7 hair-style pick) is decompiled but dead
|
||||
/// code and is not ported. Each field is only rolled when its list is
|
||||
/// non-empty (retail's own per-field <c>if (count != 0)</c> guards);
|
||||
/// <c>skinShade</c>/<c>hairShade</c> are <c>vtable->GetRandomReal()</c>
|
||||
/// — the SAME <c>rand()*(1/32768)</c> uniform-[0,1) shade roll every
|
||||
/// other Randomize* function below uses explicitly inline.</summary>
|
||||
private void RandomizeAppearanceLocked()
|
||||
{
|
||||
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
|
||||
return;
|
||||
|
||||
RuntimeCharacterCreationAppearance a = _appearance;
|
||||
if (gender.EyeStrips.Count > 0)
|
||||
a = a with { EyesStrip = RandomizeIndexExcludingLocked(gender.EyeStrips.Count, a.EyesStrip) };
|
||||
if (gender.NoseStrips.Count > 0)
|
||||
a = a with { NoseStrip = RandomizeIndexExcludingLocked(gender.NoseStrips.Count, a.NoseStrip) };
|
||||
if (gender.MouthStrips.Count > 0)
|
||||
a = a with { MouthStrip = RandomizeIndexExcludingLocked(gender.MouthStrips.Count, a.MouthStrip) };
|
||||
a = a with { SkinShade = _random.NextDouble(), HairShade = _random.NextDouble() };
|
||||
if (gender.HairColors.Count > 0)
|
||||
a = a with { HairColor = RandomizeIndexExcludingLocked(gender.HairColors.Count, a.HairColor) };
|
||||
if (gender.EyeColors.Count > 0)
|
||||
a = a with { EyeColor = RandomizeIndexExcludingLocked(gender.EyeColors.Count, a.EyeColor) };
|
||||
if (gender.HairStyles.Count > 0)
|
||||
a = a with { HairStyle = RandomizeIndexExcludingLocked(gender.HairStyles.Count, a.HairStyle) };
|
||||
_appearance = a;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>CharGenState::RandomizeHeadgear(this, arg2) @ 0x005c5e10</c>.
|
||||
/// Headgear alone gets the <c>count+1</c>-position Unset ring
|
||||
/// (<c>CharacterCreationAppearancePage.CycleIndex</c>'s own already-cited
|
||||
/// sibling finding): <paramref name="excludeCurrent"/> false (every
|
||||
/// <c>RandomizeCharacter</c> call site, <c>arg2==0</c>) rolls a plain
|
||||
/// uniform <c>RandInt(count+1)</c>; true (<c>RandomizeClothing(state,1)</c>'s
|
||||
/// own Appearance-page Random-button case) excludes the current style
|
||||
/// via <c>RandInt(count+1, headgearStyle+1)</c> — the <c>+1</c>
|
||||
/// reindexes Unset (retail's signed <c>-1</c>) to <c>0</c> so the
|
||||
/// exclude comparison stays in <c>[0,count]</c>. Color uses the SAME
|
||||
/// <see cref="AppearanceSlotCountLocked"/> shared-<c>ClothingColors</c>
|
||||
/// approximation (register AP-208) every other clothing slot's color
|
||||
/// count already uses, not retail's own per-heritage
|
||||
/// <c>numHeadgearColors</c> field acdream's model does not carry.
|
||||
/// </summary>
|
||||
private void RandomizeHeadgearLocked(bool excludeCurrent)
|
||||
{
|
||||
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
|
||||
return;
|
||||
|
||||
int styleCount = gender.Headgears.Count;
|
||||
if (styleCount > 0)
|
||||
{
|
||||
uint current = _appearance.HeadgearStyle;
|
||||
int currentPlusOne = current == RuntimeCharacterCreationAppearance.Unset
|
||||
? 0
|
||||
: (int)current + 1;
|
||||
int rolled = excludeCurrent
|
||||
? (int)RandomizeIndexExcludingLocked(styleCount + 1, (uint)currentPlusOne)
|
||||
: _random.Next(styleCount + 1);
|
||||
uint newStyle = rolled == 0 ? RuntimeCharacterCreationAppearance.Unset : (uint)(rolled - 1);
|
||||
_appearance = _appearance with { HeadgearStyle = newStyle };
|
||||
}
|
||||
|
||||
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.HeadgearColor, gender);
|
||||
if (colorCount > 0)
|
||||
{
|
||||
_appearance = _appearance with
|
||||
{
|
||||
HeadgearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.HeadgearColor),
|
||||
};
|
||||
}
|
||||
_appearance = _appearance with { HeadgearShade = _random.NextDouble() };
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::RandomizeShirt @ 0x005c5ef0</c> —
|
||||
/// unlike headgear, retail's shirt/trousers/footwear randomizers take no
|
||||
/// <c>arg2</c> and always exclude the current style/color.</summary>
|
||||
private void RandomizeShirtLocked()
|
||||
{
|
||||
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
|
||||
return;
|
||||
int styleCount = gender.Shirts.Count;
|
||||
if (styleCount > 0)
|
||||
{
|
||||
_appearance = _appearance with
|
||||
{
|
||||
ShirtStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.ShirtStyle),
|
||||
};
|
||||
}
|
||||
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.ShirtColor, gender);
|
||||
if (colorCount > 0)
|
||||
{
|
||||
_appearance = _appearance with
|
||||
{
|
||||
ShirtColor = RandomizeIndexExcludingLocked(colorCount, _appearance.ShirtColor),
|
||||
};
|
||||
}
|
||||
_appearance = _appearance with { ShirtShade = _random.NextDouble() };
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::RandomizeTrousers @ 0x005c5fb0</c>.</summary>
|
||||
private void RandomizeTrousersLocked()
|
||||
{
|
||||
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
|
||||
return;
|
||||
int styleCount = gender.Pants.Count;
|
||||
if (styleCount > 0)
|
||||
{
|
||||
_appearance = _appearance with
|
||||
{
|
||||
TrousersStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.TrousersStyle),
|
||||
};
|
||||
}
|
||||
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.TrousersColor, gender);
|
||||
if (colorCount > 0)
|
||||
{
|
||||
_appearance = _appearance with
|
||||
{
|
||||
TrousersColor = RandomizeIndexExcludingLocked(colorCount, _appearance.TrousersColor),
|
||||
};
|
||||
}
|
||||
_appearance = _appearance with { TrousersShade = _random.NextDouble() };
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::RandomizeFootwear @ 0x005c6070</c>.</summary>
|
||||
private void RandomizeFootwearLocked()
|
||||
{
|
||||
if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender))
|
||||
return;
|
||||
int styleCount = gender.Footwear.Count;
|
||||
if (styleCount > 0)
|
||||
{
|
||||
_appearance = _appearance with
|
||||
{
|
||||
FootwearStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.FootwearStyle),
|
||||
};
|
||||
}
|
||||
int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.FootwearColor, gender);
|
||||
if (colorCount > 0)
|
||||
{
|
||||
_appearance = _appearance with
|
||||
{
|
||||
FootwearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.FootwearColor),
|
||||
};
|
||||
}
|
||||
_appearance = _appearance with { FootwearShade = _random.NextDouble() };
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::RandomizeClothing(this, arg2) @
|
||||
/// 0x005c6770</c>: headgear (with <paramref name="excludeCurrent"/>
|
||||
/// forwarded), then shirt/trousers/footwear (always exclude-current,
|
||||
/// they take no <c>arg2</c>).</summary>
|
||||
private void RandomizeClothingLocked(bool excludeCurrent)
|
||||
{
|
||||
RandomizeHeadgearLocked(excludeCurrent);
|
||||
RandomizeShirtLocked();
|
||||
RandomizeTrousersLocked();
|
||||
RandomizeFootwearLocked();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>CharGenState::RandomizeTemplate @ 0x005c6500</c>. The two
|
||||
/// Olthoi heritages force template 0 unconditionally
|
||||
/// (<c>this->template_ = 1; ApplyTemplate(this);</c> in retail — but
|
||||
/// <c>ApplyTemplate</c>'s own Olthoi branch immediately re-forces
|
||||
/// <c>template_ = 0</c> regardless, so the intermediate write to 1 is
|
||||
/// observably a no-op; this port skips straight to
|
||||
/// <see cref="ApplyTemplateLocked"/>, which already carries that force).
|
||||
/// Otherwise, when the heritage has more than one template (Custom plus
|
||||
/// at least one preset), picks <c>RandInt(count-1, template_-1) + 1</c> —
|
||||
/// a uniform pick over indices <c>[1, count-1]</c> (retail's own
|
||||
/// preset templates, NEVER index 0/Custom) excluding the CURRENT
|
||||
/// template (offset by <c>-1</c> to align with the shifted range; an
|
||||
/// Unset/<c>0xFFFFFFFF</c> current value wraps far outside <c>[0,count-1)</c>
|
||||
/// and can never match, so a fresh roll off a Reset state is
|
||||
/// unconstrained).
|
||||
/// </summary>
|
||||
private void RandomizeTemplateLocked()
|
||||
{
|
||||
if (_heritageId == 0 || _genderKey == 0)
|
||||
return;
|
||||
if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage))
|
||||
return;
|
||||
|
||||
if (_heritageId == (uint)ChargenHeritageGroup.Olthoi
|
||||
|| _heritageId == (uint)ChargenHeritageGroup.OlthoiAcid)
|
||||
{
|
||||
ApplyTemplateLocked(heritage);
|
||||
return;
|
||||
}
|
||||
|
||||
int count = heritage.Templates.Count;
|
||||
if (count <= 1)
|
||||
return;
|
||||
|
||||
uint excludeShifted = unchecked(_template - 1u);
|
||||
uint picked = RandomizeIndexExcludingLocked(count - 1, excludeShifted) + 1u;
|
||||
_template = picked;
|
||||
ApplyTemplateLocked(heritage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>CharGenState::RandomizeCharacter(this, hasToD) @
|
||||
/// 0x005c6d80</c>: <see cref="ClearSessionState"/> (retail's own
|
||||
/// <c>Reset()</c>), roll a heritage
|
||||
/// (<c>SetHeritageGroup(RollDice(1, hasToD?4:3))</c> — heritage ids
|
||||
/// 1..3/4 are the four HUMAN heritage groups (Aluvian/Gharu'ndim/Sho/
|
||||
/// Viamontian, <see cref="ChargenHeritageGroup"/>'s own numbering); a
|
||||
/// "random" character in retail is deliberately always human, never one
|
||||
/// of the other nine heritages — a genuine retail quirk, not a porting
|
||||
/// shortcut), roll a gender (<c>SetGender(RollDice(1,2))</c>), then
|
||||
/// appearance/headgear/shirt/trousers/footwear/template/start-area, in
|
||||
/// that exact order. acdream has no account/DLC-ownership signal (AD-102's
|
||||
/// already-established convention: every installed heritage/town ships
|
||||
/// unconditionally selectable, matching what a ToD-owning account would
|
||||
/// see) — this reuses that SAME convention rather than inventing a
|
||||
/// second one, so the heritage roll always uses the 4-heritage bound.
|
||||
/// <c>SetHeritageGroupLocked</c> already rolls a starting area once as
|
||||
/// part of its own <c>RandomizeStartAreaLocked</c> call (mirroring
|
||||
/// retail's own <c>SetHeritageGroup</c>); the explicit
|
||||
/// <c>RandomizeStartAreaLocked</c> call at the end re-rolls it a SECOND
|
||||
/// time, matching retail's own redundant double-roll exactly (harmless —
|
||||
/// each roll is independently uniform over the same list).
|
||||
/// </summary>
|
||||
private void RandomizeCharacterLocked()
|
||||
{
|
||||
ClearSessionState();
|
||||
|
||||
uint heritageId = (uint)RollDiceLocked(1, 4);
|
||||
if (_options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
|
||||
SetHeritageGroupLocked(heritageId, heritage);
|
||||
|
||||
uint genderKey = (uint)RollDiceLocked(1, 2);
|
||||
SetGenderLocked(genderKey);
|
||||
|
||||
RandomizeAppearanceLocked();
|
||||
RandomizeHeadgearLocked(excludeCurrent: false);
|
||||
RandomizeShirtLocked();
|
||||
RandomizeTrousersLocked();
|
||||
RandomizeFootwearLocked();
|
||||
RandomizeTemplateLocked();
|
||||
if (_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? finalHeritage))
|
||||
RandomizeStartAreaLocked(finalHeritage);
|
||||
}
|
||||
|
||||
/// <summary>Public command surface for <see cref="RandomizeCharacterLocked"/> —
|
||||
/// consumed by the App layer's screen-open edge (mirrors
|
||||
/// <c>gmCharGenMainUI</c>'s ctor-time roll, retiring AP-214's
|
||||
/// honest-blank deviation) and the Summary page's Random button
|
||||
/// (<c>gmCharGenMainUI::DoRandom</c> case 5, behind the
|
||||
/// <c>ID_CharGen_RandomizeWarning</c> confirmation the App layer
|
||||
/// owns).</summary>
|
||||
internal bool TryRandomizeCharacter()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || !_active)
|
||||
return false;
|
||||
RandomizeCharacterLocked();
|
||||
_revision++;
|
||||
}
|
||||
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Public command surface for <see cref="RandomizeAppearanceLocked"/> —
|
||||
/// the Appearance page's Random button when its Face sub-tab is showing
|
||||
/// (<c>gmCharGenMainUI::DoRandom</c> case 3's <c>else</c> arm).</summary>
|
||||
internal bool TryRandomizeAppearance()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || !_active || _heritageId == 0 || _genderKey == 0)
|
||||
return false;
|
||||
RandomizeAppearanceLocked();
|
||||
_revision++;
|
||||
}
|
||||
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Public command surface for <see cref="RandomizeClothingLocked"/>
|
||||
/// with <c>excludeCurrent: true</c> — the Appearance page's Random
|
||||
/// button when its Clothes sub-tab is showing
|
||||
/// (<c>gmCharGenMainUI::DoRandom</c> case 3's
|
||||
/// <c>RandomizeClothing(state, 1)</c> arm).</summary>
|
||||
internal bool TryRandomizeClothing()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || !_active || _heritageId == 0 || _genderKey == 0)
|
||||
return false;
|
||||
RandomizeClothingLocked(excludeCurrent: true);
|
||||
_revision++;
|
||||
}
|
||||
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Town / name / slot ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Ports <c>CharGenState::SetStartArea @ 0x005C4000</c> — bounds
|
||||
|
|
@ -1290,16 +1682,19 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
refusal = trimmed.Length == 0
|
||||
? new RuntimeCharacterCreationLocalRefusal(
|
||||
NoName: true, false, false, false)
|
||||
: !confirmedUnspentCredits && _remainingAttributeCredits > 0
|
||||
: _heritageId == 0 || _genderKey == 0
|
||||
? new RuntimeCharacterCreationLocalRefusal(
|
||||
false, AttributeCreditsUnspent: true, false, false)
|
||||
: _verificationPending
|
||||
false, false, false, false, HeritageOrGenderUnset: true)
|
||||
: !confirmedUnspentCredits && _remainingAttributeCredits > 0
|
||||
? new RuntimeCharacterCreationLocalRefusal(
|
||||
false, false, AlreadyPending: true, false)
|
||||
: slotCount > 0 && rosterCount >= slotCount
|
||||
false, AttributeCreditsUnspent: true, false, false)
|
||||
: _verificationPending
|
||||
? new RuntimeCharacterCreationLocalRefusal(
|
||||
false, false, false, RosterFull: true)
|
||||
: RuntimeCharacterCreationLocalRefusal.None;
|
||||
false, false, AlreadyPending: true, false)
|
||||
: slotCount > 0 && rosterCount >= slotCount
|
||||
? new RuntimeCharacterCreationLocalRefusal(
|
||||
false, false, false, RosterFull: true)
|
||||
: RuntimeCharacterCreationLocalRefusal.None;
|
||||
|
||||
_lastLocalRefusal = refusal;
|
||||
accepted = !refusal.Any;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue