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