feat: port retail magic lifecycle and retained spell UI

Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 10:55:22 +02:00
parent 7b7ffcd278
commit 07be994d97
84 changed files with 17822 additions and 1051 deletions

View file

@ -38,6 +38,47 @@ public class InputDispatcherIsActionHeldTests
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
}
[Fact]
public void IsActionHeld_CombatBindingOnSameChord_ShadowsGamePolling()
{
var (dispatcher, kb, _, bindings) = Build();
var chord = new KeyChord(Key.W, ModifierMask.None);
bindings.Add(new Binding(chord, InputAction.MovementForward));
bindings.Add(new Binding(
chord,
InputAction.CombatCastCurrentSpell,
Scope: InputScope.MagicCombat));
dispatcher.SetCombatScope(InputScope.MagicCombat);
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
Assert.True(dispatcher.IsActionHeld(InputAction.CombatCastCurrentSpell));
dispatcher.SetCombatScope(null);
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
Assert.False(dispatcher.IsActionHeld(InputAction.CombatCastCurrentSpell));
}
[Fact]
public void IsActionHeld_ExplicitShiftCombatChord_ShadowsBareMovementFallback()
{
var (dispatcher, kb, _, bindings) = Build();
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.None),
InputAction.MovementForward));
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.Shift),
InputAction.CombatCastCurrentSpell,
Scope: InputScope.MagicCombat));
dispatcher.SetCombatScope(InputScope.MagicCombat);
kb.EmitKeyDown(Key.W, ModifierMask.Shift);
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
Assert.True(dispatcher.IsActionHeld(InputAction.CombatCastCurrentSpell));
}
[Fact]
public void IsActionHeld_returns_false_when_no_binding_for_action()
{

View file

@ -82,6 +82,44 @@ public class InputDispatcherTests
Assert.Equal(InputScope.Game, dispatcher.ActiveScope);
}
[Fact]
public void Magic_combat_scope_shadows_game_binding_for_same_chord()
{
var (dispatcher, kb, _, bindings, fired) = Build();
var chord = new KeyChord(Key.Number1, ModifierMask.None);
bindings.Add(new Binding(chord, InputAction.UseQuickSlot_1));
bindings.Add(new Binding(chord, InputAction.UseSpellSlot_1, Scope: InputScope.MagicCombat));
kb.EmitKeyDown(Key.Number1, ModifierMask.None);
dispatcher.SetCombatScope(InputScope.MagicCombat);
kb.EmitKeyDown(Key.Number1, ModifierMask.None);
Assert.Equal(
[(InputAction.UseQuickSlot_1, ActivationType.Press),
(InputAction.UseSpellSlot_1, ActivationType.Press)],
fired);
}
[Fact]
public void Changing_combat_scope_releases_hold_resolved_in_previous_scope()
{
var (dispatcher, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.Delete, ModifierMask.None),
InputAction.CombatLowAttack,
ActivationType.Hold,
InputScope.MeleeCombat));
dispatcher.SetCombatScope(InputScope.MeleeCombat);
kb.EmitKeyDown(Key.Delete, ModifierMask.None);
dispatcher.SetCombatScope(InputScope.MagicCombat);
Assert.Equal(
[(InputAction.CombatLowAttack, ActivationType.Press),
(InputAction.CombatLowAttack, ActivationType.Release)],
fired);
}
[Fact]
public void PushScope_changes_ActiveScope()
{
@ -149,6 +187,40 @@ public class InputDispatcherTests
Assert.Empty(fired); // no longer held
}
[Fact]
public void Hold_callback_scope_change_DoesNotDispatchStaleSnapshotChord()
{
var (dispatcher, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.Delete, ModifierMask.None),
InputAction.CombatLowAttack,
ActivationType.Hold,
InputScope.MeleeCombat));
bindings.Add(new Binding(
new KeyChord(Key.End, ModifierMask.None),
InputAction.CombatHighAttack,
ActivationType.Hold,
InputScope.MeleeCombat));
dispatcher.SetCombatScope(InputScope.MeleeCombat);
kb.EmitKeyDown(Key.Delete, ModifierMask.None);
kb.EmitKeyDown(Key.End, ModifierMask.None);
fired.Clear();
bool changed = false;
dispatcher.Fired += (_, activation) =>
{
if (activation != ActivationType.Hold || changed)
return;
changed = true;
dispatcher.SetCombatScope(InputScope.MagicCombat);
};
dispatcher.Tick();
Assert.Single(fired, entry => entry.Item2 == ActivationType.Hold);
Assert.Equal(2, fired.Count(entry => entry.Item2 == ActivationType.Release));
}
[Fact]
public void Release_binding_fires_only_on_KeyUp()
{

View file

@ -49,7 +49,8 @@ public class KeyBindingsJsonTests
Assert.Contains(loaded.All, x =>
x.Chord == b.Chord
&& x.Action == b.Action
&& x.Activation == b.Activation);
&& x.Activation == b.Activation
&& x.Scope == b.Scope);
}
}
finally

View file

@ -57,6 +57,20 @@ public class KeyBindingsTests
Assert.Null(b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press));
}
[Fact]
public void RetailDefaults_resolve_shared_number_key_by_scope()
{
var bindings = KeyBindings.RetailDefaults();
var chord = new KeyChord(Key.Number1, ModifierMask.None);
Assert.Equal(
InputAction.UseQuickSlot_1,
bindings.Find(chord, ActivationType.Press, InputScope.Game)?.Action);
Assert.Equal(
InputAction.UseSpellSlot_1,
bindings.Find(chord, ActivationType.Press, InputScope.MagicCombat)?.Action);
}
[Fact]
public void ForAction_returns_all_chords_bound_to_action()
{