Retail authors ONE caption element per bar with per-mode state strings, switched by a PassToChildren state cascade (gmPowerbarUI:: RecvNotice_BeginPowerbar @0x004DA730 sets 0x10000042 Jump / 0x10000043 Melee / 0x10000044 Missile / 0x10000045 DDD; installed-DAT probe confirmed every string + PassToChildren flag). - Jump bar (user gate): the floaty powerbar's caption child (0x10000035: JumpMode 'Height', authored HJustify=Center over the bar) was dropped by UiMeter's child absorption. The stateful-fill meter build now absorbs it into per-state labels; TrySetRetailState latches the caption and OnDraw shows it when no live Label provider is bound. JumpPowerbarController's existing JumpMode flip now surfaces 'Height' with zero controller changes. The mount gained the string resolver the Build call never passed. - Combat bar (user gate): label 0x10000052 authors 'MeleeCombat' -> 'Power' and 'MissileCombat' -> 'Accuracy'; the controller latched the MELEE string once at bind. CombatUiLabels now resolves both authored strings and OnCombatModeChanged sets the mode's string - switching live when swapping melee <-> missile weapons in combat. Also fixed the mode-state flip target: the states live on the BASIC PANEL (0x1000005C, PassToChildren), not the layout root (Hide/ShowDetail only) - the old _root flip was a silent no-op. New env-gated ACDREAM_PROBE_POWERBAR layout probe (kept, house pattern). App suite 4,987/3 skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
254 lines
10 KiB
C#
254 lines
10 KiB
C#
using AcDream.App.Combat;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Net.Messages;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
public sealed class CombatUiControllerTests
|
|
{
|
|
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
|
|
|
|
/// <summary>
|
|
/// OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2): the three
|
|
/// combat LEDs now read/write through the SAME server-bit seam the
|
|
/// Character tab's rows use — this fake is the test-local stand-in for
|
|
/// the production RuntimeCharacterOptionsState-backed binding.
|
|
/// </summary>
|
|
private sealed class FakeOptionBindings
|
|
{
|
|
public Dictionary<CharacterOptionId, bool> Values { get; } = new();
|
|
|
|
public CombatUiController.Bindings ToBindings() => new(
|
|
CurrentValue: id => Values.TryGetValue(id, out bool v) && v,
|
|
SetOption: (id, value) => Values[id] = value);
|
|
}
|
|
|
|
[Fact]
|
|
public void CombatMode_ShowsPhysicalAndMagicPages_AndSelectsMediumByDefault()
|
|
{
|
|
double now = 0d;
|
|
var combat = new CombatState();
|
|
using var attacks = CreateAttacks(combat, () => now, []);
|
|
var (layout, basic, spellcasting, power, high, medium, low) = BuildLayout();
|
|
var visibility = new List<bool>();
|
|
var options = new FakeOptionBindings();
|
|
using var controller = CombatUiController.Bind(
|
|
layout, combat, attacks, options.ToBindings(),
|
|
Labels, visibility.Add)!;
|
|
|
|
controller.SyncVisibility();
|
|
combat.SetCombatMode(CombatMode.Melee);
|
|
combat.SetCombatMode(CombatMode.Magic);
|
|
|
|
Assert.Equal([false, true, true], visibility);
|
|
Assert.False(basic.Visible);
|
|
Assert.True(spellcasting.Visible);
|
|
Assert.False(high.Selected);
|
|
Assert.True(medium.Selected);
|
|
Assert.False(low.Selected);
|
|
Assert.Equal(RuntimeCombatAttackState.InitialDesiredPower, power.ScalarPosition);
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoredHeightButton_PressChargesAndReleaseSendsThroughSharedController()
|
|
{
|
|
double now = 4d;
|
|
var sent = new List<(AttackHeight Height, float Power)>();
|
|
var combat = new CombatState();
|
|
using var attacks = CreateAttacks(combat, () => now, sent);
|
|
var (layout, basic, advanced, power, high, _, _) = BuildLayout();
|
|
var options = new FakeOptionBindings();
|
|
using var controller = CombatUiController.Bind(
|
|
layout, combat, attacks, options.ToBindings(),
|
|
Labels, _ => { })!;
|
|
combat.SetCombatMode(CombatMode.Melee);
|
|
|
|
high.OnEvent(new UiEvent(0, high, UiEventType.MouseDown, Data1: 2, Data2: 2));
|
|
now = 4.5d;
|
|
Assert.Equal(0.5f, power.ScalarFill()!.Value, 3);
|
|
high.OnEvent(new UiEvent(0, high, UiEventType.MouseUp, Data1: 2, Data2: 2));
|
|
|
|
var attack = Assert.Single(sent);
|
|
Assert.Equal(AttackHeight.High, attack.Height);
|
|
Assert.Equal(0.5f, attack.Power, 3);
|
|
Assert.True(basic.Visible);
|
|
Assert.False(advanced.Visible);
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoredOptionCheckbox_TogglesPersistedGameplayValue()
|
|
{
|
|
var combat = new CombatState();
|
|
using var attacks = CreateAttacks(combat, () => 0d, []);
|
|
var (layout, _, _, _, _, _, _) = BuildLayout();
|
|
var options = new FakeOptionBindings();
|
|
options.Values[CharacterOptionId.AutoTarget] = true;
|
|
using var controller = CombatUiController.Bind(
|
|
layout, combat, attacks, options.ToBindings(),
|
|
Labels, _ => { })!;
|
|
var autoTarget = Assert.IsType<UiButton>(layout.FindElement(CombatUiController.AutoTargetId));
|
|
|
|
autoTarget.OnEvent(new UiEvent(0, autoTarget, UiEventType.MouseDown, Data1: 3, Data2: 3));
|
|
autoTarget.OnEvent(new UiEvent(0, autoTarget, UiEventType.MouseUp, Data1: 3, Data2: 3));
|
|
autoTarget.OnEvent(new UiEvent(0, autoTarget, UiEventType.Click, Data1: 3, Data2: 3));
|
|
|
|
Assert.False(options.Values[CharacterOptionId.AutoTarget]);
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoredPowerLabels_KeepGrayTrackAtSides_AndUseRetailAlignment()
|
|
{
|
|
var combat = new CombatState();
|
|
using var attacks = CreateAttacks(combat, () => 0d, []);
|
|
var (layout, _, _, power, _, _, _) = BuildLayout();
|
|
var options = new FakeOptionBindings();
|
|
using var controller = CombatUiController.Bind(
|
|
layout, combat, attacks, options.ToBindings(),
|
|
Labels, _ => { })!;
|
|
|
|
var speed = Assert.IsType<UiText>(layout.FindElement(CombatUiController.SpeedLabelId));
|
|
var powerLabel = Assert.IsType<UiText>(layout.FindElement(CombatUiController.PowerLabelId));
|
|
Assert.False(speed.Centered);
|
|
Assert.False(speed.RightAligned);
|
|
Assert.False(powerLabel.Centered);
|
|
Assert.True(powerLabel.RightAligned);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-14 gate: the power label's authored per-state strings (element
|
|
/// 0x10000052: 'MeleeCombat' → 'Power', 'MissileCombat' → 'Accuracy' —
|
|
/// installed-DAT probe) switch with the combat mode, retail's basic-panel
|
|
/// PassToChildren cascade. Magic mode hides the basic panel, so the label
|
|
/// keeps whatever mode set it last.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PowerLabel_SwitchesToAccuracyInMissileMode_AndBack()
|
|
{
|
|
var combat = new CombatState();
|
|
using var attacks = CreateAttacks(combat, () => 0d, []);
|
|
var (layout, _, _, _, _, _, _) = BuildLayout();
|
|
var options = new FakeOptionBindings();
|
|
using var controller = CombatUiController.Bind(
|
|
layout, combat, attacks, options.ToBindings(),
|
|
Labels, _ => { })!;
|
|
var powerLabel = Assert.IsType<UiText>(
|
|
layout.FindElement(CombatUiController.PowerLabelId));
|
|
|
|
Assert.Equal("Power", powerLabel.LinesProvider!()[0].Text);
|
|
|
|
combat.SetCombatMode(CombatMode.Missile);
|
|
Assert.Equal("Accuracy", powerLabel.LinesProvider!()[0].Text);
|
|
|
|
combat.SetCombatMode(CombatMode.Melee);
|
|
Assert.Equal("Power", powerLabel.LinesProvider!()[0].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void ImportedFixture_ReflowUpdatesCenteredDarkRange()
|
|
{
|
|
ElementInfo info = FixtureLoader.LoadCombatInfos();
|
|
ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
|
|
var combat = new CombatState();
|
|
using var attacks = CreateAttacks(combat, () => 0d, []);
|
|
var options = new FakeOptionBindings();
|
|
using var controller = CombatUiController.Bind(
|
|
layout, combat, attacks, options.ToBindings(),
|
|
Labels, _ => { })!;
|
|
|
|
ApplyAnchors(layout.Root);
|
|
|
|
var power = Assert.IsType<UiScrollbar>(
|
|
layout.FindElement(CombatUiController.PowerControlId));
|
|
Assert.Equal((50f, 407f), power.ScalarRangeRect());
|
|
}
|
|
|
|
private static readonly CombatUiLabels Labels = new(
|
|
"Speed", "Power", "Accuracy", "Repeat Attacks", "Auto Target",
|
|
"Keep in View", "High", "Medium", "Low");
|
|
|
|
private static RuntimeCombatAttackState CreateAttacks(
|
|
CombatState combat,
|
|
Func<double> now,
|
|
List<(AttackHeight Height, float Power)> sent)
|
|
=> new(
|
|
combat,
|
|
() => true,
|
|
(height, power) =>
|
|
{
|
|
sent.Add((height, power));
|
|
return true;
|
|
},
|
|
autoRepeatAttack: () => false,
|
|
now: now);
|
|
|
|
private static (
|
|
ImportedLayout Layout,
|
|
UiElement Basic,
|
|
UiElement Advanced,
|
|
UiScrollbar Power,
|
|
UiButton High,
|
|
UiButton Medium,
|
|
UiButton Low) BuildLayout()
|
|
{
|
|
var root = new UiPanel { Width = 610, Height = 90 };
|
|
var basic = new UiPanel();
|
|
var advanced = new UiPanel();
|
|
var power = new UiScrollbar
|
|
{
|
|
Left = 13,
|
|
Top = 14,
|
|
Width = 507,
|
|
Height = 14,
|
|
Horizontal = true,
|
|
};
|
|
var speedLabel = new UiText { Left = 17, Top = 14, Width = 100, Height = 15 };
|
|
var powerLabel = new UiText { Left = 416, Top = 14, Width = 100, Height = 15 };
|
|
UiButton Button(uint id) => new(
|
|
new ElementInfo { Id = id, Type = 1, Width = 72, Height = 19 }, NoTex)
|
|
{
|
|
Width = 72,
|
|
Height = 19,
|
|
};
|
|
UiButton ToggleButton(uint id)
|
|
{
|
|
var info = new ElementInfo { Id = id, Type = 0x10000035u, Width = 100, Height = 14 };
|
|
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
|
state.Properties.Values[0x0Bu] = new UiPropertyValue
|
|
{ Kind = UiPropertyKind.Bool, BoolValue = true };
|
|
info.States[UiStateInfo.DirectStateId] = state;
|
|
return new UiButton(info, NoTex) { Width = 100, Height = 14 };
|
|
}
|
|
var high = Button(CombatUiController.HighButtonId);
|
|
var medium = Button(CombatUiController.MediumButtonId);
|
|
var low = Button(CombatUiController.LowButtonId);
|
|
var repeat = ToggleButton(CombatUiController.RepeatAttacksId);
|
|
var autoTarget = ToggleButton(CombatUiController.AutoTargetId);
|
|
var keepInView = ToggleButton(CombatUiController.KeepInViewId);
|
|
var byId = new Dictionary<uint, UiElement>
|
|
{
|
|
[CombatUiController.BasicPanelId] = basic,
|
|
[CombatUiController.AdvancedPanelId] = advanced,
|
|
[CombatUiController.PowerControlId] = power,
|
|
[CombatUiController.SpeedLabelId] = speedLabel,
|
|
[CombatUiController.PowerLabelId] = powerLabel,
|
|
[CombatUiController.HighButtonId] = high,
|
|
[CombatUiController.MediumButtonId] = medium,
|
|
[CombatUiController.LowButtonId] = low,
|
|
[CombatUiController.RepeatAttacksId] = repeat,
|
|
[CombatUiController.AutoTargetId] = autoTarget,
|
|
[CombatUiController.KeepInViewId] = keepInView,
|
|
};
|
|
return (new ImportedLayout(root, byId), basic, advanced, power, high, medium, low);
|
|
}
|
|
|
|
private static void ApplyAnchors(UiElement parent)
|
|
{
|
|
foreach (UiElement child in parent.Children)
|
|
{
|
|
child.ApplyAnchor(parent.Width, parent.Height);
|
|
ApplyAnchors(child);
|
|
}
|
|
}
|
|
}
|