# Aeshnidae.Trials

Timed solo dungeon runs, a leaderboard and a weekly trophy. A **Trial** is a listed
dungeon whose personal copies (Aeshnidae.InstancesNoDat) are a course: step through the
portal into a fresh copy and the clock starts; kill everything in it and the clock stops.
Design decided 2026-09-21: a course is more honest when the whole dungeon is the course.

**Course copies (2026-09-25).** Every Hub of Awakening dungeon is a Trial too, with its own
portal in the Trials Hub. Those dungeons are farmed as ordinary personal copies, so a Trial
entry marked `Course` times only a **course copy**: InstancesNoDat keeps a second copy per
character for a landblock whose entry names `CourseVia` portals, opened only through one of
them (`Context.IsCourseCopy`). The ordinary copy - reached by the Hub of Awakening's portal, a
retail portal, a recall - is never timed, never told anything, never closed early and keeps
its respawns. The trial citadel 02F1 exists only to be raced, so its entry is not `Course`
and every personal copy of it is one.

```
/trial                 every Trial: server record per tier, your best, this week's leader, cooldown
/trial <name>          one dungeon: top 10 (best per character, any tier), records, you, this week
/trial-award [week]    admin, console-safe: run the weekly award now (a week already done is skipped)
/trial-void <player>   admin: void a running run
/trial-status          admin: bindings, runs in progress, last award, feed
/trial-reload          admin: re-read Settings.json
```

## The run

1. **Start.** InstancesNoDat fires `Arrival.OnArrival(player, landblock, copy, tier)` when
   a player crosses into a copy (`Patches.PreRelocate` -> `Arrival.Announce`). `Runs.OnArrival`
   starts a run when all of these hold:
   - the landblock is in `Settings.Trials` and has Personal scope in InstancesNoDat;
   - for a `Course` trial, the copy is a course copy (opened through a `CourseVia` portal) -
     any other copy returns here silently;
   - the copy is **fresh**: its `Landblock.Init` ran within `FreshSeconds` (60) of the
     crossing (`Patches.PostLandblockInit` stamps every InstancesNoDat copy, a reload
     excepted) and nobody has crossed into it before (`_touched`);
   - the player came in by a **teleport** into this landblock within 30 s
     (`Patches.PreTeleport` notes it) - a portal, in practice. A copy reached by logging
     in or by walking in on foot is not timed, and the player is told;
   - the **cooldown** has passed: `CooldownMinutes` (20) since the character's last
     attempt at this landblock, voided or not (memory, seeded from the table).
   The player is told the copy is not fresh / not timed / on cooldown otherwise.
