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? Build(Handle h, List entries, List errors) { var retailObjects = h.Container.Cast().ToList(); if (!h.ValueEditable && entries.Count != h.Retail.Count) { errors.Add($"{h.Id}: its values are objects ({h.ValueType.Name}); only the chances can change, so it must keep {h.Retail.Count} entries"); return null; } if (h.Kind == "list" && entries.Count != h.Retail.Count) { errors.Add($"{h.Id}: a per-tier list keeps its length ({h.Retail.Count}); change the values, not the count"); return null; } var result = new List(); var tuple = h.Kind == "chance" ? typeof(ValueTuple<,>).MakeGenericType(h.ValueType, typeof(float)) : null; for (int i = 0; i < entries.Count; i++) { var e = entries[i]; object? v; if (!h.ValueEditable) v = h.Kind == "chance" ? retailObjects[i].GetType().GetField("Item1")!.GetValue(retailObjects[i]) : retailObjects[i]; else if (!TryParse(h.ValueType, e.Value, out v)) { errors.Add($"{h.Id} entry {i + 1}: '{e.Value}' is not a {h.ValueType.Name}"); continue; } if (h.Kind == "list") { result.Add(v!); continue; } if (double.IsNaN(e.Chance) || e.Chance < 0 || e.Chance > 1) { errors.Add($"{h.Id} entry {i + 1}: chance {e.Chance} is not between 0 and 1"); continue; } result.Add(Activator.CreateInstance(tuple!, v, (float)e.Chance)!); } if (h.Kind == "chance" && entries.Count == 0) errors.Add($"{h.Id}: a chance table needs at least one entry"); if (h.Kind == "chance" && result.Count > 0 && !result.Any(r => (float)r.GetType().GetField("Item2")!.GetValue(r)! > 0)) errors.Add($"{h.Id}: every chance is 0 - ChanceTable.Roll would throw"); // Every retail table adds up to 1. Short of it, the gap silently goes to the last entry; // over it, the last entries are squeezed out - neither is what anyone typing meant. var sum = entries.Sum(e => e.Chance); if (h.Kind == "chance" && entries.Count > 0 && Math.Abs(sum - 1) > 0.001) errors.Add($"{h.Id}: the chances add up to {sum.ToString("0.####", CultureInfo.InvariantCulture)}, not 1"); return errors.Any(x => x.StartsWith(h.Id + " ") || x.StartsWith(h.Id + ":")) ? null : result; } private static void Write(Handle h, List items) { h.Container.Clear(); foreach (var item in items) h.Container.Add(item); // ChanceTable checks its sum once, on the first roll after this; a new mix is a new table. if (h.Kind == "chance") h.Container.GetType().GetField("verified", BindingFlags.Instance | BindingFlags.NonPublic)?.SetValue(h.Container, false); } // ---- overrides ----------------------------------------------------------------------- public sealed class OverrideFile { public Dictionary> Tables { get; set; } = new(); } public sealed record ApplyResult(int Applied, List Errors, HashSet AppliedIds); /// /// Every table back to retail, then every override that checks out applied. Must run /// where nothing rolls loot: the world thread's action queue, or before the world opens. /// public static ApplyResult Apply(OverrideFile file) { var errors = new List(); var built = new List<(Handle, List)>(); foreach (var (id, entries) in file.Tables) { if (Get(id) is not { } h) { errors.Add($"{id}: no such table (ACE changed, or a typo) - left alone"); continue; } if (Build(h, entries, errors) is { } items) built.Add((h, items)); } RestoreRetail(); foreach (var (h, items) in built) Write(h, items); RebuildDerived(); return new ApplyResult(built.Count, errors, built.Select(b => b.Item1.Id).ToHashSet()); } public static void RestoreRetail() { foreach (var h in _handles) { var errors = new List(); if (Build(h, h.Retail, errors) is { } items) Write(h, items); } } /// What ACE builds from the tables and would otherwise keep stale. private static void RebuildDerived() { // CantripChance rolls from scaled copies of its T* tables when the cantrip drop-rate // properties are not 1.0; these rebuild the copies from the tables as they now are. var cc = typeof(ChanceTable<>).Assembly.GetType("ACE.Server.Factories.Tables.CantripChance"); foreach (var name in new[] { "ApplyNumCantripsMod", "ApplyCantripLevelsMod" }) try { cc?.GetMethod(name, BindingFlags.Static | BindingFlags.Public)?.Invoke(null, [false]); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] CantripChance.{name}: {ex.InnerException?.Message ?? ex.Message}", ModManager.LogLevel.Warn); } } // ---- the catalogue the staff site reads --------------------------------------------- public static void WriteCatalog(string path, ApplyResult result) { var tables = _handles.Select(h => new { id = h.Id, @class = h.Class, field = h.Field, kind = h.Kind, valueType = h.ValueType.Name, valueEditable = h.ValueEditable, values = h.ValueType.IsEnum && Enum.GetNames(h.ValueType).Length <= 400 ? Enum.GetNames(h.ValueType) : null, note = Notes.For(h), meaning = Notes.MeaningFor(h), overridden = result.AppliedIds.Contains(h.Id), retail = h.Retail, current = Current(h), }); var json = JsonSerializer.Serialize(new { written = DateTime.Now, classes = Notes.Classes, errors = result.Errors, tables, }, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); var tmp = path + ".tmp"; File.WriteAllText(tmp, json); File.Move(tmp, path, true); } }