diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 28a3d04..58af325 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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` 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` (`` 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 +## #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 `` 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` **Closed:** 2026-04-25 diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index 6fb3af0..c8a473b 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -31,6 +31,11 @@ PreserveNewest + + + PreserveNewest + diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 372699c..7c33b21 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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//net10.0/data/ + // by the csproj 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); + } + + /// + /// Issue #11 — load data/spells.csv from the bin output (copied + /// there by the csproj). Returns SpellTable.Empty + logs a + /// warning if the file is missing (e.g. when GameWindow is instantiated + /// from tooling contexts that don't include the data folder). + /// + 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() diff --git a/src/AcDream.Core/Spells/SpellMetadata.cs b/src/AcDream.Core/Spells/SpellMetadata.cs new file mode 100644 index 0000000..8654fc7 --- /dev/null +++ b/src/AcDream.Core/Spells/SpellMetadata.cs @@ -0,0 +1,44 @@ +namespace AcDream.Core.Spells; + +/// +/// Per-spell static metadata loaded once at startup from +/// docs/research/data/spells.csv via . +/// One record per known spell id (3,956 entries in the retail dump). +/// +/// +/// Used for: +/// +/// +/// Buff / debuff bar labels (, +/// , ). +/// Stacking aggregation ( — only one +/// enchantment per family-bucket is active; this is what +/// EnchantmentMath uses to filter out superseded +/// buffs in LocalPlayerState.GetMaxApprox). +/// Spell tooltips (, +/// , , +/// ). +/// Cast-bar audio + animation cues +/// ( drives the chant). +/// +/// +/// +/// 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. +/// +/// +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); diff --git a/src/AcDream.Core/Spells/SpellTable.cs b/src/AcDream.Core/Spells/SpellTable.cs new file mode 100644 index 0000000..cce06a9 --- /dev/null +++ b/src/AcDream.Core/Spells/SpellTable.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +namespace AcDream.Core.Spells; + +/// +/// Loads + queries from a CSV at startup. +/// Source: docs/research/data/spells.csv (RFC 4180-ish, 35 +/// columns, 3,956 rows). Loaded once, used by panels + by +/// EnchantmentMath for buff stacking aggregation. +/// +/// +/// Hand-rolled CSV parser — the only complication is the +/// Description column which is double-quoted with embedded +/// commas. No external CsvHelper dependency. +/// +/// +/// +/// Closes ISSUES.md #11. Required by ISSUES.md #6 for family-stacking +/// in vital-max enchantment aggregation. +/// +/// +public sealed class SpellTable +{ + private readonly Dictionary _byId; + + /// Empty table (no spells loaded). Useful for tests + + /// pre-load defaults. + public static SpellTable Empty { get; } = new(new Dictionary()); + + private SpellTable(Dictionary byId) + { + _byId = byId; + } + + /// Number of spells loaded. + public int Count => _byId.Count; + + /// Look up metadata by spell id. Returns true if + /// found; is the matching record. + public bool TryGet(uint spellId, out SpellMetadata meta) + { + if (_byId.TryGetValue(spellId, out var v)) + { + meta = v; + return true; + } + meta = null!; + return false; + } + + /// All loaded spell IDs. Stable enumeration order is not + /// guaranteed. + public IEnumerable SpellIds => _byId.Keys; + + /// + /// Load from a CSV file. Throws + /// if the path doesn't exist; bad rows are silently skipped (the + /// CSV is third-party data, not authored by us — be lenient). + /// + 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); + } + + /// + /// Load from any . Used by tests with + /// ; production loads via + /// . + /// + public static SpellTable LoadFromReader(TextReader reader) + { + var byId = new Dictionary(); + + 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(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 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 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 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 fields, int? index) + { + if (index is not int i || i >= fields.Count) return false; + return string.Equals(fields[i], "True", StringComparison.OrdinalIgnoreCase); + } + + /// + /// 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 . + /// + public static List ParseRow(string row) + { + var fields = new List(); + 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; + } +} diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs index 2025fa7..b6a6476 100644 --- a/src/AcDream.Core/Spells/Spellbook.cs +++ b/src/AcDream.Core/Spells/Spellbook.cs @@ -19,6 +19,32 @@ public sealed class Spellbook { private readonly HashSet _learnedSpells = new(); private readonly ConcurrentDictionary _activeByLayer = new(); + private readonly SpellTable _table; + + /// + /// Build a Spellbook with an optional + /// metadata source. When provided, + /// returns the static spell descriptor (name / school / family / + /// icon / mana / duration / etc.). When absent (back-compat for + /// existing constructors / tests), + /// always returns false. + /// + public Spellbook(SpellTable? table = null) + { + _table = table ?? SpellTable.Empty; + } + + /// + /// Look up static spell metadata. Closes ISSUES.md #11 — feeds the + /// buff bar UI labels + icons + the family-stacking aggregation in + /// EnchantmentMath (issue #6). + /// + public bool TryGetMetadata(uint spellId, out SpellMetadata meta) => + _table.TryGet(spellId, out meta); + + /// The spell-metadata table this spellbook was built with. + /// Returns if none was provided. + public SpellTable Metadata => _table; /// Fires when a spell is added to the player's spellbook. public event Action? SpellLearned; diff --git a/tests/AcDream.Core.Tests/Spells/SpellTableTests.cs b/tests/AcDream.Core.Tests/Spells/SpellTableTests.cs new file mode 100644 index 0000000..f0ccd11 --- /dev/null +++ b/tests/AcDream.Core.Tests/Spells/SpellTableTests.cs @@ -0,0 +1,128 @@ +using System.IO; +using AcDream.Core.Spells; + +namespace AcDream.Core.Tests.Spells; + +/// +/// Tests for the 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. +/// +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); + } +}