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