using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
namespace AcDream.Core.Spells;
///
/// Immutable lookup of retail spell metadata. Production builds create this
/// table from portal.dat through the App-layer MagicCatalog. The CSV
/// parser below remains for historical tooling and small synthetic tests.
///
///
/// 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;
}
/// Create a canonical immutable lookup from projected metadata.
public static SpellTable Create(IEnumerable metadata)
{
ArgumentNullException.ThrowIfNull(metadata);
var byId = new Dictionary();
foreach (SpellMetadata spell in metadata)
{
ArgumentNullException.ThrowIfNull(spell);
if (!byId.TryAdd(spell.SpellId, spell))
throw new ArgumentException(
$"Duplicate spell id 0x{spell.SpellId:X8}.", nameof(metadata));
}
return new SpellTable(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");
int? iSortKey = Get("SortKey");
int? iDifficulty = Get("Difficulty");
int? iFlags = Get("Flags [Hex]");
int? iGeneration = Get("Generation");
int? iFastWindup = Get("IsFastWindup");
int? iOffensive = Get("IsOffensive");
int? iUntargeted = Get("IsUntargetted");
int? iSpeed = Get("Speed");
int? iCasterFx = Get("CasterEffect");
int? iTargetFx = Get("TargetEffect");
int? iTargetMask = Get("TargetMask [Hex]");
int? iSpellType = Get("Type");
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] : "";
int sortKey = (int)ParseUInt(fields, iSortKey);
int difficulty = (int)ParseUInt(fields, iDifficulty);
uint flags = ParseHexUInt(fields, iFlags);
int generation = (int)ParseUInt(fields, iGeneration);
bool fastWindup = ParseBool(fields, iFastWindup);
bool offensive = ParseBool(fields, iOffensive);
bool untargeted = ParseBool(fields, iUntargeted);
float speed = ParseFloat(fields, iSpeed);
uint casterEffect = ParseUInt(fields, iCasterFx);
uint targetEffect = ParseUInt(fields, iTargetFx);
uint targetMask = ParseHexUInt(fields, iTargetMask);
int spellType = (int)ParseUInt(fields, iSpellType);
byId[spellId] = new SpellMetadata(
spellId, name, school, family, iconId, words, duration,
mana, isDebuff, isFellow, description, sortKey, difficulty,
flags, generation, fastWindup, offensive, untargeted, speed,
casterEffect, targetEffect, targetMask, spellType)
{
SchoolId = ParseSchool(school),
};
}
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);
}
private static MagicSchool ParseSchool(string school) => school switch
{
"War Magic" => MagicSchool.WarMagic,
"Life Magic" => MagicSchool.LifeMagic,
"Item Enchantment" => MagicSchool.ItemEnchantment,
"Creature Enchantment" => MagicSchool.CreatureEnchantment,
"Void Magic" => MagicSchool.VoidMagic,
_ => MagicSchool.None,
};
///
/// 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;
}
}