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:
Erik 2026-04-25 17:48:43 +02:00
parent 83b020499b
commit 4ceac5cb40
7 changed files with 463 additions and 23 deletions

View file

@ -31,6 +31,11 @@
<None Update="Rendering\Shaders\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</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>
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->

View file

@ -278,7 +278,13 @@ public sealed class GameWindow : IDisposable
// Exposed publicly so plugins + UI panels can bind directly.
public readonly AcDream.Core.Chat.ChatLog Chat = 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();
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
// PlayerDescription so the Vitals HUD can render those bars.
@ -390,6 +396,34 @@ public sealed class GameWindow : IDisposable
_datDir = datDir;
_worldGameState = worldGameState;
_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()

View 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);

View 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;
}
}

View file

@ -19,6 +19,32 @@ public sealed class Spellbook
{
private readonly HashSet<uint> _learnedSpells = 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>
public event Action<uint>? SpellLearned;