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

@ -61,6 +61,72 @@ public sealed class SpellbookTests
Assert.Equal(600f, e.Duration, 4);
}
[Fact]
public void OnEnchantmentAdded_SameLayerDifferentSpell_RemainsDistinct()
{
var book = new Spellbook();
book.OnEnchantmentAdded(42, 7, 300f, 0);
book.OnEnchantmentAdded(43, 7, 600f, 0);
Assert.Equal(2, book.ActiveCount);
}
[Fact]
public void LiveEnchantments_NewIdentityInsertsAtRetailListHead()
{
var book = new Spellbook();
book.OnEnchantmentAdded(Effect(1u, layer: 1u, category: 12u));
book.OnEnchantmentAdded(Effect(2u, layer: 2u, category: 12u));
Assert.Equal(2u, Assert.Single(book.EnchantmentsInEffectSnapshot).SpellId);
}
[Fact]
public void LiveEnchantment_ExistingIdentityRefreshKeepsListPosition()
{
var book = new Spellbook();
book.OnEnchantmentAdded(Effect(1u, layer: 1u, category: 12u));
book.OnEnchantmentAdded(Effect(2u, layer: 2u, category: 12u));
book.OnEnchantmentAdded(Effect(1u, layer: 1u, category: 12u));
Assert.Equal(2u, Assert.Single(book.EnchantmentsInEffectSnapshot).SpellId);
}
[Fact]
public void ManifestEnchantments_PreserveReceivedListOrder()
{
var book = new Spellbook();
book.ReplaceManifest(
new Dictionary<uint, float>(),
[Effect(1u, layer: 1u, category: 12u), Effect(2u, layer: 2u, category: 12u)],
[],
[],
0x3FFFu);
Assert.Equal(1u, Assert.Single(book.EnchantmentsInEffectSnapshot).SpellId);
}
[Fact]
public void ReplaceManifest_ReplacesFavoritesFiltersAndDesiredComponents()
{
var book = new Spellbook();
book.OnSpellLearned(999);
book.ReplaceManifest(
new Dictionary<uint, float> { [42] = 123f },
[new ActiveEnchantmentRecord(50, 2, 60, 1)],
[new uint[] { 42, 43 }],
[(100u, 25u)],
0xA5u);
Assert.False(book.Knows(999));
Assert.True(book.Knows(42));
Assert.Equal(new uint[] { 42, 43 }, book.GetFavorites(0));
Assert.Equal(25u, book.DesiredComponents[100]);
Assert.Equal(0xA5u, book.SpellbookFilters);
Assert.Equal(1, book.ActiveCount);
}
[Fact]
public void OnEnchantmentRemoved_FiresEvent()
{
@ -76,18 +142,49 @@ public sealed class SpellbookTests
}
[Fact]
public void OnPurgeAll_RemovesAllEnchantments_FiresPerRecord()
public void OnPurgeAll_RemovesOnlyFiniteMultAndAddEnchantments()
{
var book = new Spellbook();
book.OnEnchantmentAdded(1, 1, 100f, 0);
book.OnEnchantmentAdded(2, 2, 100f, 0);
book.OnEnchantmentAdded(3, 3, 100f, 0);
book.OnEnchantmentAdded(new(1, 1, 100, 0, Bucket: 1));
book.OnEnchantmentAdded(new(2, 2, 100, 0, Bucket: 2));
book.OnEnchantmentAdded(new(3, 3, -1, 0, Bucket: 2));
book.OnEnchantmentAdded(new(4, 4, 100, 0, Bucket: 4));
book.OnEnchantmentAdded(new(5, 5, 100, 0, Bucket: 8));
int removals = 0;
book.EnchantmentRemoved += _ => removals++;
book.OnPurgeAll();
Assert.Equal(3, removals);
Assert.Equal(0, book.ActiveCount);
Assert.Equal(2, removals);
Assert.Equal(3, book.ActiveCount);
Assert.Equal([3u, 4u, 5u],
book.ActiveEnchantmentSnapshot.Select(record => record.SpellId).Order().ToArray());
}
[Fact]
public void OnPurgeBad_UsesStatModBeneficialFlag_AndPreservesPermanent()
{
const uint Beneficial = 0x02000000u;
var book = new Spellbook();
book.OnEnchantmentAdded(new(1, 1, 100, 0, StatModType: 0, Bucket: 1));
book.OnEnchantmentAdded(new(2, 2, 100, 0, StatModType: Beneficial, Bucket: 2));
book.OnEnchantmentAdded(new(3, 3, -1, 0, StatModType: 0, Bucket: 2));
book.OnEnchantmentAdded(new(4, 4, 100, 0, StatModType: 0, Bucket: 8));
book.OnPurgeBadEnchantments();
Assert.Equal([2u, 3u, 4u],
book.ActiveEnchantmentSnapshot.Select(record => record.SpellId).Order().ToArray());
}
private static ActiveEnchantmentRecord Effect(uint spellId, uint layer, uint category)
=> new(
SpellId: spellId,
LayerId: layer,
Duration: 60d,
CasterGuid: 1u,
Bucket: 1u,
StartTime: 10d,
SpellCategory: category,
PowerLevel: 7u);
}