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