feat(headless): share immutable gameplay content

This commit is contained in:
Erik 2026-07-27 09:00:48 +02:00
parent 12b500d383
commit 9569dadb57
25 changed files with 1031 additions and 429 deletions

View file

@ -1,158 +0,0 @@
using AcDream.App.Spells;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using DatMagicSchool = DatReaderWriter.Enums.MagicSchool;
using DatSpellCategory = DatReaderWriter.Enums.SpellCategory;
using CoreSpellTable = AcDream.Core.Spells.SpellTable;
namespace AcDream.App.Tests.Spells;
public sealed class RetailSpellMetadataProjectorTests
{
[Fact]
public void Project_PreservesRetailFieldsAndDerivesPresentationMetadata()
{
var components = new SpellComponentTable();
components.Components.Add(10u, Component(ComponentType.Herb, "Malar"));
components.Components.Add(11u, Component(ComponentType.Powder, "Caza"));
components.Components.Add(12u, Component(ComponentType.Potion, "El"));
var source = new SpellBase
{
Name = "Incantation of Test",
Description = "A projected retail spell.",
Components = [0xC1u, 10u, 11u, 12u, 63u, 0x31u],
School = DatMagicSchool.WarMagic,
Icon = 0x06001234u,
Category = (DatSpellCategory)77u,
Bitfield = SpellIndex.Resistable | SpellIndex.Projectile | SpellIndex.FastCast,
BaseMana = 50u,
BaseRangeConstant = 40f,
BaseRangeMod = 0.25f,
Power = 400u,
SpellEconomyMod = -1f,
FormulaVersion = 3u,
ComponentLoss = 0.2f,
MetaSpellType = SpellType.Projectile,
MetaSpellId = 5000u,
CasterEffect = (PlayScript)6u,
TargetEffect = (PlayScript)7u,
FizzleEffect = (PlayScript)8u,
RecoveryInterval = 1.5,
RecoveryAmount = 2.5f,
DisplayOrder = 1234u,
NonComponentTargetType = ItemType.Creature,
ManaMod = 14u,
};
SpellMetadata actual = RetailSpellMetadataProjector.Project(
5000u, source, components);
Assert.Equal("Incantation of Test", actual.Name);
Assert.Equal("A projected retail spell.", actual.Description);
Assert.Equal("War Magic", actual.School);
Assert.Equal(AcDream.Core.Spells.MagicSchool.WarMagic, actual.SchoolId);
Assert.Equal(77u, actual.Family);
Assert.Equal(0x06001234u, actual.IconId);
Assert.Equal("Malar Cazael", actual.SpellWords);
Assert.Equal(8, actual.Generation);
Assert.Equal(0x10u, actual.TargetMask);
Assert.False(actual.IsUntargeted);
Assert.True(actual.IsProjectile);
Assert.True(actual.IsFastWindup);
Assert.True(actual.IsOffensive);
Assert.Equal(source.Components, actual.FormulaComponents);
Assert.Equal(3u, actual.FormulaVersion);
Assert.Equal(14u, actual.ManaModifier);
Assert.Equal((uint)ItemType.Creature, actual.NonComponentTargetType);
Assert.Equal(0x10u, actual.FormulaTargetType);
}
[Fact]
public void Load_EndOfRetailDat_ResolvesEveryLateLearnedSpellMissingFromCsv()
{
string? datDir = ResolveDatDir();
string? csvPath = ResolveRepoFile("docs", "research", "data", "spells.csv");
if (datDir is null || csvPath is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
MagicCatalog catalog = MagicCatalog.Load(dats);
CoreSpellTable legacy = CoreSpellTable.LoadFromCsv(csvPath);
Assert.Equal(6266, catalog.SpellTable.Count);
Assert.Equal(3956, legacy.Count);
uint[] lateSpellIds = catalog.SpellTable.SpellIds
.Where(id => !legacy.TryGet(id, out _))
.ToArray();
Assert.Equal(2310, lateSpellIds.Length);
var spellbook = new Spellbook(catalog.SpellTable);
foreach (uint spellId in lateSpellIds)
{
spellbook.OnSpellLearned(spellId);
Assert.True(spellbook.TryGetMetadata(spellId, out SpellMetadata metadata));
Assert.Equal(spellId, metadata.SpellId);
}
}
[Fact]
public void Load_EndOfRetailDat_UsesRetailFormulaTargetsInsteadOfLegacyExportMasks()
{
string? datDir = ResolveDatDir();
string? csvPath = ResolveRepoFile("docs", "research", "data", "spells.csv");
if (datDir is null || csvPath is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
MagicCatalog catalog = MagicCatalog.Load(dats);
CoreSpellTable legacy = CoreSpellTable.LoadFromCsv(csvPath);
var mismatches = new List<string>();
foreach (uint spellId in legacy.SpellIds)
{
if (!legacy.TryGet(spellId, out SpellMetadata oldSpell)
|| !catalog.SpellTable.TryGet(spellId, out SpellMetadata datSpell))
continue;
if (oldSpell.TargetMask != datSpell.TargetMask)
mismatches.Add(
$"{spellId}:{oldSpell.Name} csv=0x{oldSpell.TargetMask:X8} " +
$"dat=0x{datSpell.TargetMask:X8} formulaTarget=0x{datSpell.FormulaTargetType:X8} " +
$"formula=[{string.Join(',', datSpell.FormulaComponents)}]");
}
Assert.Equal(378, mismatches.Count);
Assert.Contains(mismatches, value => value.StartsWith("35:Blood Drinker I "));
Assert.Contains(mismatches, value => value.Contains("csv=0x00000101 dat=0x00000010"));
}
private static SpellComponentBase Component(ComponentType type, string text) => new()
{
Type = type,
Text = text,
};
private static string? ResolveDatDir()
{
string? fromEnv = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
string fallback = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(fallback) ? fallback : null;
}
private static string? ResolveRepoFile(params string[] parts)
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
string candidate = Path.Combine([current.FullName, .. parts]);
if (File.Exists(candidate)) return candidate;
current = current.Parent;
}
return null;
}
}

