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

@ -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()
{