namespace Aeshnidae.Trials; /// /// Every term of the Trials, in one file. /// /// A Trial (design decided 2026-09-21) is a timed solo run of a personal dungeon copy /// from Aeshnidae.InstancesNoDat: step through the portal into a FRESH copy of a /// listed dungeon and the clock starts; every attackable creature in the copy is /// counted once the copy has finished spawning, nothing respawns while the run is on, /// and the clock stops when the last one is dead. Leaving, dying, logging out or being /// teleported voids the run. The best time per character and per dungeon and tier is /// kept on the shard database; the fastest run of each week wins a trophy. /// public class Settings { public const string FileName = "Settings.json"; public bool Enabled { get; set; } = true; /// /// The dungeons that are Trials. Each must also be an InstancesNoDat landblock with /// Personal scope - a run is a copy of your own. The name is what the player reads; /// empty falls back to InstancesNoDat's name for the landblock. Course (see Trial) for /// every dungeon that is also farmed: the Hub of Awakening's, since 2026-09-25 each with /// a portal in the Trials Hub (trials_settings.py writes both mods' lists). /// public List Trials { get; set; } = new() { new Trial { Landblock = "02F1", Name = "The Citadel Below" }, new Trial { Landblock = "6346", Name = "Matron Hive South", Course = true }, new Trial { Landblock = "6347", Name = "Matron Hive North", Course = true }, new Trial { Landblock = "6145", Name = "Matron Hive West", Course = true }, new Trial { Landblock = "0031", Name = "Creepy Canyons", Course = true }, new Trial { Landblock = "6146", Name = "Matron Hive East", Course = true }, new Trial { Landblock = "002B", Name = "Egg Orchard", Course = true }, new Trial { Landblock = "00C8", Name = "Egg Orchard West", Course = true }, new Trial { Landblock = "0064", Name = "Egg Orchard East", Course = true }, new Trial { Landblock = "002D", Name = "Dark Design", Course = true }, new Trial { Landblock = "002A", Name = "Path of the Blind", Course = true }, new Trial { Landblock = "004B", Name = "Path of the Blind West", Course = true }, new Trial { Landblock = "00E1", Name = "Path of the Blind East", Course = true }, new Trial { Landblock = "6147", Name = "The Gotrok Citadel", Course = true }, }; /// /// One attempt per Trial per this many minutes, counted from the START of the last /// attempt, voided or not. A copy closes about two minutes after it empties, so /// without this a run could be re-opened until the spawns fell kindly. /// public double CooldownMinutes { get; set; } = 20; /// /// A copy counts as fresh when it was created within this many seconds of the /// player's arrival in it. A copy is created the moment the portal is used, and /// the crossing follows within a second or two; a copy that has been open longer /// is one the player already had - re-entered, not fresh. /// public double FreshSeconds { get; set; } = 60; /// /// Seconds after the arrival before the creatures are counted. A copy spawns on a /// background task and its generators fire on the first landblock ticks; three /// seconds is comfortably after that on the VPS. Anything that spawns later is /// added to the count as it appears, so this only decides when the opening line /// is sent. /// public double CountAfterSeconds { get; set; } = 3; /// /// The count waits for the copy's CreateWorldObjects to complete, up to this many /// seconds; past it the count is taken anyway and the run is logged as counted late. /// public double CountDeadlineSeconds { get; set; } = 30; /// "N left" is told every this many kills (1: after every kill, since 2026-09-23 - /// a runner wants the count as it falls, not every tenth)... public int ProgressEvery { get; set; } = 1; /// ...and on every kill once this many are left. public int ProgressLast { get; set; } = 5; /// /// Generators inside a copy with a run on stop respawning once their initial /// spawn is done (the prefix on WorldObject.Generator_Generate). Off, the count /// still stays honest - a respawn is added as it appears - but a dungeon that /// refills every 30 s is never "everything dead". /// public bool StopRegeneration { get; set; } = true; /// /// Floor hazards do no damage inside a Trial copy (a course copy, for a Course trial): /// ACE's Hotspot ticks - the Matron Hives' acid pools, acidfloor 6122 and acidsubmerge /// 11972, and any like them - are skipped (the prefix on Hotspot.Activate). The pool /// is still there and still looks like acid; standing in it just costs nothing. Why /// (2026-09-25): the hives' creatures stand in acid, and a course is "kill everything", /// so the acid made a full clear a matter of outlasting the floor. Farming copies and /// every other hazard in the world keep their damage. /// public bool HarmlessHazards { get; set; } = true; /// /// Radiance for a clear, through Aeshnidae.Bank's own minting (bound by name at /// runtime; nothing is paid when Bank is off). Multiplied below for a personal /// best and a server record. /// public long RadiancePerClear { get; set; } = 500; public double PersonalBestMultiplier { get; set; } = 2; public double ServerRecordMultiplier { get; set; } = 3; // ---- THE WEEKLY TROPHY --------------------------------------------------- /// /// When the week turns over, server-local: the fastest recorded run of the week /// that just ended, per Trial, gets the trophy. Sunday 17:00 - five in the /// afternoon, the same hour AllegianceBounty's personal day resets. A run's week /// is the ISO week of its finish time shifted so that this moment is the boundary: /// a clear at 18:00 on Sunday belongs to the week that has just begun. /// public string AwardDay { get; set; } = "Sunday"; public int AwardHour { get; set; } = 17; /// /// The trophy is awarded per Trial, at ANY tier: the week's fastest run of the /// dungeon, full stop. A higher tier is harder and slower, so the tier-0 runner /// will normally hold it; the board shows the tier beside every time, and the /// per-tier records are their own boards. Kept simple on purpose - one trophy per /// dungeon per week is something a player can aim at. /// public long WeeklyAwardXp { get; set; } = 1_000_000; /// The trophy weenie (ledger 2026-09-21_001): named and inscribed per award. public uint TrophyWcid { get; set; } = 3000029; /// Post the week's winners to Discord (see Discord below). public bool AnnounceAwards { get; set; } = true; // ------------------------------------------------------------------------- /// Write trials.json beside the dll after every change, for the website (see Readme). public bool WriteJson { get; set; } = true; /// Log every run start, count, void and completion. public bool LogRuns { get; set; } = true; /// A copy of a Trial landblock with nobody in it is closed after this many seconds, /// instead of InstancesNoDat's EmptyGraceSeconds (15 min): once its runner has left, the run /// is void and the copy is spent - re-entering it is never timed - so the next way in should /// open a fresh one. The few seconds ride out a hop outside and back. Below 0: off. public double CloseEmptyAfterSeconds { get; set; } = 5; /// The Goto label that asks for a reset: the Trial Keeper's Use emote ends with a Goto /// carrying it (ledger 2026-09-23_003), and Patches.PreExecuteEmote hands it to Runs.RequestReset. /// A reset closes the player's copy of every Trial, so the next step through the door opens a /// fresh, timed one, and clears their cooldown. public string ResetLabel { get; set; } = "aelrynth-trial-reset"; public DiscordSettings Discord { get; set; } = new(); // ------------------------------------------------------------------------- /// landblock -> trial, for the lookups the hot paths do. public Dictionary TrialMap() { var map = new Dictionary(); foreach (var t in Trials ?? new List()) { if (t.TryParse(out var lb)) map[lb] = t; else ModManager.Log($"[{Mod.Name}] '{t.Landblock}' is not a hex landblock id", ModManager.LogLevel.Warn); } return map; } [JsonIgnore] public DayOfWeek AwardDayOfWeek => Enum.TryParse(AwardDay ?? "", ignoreCase: true, out var day) ? day : DayOfWeek.Sunday; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, }; public static Settings Load(string modPath) { var path = Path.Combine(modPath, FileName); try { if (File.Exists(path)) return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? new Settings(); var defaults = new Settings(); defaults.Save(modPath); ModManager.Log($"[{Mod.Name}] wrote default settings to {path}"); return defaults; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not read {path}, using defaults: {ex.Message}", ModManager.LogLevel.Warn); return new Settings(); } } public void Save(string modPath) { try { File.WriteAllText(Path.Combine(modPath, FileName), JsonSerializer.Serialize(this, JsonOptions)); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not save settings: {ex.Message}", ModManager.LogLevel.Error); } } } /// One Trial: a landblock and what to call it. public class Trial { /// Four-digit hex landblock id, as in InstancesNoDat's Settings.json. public string Landblock { get; set; } = ""; public string Name { get; set; } = ""; /// /// The dungeon is also farmed as an ordinary copy, so only its COURSE copies are timed: /// the ones opened through a course portal (InstancesNoDat's CourseVia on the landblock - /// the Trials Hub's portals). The player's ordinary copy of the same dungeon is left alone: /// no clock, no message, respawns as always. False for a landblock that exists only to /// be a course (the trial citadel 02F1), where every personal copy is one. Needs an /// InstancesNoDat that knows course copies; with an older one nothing here is timed. /// public bool Course { get; set; } public bool TryParse(out ushort landblock) { var text = (Landblock ?? "").Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase); return ushort.TryParse(text, NumberStyles.HexNumber, null, out landblock); } } /// /// Where the weekly award is announced. A webhook URL goes into the deployed /// Settings.json with set-webhook.sh - never into source. With no URL, the post goes /// through the VPS's notify-discord.sh (NotifyScript, Channel) when that script /// exists, so the line lands even before a webhook is wired; with neither, the log /// has it. /// public class DiscordSettings { public string WebhookUrl { get; set; } = ""; public string Username { get; set; } = "Aelrynth Trials"; /// The notify-discord.sh channel key. "alerts" - falls back to the audit channel until wired. public string Channel { get; set; } = "alerts"; /// ~ expands to the home directory. public string NotifyScript { get; set; } = "~/notify-discord.sh"; }