View file

@ -1,216 +0,0 @@
using AcDream.App.Spells;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.Spells;
public sealed class SpellComponentRequirementServiceTests
{
[Fact]
public void RequiresEveryFormulaScidMappedToOwnedWcid()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
WeenieClassId = 100u,
ContainerId = 1u,
IsComponentPack = true,
});
var service = Service(objects, [10u, 11u], new Dictionary<uint, uint>
{
[10u] = 100u,
[11u] = 101u,
});
Assert.False(service.HasRequiredComponents(50u));
objects.AddOrUpdate(new ClientObject
{
ObjectId = 3u,
WeenieClassId = 101u,
ContainerId = 1u,
IsComponentPack = true,
});
Assert.True(service.HasRequiredComponents(50u));
}
[Fact]
public void ServerDisabledComponents_BypassesPreflightAndUsesModernFormula()
{
var objects = new ClientObjectTable();
var player = new ClientObject { ObjectId = 1u, Name = "Player" };
player.Properties.Bools[SpellComponentRequirementService.SpellComponentsRequiredProperty] = false;
objects.AddOrUpdate(player);
SpellComponentRequirementService service =
Service(objects, [1u, 63u, 10u], new Dictionary<uint, uint>());
Assert.True(service.HasRequiredComponents(50u));
Assert.Equal(new uint[] { 1u, 0xBCu }, service.GetAppropriateFormula(50u));
}
[Fact]
public void PersonalizedFormula_UsesExactCanonicalAccountSpelling()
{
const string canonical = "CanonicalAccount";
const string loginSpelling = "canonicalaccount";
uint[] formula = [6u, 63u, 10u, 64u, 20u, 30u, 65u, 40u];
IReadOnlyList<uint> canonicalFormula = RetailSpellFormula.CustomizeForAccount(
formula, 3u, canonical);
IReadOnlyList<uint> loginFormula = RetailSpellFormula.CustomizeForAccount(
formula, 3u, loginSpelling);
Assert.NotEqual(canonicalFormula, loginFormula);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var map = canonicalFormula.Where(scid => scid != 0u)
.Distinct()
.ToDictionary(scid => scid, scid => scid + 1000u);
foreach (uint wcid in map.Values)
objects.AddOrUpdate(new ClientObject
{
ObjectId = wcid + 10000u,
WeenieClassId = wcid,
ContainerId = 1u,
});
string account = canonical;
var service = new SpellComponentRequirementService(
objects, () => 1u, () => account,
new Dictionary<uint, SpellFormulaDefinition> { [50u] = new(3u, formula) },
map);
Assert.True(service.HasRequiredComponents(50u));
account = loginSpelling;
Assert.False(service.HasRequiredComponents(50u));
}
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public void InfusedSchoolOrDirectlyCarriedFocus_UsesScarabOnlyFormula(
bool infused,
bool carriesFocus)
{
var objects = new ClientObjectTable();
var player = new ClientObject { ObjectId = 1u, Name = "Player" };
if (infused)
player.Properties.Ints[0x129u] = 1;
objects.AddOrUpdate(player);
if (carriesFocus)
{
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
WeenieClassId = 900u,
ContainerId = 1u,
Type = ItemType.Container,
});
}
// War I scarab-only formula is SCID 1 + one prismatic taper (0xBC).
objects.AddOrUpdate(new ClientObject
{
ObjectId = 3u,
WeenieClassId = 101u,
ContainerId = 1u,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 4u,
WeenieClassId = 188u,
ContainerId = 1u,
});
var service = new SpellComponentRequirementService(
objects, () => 1u, () => "testaccount",
new Dictionary<uint, SpellFormulaDefinition>
{
[50u] = new(0u, [1u, 63u, 10u], School: 1u),
},
new Dictionary<uint, uint>
{
[1u] = 101u,
[0xBCu] = 188u,
},
new Dictionary<uint, uint> { [1u] = 900u });
Assert.True(service.HasRequiredComponents(50u));
}
[Fact]
public void FocusNestedInsidePack_DoesNotCountAsRetailMagicPackOwnership()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
WeenieClassId = 700u,
ContainerId = 1u,
Type = ItemType.Container,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 3u,
WeenieClassId = 900u,
ContainerId = 2u,
Type = ItemType.Container,
});
var service = new SpellComponentRequirementService(
objects, () => 1u, () => "testaccount",
new Dictionary<uint, SpellFormulaDefinition>
{
[50u] = new(0u, [1u, 63u], School: 1u),
},
new Dictionary<uint, uint>(),
new Dictionary<uint, uint> { [1u] = 900u });
Assert.False(service.HasRequiredComponents(50u));
}
[Fact]
public void ExaminationFormulaPreservesOrderAndReportsNestedOwnership()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
objects.AddOrUpdate(new ClientObject
{
ObjectId = 2u,
ContainerId = 1u,
Type = ItemType.Container,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 3u,
WeenieClassId = 101u,
ContainerId = 2u,
});
var service = Service(
objects,
[10u, 11u, 10u],
new Dictionary<uint, uint>
{
[10u] = 101u,
[11u] = 102u,
});
Assert.Equal(
new uint[] { 10u, 11u, 10u },
service.GetAppropriateFormula(50u));
Assert.True(service.IsComponentOwned(10u));
Assert.False(service.IsComponentOwned(11u));
}
private static SpellComponentRequirementService Service(
ClientObjectTable objects,
IReadOnlyList<uint> components,
IReadOnlyDictionary<uint, uint> map) => new(
objects,
() => 1u,
() => "testaccount",
new Dictionary<uint, SpellFormulaDefinition>
{
[50u] = new(0u, components),
},
map);
}