2. **Count.** `CountAfterSeconds` (3) after the start, once `Landblock.CreateWorldObjectsCompleted`
   is true (or at `CountDeadlineSeconds`, logged as late), every object in the copy's own
   table (`GetAllWorldObjectsForDiagnostics` plus its private `pendingAdditions`, where a
   spawn sits until the block's next tick) that is a `Creature`, not a Player, Pet or
   Vendor, `Attackable` and not dead goes into the run's set. "N things breathing. Clock
   running." Kills before the count are counted and folded in.
3. **Kills.** `Creature.Die(lastDamager, topDamager)` postfix (the base method - Player's
   override is separate) removes the guid: "N left" every `ProgressEvery` (1) kills and
   for the last `ProgressLast` (5). A counted creature that is destroyed without dying
   (`WorldObject.Destroy` prefix) leaves the set too, logged as gone, so the run cannot
   hang on a phantom. A creature added to the copy after the count (`Landblock.AddWorldObject`
   postfix) joins the set: "one more appeared".
4. **No respawns.** `WorldObject.Generator_Generate` prefix: inside a copy with a run on,
   a generator whose power-up is done (`!CurrentlyPoweringUp`) is skipped, so what has
   been killed stays killed. Nothing on the generator is changed; the moment the run
   ends its next tick respawns as normal (`StopRegeneration`).
5. **End.** Empty set: complete. Otherwise voided by: a teleport out of the landblock
   (`Player.Teleport` prefix - portal, recall, summon, /teleto), dying (`Player.Die`
   postfix), logging out (`LogOut_Inner` postfix), anyone else crossing into the copy
   (a staff teleport in, inst-send - `OnArrival`), the runner crossing into a different
   copy (inst-tele), the copy closing, or the watchdog on the world tick seeing the
   player's `CurrentLandblock` off the copy for three checks in a row. Every void is
   told to the player with its reason and logged; the copy carries on as a copy.

6. **Reset.** The Trial Keeper's Use emote ends with a Goto carrying `ResetLabel`
   ("aelrynth-trial-reset", ledger 2026-09-23_003); `EmoteManager.ExecuteEmote` prefix queues
   it and the world tick carries it out: for every Trial the player's own copy is closed
   (`InstanceWorld.Close` - unless someone is inside it) and their cooldown cleared, so the
   next step through the portal is a fresh, timed run. For a `Course` trial that is the course
   copy (`Context.CourseCopyFor`), never the ordinary copy the player farms. Needed because a copy stays open
   `EmptyGraceSeconds` (900) after it empties, and re-entering it is never timed. The
   cooldown clear is in memory; a restart re-reads the last attempt from the table.

7. **Spent copies close.** A Trial copy (for a `Course` trial: a course copy only) with nobody in it is closed after
   `CloseEmptyAfterSeconds` (5), not InstancesNoDat's 15 minutes: once the runner has left, the
   run is void and the copy is used up (re-entering it is never timed), so the next way in
   opens a fresh one. Tracked from the arrival; checked once a second on the world tick.

All of it runs on the world thread (`LandblockManager.Tick` postfix drives the count,
the watchdog and the weekly check); the database is touched only when a run ends.

**Weak points, honestly:**
- The start is InstancesNoDat's `Arrival.OnArrival`, which `Arrival.Announce` fires only
  when its `AnnounceArrival` setting is on (it returns before the hook otherwise). It is
  on, live and staging; switch it off and no run ever starts.
- Freshness rests on the `Landblock.Init` stamp. Enabling this mod with copies already
  open makes those copies "not fresh" (safe). An InstancesNoDat hot reload re-fires
  nothing here; the arrival hook is re-bound on the next teleport.
- A generator with a `GeneratorInitialDelay` longer than the run spawns after the count
  and is added then - fine - but a run that clears everything before such a generator
  fires completes without it. None of the Trials has one (checked 2026-09-25 for the thirteen
  Hub of Awakening dungeons: no GeneratorInitialDelay, no event or time-of-day generator, no
  creature switched attackable by an emote, every generator row probability -1); check before
  listing more. Sizes at the start: Matron Hives ~424 each (69 swarm spawners x 6 + Matron +
  grubs - long courses, and no exit portal: leaving is a recall), Egg Orchards 159, Gotrok
  Citadel 157, Dark Design 146, Path of the Blind 81, Creepy Canyons 62, trial citadel 105.
  The 2% Tanada Shou-jen death spawn (Tukora Commander, Olthoi Ripper, Mosswart Elder) is a
  generator spawn and is held during a run.
- The count is "attackable creatures in the copy". A creature that is `Attackable` false
  until an emote flips it is not counted until it appears attackable to a re-add; a
  dungeon built that way would need a rule of its own.
- Kills are the base `Creature.Die`. A creature removed by a script, an admin `/delete`,
  or a landblock reload is "gone" (counted down, logged) rather than a kill.
- The watchdog's three-strike rule is one second per strike; a player who stands outside
  the copy for under three seconds and comes back is not voided - the teleport prefix
  catches every real exit anyway.
- Staff with `/teleto` into a runner's copy void the run by arriving - by design.

## Records

Shard tables, created on enable (`TrialDb`):

`aeshnidae_trial_run` - every attempt that ended: `id, character_Id, character_Name,
landblock, tier, seconds (float), kills, status ('complete' | 'void'), reason, started_at,
finished_at, week`. Tier is InstancesNoDat's `Scaling.Counted(landblock, copy)` (the
awakening tier the copy opened at) or the tier the arrival hook passed, whichever is
higher. Records are the complete rows: best per (character, landblock, tier), server
best per (landblock, tier); `/trial <name>` shows the top 10 best-per-character across
tiers. Staff (AccessLevel > Player) are timed and told, never inserted.

On a clear the player is told the time, the kills, and one of: a new server record
(previous by whom), your best (previous), or your best and the server best. Then
`RadiancePerClear` (500) Radiance through Aeshnidae.Bank's own minting (`Earning.Award`,
`History.Record`, `AnnounceRadiance`, bound by name - `Bank.cs`), x`PersonalBestMultiplier`
(2) for a personal best, x`ServerRecordMultiplier` (3) for a record; nothing when Bank is
off. Not GrantXP: the clear's reward is Radiance, not experience.

`aeshnidae_trial_award` - one row per (week, landblock): `character_Id, character_Name,
tier, seconds, run_Id, xp, awarded_at, claimed_at`. The unique key is what makes
`trial-award` idempotent.

## The weekly trophy

