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

@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
public sealed record SpellFormulaDefinition(
uint FormulaVersion,
IReadOnlyList<uint> ComponentIds,
uint School = 0u);
/// <summary>
/// Client-side retail component preflight for ClientMagicSystem::CastSpell
/// (0x00568040). Spell formula entries are SCIDs; carried inventory objects and
/// SetDesiredComponent use WCIDs, so all comparisons cross the DAT enum map.
/// </summary>
public sealed class SpellComponentRequirementService
{
public const uint SpellComponentsRequiredProperty = 68u;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly Func<string> _accountName;
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulas;
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
public SpellComponentRequirementService(
ClientObjectTable objects,
Func<uint> playerGuid,
Func<string> accountName,
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulas,
IReadOnlyDictionary<uint, uint> wcidByScid,
IReadOnlyDictionary<uint, uint>? magicPackWcidBySchool = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_accountName = accountName ?? throw new ArgumentNullException(nameof(accountName));
_formulas = formulas ?? throw new ArgumentNullException(nameof(formulas));
_wcidByScid = wcidByScid ?? throw new ArgumentNullException(nameof(wcidByScid));
_magicPackWcidBySchool = magicPackWcidBySchool
?? new Dictionary<uint, uint>();
}
public bool HasRequiredComponents(uint spellId)
{
uint playerGuid = _playerGuid();
ClientObject? player = _objects.Get(playerGuid);
if (player is not null
&& !player.Properties.GetBool(SpellComponentsRequiredProperty, true))
return true;
if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
return false;
HashSet<uint> ownedWcids = _objects.Objects
.Where(item => IsOwnedByPlayer(item, playerGuid))
.Select(item => item.WeenieClassId)
.ToHashSet();
IReadOnlyList<uint> components = GetAppropriateFormula(
formula, player, playerGuid);
foreach (uint scid in components)
{
if (scid == 0u) continue;
if (!_wcidByScid.TryGetValue(scid, out uint wcid)
|| !ownedWcids.Contains(wcid))
return false;
}
return true;
}
/// <summary>
/// Retail <c>ClientMagicSystem::GetAppropriateSpellFormula</c>
/// (0x00567D50): an infused school or a directly carried school focus uses
/// the scarab-only formula; otherwise the account-customized formula wins.
/// </summary>
private IReadOnlyList<uint> GetAppropriateFormula(
SpellFormulaDefinition formula,
ClientObject? player,
uint playerGuid)
{
bool infused = SchoolInfusionProperty(formula.School) is uint propertyId
&& player?.Properties.GetInt(propertyId) > 0;
bool carriesMagicPack = _magicPackWcidBySchool.TryGetValue(
formula.School, out uint packWcid)
&& _objects.Objects.Any(item =>
item.ContainerId == playerGuid
&& item.WeenieClassId == packWcid);
return infused || carriesMagicPack
? RetailSpellFormula.InqScarabOnlyFormula(formula.ComponentIds)
: RetailSpellFormula.CustomizeForAccount(
formula.ComponentIds, formula.FormulaVersion, _accountName());
}
private static uint? SchoolInfusionProperty(uint school) => school switch
{
1u => 0x129u, // AugmentationInfusedWarMagic
2u => 0x128u, // AugmentationInfusedLifeMagic
3u => 0x127u, // AugmentationInfusedItemMagic
4u => 0x126u, // AugmentationInfusedCreatureMagic
5u => 0x148u, // AugmentationInfusedVoidMagic
_ => null,
};
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
{
if (playerGuid == 0u) return false;
ClientObject current = item;
for (int depth = 0; depth < 16; depth++)
{
if (current.WielderId == playerGuid || current.ContainerId == playerGuid)
return true;
if (current.ContainerId == 0u
|| _objects.Get(current.ContainerId) is not { } parent)
return false;
current = parent;
}
return false;
}
}