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>
217 lines
8.1 KiB
C#
217 lines
8.1 KiB
C#
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;
|
|
}
|
|
}
|