feat(spells): #11 SpellTable - hydrate metadata from spells.csv at startup
New SpellMetadata + SpellTable. Loads docs/research/data/spells.csv at GameWindow construction (3,956 spells x 11 useful fields including Family for buff stacking which issue #6 needs). The CSV is copied to bin/<config>/net10.0/data/spells.csv via the csproj <None Include> entry; SpellTable.LoadFromCsv resolves relative to AppContext.BaseDirectory. Hand-rolled CSV parser handles RFC 4180 quoted fields with embedded commas (the Description column) + escaped double-quotes ("" -> "). No external CsvHelper dep. Falls back to SpellTable.Empty + console warning if the file is missing (tooling contexts). Spellbook now accepts an optional SpellTable in its constructor + exposes TryGetMetadata(spellId, out SpellMetadata). When the table is absent (legacy `new Spellbook()` calls), TryGetMetadata returns false gracefully so existing tests keep passing. GameWindow: - SpellTable field initialized via LoadSpellTable() helper that handles the missing-file case + emits the spells: loaded N entries log line. - SpellBook field constructor-initialized with the loaded SpellTable so TryGetMetadata works for the live session. 10 new tests (SpellTableTests): - Empty table behavior - Header-only loads to empty - Single row populates all metadata - Quoted Description with embedded commas - Blank lines skipped - Bad-spell-id rows silently skipped (third-party data is messy) - Unknown spell-id lookup returns false - ParseRow primitive: simple comma split, quoted-field with comma, escaped double-quote. Total tests: 818 -> 828. Closes #11. Phase G (issue #6 — fold enchantment buffs into vital max via EnchantmentMath using SpellTable.Family for stacking) unblocked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
83b020499b
commit
4ceac5cb40
7 changed files with 463 additions and 23 deletions
|
|
@ -177,32 +177,18 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## #11 — Spell metadata loader (`spells.csv` → `SpellTable`)
|
|
||||||
|
|
||||||
**Status:** OPEN
|
|
||||||
**Severity:** LOW (unblocks issue #6's stacking aggregation; also adds tooltip / icon / school metadata for future panels)
|
|
||||||
**Filed:** 2026-04-25
|
|
||||||
**Component:** core / spells
|
|
||||||
|
|
||||||
**Description:** `docs/research/data/spells.csv` (3,956 spells × 35 cols) has all the per-spell metadata the existing `Spellbook` lacks: `Name`, `School`, `Family` (buff stacking bucket), `IconId`, `Mana`, `Duration`, `IsDebuff`, `IsFellowship`, `Description`. Need a `SpellTable` loader that hydrates a `Dictionary<uint, SpellMetadata>` at startup so `Spellbook.TryGetMetadata(spellId, out)` works.
|
|
||||||
|
|
||||||
**Root cause / status:** Issue #6 (vital max ignores enchantment buffs) needs `Family` to do correct stacking aggregation (only one buff per family wins; highest generation). That field comes only from `spells.csv`.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `src/AcDream.Core/Spells/SpellMetadata.cs` (new record).
|
|
||||||
- `src/AcDream.Core/Spells/SpellTable.cs` (new loader).
|
|
||||||
- `src/AcDream.App/Rendering/GameWindow.cs` (load at OnLoad).
|
|
||||||
- `src/AcDream.App/AcDream.App.csproj` (`<None Update>` to copy CSV to bin output).
|
|
||||||
- `src/AcDream.Core/Spells/Spellbook.cs` (accept optional `SpellTable`, expose `TryGetMetadata`).
|
|
||||||
|
|
||||||
**Acceptance:** Launch with `ACDREAM_DEVTOOLS=1` shows console line `spells: loaded 3956 entries from spells.csv`. `Spellbook.TryGetMetadata(spellId, out)` returns valid record for active enchantment lookups.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Recently closed
|
# Recently closed
|
||||||
|
|
||||||
|
## #11 — [DONE 2026-04-25] Spell metadata loader (spells.csv → SpellTable)
|
||||||
|
|
||||||
|
**Closed:** 2026-04-25
|
||||||
|
**Commit:** `feat(spells): #11 SpellTable — hydrate metadata from spells.csv at startup`
|
||||||
|
**Resolution:** Added `SpellMetadata` record + `SpellTable` CSV loader (hand-rolled RFC 4180-ish parser for the quoted Description column with embedded commas). Wired into `Spellbook` constructor as optional metadata source; `Spellbook.TryGetMetadata(spellId, out)` returns the static record when found. `GameWindow` loads `data/spells.csv` from bin output at construction (file copied via `<None Include>` in `AcDream.App.csproj` from `docs/research/data/spells.csv`). Falls back to `SpellTable.Empty` + console warning if the file is missing (e.g. tooling contexts). 10 new tests covering: empty table, header-only, simple row, quoted description with commas, blank lines skipped, bad spell-id rows skipped, lookup hit/miss, RFC 4180 escaped-quote parsing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## #9 — [DONE 2026-04-25] Address-correction sweep on `acclient_function_map.md`
|
## #9 — [DONE 2026-04-25] Address-correction sweep on `acclient_function_map.md`
|
||||||
|
|
||||||
**Closed:** 2026-04-25
|
**Closed:** 2026-04-25
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@
|
||||||
<None Update="Rendering\Shaders\*.*">
|
<None Update="Rendering\Shaders\*.*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
<!-- Issue #11: copy spells.csv from docs/research/data/ to bin output's
|
||||||
|
data/ subdir so SpellTable.LoadFromCsv can find it at runtime. -->
|
||||||
|
<None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->
|
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->
|
||||||
|
|
|
||||||
|
|
@ -278,7 +278,13 @@ public sealed class GameWindow : IDisposable
|
||||||
// Exposed publicly so plugins + UI panels can bind directly.
|
// Exposed publicly so plugins + UI panels can bind directly.
|
||||||
public readonly AcDream.Core.Chat.ChatLog Chat = new();
|
public readonly AcDream.Core.Chat.ChatLog Chat = new();
|
||||||
public readonly AcDream.Core.Combat.CombatState Combat = new();
|
public readonly AcDream.Core.Combat.CombatState Combat = new();
|
||||||
public readonly AcDream.Core.Spells.Spellbook SpellBook = new();
|
// Issue #11 — load static spell metadata from data/spells.csv at startup.
|
||||||
|
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
|
||||||
|
// for the future Spellbook panel. The CSV is copied to bin/<config>/net10.0/data/
|
||||||
|
// by the csproj <None Include="...spells.csv"> entry. Loads silently to
|
||||||
|
// SpellTable.Empty if the file is missing (e.g. tooling contexts).
|
||||||
|
public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable();
|
||||||
|
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
|
||||||
public readonly AcDream.Core.Items.ItemRepository Items = new();
|
public readonly AcDream.Core.Items.ItemRepository Items = new();
|
||||||
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
|
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
|
||||||
// PlayerDescription so the Vitals HUD can render those bars.
|
// PlayerDescription so the Vitals HUD can render those bars.
|
||||||
|
|
@ -390,6 +396,34 @@ public sealed class GameWindow : IDisposable
|
||||||
_datDir = datDir;
|
_datDir = datDir;
|
||||||
_worldGameState = worldGameState;
|
_worldGameState = worldGameState;
|
||||||
_worldEvents = worldEvents;
|
_worldEvents = worldEvents;
|
||||||
|
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #11 — load <c>data/spells.csv</c> from the bin output (copied
|
||||||
|
/// there by the csproj). Returns <c>SpellTable.Empty</c> + logs a
|
||||||
|
/// warning if the file is missing (e.g. when GameWindow is instantiated
|
||||||
|
/// from tooling contexts that don't include the data folder).
|
||||||
|
/// </summary>
|
||||||
|
private static AcDream.Core.Spells.SpellTable LoadSpellTable()
|
||||||
|
{
|
||||||
|
string path = System.IO.Path.Combine(
|
||||||
|
System.AppContext.BaseDirectory, "data", "spells.csv");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (System.IO.File.Exists(path))
|
||||||
|
{
|
||||||
|
var t = AcDream.Core.Spells.SpellTable.LoadFromCsv(path);
|
||||||
|
Console.WriteLine($"spells: loaded {t.Count} entries from spells.csv");
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
Console.WriteLine($"spells: data/spells.csv not found at {path}; using empty table");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"spells: load failed ({ex.Message}); using empty table");
|
||||||
|
}
|
||||||
|
return AcDream.Core.Spells.SpellTable.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Run()
|
public void Run()
|
||||||
|
|
|
||||||
44
src/AcDream.Core/Spells/SpellMetadata.cs
Normal file
44
src/AcDream.Core/Spells/SpellMetadata.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
namespace AcDream.Core.Spells;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-spell static metadata loaded once at startup from
|
||||||
|
/// <c>docs/research/data/spells.csv</c> via <see cref="SpellTable"/>.
|
||||||
|
/// One record per known spell id (3,956 entries in the retail dump).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Used for:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Buff / debuff bar labels (<see cref="Name"/>,
|
||||||
|
/// <see cref="School"/>, <see cref="IconId"/>).</item>
|
||||||
|
/// <item>Stacking aggregation (<see cref="Family"/> — only one
|
||||||
|
/// enchantment per family-bucket is active; this is what
|
||||||
|
/// <c>EnchantmentMath</c> uses to filter out superseded
|
||||||
|
/// buffs in <c>LocalPlayerState.GetMaxApprox</c>).</item>
|
||||||
|
/// <item>Spell tooltips (<see cref="Description"/>,
|
||||||
|
/// <see cref="ManaCost"/>, <see cref="Duration"/>,
|
||||||
|
/// <see cref="SpellWords"/>).</item>
|
||||||
|
/// <item>Cast-bar audio + animation cues
|
||||||
|
/// (<see cref="SpellWords"/> drives the chant).</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Fields not exposed (yet) from the 35-column source CSV: SortKey,
|
||||||
|
/// Difficulty, Flags, Generation, IsFastWindup, IsIrresistible,
|
||||||
|
/// IsOffensive, IsUntargetted, Speed, CasterEffect, TargetEffect,
|
||||||
|
/// TargetMask, Type, plus 10 anonymous Unknown1..10. Add them on
|
||||||
|
/// demand as panels grow.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SpellMetadata(
|
||||||
|
uint SpellId,
|
||||||
|
string Name,
|
||||||
|
string School,
|
||||||
|
uint Family,
|
||||||
|
uint IconId,
|
||||||
|
string SpellWords,
|
||||||
|
float Duration,
|
||||||
|
int ManaCost,
|
||||||
|
bool IsDebuff,
|
||||||
|
bool IsFellowship,
|
||||||
|
string Description);
|
||||||
217
src/AcDream.Core/Spells/SpellTable.cs
Normal file
217
src/AcDream.Core/Spells/SpellTable.cs
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Spells;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads + queries <see cref="SpellMetadata"/> from a CSV at startup.
|
||||||
|
/// Source: <c>docs/research/data/spells.csv</c> (RFC 4180-ish, 35
|
||||||
|
/// columns, 3,956 rows). Loaded once, used by panels + by
|
||||||
|
/// <c>EnchantmentMath</c> for buff stacking aggregation.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Hand-rolled CSV parser — the only complication is the
|
||||||
|
/// <c>Description</c> column which is double-quoted with embedded
|
||||||
|
/// commas. No external CsvHelper dependency.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Closes ISSUES.md #11. Required by ISSUES.md #6 for family-stacking
|
||||||
|
/// in vital-max enchantment aggregation.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpellTable
|
||||||
|
{
|
||||||
|
private readonly Dictionary<uint, SpellMetadata> _byId;
|
||||||
|
|
||||||
|
/// <summary>Empty table (no spells loaded). Useful for tests +
|
||||||
|
/// pre-load defaults.</summary>
|
||||||
|
public static SpellTable Empty { get; } = new(new Dictionary<uint, SpellMetadata>());
|
||||||
|
|
||||||
|
private SpellTable(Dictionary<uint, SpellMetadata> byId)
|
||||||
|
{
|
||||||
|
_byId = byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of spells loaded.</summary>
|
||||||
|
public int Count => _byId.Count;
|
||||||
|
|
||||||
|
/// <summary>Look up metadata by spell id. Returns <c>true</c> if
|
||||||
|
/// found; <paramref name="meta"/> is the matching record.</summary>
|
||||||
|
public bool TryGet(uint spellId, out SpellMetadata meta)
|
||||||
|
{
|
||||||
|
if (_byId.TryGetValue(spellId, out var v))
|
||||||
|
{
|
||||||
|
meta = v;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
meta = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>All loaded spell IDs. Stable enumeration order is not
|
||||||
|
/// guaranteed.</summary>
|
||||||
|
public IEnumerable<uint> SpellIds => _byId.Keys;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load from a CSV file. Throws <see cref="FileNotFoundException"/>
|
||||||
|
/// if the path doesn't exist; bad rows are silently skipped (the
|
||||||
|
/// CSV is third-party data, not authored by us — be lenient).
|
||||||
|
/// </summary>
|
||||||
|
public static SpellTable LoadFromCsv(string csvPath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(csvPath))
|
||||||
|
throw new FileNotFoundException("spells.csv not found", csvPath);
|
||||||
|
using var reader = new StreamReader(csvPath);
|
||||||
|
return LoadFromReader(reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load from any <see cref="TextReader"/>. Used by tests with
|
||||||
|
/// <see cref="StringReader"/>; production loads via
|
||||||
|
/// <see cref="LoadFromCsv"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static SpellTable LoadFromReader(TextReader reader)
|
||||||
|
{
|
||||||
|
var byId = new Dictionary<uint, SpellMetadata>();
|
||||||
|
|
||||||
|
string? header = reader.ReadLine();
|
||||||
|
if (header is null) return new SpellTable(byId);
|
||||||
|
|
||||||
|
// Map column-name → index. The CSV order is documented in
|
||||||
|
// docs/research/data/README.md but we don't depend on it —
|
||||||
|
// resolve every column we care about by name.
|
||||||
|
var columns = ParseRow(header);
|
||||||
|
var colIndex = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (int i = 0; i < columns.Count; i++)
|
||||||
|
colIndex[columns[i]] = i;
|
||||||
|
|
||||||
|
int? Get(string name) => colIndex.TryGetValue(name, out int i) ? i : (int?)null;
|
||||||
|
|
||||||
|
int? iSpellId = Get("Spell ID");
|
||||||
|
int? iName = Get("Name");
|
||||||
|
int? iSchool = Get("School");
|
||||||
|
int? iFamily = Get("Family");
|
||||||
|
int? iIconHex = Get("IconId [Hex]");
|
||||||
|
int? iWords = Get("Spell Words");
|
||||||
|
int? iDuration = Get("Duration");
|
||||||
|
int? iMana = Get("Mana");
|
||||||
|
int? iIsDebuff = Get("IsDebuff");
|
||||||
|
int? iIsFellow = Get("IsFellowship");
|
||||||
|
int? iDescription = Get("Description");
|
||||||
|
|
||||||
|
if (iSpellId is null || iName is null) return new SpellTable(byId);
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = reader.ReadLine()) is not null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
var fields = ParseRow(line);
|
||||||
|
if (fields.Count <= iSpellId.Value) continue;
|
||||||
|
|
||||||
|
// Spell ID is the only non-optional column.
|
||||||
|
if (!uint.TryParse(fields[iSpellId.Value], NumberStyles.Integer, CultureInfo.InvariantCulture, out uint spellId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string name = iName is int n && n < fields.Count ? fields[n] : "";
|
||||||
|
string school = iSchool is int s && s < fields.Count ? fields[s] : "";
|
||||||
|
uint family = ParseUInt(fields, iFamily);
|
||||||
|
uint iconId = ParseHexUInt(fields, iIconHex);
|
||||||
|
string words = iWords is int w && w < fields.Count ? fields[w] : "";
|
||||||
|
float duration = ParseFloat(fields, iDuration);
|
||||||
|
int mana = (int)ParseUInt(fields, iMana);
|
||||||
|
bool isDebuff = ParseBool(fields, iIsDebuff);
|
||||||
|
bool isFellow = ParseBool(fields, iIsFellow);
|
||||||
|
string description = iDescription is int d && d < fields.Count ? fields[d] : "";
|
||||||
|
|
||||||
|
byId[spellId] = new SpellMetadata(
|
||||||
|
spellId, name, school, family, iconId, words, duration,
|
||||||
|
mana, isDebuff, isFellow, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SpellTable(byId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint ParseUInt(IList<string> fields, int? index)
|
||||||
|
{
|
||||||
|
if (index is not int i || i >= fields.Count) return 0;
|
||||||
|
return uint.TryParse(fields[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out uint v) ? v : 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint ParseHexUInt(IList<string> fields, int? index)
|
||||||
|
{
|
||||||
|
if (index is not int i || i >= fields.Count) return 0;
|
||||||
|
string s = fields[i];
|
||||||
|
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
s = s[2..];
|
||||||
|
return uint.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint v) ? v : 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ParseFloat(IList<string> fields, int? index)
|
||||||
|
{
|
||||||
|
if (index is not int i || i >= fields.Count) return 0f;
|
||||||
|
return float.TryParse(fields[i], NumberStyles.Float, CultureInfo.InvariantCulture, out float v) ? v : 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ParseBool(IList<string> fields, int? index)
|
||||||
|
{
|
||||||
|
if (index is not int i || i >= fields.Count) return false;
|
||||||
|
return string.Equals(fields[i], "True", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hand-rolled RFC 4180-ish CSV row parser. Handles double-quoted
|
||||||
|
/// fields with embedded commas (the Description column). Embedded
|
||||||
|
/// double quotes are escaped by doubling (`""` → `"`). Public so
|
||||||
|
/// callers (incl. tests) can reuse the parser without instantiating
|
||||||
|
/// a full <see cref="SpellTable"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> ParseRow(string row)
|
||||||
|
{
|
||||||
|
var fields = new List<string>();
|
||||||
|
int i = 0;
|
||||||
|
while (i < row.Length)
|
||||||
|
{
|
||||||
|
string field;
|
||||||
|
if (row[i] == '"')
|
||||||
|
{
|
||||||
|
// Quoted field — consume until matching close-quote
|
||||||
|
// (handle "" as an escaped quote within the field).
|
||||||
|
i++; // skip opening
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
while (i < row.Length)
|
||||||
|
{
|
||||||
|
if (row[i] == '"')
|
||||||
|
{
|
||||||
|
// Either a closing quote or an escaped "".
|
||||||
|
if (i + 1 < row.Length && row[i + 1] == '"')
|
||||||
|
{
|
||||||
|
sb.Append('"');
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i++; // skip closing quote
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sb.Append(row[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
field = sb.ToString();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int start = i;
|
||||||
|
while (i < row.Length && row[i] != ',')
|
||||||
|
i++;
|
||||||
|
field = row[start..i];
|
||||||
|
}
|
||||||
|
fields.Add(field);
|
||||||
|
// Skip the comma separator if present.
|
||||||
|
if (i < row.Length && row[i] == ',')
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,32 @@ public sealed class Spellbook
|
||||||
{
|
{
|
||||||
private readonly HashSet<uint> _learnedSpells = new();
|
private readonly HashSet<uint> _learnedSpells = new();
|
||||||
private readonly ConcurrentDictionary<uint, ActiveEnchantmentRecord> _activeByLayer = new();
|
private readonly ConcurrentDictionary<uint, ActiveEnchantmentRecord> _activeByLayer = new();
|
||||||
|
private readonly SpellTable _table;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a Spellbook with an optional <see cref="SpellTable"/>
|
||||||
|
/// metadata source. When provided, <see cref="TryGetMetadata"/>
|
||||||
|
/// returns the static spell descriptor (name / school / family /
|
||||||
|
/// icon / mana / duration / etc.). When absent (back-compat for
|
||||||
|
/// existing constructors / tests), <see cref="TryGetMetadata"/>
|
||||||
|
/// always returns <c>false</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Spellbook(SpellTable? table = null)
|
||||||
|
{
|
||||||
|
_table = table ?? SpellTable.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Look up static spell metadata. Closes ISSUES.md #11 — feeds the
|
||||||
|
/// buff bar UI labels + icons + the family-stacking aggregation in
|
||||||
|
/// <c>EnchantmentMath</c> (issue #6).
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetMetadata(uint spellId, out SpellMetadata meta) =>
|
||||||
|
_table.TryGet(spellId, out meta);
|
||||||
|
|
||||||
|
/// <summary>The spell-metadata table this spellbook was built with.
|
||||||
|
/// Returns <see cref="SpellTable.Empty"/> if none was provided.</summary>
|
||||||
|
public SpellTable Metadata => _table;
|
||||||
|
|
||||||
/// <summary>Fires when a spell is added to the player's spellbook.</summary>
|
/// <summary>Fires when a spell is added to the player's spellbook.</summary>
|
||||||
public event Action<uint>? SpellLearned;
|
public event Action<uint>? SpellLearned;
|
||||||
|
|
|
||||||
128
tests/AcDream.Core.Tests/Spells/SpellTableTests.cs
Normal file
128
tests/AcDream.Core.Tests/Spells/SpellTableTests.cs
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
using System.IO;
|
||||||
|
using AcDream.Core.Spells;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Spells;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="SpellTable"/> CSV loader. Uses synthetic
|
||||||
|
/// fixture strings rather than a real spells.csv so we don't depend
|
||||||
|
/// on docs/research/data/ contents at test time.
|
||||||
|
///
|
||||||
|
/// Closes ISSUES.md #11 — spell metadata pipeline.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpellTableTests
|
||||||
|
{
|
||||||
|
// Header used across fixtures — matches the column names from the
|
||||||
|
// real docs/research/data/spells.csv. Row format is the same.
|
||||||
|
private const string Header =
|
||||||
|
"Spell ID,Spell ID [Hex],Name,SortKey,IconId [Hex],Difficulty,Duration,Family,Flags [Hex],Generation,IsDebuff,IsFastWindup,IsFellowship,IsIrresistible,IsOffensive,IsUntargetted,Mana,School,Speed,Spell Words,CasterEffect,TargetEffect,TargetMask [Hex],Type,Description,Unknown1,Unknown2,Unknown3,Unknown4,Unknown5,Unknown6,Unknown7,Unknown8,Unknown9,Unknown10";
|
||||||
|
|
||||||
|
private static SpellTable LoadFrom(string csv) =>
|
||||||
|
SpellTable.LoadFromReader(new StringReader(csv));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_TableHasZeroEntries()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, SpellTable.Empty.Count);
|
||||||
|
Assert.False(SpellTable.Empty.TryGet(1u, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromReader_HeaderOnly_EmptyTable()
|
||||||
|
{
|
||||||
|
var table = LoadFrom(Header);
|
||||||
|
Assert.Equal(0, table.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromReader_SingleSimpleRow_HasMetadata()
|
||||||
|
{
|
||||||
|
// Spell ID 1 = Strength Other I, Family 1 (the actual real-data row).
|
||||||
|
string csv =
|
||||||
|
Header + "\n" +
|
||||||
|
"1,0x1,Strength Other I,6450,0x600138C,1,1800,1,0x6,1,False,False,False,True,False,False,10,Creature Enchantment,0,Malar Cazael,0,6,0x10,1,Increases Strength.,5,1,1,1,0,0,0,0,0,0";
|
||||||
|
var table = LoadFrom(csv);
|
||||||
|
|
||||||
|
Assert.Equal(1, table.Count);
|
||||||
|
Assert.True(table.TryGet(1u, out var meta));
|
||||||
|
Assert.Equal("Strength Other I", meta.Name);
|
||||||
|
Assert.Equal("Creature Enchantment", meta.School);
|
||||||
|
Assert.Equal(1u, meta.Family);
|
||||||
|
Assert.Equal(0x600138Cu, meta.IconId);
|
||||||
|
Assert.Equal("Malar Cazael", meta.SpellWords);
|
||||||
|
Assert.Equal(1800f, meta.Duration);
|
||||||
|
Assert.Equal(10, meta.ManaCost);
|
||||||
|
Assert.False(meta.IsDebuff);
|
||||||
|
Assert.False(meta.IsFellowship);
|
||||||
|
Assert.Equal("Increases Strength.", meta.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromReader_QuotedDescriptionWithCommas_ParsesIntactly()
|
||||||
|
{
|
||||||
|
// The Description field in the real CSV is double-quoted so the
|
||||||
|
// embedded comma doesn't split the row.
|
||||||
|
string csv =
|
||||||
|
Header + "\n" +
|
||||||
|
"2,0x2,Strength Self I,6464,0x600138C,1,1800,1,0x400C,1,False,True,False,True,False,True,15,Creature Enchantment,0.01,Malar Cazael,0,6,0x10,1,\"Increases the caster's Strength by 10 points, lasting 30 minutes.\",0,0,1,2,0,0,0,0,0,0";
|
||||||
|
var table = LoadFrom(csv);
|
||||||
|
|
||||||
|
Assert.True(table.TryGet(2u, out var meta));
|
||||||
|
Assert.Equal("Increases the caster's Strength by 10 points, lasting 30 minutes.", meta.Description);
|
||||||
|
Assert.Equal("Strength Self I", meta.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromReader_BlankLines_AreSkipped()
|
||||||
|
{
|
||||||
|
string csv =
|
||||||
|
Header + "\n" +
|
||||||
|
"1,0x1,Test,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0\n" +
|
||||||
|
"\n" +
|
||||||
|
" \n" +
|
||||||
|
"2,0x2,Test2,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0";
|
||||||
|
var table = LoadFrom(csv);
|
||||||
|
Assert.Equal(2, table.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromReader_BadSpellId_RowSkipped()
|
||||||
|
{
|
||||||
|
string csv =
|
||||||
|
Header + "\n" +
|
||||||
|
"not_a_uint,0x1,Bad,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0\n" +
|
||||||
|
"5,0x5,Good,0,0x0,1,1,1,0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0";
|
||||||
|
var table = LoadFrom(csv);
|
||||||
|
Assert.Equal(1, table.Count);
|
||||||
|
Assert.True(table.TryGet(5u, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGet_UnknownSpellId_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var table = LoadFrom(Header);
|
||||||
|
Assert.False(table.TryGet(99999u, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseRow_SimpleCsv_SplitsOnCommas()
|
||||||
|
{
|
||||||
|
var fields = SpellTable.ParseRow("a,b,c");
|
||||||
|
Assert.Equal(new[] { "a", "b", "c" }, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseRow_QuotedFieldWithComma_KeepsComma()
|
||||||
|
{
|
||||||
|
var fields = SpellTable.ParseRow("a,\"b,c\",d");
|
||||||
|
Assert.Equal(new[] { "a", "b,c", "d" }, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseRow_EscapedDoubleQuoteInsideQuotedField()
|
||||||
|
{
|
||||||
|
// RFC 4180: "" inside a quoted field is a literal " character.
|
||||||
|
var fields = SpellTable.ParseRow("a,\"b\"\"c\",d");
|
||||||
|
Assert.Equal(new[] { "a", "b\"c", "d" }, fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue