using System.Collections;
using System.Globalization;
using ACE.Server.Factories.Entity;
namespace Aeshnidae.LootTables;
///
/// ACE's code-side loot tables, found, snapshotted and overridden at runtime (Tom,
/// 2026-09-24: the per-tier loot tables editable on the website, live).
///
/// Found, not listed: every static field in ACE.Server.Factories.Tables (and a few loot
/// constants in LootGenerationFactory / Factories.Entity) whose type is a ChanceTable<T>
/// (a List of (result, chance)) or a List<float> / List<int> (per-tier values). A table
/// ACE adds later shows up without anyone listing it.
///
/// Edited in place, never replaced: almost every table is reached through a tier list that
/// captured the table OBJECT at startup, so a new object in the field would never be read.
/// Clear() + Add() on the object every roll already holds is what takes effect.
///
/// Applied on the world thread: loot rolls on several landblock threads at once and
/// ChanceTable.Roll walks the list with foreach - an edit mid-roll throws. WorldManager's
/// action queue runs between the landblock ticks, when nothing is rolling.
///
/// Retail is snapshotted at load and put back on unload, so the next load's snapshot is
/// retail again, not the last load's overrides.
///
public static class Tables
{
public sealed class Handle
{
public required string Id { get; init; } // "WorkmanshipChance.T3_Chances"
public required string Class { get; init; } // "WorkmanshipChance", "Wcids.HeavyWeaponWcids"
public required string Field { get; init; }
public required string Kind { get; init; } // "chance" or "list"
public required Type ValueType { get; init; }
public required IList Container { get; init; }
public required List Retail { get; init; }
public bool ValueEditable => ValueType.IsEnum || ValueType.IsPrimitive;
}
public sealed record Entry(string Value, double Chance);
private static readonly List _handles = new();
private static readonly Dictionary _byId = new(StringComparer.Ordinal);
public static IReadOnlyList All => _handles;
public static Handle? Get(string id) => _byId.GetValueOrDefault(id);
// ---- discovery --------------------------------------------------------------------
public static void Discover()
{
_handles.Clear();
_byId.Clear();
var asm = typeof(ChanceTable<>).Assembly;
const string ns = "ACE.Server.Factories.Tables";
var extra = new[] { typeof(LootGenerationFactory), typeof(MissileMagicDefense) };
foreach (var type in asm.GetTypes().Where(t => (t.Namespace?.StartsWith(ns, StringComparison.Ordinal) ?? false) || extra.Contains(t)))
{
if (type.IsGenericTypeDefinition)
continue;
foreach (var f in type.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
{
var ft = f.FieldType;
string kind;
Type valueType;
if (ft.IsGenericType && ft.GetGenericTypeDefinition() == typeof(ChanceTable<>))
{
kind = "chance";
valueType = ft.GetGenericArguments()[0];
}
else if (ft == typeof(List) || ft == typeof(List))
{
kind = "list";
valueType = ft.GetGenericArguments()[0];
}
else
continue;
IList? container;
try { container = f.GetValue(null) as IList; }
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] {type.Name}.{f.Name} could not be read: {ex.InnerException?.Message ?? ex.Message}", ModManager.LogLevel.Warn);
continue;
}
if (container is null || _handles.Any(h => ReferenceEquals(h.Container, container)))
continue; // null, or the same object reached by two names
var cls = type.Namespace == ns ? type.Name
: type.Namespace!.StartsWith(ns + ".") ? type.Namespace[(ns.Length + 1)..] + "." + type.Name
: type.Name;
var h = new Handle
{
Id = $"{cls}.{f.Name}",
Class = cls,
Field = f.Name,
Kind = kind,
ValueType = valueType,
Container = container,
Retail = Read(kind, container),
};
_handles.Add(h);
_byId[h.Id] = h;
}
}
_handles.Sort((a, b) => string.CompareOrdinal(a.Id, b.Id));
}
// ---- reading and writing a container ------------------------------------------------
private static List Read(string kind, IList container)
{
var list = new List();
foreach (var item in container)
{
if (kind == "list")
list.Add(new Entry(Format(item), 0));
else
{
var t = item!.GetType();
list.Add(new Entry(Format(t.GetField("Item1")!.GetValue(item)), Convert.ToDouble(t.GetField("Item2")!.GetValue(item), CultureInfo.InvariantCulture)));
}
}
return list;
}
public static List Current(Handle h) => Read(h.Kind, h.Container);
private static string Format(object? v) => v switch
{
null => "",
Enum e => e.ToString(),
float f => f.ToString("R", CultureInfo.InvariantCulture),
double d => d.ToString("R", CultureInfo.InvariantCulture),
IFormattable x => x.ToString(null, CultureInfo.InvariantCulture),
_ => v.ToString() ?? "",
};
private static bool TryParse(Type t, string text, out object? value)
{
value = null;
text = text.Trim();
if (t.IsEnum)
{
if (Enum.TryParse(t, text, true, out var e) && (Enum.IsDefined(t, e!) || long.TryParse(text, out _)))
{
value = e;
return true;
}
return false;
}
try
{
if (t == typeof(bool))
{
if (text is not ("1" or "0" or "true" or "false" or "True" or "False"))
return false;
value = text is "1" or "true" or "True";
}
else
value = Convert.ChangeType(text, t, CultureInfo.InvariantCulture);
return true;
}
catch { return false; }
}
///
/// The entries as objects for the container, or the reasons they cannot be. A table
/// whose values cannot be edited (a GemResult is an object) keeps its values by
/// position and takes new chances only.
///
private static List