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

@ -1,4 +1,5 @@
using System;
using System.Linq;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
@ -77,13 +78,15 @@ public static class GameEventWiring
FriendsState? friends = null,
SquelchState? squelch = null,
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null)
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
Func<double>? clientTime = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(chat);
clientTime ??= static () => 0d;
// ── Chat ──────────────────────────────────────────────────
dispatcher.Register(GameEventType.ChannelBroadcast, e =>
@ -283,21 +286,44 @@ public static class GameEventWiring
dispatcher.Register(GameEventType.MagicUpdateEnchantment, e =>
{
var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentAdded(
p.Value.SpellId, p.Value.LayerId, p.Value.Duration, p.Value.CasterGuid);
if (p is not null) spellbook.OnEnchantmentAdded(ToActiveEnchantment(p.Value, clientTime()));
});
dispatcher.Register(GameEventType.MagicUpdateMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicUpdateMultipleEnchantments(e.Payload.Span);
if (entries is not null)
{
double receivedAt = clientTime();
spellbook.OnEnchantmentsAdded(entries.Select(entry =>
ToActiveEnchantment(entry, receivedAt)));
}
});
dispatcher.Register(GameEventType.MagicRemoveEnchantment, e =>
{
var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicRemoveMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
if (entries is not null)
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
});
dispatcher.Register(GameEventType.MagicDispelEnchantment, e =>
{
var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicDispelMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
if (entries is not null)
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
});
dispatcher.Register(GameEventType.MagicPurgeEnchantments,
_ => spellbook.OnPurgeAll());
dispatcher.Register(GameEventType.MagicPurgeBadEnchantments,
_ => spellbook.OnPurgeBadEnchantments());
// ── Inventory ─────────────────────────────────────────────
dispatcher.Register(GameEventType.WieldObject, e =>
@ -407,8 +433,8 @@ public static class GameEventWiring
// spellbook arrives via PlayerDescription (0x0013), which uses
// a different wire format (see WorldSession + LocalPlayerState
// — feeds vitals from PrivateUpdateVital instead).
foreach (uint sid in p.Value.SpellBook)
spellbook.OnSpellLearned(sid);
// The appraised spellbook belongs to that item. The local player's
// learned spell manifest arrives only in PlayerDescription.
});
// ── Player ────────────────────────────────────────────────
@ -441,6 +467,17 @@ public static class GameEventWiring
onCharacterOptions?.Invoke(p.Value.Options1, p.Value.Options2);
onDesiredComponents?.Invoke(p.Value.DesiredComps);
double receivedAt = clientTime();
ActiveEnchantmentRecord[] enchantments = p.Value.Enchantments
.Select(entry => ToActiveEnchantment(entry, receivedAt))
.ToArray();
spellbook.ReplaceManifest(
p.Value.Spells,
enchantments,
p.Value.HotbarSpells,
p.Value.DesiredComps,
p.Value.SpellbookFilters);
// B-Wire: deliver the player's OWN properties to the player ClientObject.
// (PD's "membership manifest" rule is about ITEMS, whose data comes from
// CreateObject; the player's own stats legitimately come from PD.) Upsert
@ -508,9 +545,6 @@ public static class GameEventWiring
}
}
foreach (uint sid in p.Value.Spells.Keys)
spellbook.OnSpellLearned(sid);
// K-fix7 (2026-04-26): push Run + Jump skill values to the
// PlayerMovementController so the runRate / jump-arc formulas
// use the SERVER's authoritative skill instead of our
@ -566,21 +600,6 @@ public static class GameEventWiring
// Issue #7 — enchantment block: feed each entry into the
// Spellbook with full StatMod data so EnchantmentMath can
// aggregate buffs in vital-max calc (issue #6 lights up).
foreach (var ench in p.Value.Enchantments)
{
spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord(
SpellId: ench.SpellId,
LayerId: ench.Layer,
Duration: (float)ench.Duration,
CasterGuid: ench.CasterGuid,
StatModType: ench.StatModType,
StatModKey: ench.StatModKey,
StatModValue: ench.StatModValue,
Bucket: (uint)ench.Bucket));
if (dumpPd)
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
}
// D.5.4: PlayerDescription is a membership MANIFEST, not the data
// source. Record existence (+ equip slot); CreateObject fills the
// actual weenie data via ObjectTableWiring. (Previously this seeded
@ -630,4 +649,46 @@ public static class GameEventWiring
onShortcuts?.Invoke(p.Value.Shortcuts);
});
}
private static ActiveEnchantmentRecord ToActiveEnchantment(
PlayerDescriptionParser.EnchantmentEntry enchantment,
double receivedAt) => new(
SpellId: enchantment.SpellId,
LayerId: enchantment.Layer,
Duration: enchantment.Duration,
CasterGuid: enchantment.CasterGuid,
StatModType: enchantment.StatModType,
StatModKey: enchantment.StatModKey,
StatModValue: enchantment.StatModValue,
Bucket: enchantment.Bucket == 0
? ClassifyLiveEnchantmentBucket(enchantment.StatModType)
: (uint)enchantment.Bucket,
// Retail Enchantment::UnPack (0x005CB040) converts both relative
// wire timestamps to the monotonic client Timer domain at receipt.
StartTime: receivedAt + enchantment.StartTime,
SpellCategory: enchantment.SpellCategory,
PowerLevel: enchantment.PowerLevel,
DegradeModifier: enchantment.DegradeModifier,
DegradeLimit: enchantment.DegradeLimit,
LastTimeDegraded: receivedAt + enchantment.LastTimeDegraded,
SpellSetId: enchantment.SpellSetId);
/// <summary>
/// Live 0x02C2/0x02C4 records do not carry PlayerDescription's outer
/// EnchantmentMask bucket. Retail reconstructs the registry list from the
/// StatMod type flags; this is the same ordering used by ACE's
/// EnchantmentRegistry.BuildCategories.
/// </summary>
private static uint ClassifyLiveEnchantmentBucket(uint statModType)
{
const uint Multiplicative = 0x00004000u;
const uint Additive = 0x00008000u;
const uint Vitae = 0x00800000u;
const uint Cooldown = 0x01000000u;
if ((statModType & Vitae) != 0) return 4u;
if ((statModType & Cooldown) != 0) return 8u;
if ((statModType & Multiplicative) != 0) return 1u;
if ((statModType & Additive) != 0) return 2u;
return 0u;
}
}