`Awards.MaybeAward` (world tick, once a minute) compares the week that last ended
(`Weeks.PreviousKey`) with `lastaward.txt`; when it differs, `AwardWeek` runs and the
file is written. For every Trial with no award row for that week: the fastest complete
run of the week (`WeekBest`, any tier - see the caveat below) is written to the award
table, then delivered if the winner is online (`Awards.Deliver`): a `TrophyWcid`
(3000029, ledger `2026-09-21_001`) with `Name` "Trial Trophy: <dungeon>", `Inscription`
"Cleared in m:ss, week of <date>, tier N", `LongDesc` naming the winner, into the pack
(`TryCreateInInventoryWithNetworking`), then `WeeklyAwardXp` (1,000,000) via
`GrantXP(Quest, None)` (no xp_modifier - GrantXP is after modifiers), then `claimed_at`.
A winner offline, or with a full pack, keeps the row pending; `Patches.PostEnterWorld`
hands it over 8 s after the next login. The winners are posted to Discord in one
message (`Discord.cs`: the webhook in the deployed Settings.json if set, else the VPS's
`notify-discord.sh <Channel>` - "alerts", which falls back to the audit channel until
`set-webhook.sh alerts` is run - else the log).

**Week keys.** A run's `week` is the ISO week ("2026-W38") of its finish time
shifted so the award moment is the boundary: with Sunday 17:00 the shift is +7 h, so a
clear at Sunday 18:00 belongs to the new week and the award at 17:00 closes a week that
ends exactly then (`Weeks.cs`). "Week of <date>" in the inscription is the Sunday 17:00
the week began.

**The tier caveat.** The trophy is per Trial at ANY tier: the week's fastest run of the
dungeon. A higher tier is harder and slower, so the tier-0 runner will normally win; 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.

## The site and the Codex

`trials.json` beside the dll (`Feed.cs`), rewritten after every recorded clear and every
award: `generated, cooldownMinutes, week, weekOf, nextAward, trials[] {landblock, name,
records[] per tier, top[] (10), weekLeader}, awards[] (last 20)`. It sits beside
Aeshnidae.Leaderboard's `leaderboard.json` rather than inside it: Leaderboard's boards
are a fixed list in its `Boards.cs` with no provider mechanism, and one extra file is
less to break than a reflective board. `C:\Aeshnidae\ops\publish-leaderboard.sh` copies
it into the web root beside the other two; the site's Leaderboards page does not read
it yet (a section for it is the site's next step).

`CodexPage` (found by Aeshnidae.Codex by reflection when "Aeshnidae.Trials" is in its
`Sections`): your bests, records held, trophies won; the server records. Codex is off
under retail rules; the page costs nothing while it is.

## Settings

- `Trials` - `[{Landblock, Name, Course}]`: 02F1 The Citadel Below (not Course), then the Hub of
  Awakening's thirteen, all `Course: true` - 6346/6347/6145 Matron Hive South/North/West, 0031 Creepy
  Canyons, 6146 Matron Hive East, 002B/00C8/0064 Egg Orchard (West, East), 002D Dark Design,
  002A/004B/00E1 Path of the Blind (West, East), 6147 The Gotrok Citadel. Written by
  `Mods/Content/tools/trials_settings.py`, with the InstancesNoDat side.
- `CooldownMinutes` (20), `FreshSeconds` (60), `CountAfterSeconds` (3), `CountDeadlineSeconds` (30)
- `ProgressEvery` (1), `ProgressLast` (5), `StopRegeneration` (true)
- `HarmlessHazards` (true) - floor hazards (ACE Hotspots: the Matron Hives' acid, acidfloor 6122 /
  acidsubmerge 11972) do no damage in a Trial copy (a course copy for a Course trial); they stay and
  look the same. `Hotspot.Activate` prefix. The hives' creatures stand in acid, and a course is
  "kill everything" (2026-09-25). Farming copies and the rest of the world keep their acid.
- `RadiancePerClear` (500), `PersonalBestMultiplier` (2), `ServerRecordMultiplier` (3)
- `AwardDay` ("Sunday"), `AwardHour` (17), `WeeklyAwardXp` (1000000), `TrophyWcid` (3000029), `AnnounceAwards` (true)
- `WriteJson` (true), `LogRuns` (true)
- `Discord`: `WebhookUrl` (""), `Username`, `Channel` ("alerts"), `NotifyScript` ("~/notify-discord.sh")

| File | |
| --- | --- |
| `Runs.cs` | The run: start, count, kills, endings, the watchdog. |
| `Patches.cs` | The ACE hooks (listed at the top of the file). |
| `Instances.cs` | InstancesNoDat by name: the arrival hook, Find/CopyOf/IsOurs/PlayersIn, the tier, the name. |
| `Bank.cs` | Radiance through Aeshnidae.Bank, by name. |
| `TrialDb.cs` | The two tables and every query. |
| `Awards.cs` | The weekly trophy and its delivery. |
| `Weeks.cs` | The shifted ISO week key. |
| `Feed.cs` | `trials.json`. |
| `Discord.cs` | The award post. |
| `CodexPage.cs` | The Codex pages. |
| `Commands.cs` | `/trial`, `trial-award`, `trial-void`, `trial-status`, `trial-reload`. |
