Merge campaign-cc-batch-g: Batch G — real color wheel mechanism (inert until closeout wiring)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	docs/research/2026-08-16-campaign-cc-gate-round1-findings.md
This commit is contained in:
Erik 2026-08-16 14:19:30 +02:00
commit 1f6365d3e5
10 changed files with 1558 additions and 30 deletions

View file

@ -0,0 +1,447 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217): fixture
/// tests for <see cref="CharacterCreationAppearancePage"/>'s real swatch/
/// gradient-disc color computation — a hand-built layout + fake DAT sources
/// (no installed dat), so recompute triggers can be asserted deterministically.
/// Real installed-DAT color values are pinned separately in
/// <c>AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogColorTests</c>.
/// </summary>
public sealed class CharacterCreationAppearancePageSwatchColorTests
{
private const uint HeritageId = 1u;
private const int GenderKey = 1;
private const uint HairPalSetIdA = 0x0F00_0001u;
private const uint HairPalSetIdB = 0x0F00_0002u;
private const uint EyePaletteIdA = 0x0400_0010u;
private const uint EyePaletteIdB = 0x0400_0011u;
private const uint HeadgearClothingTableId = 0x1900_0001u;
private const uint HeadgearDyePalSetId = 0x0F00_0010u;
private const uint SkinPalSetId = 0x0F00_0099u;
private static readonly ChargenSwatchRgb HairColorA = new(10, 20, 30);
private static readonly ChargenSwatchRgb HairColorB = new(40, 50, 60);
private static readonly ChargenSwatchRgb EyeColorA = new(70, 80, 90);
private static readonly ChargenSwatchRgb EyeColorB = new(100, 110, 120);
private static readonly ChargenSwatchRgb HeadgearColorA = new(130, 140, 150);
private static readonly ChargenSwatchRgb SkinColor = new(160, 170, 180);
private sealed class FakePalSetSource : IChargenPalSetSource
{
private readonly Dictionary<uint, ChargenPalSet> _sets = new();
public void Add(uint id, params uint[] paletteIds) => _sets[id] = new ChargenPalSet(paletteIds);
public ChargenPalSet? TryGetPalSet(uint palSetId) => _sets.TryGetValue(palSetId, out var s) ? s : null;
}
private sealed class FakeClothingTableSource : IChargenClothingTableSource
{
private readonly Dictionary<uint, ChargenClothingTable> _tables = new();
public void Add(uint id, ChargenClothingTable table) => _tables[id] = table;
public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) =>
_tables.TryGetValue(clothingTableId, out var t) ? t : null;
}
private sealed class FakeColorSource : IChargenPaletteColorSource
{
private readonly Dictionary<uint, ChargenSwatchRgb> _colors = new();
public void Add(uint paletteId, ChargenSwatchRgb color) => _colors[paletteId] = color;
public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) =>
_colors.TryGetValue(paletteId, out color);
}
private static ChargenGenderOptions MakeGender() => new(
GenderKey: GenderKey,
Name: "Male",
Scale: 100u,
SetupId: 0x0200_0001u,
SoundTableId: 0u,
IconId: 0u,
BasePaletteId: 0u,
SkinPalSetId: SkinPalSetId,
PhysicsTableId: 0u,
MotionTableId: 0u,
CombatTableId: 0u,
BaseObjDesc: ChargenObjDesc.Empty,
HairColors: [HairPalSetIdA, HairPalSetIdB],
HairStyles: [new ChargenHairStyle(0u, false, 0u, ChargenObjDesc.Empty)],
EyeColors: [EyePaletteIdA, EyePaletteIdB],
EyeStrips: [new ChargenEyeStrip(0u, 0u, ChargenObjDesc.Empty, ChargenObjDesc.Empty)],
NoseStrips: [new ChargenFaceStrip(0u, ChargenObjDesc.Empty)],
MouthStrips: [new ChargenFaceStrip(0u, ChargenObjDesc.Empty)],
Headgears: [new ChargenGearOption("Cap", HeadgearClothingTableId, 0u)],
Shirts: [],
Pants: [],
Footwear: [],
ClothingColors: [9u]);
private static ChargenOptions MakeOptions(uint heritageId, ChargenGenderOptions gender)
{
var heritage = new ChargenHeritageOptions(
heritageId, "Test", 0u, 0x0200_0001u, 0u,
180u, 100u, [0], [],
new Dictionary<uint, ChargenSkillCost>(), [],
new Dictionary<int, ChargenGenderOptions> { [GenderKey] = gender });
return new ChargenOptions(
[],
new Dictionary<uint, ChargenHeritageOptions> { [heritageId] = heritage },
new Dictionary<uint, ChargenSkillCost>());
}
private static (FakePalSetSource pal, FakeClothingTableSource clothing, FakeColorSource colors) MakeSources()
{
var pal = new FakePalSetSource();
pal.Add(HairPalSetIdA, 0x0400_0001u);
pal.Add(HairPalSetIdB, 0x0400_0002u);
pal.Add(HeadgearDyePalSetId, 0x0400_0003u);
pal.Add(SkinPalSetId, 0x0400_0004u);
var colors = new FakeColorSource();
colors.Add(0x0400_0001u, HairColorA);
colors.Add(0x0400_0002u, HairColorB);
colors.Add(EyePaletteIdA, EyeColorA);
colors.Add(EyePaletteIdB, EyeColorB);
colors.Add(0x0400_0003u, HeadgearColorA);
colors.Add(0x0400_0004u, SkinColor);
var choice = new ChargenClothingSubPaletteChoice(HeadgearDyePalSetId, [new ChargenClothingSubPaletteRange(0, 8)]);
var templates = new Dictionary<uint, ChargenClothingPaletteTemplate>
{
[9u] = new ChargenClothingPaletteTemplate([choice]),
};
var clothing = new FakeClothingTableSource();
clothing.Add(HeadgearClothingTableId, new ChargenClothingTable(
new Dictionary<uint, ChargenClothingBaseEffect>(), templates));
return (pal, clothing, colors);
}
private sealed class FakeView(ChargenOptions options, uint heritageId) : IRuntimeCharacterCreationView
{
public RuntimeCharacterCreationSnapshot Snapshot { get; set; } = new(
new RuntimeGenerationToken(1u),
IsActive: true,
Revision: 1,
HeritageId: heritageId,
GenderKey: (uint)GenderKey,
Appearance: RuntimeCharacterCreationAppearance.Default,
Template: RuntimeCharacterCreationSnapshot.TemplateUnset,
Attributes: default,
AttributeLockMask: 0u,
TotalAttributeCredits: 0u,
RemainingAttributeCredits: 0,
TotalSkillCredits: 0u,
RemainingSkillCredits: 0,
Name: string.Empty,
StartArea: -1,
Slot: 0u,
VerificationPending: false,
LastLocalRefusal: default,
LastRejection: null,
LastCreated: null);
public ChargenOptions Options { get; } = options;
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => ChargenSkillAdvancementClass.Inactive;
public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => NullSubscription.Instance;
private sealed class NullSubscription : IDisposable
{
public static readonly NullSubscription Instance = new();
public void Dispose() { }
}
}
/// <summary>Minimal appearance-page fixture — just what
/// <see cref="CharacterCreationAppearancePage"/>'s constructor resolves
/// (spins for Hair/Eyes/Headgear used to drive <c>_currentPart</c> via
/// their own select-zone click, matching every other test in this
/// campaign's <c>CharacterCreationUiControllerTests</c> harness; the
/// nine swatches + the gradient disc, this test's own subject).</summary>
private static UiElement BuildPageRoot()
{
var page = new ElementInfo
{
Id = 0x100003D4u,
Type = 3u,
Width = 800f,
Height = 500f,
};
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.HairSpinId));
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.EyesSpinId));
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.SkinSpinId));
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.HeadgearSpinId));
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
page.Children.Add(ButtonInfo(swatchId));
foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds)
page.Children.Add(ContainerInfo(overlayId));
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId));
return LayoutImporter.Build(page, _ => (0u, 0, 0), null).Root;
}
private static ElementInfo SpinInfo(uint id)
{
var spin = new ElementInfo { Id = id, Type = 1u, Width = 200f, Height = 24f };
spin.Children.Add(new ElementInfo { Id = 0x1000030Au, Type = 1u, X = 80f, Width = 47f, Height = 24f });
spin.Children.Add(new ElementInfo { Id = 0x1000030Bu, Type = 1u, X = 127f, Width = 47f, Height = 24f });
return spin;
}
private static ElementInfo ButtonInfo(uint id) => new() { Id = id, Type = 1u, Width = 20f, Height = 20f };
private static ElementInfo ContainerInfo(uint id) => new() { Id = id, Type = 3u, Width = 64f, Height = 64f };
private static (CharacterCreationAppearancePage Page, FakeView View, UiElement Root) BuildPage(
FakePalSetSource pal, FakeClothingTableSource clothing, FakeColorSource colors)
{
var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId);
var bindings = new CharacterCreationRuntimeBindings(
() => view,
_ => default,
_ => default,
_ => default,
(_, _) => default,
(_, _) => default,
_ => default,
_ => default,
_ => default,
_ => default,
_ => default,
() => { },
SetAppearanceIndex: (_, _) => default);
UiElement pageRoot = BuildPageRoot();
var page = new CharacterCreationAppearancePage(pageRoot, bindings)
{
PalSetSource = pal,
ClothingTableSource = clothing,
PaletteColorSource = colors,
};
return (page, view, pageRoot);
}
private static UiButton Swatch(UiElement pageRoot, int index) =>
Assert.IsType<UiButton>(UiElement.FindDescendant(
pageRoot, CharacterCreationAppearancePage.SwatchIds[index]));
private static ChargenSwatchColorTile SwatchTile(UiElement pageRoot, int index) =>
Assert.IsType<ChargenSwatchColorTile>(Assert.Single(Swatch(pageRoot, index).Children));
private static ChargenSwatchColorTile GradTile(UiElement pageRoot) =>
Assert.IsType<ChargenSwatchColorTile>(Assert.Single(
UiElement.FindDescendant(pageRoot, CharacterCreationAppearancePage.GradCircleId)!.Children));
private static Vector4 ToVector4(ChargenSwatchRgb rgb) => new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f);
[Fact]
public void HairPart_PaintsBothSwatchesWithTheirOwnDistinctColors()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color);
Assert.True(SwatchTile(root, 0).Visible);
Assert.Equal(ToVector4(HairColorB), SwatchTile(root, 1).Color);
Assert.True(SwatchTile(root, 1).Visible);
// Only two hair colors exist — swatch 2 must be blank.
Assert.Null(SwatchTile(root, 2).Color);
Assert.False(SwatchTile(root, 2).Visible);
}
/// <summary>Part change (Hair -&gt; Eyes via the spin's own select-zone
/// click) must recompute the whole swatch set from the NEW part's own
/// color source.</summary>
[Fact]
public void PartChange_RecomputesTheSwatchSet()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color);
UiButton eyesSpin = Assert.IsType<UiButton>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
eyesSpin.OnClickAt!(180, 10); // select zone — switches _currentPart, no index change.
Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color);
Assert.Equal(ToVector4(EyeColorB), SwatchTile(root, 1).Color);
}
/// <summary>Color change (clicking a different swatch) must retint the
/// gradient disc to the NEWLY selected swatch's own color.</summary>
[Fact]
public void ColorChange_RetintsTheGradientDiscToTheNewlySelectedSwatch()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
// No color selected yet (Unset) — no tint.
Assert.Null(GradTile(root).Color);
Assert.False(GradTile(root).Visible);
Swatch(root, 0).OnClick!();
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { HairColor = 0u },
};
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), GradTile(root).Color);
Assert.True(GradTile(root).Visible);
Swatch(root, 1).OnClick!();
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { HairColor = 1u },
};
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorB), GradTile(root).Color);
}
/// <summary>AP-217: Eyes always blanks the gradient disc's tile — no
/// tint is ever shown for Eyes, regardless of the selected eye color.</summary>
[Fact]
public void EyesPart_GradientDiscTileStaysBlank()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { EyeColor = 0u },
};
page.Refresh(view, view.Snapshot);
UiButton eyesSpin = Assert.IsType<UiButton>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
eyesSpin.OnClickAt!(180, 10);
Assert.False(GradTile(root).Visible);
Assert.Null(GradTile(root).Color);
// The swatches themselves still show real eye colors — only the
// disc blanks.
Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color);
}
/// <summary>Nose/Mouth/Skin (colorSlot == null): retail still shows
/// exactly ONE representative swatch, sourced from the shared skin
/// PalSet, and the gradient disc tints from that SAME swatch — see
/// <c>ComputeSwatchColors</c>'s own doc.</summary>
[Fact]
public void SkinPart_ShowsExactlyOneRepresentativeSwatchAndTintsTheDiscFromIt()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
UiButton skinSpin = Assert.IsType<UiButton>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.SkinSpinId));
skinSpin.OnClickAt!(10, 10); // skin has no arrow zones — every click selects it.
Assert.Equal(ToVector4(SkinColor), SwatchTile(root, 0).Color);
Assert.True(SwatchTile(root, 0).Visible);
Assert.Null(SwatchTile(root, 1).Color);
Assert.False(SwatchTile(root, 1).Visible);
Assert.Equal(ToVector4(SkinColor), GradTile(root).Color);
Assert.True(GradTile(root).Visible);
}
/// <summary>Headgear's swatch resolves through the CURRENTLY EQUIPPED
/// garment's own ClothingTable — an Unset headgear style (no garment)
/// shows no swatches at all.</summary>
[Fact]
public void HeadgearPart_ResolvesThroughTheEquippedGarment_UnsetShowsNoSwatches()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { HeadgearStyle = 0u },
};
page.Refresh(view, view.Snapshot);
UiButton headgearSpin = Assert.IsType<UiButton>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.HeadgearSpinId));
headgearSpin.OnClickAt!(180, 10);
Assert.Equal(ToVector4(HeadgearColorA), SwatchTile(root, 0).Color);
// Now un-equip (Unset) and refresh again — no garment, no colors.
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { HeadgearStyle = RuntimeCharacterCreationAppearance.Unset },
};
page.Refresh(view, view.Snapshot);
Assert.Null(SwatchTile(root, 0).Color);
Assert.False(SwatchTile(root, 0).Visible);
}
/// <summary>Heritage/gender change must re-source the color computation
/// from the NEW gender's own option lists — a second, differently-
/// colored fixture proves the recompute isn't cached from the first.</summary>
[Fact]
public void HeritageChange_RecomputesFromTheNewGendersOwnColorLists()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color);
// A second heritage with a DIFFERENT hair-color list.
const uint otherHairPalSetId = 0x0F00_00AAu;
var otherHairColor = new ChargenSwatchRgb(200, 201, 202);
pal.Add(otherHairPalSetId, 0x0400_00AAu);
colors.Add(0x0400_00AAu, otherHairColor);
const uint otherHeritageId = 2u;
ChargenGenderOptions otherGender = MakeGender() with { HairColors = [otherHairPalSetId] };
ChargenOptions otherOptions = MakeOptions(otherHeritageId, otherGender);
var otherView = new FakeView(otherOptions, otherHeritageId);
page.Refresh(otherView, otherView.Snapshot);
Assert.Equal(ToVector4(otherHairColor), SwatchTile(root, 0).Color);
}
/// <summary>Before <see cref="CharacterCreationAppearancePage.PalSetSource"/>/
/// <see cref="CharacterCreationAppearancePage.ClothingTableSource"/>/
/// <see cref="CharacterCreationAppearancePage.PaletteColorSource"/> are
/// wired (composition-root STOPPED item), every tile MUST stay
/// invisible — the mechanism is fully inert, not a half-broken draw.</summary>
[Fact]
public void UnwiredSources_LeaveEveryTileInvisible()
{
var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId);
var bindings = new CharacterCreationRuntimeBindings(
() => view,
_ => default,
_ => default,
_ => default,
(_, _) => default,
(_, _) => default,
_ => default,
_ => default,
_ => default,
_ => default,
_ => default,
() => { },
SetAppearanceIndex: (_, _) => default);
UiElement root = BuildPageRoot();
var page = new CharacterCreationAppearancePage(root, bindings); // sources left null.
page.Refresh(view, view.Snapshot);
Assert.False(GradTile(root).Visible);
for (int i = 0; i < CharacterCreationAppearancePage.SwatchIds.Length; i++)
Assert.False(SwatchTile(root, i).Visible);
}
}