fix(chargen): Campaign CC CC5 review fix round — F1-F14
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>
This commit is contained in:
parent
a975efd1d5
commit
0c8e1e7df1
14 changed files with 720 additions and 141 deletions
|
|
@ -1055,6 +1055,64 @@ public sealed class CharacterCreationUiControllerTests
|
|||
Assert.False(field.CharacterFilter!('$'));
|
||||
}
|
||||
|
||||
// ── CC5 review fix round F12(d): RebuildListbox content ─────────────
|
||||
|
||||
/// <summary>
|
||||
/// F12(d) (2026-08-16): pins the F3 fix directly against the listbox's
|
||||
/// actual built rows (not just the header/pair TEMPLATE ids the fixture
|
||||
/// wires) — a skill row uses the key/value pair template (KEY = skill
|
||||
/// name, VALUE = <see cref="FakeRuntime.GetSkillScore"/>'s deterministic
|
||||
/// stand-in), and BOTH bucket headers appear even though only ONE bucket
|
||||
/// (Trained) has a matching skill — the case that actually exercises
|
||||
/// "unconditional" (a header shown only because some row happened to
|
||||
/// match would have passed even with the pre-fix lazy-header bug).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Summary_RebuildListbox_SkillRows_UseKeyValueTemplate_WithUnconditionalHeaders()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
environment.Controller.Open();
|
||||
SelectAluvianMale(environment);
|
||||
// SkillTrainOnly (1, "Axe") is Trained; SkillSpecializable (2,
|
||||
// "Bow") is left Inactive entirely — no skill occupies the
|
||||
// Specialized bucket.
|
||||
environment.Runtime.View.SetSkillLevel(SkillTrainOnly, ChargenSkillAdvancementClass.Trained);
|
||||
GoToSummary(environment);
|
||||
|
||||
UiTemplateListBox listBox = environment.SummaryListBox();
|
||||
IReadOnlyList<UiElement> rows = Assert.IsType<UiScrollablePanel>(
|
||||
listBox.ViewportForTest).Children;
|
||||
|
||||
// Ids match CharacterCreationSummaryPage's own private
|
||||
// HeaderTextId/KeyTextId/ValueTextId constants — the same literals
|
||||
// BuildSummaryHeaderTemplate/BuildSummaryPairTemplate below already
|
||||
// hardcode for the fixture's row templates.
|
||||
const uint headerTextId = 0x100000FEu;
|
||||
const uint keyTextId = 0x100002FCu;
|
||||
const uint valueTextId = 0x100002FDu;
|
||||
|
||||
var headers = new List<string>();
|
||||
var pairs = new List<(string Key, string Value)>();
|
||||
foreach (UiElement row in rows)
|
||||
{
|
||||
if (UiElement.FindDescendant(row, headerTextId) is UiText header)
|
||||
headers.Add(JoinedText(header));
|
||||
else if (UiElement.FindDescendant(row, keyTextId) is UiText key
|
||||
&& UiElement.FindDescendant(row, valueTextId) is UiText value)
|
||||
{
|
||||
pairs.Add((JoinedText(key), JoinedText(value)));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Contains("Specialized Skills", headers);
|
||||
Assert.Contains("Trained Skills", headers);
|
||||
Assert.Single(pairs, p => p.Key == "Axe" && p.Value == "10");
|
||||
Assert.DoesNotContain(pairs, p => p.Key == "Bow");
|
||||
}
|
||||
|
||||
private static string JoinedText(UiText text) =>
|
||||
string.Join(" ", text.LinesProvider().Select(static line => line.Text));
|
||||
|
||||
// ── CC5: 0xF643 rejection dialogs ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1082,6 +1140,60 @@ public sealed class CharacterCreationUiControllerTests
|
|||
Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F12(c) (CC5 review-fix round, 2026-08-16): a SECOND rejection with
|
||||
/// IDENTICAL field values (same RawCode/Code/Reason/AttemptedName —
|
||||
/// e.g. the player retried Finish with the SAME already-taken name)
|
||||
/// must still show the dialog. <c>ReconcileDialogs</c>' own
|
||||
/// <c>_lastShownRejection</c> dedup only suppresses re-showing a value
|
||||
/// that is STILL the current <c>LastRejection</c> across ticks
|
||||
/// (<see cref="CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog"/>);
|
||||
/// once <see cref="FakeRuntime.AcknowledgeRejection"/> nulls the
|
||||
/// snapshot's rejection (mirroring the real
|
||||
/// <c>RuntimeCharacterCreationState.TryAcknowledgeRejection</c>),
|
||||
/// <c>_lastShownRejection</c> resets to null too, so the identical
|
||||
/// value arriving a second time is treated as new.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreationFailed_IdenticalRejectionAfterAcknowledge_ReshowsTheDialog()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
environment.Runtime.ResolvedStrings["ID_Character_Err_NameReserved"] = "That name is in use.";
|
||||
environment.Controller.Open();
|
||||
|
||||
var rejection = new RuntimeCharacterCreationRejection(
|
||||
3u, CharGenVerificationResponse.Code.NameInUse, "NameInUse", "Adventurer");
|
||||
|
||||
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection };
|
||||
BumpRevisionAndTick(environment);
|
||||
Assert.True(environment.Dialogs.IsOpen);
|
||||
|
||||
environment.DismissActiveMessageDialog();
|
||||
Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls);
|
||||
Assert.False(environment.Dialogs.IsOpen);
|
||||
Assert.Null(environment.Runtime.View.Snapshot.LastRejection);
|
||||
|
||||
// One intervening Tick with LastRejection == null — exactly what
|
||||
// happens continuously in the real game loop between the
|
||||
// acknowledge callback and the player's next Finish attempt — lets
|
||||
// ReconcileDialogs' own `if (rejection is null) _lastShownRejection
|
||||
// = null;` branch run BEFORE the identical value arrives again.
|
||||
// Without this, _lastShownRejection still holds the acknowledged
|
||||
// value and the dedup would (correctly, per its OWN contract)
|
||||
// suppress a value that never actually went away in between.
|
||||
environment.Controller.Tick();
|
||||
|
||||
// The identical rejection value arrives again.
|
||||
environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection };
|
||||
BumpRevisionAndTick(environment);
|
||||
|
||||
Assert.True(environment.Dialogs.IsOpen);
|
||||
Assert.Equal("That name is in use.", environment.LastDialogMessage());
|
||||
|
||||
environment.DismissActiveMessageDialog();
|
||||
Assert.Equal(2, environment.Runtime.AcknowledgeRejectionCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog()
|
||||
{
|
||||
|
|
@ -1188,6 +1300,9 @@ public sealed class CharacterCreationUiControllerTests
|
|||
public UiTemplateListBox SkillsList() =>
|
||||
Assert.IsType<UiTemplateListBox>(Screen.FindElement(0x100003F7u));
|
||||
|
||||
public UiTemplateListBox SummaryListBox() =>
|
||||
Assert.IsType<UiTemplateListBox>(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId));
|
||||
|
||||
public UiScrollbar ShadeScroll() =>
|
||||
Assert.IsType<UiScrollbar>(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId));
|
||||
|
||||
|
|
@ -1278,9 +1393,19 @@ public sealed class CharacterCreationUiControllerTests
|
|||
RandomizeCharacter: RandomizeCharacter,
|
||||
RandomizeAppearance: () => { RandomizeAppearanceCalls++; return Result(RuntimeCommandStatus.Accepted); },
|
||||
RandomizeClothing: () => { RandomizeClothingCalls++; return Result(RuntimeCommandStatus.Accepted); },
|
||||
GetSkillScore: GetSkillScore,
|
||||
OpenOnStart: false);
|
||||
}
|
||||
|
||||
/// <summary>F3/F12(d) (CC5 review-fix round, 2026-08-16): deterministic
|
||||
/// stand-in for <c>RetailSkillFormula.CalculateChargenScore</c> —
|
||||
/// <c>skillId * 10</c> so tests can assert an exact, unambiguous value
|
||||
/// without needing a real SkillTable.</summary>
|
||||
private static uint GetSkillScore(
|
||||
uint skillId,
|
||||
ChargenAttributeValues attributes,
|
||||
ChargenSkillAdvancementClass level) => skillId * 10u;
|
||||
|
||||
public FakeView View { get; }
|
||||
public CharacterCreationRuntimeBindings Bindings { get; }
|
||||
public bool ProvideView { get; set; } = true;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue