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
|
|
@ -191,6 +191,8 @@ public sealed class ClientObject
|
|||
public byte? CombatUse { get; set; }
|
||||
/// <summary>Retail <c>PublicWeenieDesc._ammoType</c>; zero is <c>AMMO_NONE</c>.</summary>
|
||||
public ushort? AmmoType { get; set; }
|
||||
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
|
||||
public uint? SpellId { get; set; }
|
||||
/// <summary>Client-side trade state used by ItemHolder legality gates.</summary>
|
||||
public int TradeState { get; set; }
|
||||
/// <summary>Resolved membership in SpellComponentTable (retail IsComponentPack).</summary>
|
||||
|
|
@ -258,7 +260,8 @@ public readonly record struct WeenieData(
|
|||
byte? CombatUse = null,
|
||||
string? PluralName = null,
|
||||
uint? PetOwnerId = null,
|
||||
ushort? AmmoType = null);
|
||||
ushort? AmmoType = null,
|
||||
uint? SpellId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||||
|
|
|
|||
|
|
@ -700,6 +700,7 @@ public sealed class ClientObjectTable
|
|||
if (d.PetOwnerId is { } petOwnerId) obj.PetOwnerId = petOwnerId;
|
||||
if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse;
|
||||
if (d.AmmoType is { } ammoType) obj.AmmoType = ammoType;
|
||||
if (d.SpellId is { } spellId) obj.SpellId = spellId;
|
||||
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
|
||||
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
|
||||
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
|
||||
|
|
|
|||
67
src/AcDream.Core/Spells/EnchantmentRegistryProjection.cs
Normal file
67
src/AcDream.Core/Spells/EnchantmentRegistryProjection.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Pure port of retail's ordinary in-effect registry projection:
|
||||
/// <c>CEnchantmentRegistry::GetEnchantmentsInEffect @ 0x00594540</c>,
|
||||
/// <c>GetEnchantmentsInEffectFromList @ 0x00594430</c>,
|
||||
/// <c>Duel @ 0x005942B0</c>, and <c>Enchantment::Duel @ 0x005CACE0</c>.
|
||||
/// </summary>
|
||||
public static class EnchantmentRegistryProjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Merges multiplicative then additive records and retains one winner per
|
||||
/// spell category. Higher power wins; equal power is replaced only by a
|
||||
/// later start time. Vitae and cooldown registries do not participate.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ActiveEnchantmentRecord> GetEnchantmentsInEffect(
|
||||
IEnumerable<ActiveEnchantmentRecord> enchantments)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(enchantments);
|
||||
ActiveEnchantmentRecord[] source = enchantments as ActiveEnchantmentRecord[]
|
||||
?? [.. enchantments];
|
||||
var result = new List<ActiveEnchantmentRecord>();
|
||||
DuelList(source, bucket: 1u, result);
|
||||
DuelList(source, bucket: 2u, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void DuelList(
|
||||
IReadOnlyList<ActiveEnchantmentRecord> source,
|
||||
uint bucket,
|
||||
List<ActiveEnchantmentRecord> result)
|
||||
{
|
||||
for (int sourceIndex = 0; sourceIndex < source.Count; sourceIndex++)
|
||||
{
|
||||
ActiveEnchantmentRecord candidate = source[sourceIndex];
|
||||
if (candidate.Bucket != bucket) continue;
|
||||
|
||||
int incumbentIndex = result.FindIndex(existing =>
|
||||
existing.SpellCategory == candidate.SpellCategory);
|
||||
if (incumbentIndex >= 0)
|
||||
{
|
||||
ActiveEnchantmentRecord incumbent = result[incumbentIndex];
|
||||
if (!CandidateWins(incumbent, candidate))
|
||||
continue;
|
||||
// Retail removes the losing node, then appends the winner.
|
||||
result.RemoveAt(incumbentIndex);
|
||||
}
|
||||
result.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CandidateWins(
|
||||
ActiveEnchantmentRecord incumbent,
|
||||
ActiveEnchantmentRecord candidate)
|
||||
{
|
||||
// Retail Enchantment::_power_level is signed int even though the wire
|
||||
// field is read as four raw bytes.
|
||||
int incumbentPower = unchecked((int)incumbent.PowerLevel);
|
||||
int candidatePower = unchecked((int)candidate.PowerLevel);
|
||||
return candidatePower > incumbentPower
|
||||
|| (candidatePower == incumbentPower
|
||||
&& candidate.StartTime > incumbent.StartTime);
|
||||
}
|
||||
}
|
||||
190
src/AcDream.Core/Spells/RetailSpellFormula.cs
Normal file
190
src/AcDream.Core/Spells/RetailSpellFormula.cs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Pure retail spell-formula helpers. The formula is an eight-entry list of
|
||||
/// spell-component enum IDs (SCIDs), not inventory weenie class IDs.
|
||||
/// </summary>
|
||||
public static class RetailSpellFormula
|
||||
{
|
||||
private const uint LowestTaperId = 63u;
|
||||
private const uint PrismaticTaperId = 0xBCu;
|
||||
|
||||
/// <summary>
|
||||
/// Retail MagicSystem::DeterminePowerLevelOfComponent (0x005BD240).
|
||||
/// </summary>
|
||||
public static uint DeterminePowerLevelOfComponent(uint componentId) => componentId switch
|
||||
{
|
||||
>= 1u and <= 6u => componentId,
|
||||
0x6Eu => 7u,
|
||||
0x70u => 8u,
|
||||
0xC0u => 9u,
|
||||
0xC1u => 10u,
|
||||
_ => 0u,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Retail CSpellBase::InqSpellLevelByRoughHeuristic (0x00597260).
|
||||
/// This is the I-VIII level used by gmSpellbookUI's level filters.
|
||||
/// </summary>
|
||||
public static int InqSpellLevelByRoughHeuristic(IReadOnlyList<uint> components)
|
||||
{
|
||||
uint power = components.Count == 0
|
||||
? 0u
|
||||
: DeterminePowerLevelOfComponent(components[0]);
|
||||
return power switch
|
||||
{
|
||||
< 7u => (int)power,
|
||||
>= 9u => (int)power - 2,
|
||||
_ => (int)power - 1,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CSpellBase::InqScarabOnlyFormula</c> (0x00597050).
|
||||
/// Foci and the infused-magic augmentations replace the ordinary taper
|
||||
/// formula with its power components plus one to four prismatic tapers.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<uint> InqScarabOnlyFormula(
|
||||
IReadOnlyList<uint> components)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(components);
|
||||
|
||||
var result = new List<uint>(8);
|
||||
uint strongestPower = 0u;
|
||||
foreach (uint component in components.Take(8))
|
||||
{
|
||||
if (component == 0u)
|
||||
break;
|
||||
if (!IsPowerComponent(component))
|
||||
continue;
|
||||
|
||||
result.Add(component);
|
||||
strongestPower = Math.Max(
|
||||
strongestPower,
|
||||
DeterminePowerLevelOfComponent(component));
|
||||
}
|
||||
|
||||
int taperCount = strongestPower switch
|
||||
{
|
||||
1u => 1,
|
||||
2u => 2,
|
||||
3u or 4u or 7u => 3,
|
||||
5u or 6u or 8u or 9u or 10u => 4,
|
||||
_ => 0,
|
||||
};
|
||||
while (taperCount-- > 0 && result.Count < 8)
|
||||
result.Add(PrismaticTaperId);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsPowerComponent(uint component) => component is
|
||||
>= 1u and <= 6u or 0x6Eu or 0x6Fu or 0x70u or 0xC0u or 0xC1u;
|
||||
|
||||
/// <summary>
|
||||
/// Retail PString's CP-1252 hash used by SpellFormula::RandomizeForName.
|
||||
/// </summary>
|
||||
public static uint ComputeNameHash(string value)
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
long result = 0;
|
||||
foreach (byte raw in Encoding.GetEncoding(1252).GetBytes(value ?? string.Empty))
|
||||
{
|
||||
sbyte character = unchecked((sbyte)raw);
|
||||
result = character + (result << 4);
|
||||
if ((result & 0xF0000000L) != 0)
|
||||
result = (result ^ ((result & 0xF0000000L) >> 24)) & 0x0FFFFFFF;
|
||||
}
|
||||
return unchecked((uint)result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail SpellFormula::RandomizeForName (0x005BD050), cross-checked with
|
||||
/// ACE.DatLoader SpellTable.GetSpellFormula. Only taper SCIDs change.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<uint> CustomizeForAccount(
|
||||
IReadOnlyList<uint> components,
|
||||
uint formulaVersion,
|
||||
string accountName)
|
||||
{
|
||||
uint[] result = components.Take(8).ToArray();
|
||||
return formulaVersion switch
|
||||
{
|
||||
1u => RandomizeVersion1(result, accountName),
|
||||
2u => RandomizeVersion2(result, accountName),
|
||||
3u => RandomizeVersion3(result, accountName),
|
||||
_ => result,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<uint> RandomizeVersion1(uint[] c, string accountName)
|
||||
{
|
||||
int count = c.Count(value => value != 0);
|
||||
if (count < 5) return c;
|
||||
|
||||
uint seed = ComputeNameHash(accountName) % 0x13D573u;
|
||||
uint scarab = c[0];
|
||||
int herbIndex = count > 5 ? 2 : 1;
|
||||
uint herb = c[herbIndex];
|
||||
int powderIndex = herbIndex + 1 + (count > 6 ? 1 : 0);
|
||||
if (powderIndex + 1 >= c.Length) return c;
|
||||
uint powder = c[powderIndex];
|
||||
uint potion = c[powderIndex + 1];
|
||||
int talismanIndex = powderIndex + 2 + (count > 7 ? 1 : 0);
|
||||
if (talismanIndex >= c.Length) return c;
|
||||
uint talisman = c[talismanIndex];
|
||||
|
||||
if (count > 5)
|
||||
c[1] = unchecked(powder + 2u * herb + potion + talisman + scarab) % 12u + LowestTaperId;
|
||||
if (count > 6)
|
||||
{
|
||||
uint denominator = unchecked(scarab + powder + potion);
|
||||
if (denominator != 0)
|
||||
c[3] = unchecked((scarab + herb + talisman + 2u * (powder + potion))
|
||||
* (seed / denominator)) % 12u + LowestTaperId;
|
||||
}
|
||||
if (count > 7)
|
||||
{
|
||||
uint denominator = unchecked(talisman + scarab);
|
||||
if (denominator != 0)
|
||||
c[6] = unchecked((powder + 2u * talisman + potion + herb + scarab)
|
||||
* (seed / denominator)) % 12u + LowestTaperId;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<uint> RandomizeVersion2(uint[] c, string accountName)
|
||||
{
|
||||
if (c.Length < 8) return c;
|
||||
uint seed = ComputeNameHash(accountName) % 0x13D573u;
|
||||
uint p1 = c[0], c4 = c[4], x = c[5], a = c[7];
|
||||
c[3] = unchecked(a + 3u * p1 + 2u * c4 * x + c[2] + c[1]) % 12u + LowestTaperId;
|
||||
uint denominator = unchecked(c[1] * a + 2u * c4);
|
||||
if (denominator != 0)
|
||||
c[6] = unchecked((a + 3u * p1 * c[2] + 2u * x + c4)
|
||||
* (seed / denominator)) % 12u + LowestTaperId;
|
||||
return c;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<uint> RandomizeVersion3(uint[] c, string accountName)
|
||||
{
|
||||
if (c.Length < 7) return c;
|
||||
uint hash = ComputeNameHash(accountName);
|
||||
uint h0 = unchecked(hash % 0x13D573u + c[0]) % 12u;
|
||||
uint h1 = unchecked(hash % 0x4AEFDu + c[1]) % 12u;
|
||||
uint h2 = unchecked(hash % 0x96A7Fu + c[2]) % 12u;
|
||||
uint h4 = unchecked(hash % 0x100A03u + c[4]) % 12u;
|
||||
uint h5 = unchecked(hash % 0xEB2EFu + c[5]) % 12u;
|
||||
uint h7 = unchecked(hash % 0x121E7Du + (c.Length > 7 ? c[7] : 0u)) % 12u;
|
||||
c[3] = unchecked(h0 + h1 + h2 + h4 + h5 + h2 * h5 + h0 * h1 + h7 * (h4 + 1u))
|
||||
% 12u + LowestTaperId;
|
||||
c[6] = unchecked(h0 + h1 + h2 + h4 + hash % 0x65039u % 12u
|
||||
+ h7 * (h4 * (h0 * h1 * h2 * h5 + 7u) + 1u)
|
||||
+ h5 + 5u * h0 * h1 + 11u * h2 * h5) % 12u + LowestTaperId;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
|
@ -41,4 +41,21 @@ public sealed record SpellMetadata(
|
|||
int ManaCost,
|
||||
bool IsDebuff,
|
||||
bool IsFellowship,
|
||||
string Description);
|
||||
string Description,
|
||||
int SortKey,
|
||||
int Difficulty,
|
||||
uint Flags,
|
||||
int Generation,
|
||||
bool IsFastWindup,
|
||||
bool IsOffensive,
|
||||
bool IsUntargeted,
|
||||
float Speed,
|
||||
uint CasterEffect,
|
||||
uint TargetEffect,
|
||||
uint TargetMask,
|
||||
int SpellType)
|
||||
{
|
||||
public bool IsSelfTargeted => (Flags & 0x00000008u) != 0;
|
||||
public bool IsBeneficial => (Flags & 0x00000004u) != 0;
|
||||
public bool IsProjectile => (Flags & 0x00000100u) != 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,9 @@ public enum MagicSchool : uint
|
|||
None = 0,
|
||||
WarMagic = 1,
|
||||
LifeMagic = 2,
|
||||
CreatureEnchantment = 3,
|
||||
ItemEnchantment = 4,
|
||||
PortalMagic = 5,
|
||||
// VoidMagic added in later retail revisions; uses LifeMagic skill.
|
||||
VoidMagic = 6,
|
||||
ItemEnchantment = 3,
|
||||
CreatureEnchantment = 4,
|
||||
VoidMagic = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -55,14 +53,23 @@ public enum SpellCategory : uint
|
|||
public enum SpellFlags : uint
|
||||
{
|
||||
None = 0,
|
||||
Beneficial = 0x00000001,
|
||||
Resistable = 0x00000002,
|
||||
Projectile = 0x00000004,
|
||||
EnchantmentDispel = 0x00000008,
|
||||
PurgeOnReset = 0x00000010,
|
||||
Resistable = 0x00000001,
|
||||
PKSensitive = 0x00000002,
|
||||
Beneficial = 0x00000004,
|
||||
SelfTargeted = 0x00000008,
|
||||
Reversed = 0x00000010,
|
||||
NotIndoors = 0x00000020,
|
||||
Melee = 0x00000040,
|
||||
Missile = 0x00000080,
|
||||
NotOutdoors = 0x00000040,
|
||||
NotResearchable = 0x00000080,
|
||||
Projectile = 0x00000100,
|
||||
CreatureSpell = 0x00000200,
|
||||
ExcludedFromItemDescriptions = 0x00000400,
|
||||
IgnoresManaConversion = 0x00000800,
|
||||
NonTrackingProjectile = 0x00001000,
|
||||
FellowshipSpell = 0x00002000,
|
||||
FastCast = 0x00004000,
|
||||
IndoorLongRange = 0x00008000,
|
||||
DamageOverTime = 0x00010000,
|
||||
// more flags in r01 §1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,18 @@ public sealed class SpellTable
|
|||
int? iIsDebuff = Get("IsDebuff");
|
||||
int? iIsFellow = Get("IsFellowship");
|
||||
int? iDescription = Get("Description");
|
||||
int? iSortKey = Get("SortKey");
|
||||
int? iDifficulty = Get("Difficulty");
|
||||
int? iFlags = Get("Flags [Hex]");
|
||||
int? iGeneration = Get("Generation");
|
||||
int? iFastWindup = Get("IsFastWindup");
|
||||
int? iOffensive = Get("IsOffensive");
|
||||
int? iUntargeted = Get("IsUntargetted");
|
||||
int? iSpeed = Get("Speed");
|
||||
int? iCasterFx = Get("CasterEffect");
|
||||
int? iTargetFx = Get("TargetEffect");
|
||||
int? iTargetMask = Get("TargetMask [Hex]");
|
||||
int? iSpellType = Get("Type");
|
||||
|
||||
if (iSpellId is null || iName is null) return new SpellTable(byId);
|
||||
|
||||
|
|
@ -125,10 +137,24 @@ public sealed class SpellTable
|
|||
bool isDebuff = ParseBool(fields, iIsDebuff);
|
||||
bool isFellow = ParseBool(fields, iIsFellow);
|
||||
string description = iDescription is int d && d < fields.Count ? fields[d] : "";
|
||||
int sortKey = (int)ParseUInt(fields, iSortKey);
|
||||
int difficulty = (int)ParseUInt(fields, iDifficulty);
|
||||
uint flags = ParseHexUInt(fields, iFlags);
|
||||
int generation = (int)ParseUInt(fields, iGeneration);
|
||||
bool fastWindup = ParseBool(fields, iFastWindup);
|
||||
bool offensive = ParseBool(fields, iOffensive);
|
||||
bool untargeted = ParseBool(fields, iUntargeted);
|
||||
float speed = ParseFloat(fields, iSpeed);
|
||||
uint casterEffect = ParseUInt(fields, iCasterFx);
|
||||
uint targetEffect = ParseUInt(fields, iTargetFx);
|
||||
uint targetMask = ParseHexUInt(fields, iTargetMask);
|
||||
int spellType = (int)ParseUInt(fields, iSpellType);
|
||||
|
||||
byId[spellId] = new SpellMetadata(
|
||||
spellId, name, school, family, iconId, words, duration,
|
||||
mana, isDebuff, isFellow, description);
|
||||
mana, isDebuff, isFellow, description, sortKey, difficulty,
|
||||
flags, generation, fastWindup, offensive, untargeted, speed,
|
||||
casterEffect, targetEffect, targetMask, spellType);
|
||||
}
|
||||
|
||||
return new SpellTable(byId);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AcDream.Core.Spells;
|
||||
|
||||
|
|
@ -17,9 +17,14 @@ namespace AcDream.Core.Spells;
|
|||
/// </summary>
|
||||
public sealed class Spellbook
|
||||
{
|
||||
private readonly HashSet<uint> _learnedSpells = new();
|
||||
private readonly ConcurrentDictionary<uint, ActiveEnchantmentRecord> _activeByLayer = new();
|
||||
private readonly Dictionary<uint, float> _learnedSpells = new();
|
||||
private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new();
|
||||
private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new();
|
||||
private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8)
|
||||
.Select(_ => new List<uint>()).ToArray();
|
||||
private readonly Dictionary<uint, uint> _desiredComponents = new();
|
||||
private readonly SpellTable _table;
|
||||
private uint _spellbookFilters = 0x3FFFu;
|
||||
|
||||
/// <summary>
|
||||
/// Build a Spellbook with an optional <see cref="SpellTable"/>
|
||||
|
|
@ -69,39 +74,98 @@ public sealed class Spellbook
|
|||
/// <summary>Fires when an enchantment is removed (expired / dispelled).</summary>
|
||||
public event Action<ActiveEnchantmentRecord>? EnchantmentRemoved;
|
||||
|
||||
/// <summary>Fires when any manifest-backed magic UI state changes.</summary>
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <summary>Fires when learned spells, favorites, or spellbook filters change.</summary>
|
||||
public event Action? SpellbookChanged;
|
||||
|
||||
/// <summary>Fires when the active enchantment set changes.</summary>
|
||||
public event Action? EnchantmentsChanged;
|
||||
|
||||
/// <summary>Fires when the desired spell-component levels change.</summary>
|
||||
public event Action? DesiredComponentsChanged;
|
||||
|
||||
/// <summary>All currently learned spell ids.</summary>
|
||||
public IReadOnlyCollection<uint> LearnedSpells => _learnedSpells;
|
||||
public IReadOnlyCollection<uint> LearnedSpells => _learnedSpells.Keys;
|
||||
|
||||
/// <summary>Stable learned-spell snapshot, including server spell power.</summary>
|
||||
public IReadOnlyList<LearnedSpellRecord> LearnedSpellSnapshot => _learnedSpells
|
||||
.Select(pair => new LearnedSpellRecord(pair.Key, pair.Value))
|
||||
.OrderBy(spell => spell.SpellId)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>All currently-active enchantments.</summary>
|
||||
public IEnumerable<ActiveEnchantmentRecord> ActiveEnchantments => _activeByLayer.Values;
|
||||
public IEnumerable<ActiveEnchantmentRecord> ActiveEnchantments => _activeById.Values;
|
||||
|
||||
public IReadOnlyList<ActiveEnchantmentRecord> ActiveEnchantmentSnapshot =>
|
||||
_activeById.Values.OrderBy(record => record.Identity).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Retail ordinary-effect projection after mult/add filtering and the
|
||||
/// spell-category duel. This is the source for effects-list rows; retail's
|
||||
/// indicator counts scan the raw mult/add records before this projection.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ActiveEnchantmentRecord> EnchantmentsInEffectSnapshot
|
||||
{
|
||||
get
|
||||
{
|
||||
var ordered = new List<ActiveEnchantmentRecord>();
|
||||
AppendBucket(bucket: 1u, ordered);
|
||||
AppendBucket(bucket: 2u, ordered);
|
||||
return EnchantmentRegistryProjection.GetEnchantmentsInEffect(ordered);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<uint> GetFavorites(int tabIndex)
|
||||
{
|
||||
if ((uint)tabIndex >= _favoriteSpells.Length) return Array.Empty<uint>();
|
||||
return _favoriteSpells[tabIndex].ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<uint, uint> DesiredComponents => _desiredComponents;
|
||||
|
||||
public uint SpellbookFilters => _spellbookFilters;
|
||||
|
||||
public int LearnedCount => _learnedSpells.Count;
|
||||
public int ActiveCount => _activeByLayer.Count;
|
||||
public int ActiveCount => _activeById.Count;
|
||||
|
||||
public bool Knows(uint spellId) => _learnedSpells.Contains(spellId);
|
||||
public bool Knows(uint spellId) => _learnedSpells.ContainsKey(spellId);
|
||||
|
||||
// ── Inbound handlers ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>0x02C1 MagicUpdateSpell: learn a spell.</summary>
|
||||
public void OnSpellLearned(uint spellId)
|
||||
public void OnSpellLearned(uint spellId, float power = 0f)
|
||||
{
|
||||
if (_learnedSpells.Add(spellId))
|
||||
if (_learnedSpells.TryAdd(spellId, power))
|
||||
{
|
||||
SpellLearned?.Invoke(spellId);
|
||||
NotifySpellbookChanged();
|
||||
}
|
||||
else if (_learnedSpells[spellId] != power)
|
||||
{
|
||||
_learnedSpells[spellId] = power;
|
||||
NotifySpellbookChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x01A8 MagicRemoveSpell: forget a spell.</summary>
|
||||
public void OnSpellForgotten(uint spellId)
|
||||
{
|
||||
if (_learnedSpells.Remove(spellId))
|
||||
{
|
||||
SpellForgotten?.Invoke(spellId);
|
||||
NotifySpellbookChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x02C2 MagicUpdateEnchantment: enchantment added / refreshed.</summary>
|
||||
public void OnEnchantmentAdded(uint spellId, uint layerId, float duration, uint casterGuid)
|
||||
{
|
||||
var record = new ActiveEnchantmentRecord(spellId, layerId, duration, casterGuid);
|
||||
_activeByLayer[layerId] = record;
|
||||
UpsertLiveEnchantment(record);
|
||||
EnchantmentAdded?.Invoke(record);
|
||||
NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -113,56 +177,305 @@ public sealed class Spellbook
|
|||
/// </summary>
|
||||
public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
|
||||
{
|
||||
_activeByLayer[record.LayerId] = record;
|
||||
UpsertLiveEnchantment(record);
|
||||
EnchantmentAdded?.Invoke(record);
|
||||
NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
/// <summary>0x02C3 / 0x02C7 MagicRemove/DispelEnchantment.</summary>
|
||||
public void OnEnchantmentRemoved(uint layerId, uint spellId)
|
||||
{
|
||||
if (_activeByLayer.TryRemove(layerId, out var record))
|
||||
uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layerId);
|
||||
if (RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record))
|
||||
{
|
||||
EnchantmentRemoved?.Invoke(record);
|
||||
else
|
||||
EnchantmentRemoved?.Invoke(new ActiveEnchantmentRecord(spellId, layerId, 0f, 0));
|
||||
NotifyEnchantmentsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x02C6 MagicPurgeEnchantments: clear all active buffs.</summary>
|
||||
public void OnEnchantmentsAdded(IEnumerable<ActiveEnchantmentRecord> records)
|
||||
{
|
||||
foreach (ActiveEnchantmentRecord record in records)
|
||||
{
|
||||
UpsertLiveEnchantment(record);
|
||||
EnchantmentAdded?.Invoke(record);
|
||||
}
|
||||
NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
public void OnEnchantmentsRemoved(IEnumerable<(uint SpellId, uint Layer)> spells)
|
||||
{
|
||||
bool changed = false;
|
||||
foreach ((uint spellId, uint layer) in spells)
|
||||
{
|
||||
uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layer);
|
||||
if (!RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record)) continue;
|
||||
changed = true;
|
||||
EnchantmentRemoved?.Invoke(record);
|
||||
}
|
||||
if (changed) NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail EnchantmentRegistry::PurgeEnchantments (0x00594DE0): purge only
|
||||
/// finite-duration multiplicative/additive enchantments. Vitae, cooldowns,
|
||||
/// and permanent enchantments live in separate registries and survive.
|
||||
/// </summary>
|
||||
public void OnPurgeAll()
|
||||
{
|
||||
foreach (var rec in _activeByLayer.Values)
|
||||
ActiveEnchantmentRecord[] removed = _activeById.Values
|
||||
.Where(IsPurgeable)
|
||||
.ToArray();
|
||||
foreach (ActiveEnchantmentRecord record in removed)
|
||||
RemoveActiveEnchantment(record.Identity, out _);
|
||||
foreach (var rec in removed)
|
||||
EnchantmentRemoved?.Invoke(rec);
|
||||
_activeByLayer.Clear();
|
||||
if (removed.Length != 0) NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
public void OnPurgeBadEnchantments()
|
||||
{
|
||||
// Retail PurgeBadEnchantments (0x00594E00) tests the StatMod
|
||||
// Beneficial bit directly; spell-table labels do not participate.
|
||||
const uint BeneficialStatMod = 0x02000000u;
|
||||
ActiveEnchantmentRecord[] removed = _activeById.Values
|
||||
.Where(record => IsPurgeable(record)
|
||||
&& (record.StatModType.GetValueOrDefault() & BeneficialStatMod) == 0)
|
||||
.ToArray();
|
||||
foreach (ActiveEnchantmentRecord record in removed)
|
||||
RemoveActiveEnchantment(record.Identity, out _);
|
||||
foreach (ActiveEnchantmentRecord record in removed)
|
||||
EnchantmentRemoved?.Invoke(record);
|
||||
if (removed.Length != 0) NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
private static bool IsPurgeable(ActiveEnchantmentRecord record) =>
|
||||
(record.Bucket is 1u or 2u) && record.Duration != -1f;
|
||||
|
||||
/// <summary>
|
||||
/// PlayerDescription is a complete character manifest. Replace every
|
||||
/// magic collection atomically so relogging or switching characters cannot
|
||||
/// retain state from the prior snapshot.
|
||||
/// </summary>
|
||||
public void ReplaceManifest(
|
||||
IReadOnlyDictionary<uint, float> learnedSpells,
|
||||
IEnumerable<ActiveEnchantmentRecord> enchantments,
|
||||
IReadOnlyList<IReadOnlyList<uint>> favorites,
|
||||
IReadOnlyList<(uint Id, uint Amount)> desiredComponents,
|
||||
uint spellbookFilters)
|
||||
{
|
||||
_learnedSpells.Clear();
|
||||
foreach ((uint spellId, float power) in learnedSpells)
|
||||
_learnedSpells[spellId] = power;
|
||||
|
||||
_activeById.Clear();
|
||||
_enchantmentOrderByBucket.Clear();
|
||||
foreach (ActiveEnchantmentRecord enchantment in enchantments)
|
||||
UpsertManifestEnchantment(enchantment);
|
||||
|
||||
for (int i = 0; i < _favoriteSpells.Length; i++)
|
||||
{
|
||||
_favoriteSpells[i].Clear();
|
||||
if (i < favorites.Count)
|
||||
_favoriteSpells[i].AddRange(favorites[i]);
|
||||
}
|
||||
|
||||
_desiredComponents.Clear();
|
||||
foreach ((uint id, uint amount) in desiredComponents)
|
||||
_desiredComponents[id] = amount;
|
||||
_spellbookFilters = spellbookFilters;
|
||||
SpellbookChanged?.Invoke();
|
||||
EnchantmentsChanged?.Invoke();
|
||||
DesiredComponentsChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void SetFavorite(int tabIndex, int position, uint spellId)
|
||||
{
|
||||
if ((uint)tabIndex >= _favoriteSpells.Length || position < 0) return;
|
||||
List<uint> tab = _favoriteSpells[tabIndex];
|
||||
if (position > tab.Count) position = tab.Count;
|
||||
tab.Remove(spellId);
|
||||
tab.Insert(Math.Min(position, tab.Count), spellId);
|
||||
NotifySpellbookChanged();
|
||||
}
|
||||
|
||||
public void RemoveFavorite(int tabIndex, uint spellId)
|
||||
{
|
||||
if ((uint)tabIndex >= _favoriteSpells.Length) return;
|
||||
if (_favoriteSpells[tabIndex].Remove(spellId)) NotifySpellbookChanged();
|
||||
}
|
||||
|
||||
public void SetSpellbookFilters(uint filters)
|
||||
{
|
||||
if (_spellbookFilters == filters) return;
|
||||
_spellbookFilters = filters;
|
||||
NotifySpellbookChanged();
|
||||
}
|
||||
|
||||
public void SetDesiredComponent(uint componentId, uint amount)
|
||||
{
|
||||
uint current = _desiredComponents.TryGetValue(componentId, out uint existing)
|
||||
? existing : 0u;
|
||||
if (current == amount) return;
|
||||
if (amount == 0) _desiredComponents.Remove(componentId);
|
||||
else _desiredComponents[componentId] = amount;
|
||||
DesiredComponentsChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_learnedSpells.Clear();
|
||||
_activeByLayer.Clear();
|
||||
_activeById.Clear();
|
||||
_enchantmentOrderByBucket.Clear();
|
||||
foreach (List<uint> tab in _favoriteSpells) tab.Clear();
|
||||
_desiredComponents.Clear();
|
||||
_spellbookFilters = 0x3FFFu;
|
||||
SpellbookChanged?.Invoke();
|
||||
EnchantmentsChanged?.Invoke();
|
||||
DesiredComponentsChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void NotifySpellbookChanged()
|
||||
{
|
||||
SpellbookChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void NotifyEnchantmentsChanged()
|
||||
{
|
||||
EnchantmentsChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void AppendBucket(uint bucket, List<ActiveEnchantmentRecord> destination)
|
||||
{
|
||||
if (!_enchantmentOrderByBucket.TryGetValue(bucket, out List<uint>? order)) return;
|
||||
foreach (uint identity in order)
|
||||
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord record)
|
||||
&& record.Bucket == bucket)
|
||||
destination.Add(record);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>AddEnchantmentToList @ 0x00593DF0</c> inserts a new live
|
||||
/// mult/add record at the list head. Refreshing an existing identity keeps
|
||||
/// its node position; moving buckets creates a new head node.
|
||||
/// </summary>
|
||||
private void UpsertLiveEnchantment(ActiveEnchantmentRecord record)
|
||||
{
|
||||
uint identity = record.Identity;
|
||||
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous))
|
||||
{
|
||||
_activeById[identity] = record;
|
||||
if (previous.Bucket == record.Bucket)
|
||||
{
|
||||
EnsureOrdered(identity, record.Bucket, insertAtHead: true);
|
||||
return;
|
||||
}
|
||||
RemoveFromOrder(previous);
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeById.Add(identity, record);
|
||||
}
|
||||
GetBucketOrder(record.Bucket).Insert(0, identity);
|
||||
}
|
||||
|
||||
/// <summary>Bulk UnPack preserves the received head-to-tail list order.</summary>
|
||||
private void UpsertManifestEnchantment(ActiveEnchantmentRecord record)
|
||||
{
|
||||
uint identity = record.Identity;
|
||||
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous))
|
||||
{
|
||||
_activeById[identity] = record;
|
||||
if (previous.Bucket == record.Bucket)
|
||||
{
|
||||
EnsureOrdered(identity, record.Bucket, insertAtHead: false);
|
||||
return;
|
||||
}
|
||||
RemoveFromOrder(previous);
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeById.Add(identity, record);
|
||||
}
|
||||
GetBucketOrder(record.Bucket).Add(identity);
|
||||
}
|
||||
|
||||
private bool RemoveActiveEnchantment(
|
||||
uint identity,
|
||||
out ActiveEnchantmentRecord record)
|
||||
{
|
||||
if (!_activeById.Remove(identity, out record)) return false;
|
||||
RemoveFromOrder(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void EnsureOrdered(uint identity, uint bucket, bool insertAtHead)
|
||||
{
|
||||
List<uint> order = GetBucketOrder(bucket);
|
||||
if (order.Contains(identity)) return;
|
||||
if (insertAtHead) order.Insert(0, identity);
|
||||
else order.Add(identity);
|
||||
}
|
||||
|
||||
private List<uint> GetBucketOrder(uint bucket)
|
||||
{
|
||||
if (_enchantmentOrderByBucket.TryGetValue(bucket, out List<uint>? order))
|
||||
return order;
|
||||
order = new List<uint>();
|
||||
_enchantmentOrderByBucket.Add(bucket, order);
|
||||
return order;
|
||||
}
|
||||
|
||||
private void RemoveFromOrder(ActiveEnchantmentRecord record)
|
||||
{
|
||||
if (!_enchantmentOrderByBucket.TryGetValue(record.Bucket, out List<uint>? order))
|
||||
return;
|
||||
order.Remove(record.Identity);
|
||||
if (order.Count == 0)
|
||||
_enchantmentOrderByBucket.Remove(record.Bucket);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct LearnedSpellRecord(uint SpellId, float Power);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of one active enchantment layer on the player. The
|
||||
/// optional StatMod fields (issue #12) carry the wire-level
|
||||
/// `_smod` triad <c>(type, key, val)</c> when available — only
|
||||
/// `PlayerDescription`'s enchantment block currently populates these
|
||||
/// (<see cref="AcDream.Core.Net.Messages.PlayerDescriptionParser"/>).
|
||||
/// `MagicUpdateEnchantment` events still produce records with these
|
||||
/// fields null until the wire parser is extended.
|
||||
/// `_smod` triad <c>(type, key, val)</c>. Both the complete
|
||||
/// `PlayerDescription` snapshot and live MagicUpdateEnchantment events
|
||||
/// populate the same record shape.
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="Bucket"/> tells <c>EnchantmentMath</c> whether this
|
||||
/// enchantment's StatMod is multiplicative (<c>0x01</c>), additive
|
||||
/// (<c>0x02</c>), cooldown (<c>0x04</c>), or vitae (<c>0x08</c>) per
|
||||
/// (<c>0x02</c>), vitae (<c>0x04</c>), or cooldown (<c>0x08</c>) per
|
||||
/// the retail <c>EnchantmentMask</c> classification.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct ActiveEnchantmentRecord(
|
||||
uint SpellId,
|
||||
uint LayerId,
|
||||
float Duration,
|
||||
double Duration,
|
||||
uint CasterGuid,
|
||||
uint? StatModType = null,
|
||||
uint? StatModKey = null,
|
||||
float? StatModValue = null,
|
||||
uint Bucket = 0);
|
||||
uint Bucket = 0,
|
||||
double StartTime = 0,
|
||||
uint SpellCategory = 0,
|
||||
uint PowerLevel = 0,
|
||||
float DegradeModifier = 0,
|
||||
float DegradeLimit = 0,
|
||||
double LastTimeDegraded = 0,
|
||||
uint? SpellSetId = null)
|
||||
{
|
||||
public uint Identity => MakeIdentity(SpellId, LayerId);
|
||||
|
||||
public static uint MakeIdentity(uint spellId, uint layerId) =>
|
||||
(spellId & 0xFFFFu) | ((layerId & 0xFFFFu) << 16);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue