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:
parent
7b7ffcd278
commit
07be994d97
84 changed files with 17822 additions and 1051 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue