feat(headless): share immutable gameplay content
This commit is contained in:
parent
12b500d383
commit
9569dadb57
25 changed files with 1031 additions and 429 deletions
143
tests/AcDream.Content.Tests/SharedPreparedCollisionCacheTests.cs
Normal file
143
tests/AcDream.Content.Tests/SharedPreparedCollisionCacheTests.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using System.Collections.Immutable;
|
||||
using AcDream.Content.Pak;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Content.Tests;
|
||||
|
||||
public sealed class SharedPreparedCollisionCacheTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoadedPayloadIsSharedByReferenceAndLeastRecentlyUsedIsBounded()
|
||||
{
|
||||
var source = new FixtureSource();
|
||||
using var cache = new SharedPreparedCollisionCache(
|
||||
source,
|
||||
capacity: 2);
|
||||
|
||||
FlatSetupCollision first =
|
||||
Assert.IsType<FlatSetupCollision>(
|
||||
cache.ReadSetupCollision(1u).Data);
|
||||
Assert.Same(
|
||||
first,
|
||||
cache.ReadSetupCollision(1u).Data);
|
||||
_ = cache.ReadSetupCollision(2u);
|
||||
Assert.Same(
|
||||
first,
|
||||
cache.ReadSetupCollision(1u).Data);
|
||||
_ = cache.ReadSetupCollision(3u);
|
||||
|
||||
SharedPreparedCollisionCacheSnapshot snapshot =
|
||||
cache.CaptureSnapshot();
|
||||
Assert.Equal(2, snapshot.Count);
|
||||
Assert.True(snapshot.IsBounded);
|
||||
Assert.Equal(2, snapshot.Hits);
|
||||
Assert.Equal(3, snapshot.Misses);
|
||||
Assert.Equal(1, source.SetupReads[1u]);
|
||||
Assert.Equal(1, source.SetupReads[2u]);
|
||||
|
||||
_ = cache.ReadSetupCollision(2u);
|
||||
|
||||
Assert.Equal(2, source.SetupReads[2u]);
|
||||
Assert.True(cache.CaptureSnapshot().IsBounded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TypeParticipatesInIdentityAndDisposeDoesNotOwnPackage()
|
||||
{
|
||||
var source = new FixtureSource();
|
||||
var cache = new SharedPreparedCollisionCache(source, capacity: 4);
|
||||
|
||||
FlatSetupCollision setup =
|
||||
Assert.IsType<FlatSetupCollision>(
|
||||
cache.ReadSetupCollision(7u).Data);
|
||||
FlatGfxObjCollisionAsset gfx =
|
||||
Assert.IsType<FlatGfxObjCollisionAsset>(
|
||||
cache.ReadGfxObjCollision(7u).Data);
|
||||
|
||||
Assert.NotSame(setup, gfx);
|
||||
Assert.Equal(1, source.SetupReads[7u]);
|
||||
Assert.Equal(1, source.GfxReads[7u]);
|
||||
|
||||
cache.Dispose();
|
||||
|
||||
Assert.False(source.IsDisposed);
|
||||
Assert.True(cache.CaptureSnapshot().IsDisposed);
|
||||
Assert.Throws<ObjectDisposedException>(
|
||||
() => cache.ReadSetupCollision(7u));
|
||||
}
|
||||
|
||||
private sealed class FixtureSource : IPreparedCollisionSource
|
||||
{
|
||||
internal Dictionary<uint, int> SetupReads { get; } = new();
|
||||
internal Dictionary<uint, int> GfxReads { get; } = new();
|
||||
internal bool IsDisposed { get; private set; }
|
||||
|
||||
public PreparedCollisionSourceStats CollisionStats => default;
|
||||
|
||||
public PreparedAssetPresence ProbeCollision(
|
||||
PakAssetType type,
|
||||
uint sourceFileId) =>
|
||||
PreparedAssetPresence.Available;
|
||||
|
||||
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
|
||||
ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Increment(GfxReads, sourceFileId);
|
||||
return PreparedCollisionReadResult<
|
||||
FlatGfxObjCollisionAsset>.Loaded(
|
||||
new FlatGfxObjCollisionAsset(
|
||||
EmptyPhysicsBsp(),
|
||||
null,
|
||||
null));
|
||||
}
|
||||
|
||||
public PreparedCollisionReadResult<FlatSetupCollision>
|
||||
ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Increment(SetupReads, sourceFileId);
|
||||
return PreparedCollisionReadResult<FlatSetupCollision>.Loaded(
|
||||
new FlatSetupCollision(
|
||||
ImmutableArray<FlatCollisionCylinder>.Empty,
|
||||
ImmutableArray<FlatCollisionSphere>.Empty,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
0f));
|
||||
}
|
||||
|
||||
public PreparedCollisionReadResult<
|
||||
FlatCellStructureCollisionAsset>
|
||||
ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
PreparedCollisionReadResult<
|
||||
FlatCellStructureCollisionAsset>.Missing;
|
||||
|
||||
public PreparedCollisionReadResult<FlatEnvCellTopology>
|
||||
ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
PreparedCollisionReadResult<FlatEnvCellTopology>.Missing;
|
||||
|
||||
public void Dispose() => IsDisposed = true;
|
||||
|
||||
private static FlatPhysicsBsp EmptyPhysicsBsp() => new(
|
||||
-1,
|
||||
ImmutableArray<FlatPhysicsBspNode>.Empty,
|
||||
ImmutableArray<int>.Empty,
|
||||
FlatPolygonTable.Empty);
|
||||
|
||||
private static void Increment(
|
||||
Dictionary<uint, int> counts,
|
||||
uint id) =>
|
||||
counts[id] = counts.TryGetValue(id, out int count)
|
||||
? count + 1
|
||||
: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
using AcDream.Content;
|
||||
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.Content.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 rawDats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var dats = new DatCollectionAdapter(rawDats);
|
||||
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 rawDats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var dats = new DatCollectionAdapter(rawDats);
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue