diff --git a/docs/superpowers/plans/2026-08-12-derived-durations.md b/docs/superpowers/plans/2026-08-12-derived-durations.md
new file mode 100644
index 00000000..b378da1d
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-derived-durations.md
@@ -0,0 +1,1152 @@
+# Derived Durations Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Show a countdown for DoTs and debuffs that have never been measured, derived from the wiki's base duration scaled by spell rank and marked as an estimate — and stop reporting every measured DoT one tick too long.
+
+**Architecture:** A build-time promote script reduces the existing eqlwiki harvest to an embedded `SpellDurations.json`. `SpellDurationCatalog` resolves a cast name to a duration, asking the catalog for an exact name match before it treats a trailing Roman numeral as a rank. `DebuffTracker` gains a three-tier expiry lookup (per-rank samples → catalog-derived → null) and starts keying effects by base name while displaying the ranked name.
+
+**Tech Stack:** C# / .NET 10, xUnit, Python 3 for the promote script (stdlib only, matching `quests-promote.py`).
+
+## Global Constraints
+
+- **Trust order is absolute:** Measured > Derived > Unknown. A measurement is never adjusted toward the catalog. Tepid Deeds keeps its measured ~126s against a catalog 150s.
+- **No runtime network.** The catalog is an embedded resource. Do not add an HTTP client.
+- **Never invent a number.** A spell with no catalog entry, or an unknown rank, shows `--`.
+- **Duration formula:** `duration = base × (1 + 0.10 × tier)` — additive, not compounding.
+- **Build/test commands** (the WPF app cannot build on this box — never run `dotnet build EQBuddy.slnx`):
+ - `dotnet build src/EQBuddy.Avalonia/EQBuddy.Avalonia.csproj -c Release`
+ - `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release`
+ - `dotnet test tests/EQBuddy.Avalonia.Tests/EQBuddy.Avalonia.Tests.csproj -c Release`
+- **`dotnet test` can exit 0 despite `[FATAL ERROR] Catastrophic failure` — read the test count, never trust the exit code.** Baseline on branch `derived-durations`, verified 2026-08-12: **`EQBuddy.Tests` 842**, **`EQBuddy.Avalonia.Tests` 81** (923 combined). Every per-task count below is for `EQBuddy.Tests` alone.
+- **Never run the Avalonia tests while the app is running.** Check `ps -C EQBuddy.Avalonia` first.
+- Test line shapes must be verbatim from `tests/fixtures/eqlog_Daggo_freeport.txt`.
+
+---
+
+### Task 1: Drop the phantom trailing tick
+
+The no-fade measurement path adds a `ServerTickSeconds` that the log says is not there. `OnFade` computes `fade − LandedAt` and is already correct; after this change both paths agree at 48s for Immolate.
+
+**Files:**
+- Modify: `src/EQBuddy.Core/DebuffTracker.cs:60-62` (delete `ServerTickSeconds`), `:242-246` (`Record(DebuffState)`)
+- Test: `tests/EQBuddy.Tests/DebuffTrackerTests.cs`
+
+**Interfaces:**
+- Consumes: nothing
+- Produces: `DebuffTracker.LearnedDurations` now reports `lastTick − firstTick` for tick-retired effects. `DebuffTracker.ServerTickSeconds` is **deleted** — no later task may reference it. (`MezTracker.ServerTickSeconds` is a separate constant and stays.)
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `tests/EQBuddy.Tests/DebuffTrackerTests.cs`:
+
+```csharp
+/// Immolate's wiki duration is 48s, and in the fixture its fade line arrives at the
+/// last tick, not a tick after it: nine ticks spanning 48s, then "worn off" in the same second.
+/// The tick-retired path used to add a phantom trailing tick and teach 54s - the number that
+/// looked like a 6s anchoring error and is not one.
+[Fact]
+public void ATickRetiredDotMeasuresFirstTickToLastTick()
+{
+ var tracker = new DebuffTracker();
+ for (var i = 0; i <= 48; i += 6)
+ tracker.Apply(Tick("a sand giant", "Immolate", i));
+
+ // Ticks stop; the effect retires once TickGrace has passed.
+ tracker.Active(T0.AddSeconds(48 + 13));
+
+ Assert.Equal(48, tracker.LearnedDurations["Immolate"]);
+}
+
+/// The fade path and the tick-retired path must agree. They measure the same event
+/// by different evidence, so a disagreement means one of them is wrong.
+[Fact]
+public void TheFadePathAndTheTickPathMeasureTheSameDuration()
+{
+ var faded = new DebuffTracker();
+ for (var i = 0; i <= 48; i += 6)
+ faded.Apply(Tick("a sand giant", "Immolate", i));
+ faded.Apply(new SpellWornOffEvent(T0.AddSeconds(48), "Immolate", "a sand giant"));
+
+ var ticked = new DebuffTracker();
+ for (var i = 0; i <= 48; i += 6)
+ ticked.Apply(Tick("a sand giant", "Immolate", i));
+ ticked.Active(T0.AddSeconds(48 + 13));
+
+ Assert.Equal(faded.LearnedDurations["Immolate"], ticked.LearnedDurations["Immolate"]);
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release --filter "FullyQualifiedName~DebuffTrackerTests"`
+
+Expected: FAIL. `ATickRetiredDotMeasuresFirstTickToLastTick` reports `Assert.Equal() Failure: Expected: 48, Actual: 54`. `TheFadePathAndTheTickPathMeasureTheSameDuration` reports `Expected: 48, Actual: 54`.
+
+- [ ] **Step 3: Delete the constant**
+
+In `src/EQBuddy.Core/DebuffTracker.cs`, delete these three lines (the `` and the `const`):
+
+```csharp
+ /// A DoT ticks on the six-second server heartbeat, and the first tick lands one
+ /// heartbeat after the cast, so a cast's length is (last - first) + one tick.
+ public const double ServerTickSeconds = 6;
+```
+
+- [ ] **Step 4: Fix the measurement**
+
+Replace `Record(DebuffState state)`:
+
+```csharp
+ private void Record(DebuffState state)
+ {
+ if (state.LastTickAt <= state.LandedAt) return; // a single tick measures nothing
+ Record(state.Spell, (state.LastTickAt - state.LandedAt).TotalSeconds);
+ }
+```
+
+Then correct the class docstring, which currently states the wrong model. Replace the paragraph beginning "The log never states a duration" with:
+
+```csharp
+/// The log never states a duration, but it does not have to. The first tick IS the landing,
+/// ticks arrive every ~6 seconds naming the spell, and the last tick falls on the expiry - the
+/// fade line arrives in the same second, not one tick later. So a completed cast measures
+/// itself as first tick to last tick, and that measurement drives the NEXT cast of the same
+/// spell, which is why the first cast of anything shows no countdown and every one after does.
+///
+/// Measured across the 690k-line fixture (six DoTs, 138 completed casts): first-tick-to-fade
+/// equals the wiki duration exactly for every spell whose wiki value is given in exact seconds
+/// or ticks - Immolate 48, Drones of Doom 48, Gasping Embrace 48, Stinging Swarm 54. Anchoring
+/// on the CAST line instead matches none of them, running long by each spell's own cast time
+/// (Immolate 2.5s, Shiftless Deeds 6.0s), which is why the error is not a constant six seconds.
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release`
+
+Expected: PASS **only after the step below**. Four existing tests encode the phantom tick and must be corrected — they are the regression evidence, not collateral damage. Make exactly these edits in `tests/EQBuddy.Tests/DebuffTrackerTests.cs`, changing expected values and their explanatory comments only:
+
+| Test | Tick span | Was | Becomes |
+|---|---|---|---|
+| `ADurationLearnedFromOneCastCountsDownTheNext` | 0→48 | `54` (twice: `LearnedDurations` and `RemainingSeconds`) | `48` |
+| `RecastingRestartsTheClockRatherThanExtendingIt` | 36→90 | `60` | `54` |
+| `TheRepeatedMeasurementWinsOverAnOddOne` | 0→48 | `54` | `48` |
+| `AGapBetweenTicksEndsTheEffectEvenWithoutAnActiveCall` | 0→6 | `12` | `6` |
+
+Also update the inline comments that state the old arithmetic:
+- In `RecastingRestartsTheClockRatherThanExtendingIt`: `// 36..90 is the second cast: 54s + the tick already paid for = 60, not 96.` becomes `// 36..90 is the second cast: 54s, not 96. The last tick falls on the expiry.`
+- In `TheRepeatedMeasurementWinsOverAnOddOne`: the `// 54s` comments become `// 48s`, and `// 24s - the odd one out` becomes `// 18s - the odd one out`.
+
+**These four must NOT change:** `AFadeLineEndsTheEffectAndMeasuresItExactly` (54), `ATheirSlowBorrowsADurationYouMeasuredYourself` (60), `ASlowSurvivesLongerThanTheTickGapAndIsEndedByItsFade` (60), `AnUnmeasuredSpellHasNoCountdownRatherThanAGuess` (nulls). They all exercise the fade path or the no-duration path, which this task does not touch — if any of them fails, **stop and report**, because that means the change reached further than intended.
+
+Then run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release`
+
+Expected: PASS. Read the total — it must be 844 (842 baseline + 2 new), with 0 failed.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/EQBuddy.Core/DebuffTracker.cs tests/EQBuddy.Tests/DebuffTrackerTests.cs
+git commit -m "The DoT measurement that was one tick long"
+```
+
+---
+
+### Task 2: Rank parsing
+
+A pure string helper: split a trailing Roman numeral off a spell name. It makes no judgement about whether the numeral is a rank — Task 3 owns that decision, because only the catalog can tell `Shiftless Deeds IV` from `Clarity II`.
+
+**Files:**
+- Create: `src/EQBuddy.Core/SpellRank.cs`
+- Test: `tests/EQBuddy.Tests/SpellRankTests.cs`
+
+**Interfaces:**
+- Consumes: nothing
+- Produces:
+ - `public static (string Base, int Tier) SpellRank.Split(string name)` — `("Shiftless Deeds", 4)` for `"Shiftless Deeds IV"`; `(name.Trim(), 0)` when there is no trailing numeral.
+ - `public static double SpellRank.Scale(double baseSeconds, int tier)`
+ - `public const double SpellRank.PerTier = 0.10`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/EQBuddy.Tests/SpellRankTests.cs`:
+
+```csharp
+using EQBuddy.Core;
+using Xunit;
+
+namespace EQBuddy.Tests;
+
+///
+/// Splitting a rank off a spell name. This helper deliberately does NOT decide whether the
+/// numeral it found is a rank at all - 121 spells in the wiki catalog are genuinely NAMED with
+/// a trailing numeral ("Clarity II", "Burnout IV"), and only the catalog can tell them apart.
+/// See SpellDurationCatalog.
+///
+public class SpellRankTests
+{
+ [Theory]
+ [InlineData("Shiftless Deeds IV", "Shiftless Deeds", 4)]
+ [InlineData("Mesmerization V", "Mesmerization", 5)]
+ [InlineData("Shiftless Deeds VI", "Shiftless Deeds", 6)]
+ [InlineData("Beguile II", "Beguile", 2)]
+ [InlineData("Heroic Leap I", "Heroic Leap", 1)]
+ [InlineData("Efflorescing Heal III", "Efflorescing Heal", 3)]
+ public void ATrailingRomanNumeralIsSplitOff(string name, string expectedBase, int expectedTier)
+ {
+ Assert.Equal((expectedBase, expectedTier), SpellRank.Split(name));
+ }
+
+ [Theory]
+ [InlineData("Immolate")]
+ [InlineData("Drifting Death")]
+ [InlineData("Vengeance of the Wild")]
+ public void AnUnrankedNameIsTierZero(string name)
+ {
+ Assert.Equal((name, 0), SpellRank.Split(name));
+ }
+
+ /// Real spell words that happen to be Roman letters must not be eaten. "Ice" is a
+ /// spell Daggo casts 285 times in the fixture; "Mix" and "Dim" are Roman-parseable strings.
+ [Theory]
+ [InlineData("Ice")]
+ [InlineData("Mana Sieve")]
+ [InlineData("Chaos Flux")]
+ public void ASingleWordNameIsNeverTreatedAsARank(string name)
+ {
+ Assert.Equal((name, 0), SpellRank.Split(name));
+ }
+
+ /// The formula is additive, not compounding: 1.1^6 is 1.77, and Shiftless Deeds VI
+ /// shows exactly 4 minutes in game against a 150s base.
+ [Theory]
+ [InlineData(150, 6, 240)] // Shiftless Deeds VI - 4 min, confirmed in game
+ [InlineData(150, 4, 210)] // Shiftless Deeds IV
+ [InlineData(24, 5, 36)] // Mesmerization V - measured ~36s
+ [InlineData(48, 0, 48)] // unranked is untouched
+ public void DurationScalesTenPercentPerTier(double baseSeconds, int tier, double expected)
+ {
+ Assert.Equal(expected, SpellRank.Scale(baseSeconds, tier), precision: 6);
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release --filter "FullyQualifiedName~SpellRankTests"`
+
+Expected: FAIL to compile — `The name 'SpellRank' does not exist in the current context`.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `src/EQBuddy.Core/SpellRank.cs`:
+
+```csharp
+namespace EQBuddy.Core;
+
+///
+/// The rank suffix on a spell name. EverQuest Legends adds roman-numeral ranks to spells
+/// ("Shiftless Deeds IV"), and each tier adds 10% duration - additive, confirmed three ways:
+/// Shiftless Deeds VI shows 4 minutes in game against a 150s base (x1.6), the wiki's Spell
+/// Level slider reads "Duration +60%" at level 6, and Mesmerization V measures ~36s against a
+/// 24s base (x1.5). Compounding would give 1.1^6 = 1.77, which matches no observed value.
+///
+/// This splitter is deliberately naive about WHETHER the numeral is a rank. 121 spells in the
+/// wiki catalog are genuinely named with a trailing numeral - "Clarity II", "Burnout IV",
+/// "Cannibalize IV" - and are not ranks of anything. Only the catalog can tell the two apart,
+/// so that call lives in .
+///
+public static class SpellRank
+{
+ /// Duration added per rank tier.
+ public const double PerTier = 0.10;
+
+ /// Splits a trailing roman numeral off a name. Returns tier 0 when there is none.
+ /// A single-word name is never split: the whole name would vanish, and "Ice" is a spell.
+ public static (string Base, int Tier) Split(string name)
+ {
+ var trimmed = name.Trim();
+ var space = trimmed.LastIndexOf(' ');
+ if (space <= 0) return (trimmed, 0);
+
+ var tier = ParseRoman(trimmed[(space + 1)..]);
+ return tier > 0 ? (trimmed[..space], tier) : (trimmed, 0);
+ }
+
+ public static double Scale(double baseSeconds, int tier) =>
+ baseSeconds * (1 + PerTier * tier);
+
+ /// I..XXXIX, or 0 for anything that is not a well-formed roman numeral. Only
+ /// I/V/X are accepted - L and beyond cannot be a spell rank, and "Cazic" should not be
+ /// read as a number because it starts with C.
+ private static int ParseRoman(string token)
+ {
+ if (token.Length is 0 or > 6) return 0;
+ var total = 0; var previous = 0;
+ for (var i = token.Length - 1; i >= 0; i--)
+ {
+ var value = token[i] switch { 'I' => 1, 'V' => 5, 'X' => 10, _ => 0 };
+ if (value == 0) return 0;
+ total += value < previous ? -value : value;
+ previous = Math.Max(previous, value);
+ }
+ // Round-trips only for canonical spellings, so "IIII" and "VV" are rejected.
+ return total is > 0 and < 40 && ToRoman(total) == token ? total : 0;
+ }
+
+ private static string ToRoman(int value)
+ {
+ var result = "";
+ foreach (var (number, symbol) in
+ (ReadOnlySpan<(int, string)>)[(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")])
+ while (value >= number) { result += symbol; value -= number; }
+ return result;
+ }
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release --filter "FullyQualifiedName~SpellRankTests"`
+
+Expected: PASS, 16 tests (4 theories, 16 cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/EQBuddy.Core/SpellRank.cs tests/EQBuddy.Tests/SpellRankTests.cs
+git commit -m "Ranks add ten percent each, and Ice is not a numeral"
+```
+
+---
+
+### Task 3: The duration catalog
+
+Promote the existing harvest into an embedded catalog, and resolve a cast name against it. The catalog is the authority on names: an exact hit always beats a rank interpretation.
+
+**Files:**
+- Create: `scripts/harvests/eqlwiki/spells-promote.py`
+- Create: `src/EQBuddy.Core/Data/SpellDurations.json` (generated by that script)
+- Create: `src/EQBuddy.Core/SpellDurationCatalog.cs`
+- Modify: `src/EQBuddy.Core/EQBuddy.Core.csproj:19-27` (add the `EmbeddedResource`)
+- Test: `tests/EQBuddy.Tests/SpellDurationCatalogTests.cs`
+
+**Interfaces:**
+- Consumes: `SpellRank.Split`, `SpellRank.Scale` from Task 2
+- Produces:
+ - `public enum DurationCertainty { Unknown, Derived, Measured }`
+ - `public sealed record ResolvedDuration(string BaseName, int Tier, double Seconds)`
+ - `public sealed class SpellDurationCatalog`
+ - `public SpellDurationCatalog(IReadOnlyDictionary? durations = null)`
+ - `public static SpellDurationCatalog Embedded { get; }` — lazily loaded shared instance
+ - `public ResolvedDuration? Resolve(string castName)` — null when the catalog cannot answer
+ - `public string BaseNameOf(string castName)` — the catalog-aware base name, used as a tracking key
+
+- [ ] **Step 1: Write the promote script**
+
+Create `scripts/harvests/eqlwiki/spells-promote.py`:
+
+```python
+#!/usr/bin/env python3
+"""Promote the spell harvest into the embedded duration catalog (SpellDurations.json).
+
+Feeds the DoT/debuff panel's DERIVED durations - the cold-start fallback shown, marked as an
+estimate, until the log measures the real thing. Base durations come from the wiki's
+| duration = field, already parsed into duration_seconds by spells-harvest.py.
+
+Excluded on purpose:
+ - duration_seconds of 0 or None ("Instant") - not a debuff, nothing to count down.
+ - the ~337 entries whose raw duration is a LEVEL-SCALED RANGE that the harvest could not
+ reduce to a number ("6.3 minutes @L53 to 7.0 minutes @L60", Cripple). These have no single
+ base to multiply, so they are absent from the catalog and the panel shows "--". Inventing
+ a midpoint here would put a confident wrong number in front of the one decision the panel
+ exists to serve.
+
+Ranks are NOT expanded here. "Shiftless Deeds IV" is derived at runtime from the base entry
+(see SpellRank); pre-expanding would bloat the catalog and freeze the formula into data.
+
+Serialization matches quests-promote.py: sorted keys, compact separators, so knowledge-refresh
+PRs diff as DATA rather than formatting.
+"""
+
+import json
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+SRC = HERE / "spells.json"
+OUT = HERE.parents[2] / "src" / "EQBuddy.Core" / "Data" / "SpellDurations.json"
+
+
+def promote(spells):
+ durations = {}
+ for s in spells:
+ seconds = s.get("duration_seconds")
+ if not isinstance(seconds, (int, float)) or seconds <= 0:
+ continue
+ name = s["name"].strip()
+ if name:
+ durations[name] = round(float(seconds), 1)
+ return durations
+
+
+def main():
+ spells = json.loads(SRC.read_text(encoding="utf-8"))
+ durations = promote(spells)
+ payload = {
+ "comment": (
+ "Base spell durations from eqlwiki.com's | duration = field, promoted by "
+ "spells-promote.py. Seconds, at BASE rank. Roman-numeral ranks add 10% per tier "
+ "and are derived at runtime (SpellRank). Level-scaled and Instant durations are "
+ "excluded, so an absent spell means unknown - never a guess."
+ ),
+ "durations": dict(sorted(durations.items())),
+ }
+ OUT.write_text(
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":"), indent=1) + "\n",
+ encoding="utf-8",
+ )
+ print(f"{len(durations)} durations -> {OUT}")
+
+
+if __name__ == "__main__":
+ main()
+```
+
+- [ ] **Step 2: Run it and sanity-check the output**
+
+```bash
+python3 scripts/harvests/eqlwiki/spells-promote.py
+python3 -c "
+import json;d=json.load(open('src/EQBuddy.Core/Data/SpellDurations.json'))['durations']
+print('entries',len(d))
+for n in ['Shiftless Deeds','Tepid Deeds','Immolate','Mesmerization','Clarity II','Burnout IV']:
+ print(f' {n}: {d.get(n)}')
+print('Cripple present?', 'Cripple' in d)
+"
+```
+
+Expected: **exactly 680 entries**; `Shiftless Deeds: 150.0`, `Tepid Deeds: 150.0`, `Immolate: 48.0`, `Mesmerization: 24.0`, `Clarity II: 2100.0`, `Burnout IV: 900.0`; `Cripple present? False`.
+
+If any value differs, **stop and report** — the catalog is the evidence base for the whole feature.
+
+- [ ] **Step 3: Register the embedded resource**
+
+In `src/EQBuddy.Core/EQBuddy.Core.csproj`, alongside the existing `Data\*.json` entries, add:
+
+```xml
+
+```
+
+- [ ] **Step 4: Write the failing test**
+
+Create `tests/EQBuddy.Tests/SpellDurationCatalogTests.cs`:
+
+```csharp
+using EQBuddy.Core;
+using Xunit;
+
+namespace EQBuddy.Tests;
+
+///
+/// Resolving a cast name to a base duration. The whole contract is the ordering: an exact
+/// catalog hit beats a rank interpretation, because 121 wiki spells are genuinely NAMED with a
+/// trailing numeral and reading "Clarity II" as tier-2 Clarity would scale a duration the
+/// catalog already knows exactly.
+///
+public class SpellDurationCatalogTests
+{
+ private static readonly SpellDurationCatalog Catalog = new(new Dictionary
+ {
+ ["Shiftless Deeds"] = 150,
+ ["Mesmerization"] = 24,
+ ["Immolate"] = 48,
+ ["Clarity II"] = 2100,
+ });
+
+ /// The trap. "Clarity II" is a spell, not a rank of "Clarity" - and note the
+ /// fixture catalog has no "Clarity" entry at all, so a rank reading would resolve nothing
+ /// while the correct reading answers exactly.
+ [Fact]
+ public void ASpellNamedWithANumeralResolvesExactlyAndIsNotScaled()
+ {
+ var resolved = Catalog.Resolve("Clarity II");
+
+ Assert.Equal(new ResolvedDuration("Clarity II", 0, 2100), resolved);
+ }
+
+ [Fact]
+ public void ARankedSpellDerivesFromItsBase()
+ {
+ Assert.Equal(new ResolvedDuration("Shiftless Deeds", 4, 210), Catalog.Resolve("Shiftless Deeds IV"));
+ Assert.Equal(new ResolvedDuration("Shiftless Deeds", 6, 240), Catalog.Resolve("Shiftless Deeds VI"));
+ Assert.Equal(new ResolvedDuration("Mesmerization", 5, 36), Catalog.Resolve("Mesmerization V"));
+ }
+
+ [Fact]
+ public void AnUnrankedSpellResolvesToItsOwnDuration()
+ {
+ Assert.Equal(new ResolvedDuration("Immolate", 0, 48), Catalog.Resolve("Immolate"));
+ }
+
+ /// No base page means no number. Heroic Leap has no wiki duration entry, so the
+ /// panel must say "--" rather than reach for something plausible.
+ [Fact]
+ public void AnUnknownSpellResolvesToNothing()
+ {
+ Assert.Null(Catalog.Resolve("Heroic Leap I"));
+ Assert.Null(Catalog.Resolve("Some Spell That Does Not Exist"));
+ }
+
+ /// The tracking key: ticks and fade lines never carry the rank, so a ranked cast
+ /// has to collapse onto the same key the tick lines will use.
+ [Fact]
+ public void TheBaseNameIsTheNameTickLinesWillUse()
+ {
+ Assert.Equal("Shiftless Deeds", Catalog.BaseNameOf("Shiftless Deeds IV"));
+ Assert.Equal("Immolate", Catalog.BaseNameOf("Immolate"));
+ // A spell genuinely named with a numeral keeps it - its tick lines carry it too.
+ Assert.Equal("Clarity II", Catalog.BaseNameOf("Clarity II"));
+ // Unknown to the catalog: fall back to the naive split rather than refuse to track.
+ Assert.Equal("Heroic Leap", Catalog.BaseNameOf("Heroic Leap I"));
+ }
+
+ /// Guards the shipped data, not the code. These four are the values the whole
+ /// feature was verified against.
+ [Fact]
+ public void TheEmbeddedCatalogCarriesTheVerifiedDurations()
+ {
+ var catalog = SpellDurationCatalog.Embedded;
+
+ Assert.Equal(150, catalog.Resolve("Shiftless Deeds")!.Seconds);
+ Assert.Equal(48, catalog.Resolve("Immolate")!.Seconds);
+ Assert.Equal(24, catalog.Resolve("Mesmerization")!.Seconds);
+ // Confirmed in game: Shiftless Deeds VI shows 4 minutes.
+ Assert.Equal(240, catalog.Resolve("Shiftless Deeds VI")!.Seconds);
+ }
+
+ /// Cripple's wiki duration is the level-scaled range "6.3 minutes @L53 to 7.0
+ /// minutes @L60". There is no single base to multiply, so it is absent by design.
+ [Fact]
+ public void ALevelScaledDurationIsAbsentRatherThanAveraged()
+ {
+ Assert.Null(SpellDurationCatalog.Embedded.Resolve("Cripple"));
+ }
+}
+```
+
+- [ ] **Step 5: Run the tests to verify they fail**
+
+Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release --filter "FullyQualifiedName~SpellDurationCatalogTests"`
+
+Expected: FAIL to compile — `The name 'SpellDurationCatalog' does not exist in the current context`.
+
+- [ ] **Step 6: Write the implementation**
+
+Create `src/EQBuddy.Core/SpellDurationCatalog.cs`:
+
+```csharp
+using System.Reflection;
+using System.Text.Json;
+
+namespace EQBuddy.Core;
+
+/// How much to trust a countdown. The ordering is the feature: a measurement always
+/// beats an estimate, and an estimate always beats a guess - of which there are none.
+public enum DurationCertainty
+{
+ /// Nobody knows. The chip shows "--", never a number.
+ Unknown,
+ /// Wiki base duration, scaled for rank. Shown marked, and discarded the moment a
+ /// real measurement lands.
+ Derived,
+ /// Measured from this log. Authoritative - never adjusted toward the wiki.
+ Measured,
+}
+
+/// A cast name resolved against the catalog. is 0 when the
+/// spell is unranked or is genuinely named with a numeral.
+public sealed record ResolvedDuration(string BaseName, int Tier, double Seconds);
+
+///
+/// Base spell durations from eqlwiki, embedded rather than fetched (Data/SpellDurations.json).
+/// A game overlay should not make an HTTP request mid-fight for a number that is only a
+/// fallback estimate, and the harvest already on disk answers 1,589 spells offline.
+///
+/// The resolution order exists because of a trap worth stating plainly: 121 spells in the wiki
+/// catalog END in a roman numeral as their real name - "Clarity II", "Burnout IV",
+/// "Cannibalize IV", "Berserker Madness III". They are distinct spell pages, not ranks. So the
+/// catalog is asked for the full name FIRST, and only a miss is reinterpreted as a rank. Read
+/// the other way round, "Clarity II" would scale a duration the catalog already knows exactly.
+///
+public sealed class SpellDurationCatalog
+{
+ private readonly IReadOnlyDictionary _durations;
+ private static SpellDurationCatalog? _embedded;
+
+ public SpellDurationCatalog(IReadOnlyDictionary? durations = null) =>
+ _durations = durations is null
+ ? LoadEmbedded()
+ : new Dictionary(durations, StringComparer.OrdinalIgnoreCase);
+
+ /// The shipped catalog, loaded once. 680 spells.
+ public static SpellDurationCatalog Embedded => _embedded ??= new SpellDurationCatalog();
+
+ /// Base seconds for a cast name, scaled for rank - or null when the catalog
+ /// cannot answer, which the panel renders as "--".
+ public ResolvedDuration? Resolve(string castName)
+ {
+ var name = castName.Trim();
+ if (name.Length == 0) return null;
+
+ // Exact first: the catalog is the authority on what is a NAME and what is a rank.
+ if (_durations.TryGetValue(name, out var exact))
+ return new ResolvedDuration(name, 0, exact);
+
+ var (baseName, tier) = SpellRank.Split(name);
+ if (tier > 0 && _durations.TryGetValue(baseName, out var seconds))
+ return new ResolvedDuration(baseName, tier, SpellRank.Scale(seconds, tier));
+
+ return null;
+ }
+
+ /// The name that this spell's tick and fade lines will use. Those lines never
+ /// carry the rank - measured across the fixture, 0 of all tick lines have a numeral, and
+ /// "Your Mesmerization spell has worn off" appears 1197 times against 630 casts of
+ /// "Mesmerization V" - so a ranked cast has to collapse onto its base to be tracked at all.
+ /// A spell genuinely NAMED with a numeral keeps it, because its own tick lines will too.
+ public string BaseNameOf(string castName)
+ {
+ var name = castName.Trim();
+ return _durations.ContainsKey(name) ? name : SpellRank.Split(name).Base;
+ }
+
+ private static Dictionary LoadEmbedded()
+ {
+ using var stream = Assembly.GetExecutingAssembly()
+ .GetManifestResourceStream("EQBuddy.Core.Data.SpellDurations.json")
+ ?? throw new InvalidOperationException("SpellDurations.json missing from resources");
+ using var doc = JsonDocument.Parse(stream);
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var entry in doc.RootElement.GetProperty("durations").EnumerateObject())
+ result[entry.Name] = entry.Value.GetDouble();
+ return result;
+ }
+}
+```
+
+- [ ] **Step 7: Run the tests to verify they pass**
+
+Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release`
+
+Expected: PASS. Total 867 (844 + Task 2's 16 theory cases + 7 new), 0 failed.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add scripts/harvests/eqlwiki/spells-promote.py src/EQBuddy.Core/Data/SpellDurations.json \
+ src/EQBuddy.Core/SpellDurationCatalog.cs src/EQBuddy.Core/EQBuddy.Core.csproj \
+ tests/EQBuddy.Tests/SpellDurationCatalogTests.cs
+git commit -m "Base durations, shipped rather than fetched"
+```
+
+---
+
+### Task 4: Wire the catalog into the tracker
+
+Three changes that have to land together, because they share the same key: effects become keyed by base name while displaying the ranked name, samples become per-rank, and expiry consults the catalog when no sample exists.
+
+**Files:**
+- Modify: `src/EQBuddy.Core/DebuffTracker.cs` (`DebuffState` record, `_active` key, `_recastPending`, `OnLanding`, `OnTick`, `Record`, `Expiry`)
+- Test: `tests/EQBuddy.Tests/DebuffTrackerTests.cs`
+
+**Interfaces:**
+- Consumes: `SpellDurationCatalog.Resolve`, `SpellDurationCatalog.BaseNameOf`, `DurationCertainty`, `ResolvedDuration` from Task 3
+- Produces:
+ - `DebuffState` gains `string BaseName` (after `Spell`) and `DurationCertainty Certainty` (last, defaults `Unknown`)
+ - `public DebuffTracker(SpellDurationCatalog? catalog = null)`
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `tests/EQBuddy.Tests/DebuffTrackerTests.cs`:
+
+```csharp
+private static DebuffTracker WithCatalog() => new(new SpellDurationCatalog(
+ new Dictionary { ["Shiftless Deeds"] = 150, ["Immolate"] = 48 }));
+
+/// Cold start: nothing has been measured, so the catalog answers - marked as an
+/// estimate so the chip can say so.
+[Fact]
+public void AnUnmeasuredSpellFallsBackToTheDerivedDuration()
+{
+ var tracker = WithCatalog();
+
+ tracker.Apply(new SpellCastEvent(T0, "Shiftless Deeds VI"));
+ tracker.Apply(new DebuffLandedEvent(T0.AddSeconds(6), "a sand giant", DebuffKind.Slow));
+
+ var state = Assert.Single(tracker.Active(T0.AddSeconds(6)));
+ Assert.Equal(DurationCertainty.Derived, state.Certainty);
+ Assert.Equal(240, state.RemainingSeconds(T0.AddSeconds(6))!.Value, precision: 3);
+}
+
+/// The trust order. Tepid Deeds measures ~126s while its wiki page says 150 - and
+/// that page contradicts itself. The measurement wins and is never corrected toward the wiki.
+[Fact]
+public void AMeasurementSupersedesTheDerivedDuration()
+{
+ var tracker = new DebuffTracker(new SpellDurationCatalog(
+ new Dictionary { ["Immolate"] = 48 }));
+
+ // First cast: nothing measured yet, so the estimate is shown.
+ tracker.Apply(new SpellCastEvent(T0, "Immolate"));
+ for (var i = 0; i <= 126; i += 6)
+ tracker.Apply(Tick("a sand giant", "Immolate", i));
+ tracker.Apply(new SpellWornOffEvent(T0.AddSeconds(126), "Immolate", "a sand giant"));
+
+ // Second cast on a fresh mob: the measured 126 is used, not the catalog's 48.
+ tracker.Apply(new SpellCastEvent(T0.AddSeconds(200), "Immolate"));
+ tracker.Apply(Tick("a griffon", "Immolate", 206));
+
+ var state = Assert.Single(tracker.Active(T0.AddSeconds(206)));
+ Assert.Equal(DurationCertainty.Measured, state.Certainty);
+ Assert.Equal(126, state.RemainingSeconds(T0.AddSeconds(206))!.Value, precision: 3);
+}
+
+/// Ranks have genuinely different durations, so their samples must not pool - a
+/// rank-I measurement must never shorten a rank-V countdown.
+[Fact]
+public void SamplesForTwoRanksOfOneSpellDoNotPool()
+{
+ var tracker = WithCatalog();
+
+ tracker.Apply(new SpellCastEvent(T0, "Shiftless Deeds IV"));
+ tracker.Apply(new DebuffLandedEvent(T0.AddSeconds(1), "a sand giant", DebuffKind.Slow));
+ tracker.Apply(new SpellWornOffEvent(T0.AddSeconds(101), "Shiftless Deeds", "a sand giant"));
+
+ Assert.Equal(100, tracker.LearnedDurations["Shiftless Deeds IV"]);
+ Assert.False(tracker.LearnedDurations.ContainsKey("Shiftless Deeds VI"));
+}
+
+/// The rank lives on the cast line and nowhere else, so a tick nobody cast has an
+/// unknown tier. Assuming base rank would read 48s against a real 72s for a rank-V DoT and
+/// warn early on every single cast.
+[Fact]
+public void ATickWithNoExplainingCastShowsNoDerivedDuration()
+{
+ var tracker = WithCatalog();
+
+ tracker.Apply(Tick("a sand giant", "Immolate", 0));
+
+ var state = Assert.Single(tracker.Active(T0));
+ Assert.Equal(DurationCertainty.Unknown, state.Certainty);
+ Assert.Null(state.RemainingSeconds(T0));
+}
+
+/// A cast from long ago must not supply a rank. _recentCasts is pruned only when a
+/// new cast arrives, so without a window this quietly becomes "the last rank I ever saw" -
+/// the guess this design rejected.
+[Fact]
+public void AStaleCastDoesNotSupplyTheRank()
+{
+ var tracker = WithCatalog();
+
+ tracker.Apply(new SpellCastEvent(T0, "Immolate III"));
+ // Two minutes later, a tick with no cast of its own to explain it.
+ tracker.Apply(Tick("a sand giant", "Immolate", 120));
+
+ var state = Assert.Single(tracker.Active(T0.AddSeconds(120)));
+ Assert.Equal("Immolate", state.Spell);
+ Assert.Equal(DurationCertainty.Unknown, state.Certainty);
+}
+
+/// The chip shows the rank you actually cast, while tracking keys on the base name
+/// the tick and fade lines use.
+[Fact]
+public void TheChipShowsTheRankedNameButTracksByBaseName()
+{
+ var tracker = WithCatalog();
+
+ tracker.Apply(new SpellCastEvent(T0, "Shiftless Deeds IV"));
+ tracker.Apply(new DebuffLandedEvent(T0.AddSeconds(1), "a sand giant", DebuffKind.Slow));
+
+ var state = Assert.Single(tracker.Active(T0.AddSeconds(1)));
+ Assert.Equal("Shiftless Deeds IV", state.Spell);
+ Assert.Equal("Shiftless Deeds", state.BaseName);
+}
+
+/// _recastPending held the RANKED cast name and was looked up with the UNRANKED tick
+/// name, so recast detection could never fire for a ranked DoT - the exact failure the tracker
+/// documents ("Immolate 115s against 54-60s for every sibling"). The fixture never caught it
+/// because none of Daggo's DoTs are ranked.
+[Fact]
+public void ARecastOfARankedDotRestartsTheClock()
+{
+ var tracker = new DebuffTracker(new SpellDurationCatalog(
+ new Dictionary { ["Immolate"] = 48 }));
+
+ tracker.Apply(new SpellCastEvent(T0, "Immolate III"));
+ tracker.Apply(Tick("a sand giant", "Immolate", 6));
+ tracker.Apply(Tick("a sand giant", "Immolate", 12));
+
+ // Refresh before it drops. The clock must restart from the new cast's first tick.
+ tracker.Apply(new SpellCastEvent(T0.AddSeconds(18), "Immolate III"));
+ tracker.Apply(Tick("a sand giant", "Immolate", 24));
+
+ var state = Assert.Single(tracker.Active(T0.AddSeconds(24)));
+ Assert.Equal(T0.AddSeconds(24), state.LandedAt);
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release --filter "FullyQualifiedName~DebuffTrackerTests"`
+
+Expected: FAIL to compile — `'DebuffState' does not contain a definition for 'BaseName'` and `'Certainty'`.
+
+- [ ] **Step 3: Extend the state record**
+
+In `src/EQBuddy.Core/DebuffTracker.cs`, replace the `DebuffState` declaration's parameter list:
+
+```csharp
+public sealed record DebuffState(
+ string Target,
+ /// What to show on the chip - the ranked name when a cast supplied one.
+ string Spell,
+ /// What to key on. Tick and fade lines never carry the rank, so tracking keys on
+ /// the base name while the chip displays the rank.
+ string BaseName,
+ string Caster,
+ bool IsMine,
+ DateTime LandedAt,
+ DateTime LastTickAt,
+ DateTime? ExpiresAt,
+ /// True for DoTs, which announce themselves every six seconds. A slow announces
+ /// itself once and then says nothing until it fades, so silence means nothing for it.
+ bool Ticks = false,
+ /// Whether the countdown was measured, derived from the wiki, or is unknown.
+ DurationCertainty Certainty = DurationCertainty.Unknown)
+```
+
+- [ ] **Step 4: Rework the tracker's keying and lookup**
+
+In `src/EQBuddy.Core/DebuffTracker.cs`:
+
+Add the catalog field, the pairing window, and the constructor, next to the existing field
+declarations:
+
+```csharp
+ /// How long after a cast a first TICK can still be attributed to it, and so
+ /// supply the rank. Wider than because the cast line precedes the
+ /// landing by the spell's own cast time, which reaches 6s (Shiftless Deeds) before the
+ /// first tick is even due. Measured across the fixture, cast-to-first-tick runs a median of
+ /// 5s and a 90th percentile of 8s. Still far below the shortest DoT duration (30s), so this
+ /// can never reach back and grab the PREVIOUS cast of the same spell.
+ public static readonly TimeSpan CastToTick = TimeSpan.FromSeconds(15);
+
+ private readonly SpellDurationCatalog _catalog;
+
+ public DebuffTracker(SpellDurationCatalog? catalog = null) =>
+ _catalog = catalog ?? SpellDurationCatalog.Embedded;
+```
+
+Replace `RememberCast`'s call site in `Apply` for `SpellCastEvent` so the pending key is the base name:
+
+```csharp
+ case SpellCastEvent cast:
+ _recastPending.Add(_catalog.BaseNameOf(cast.Spell));
+ RememberCast(cast.Time, "", cast.Spell, mine: true);
+ break;
+```
+
+Replace `OnLanding`'s body after the cast lookup:
+
+```csharp
+ var key = (landed.Target, _catalog.BaseNameOf(cast.Spell));
+ var (expires, certainty) = Expiry(cast.Spell, cast.Spell, landed.Time);
+ _active[key] = new DebuffState(
+ landed.Target, cast.Spell, key.Item2, cast.Caster, cast.Mine,
+ LandedAt: landed.Time, LastTickAt: landed.Time,
+ ExpiresAt: expires, Certainty: certainty);
+```
+
+In `OnTick`, replace the key and both construction sites. The key is already the tick's (base) name, so only the lookup of the ranked cast changes:
+
+```csharp
+ private void OnTick(DamageDealtEvent tick)
+ {
+ var key = (tick.Target, tick.Source);
+ // The rank is on the cast line and nowhere else, so a tick nobody cast has an unknown
+ // tier - and an unknown tier cannot be derived, only measured.
+ //
+ // The window matters. _recentCasts is pruned only when a new cast arrives, so an
+ // unbounded search would match a cast from ten minutes ago and silently become "the
+ // last rank I ever saw" - which is exactly the guess this design rejected.
+ var castName = _recentCasts
+ .LastOrDefault(c => c.Mine && tick.Time - c.Time <= CastToTick
+ && _catalog.BaseNameOf(c.Spell) == tick.Source).Spell;
+ ...
+```
+
+Then in the recast branch, replace the `with` expression:
+
+```csharp
+ var (refreshedAt, refreshedCertainty) =
+ Expiry(castName ?? tick.Source, castName, tick.Time);
+ _active[key] = existing with
+ {
+ Spell = castName ?? existing.Spell,
+ LandedAt = tick.Time,
+ LastTickAt = tick.Time,
+ ExpiresAt = refreshedAt,
+ Certainty = refreshedCertainty,
+ Ticks = true,
+ };
+ return;
+```
+
+And the new-effect branch at the end:
+
+```csharp
+ var (at, howSure) = Expiry(castName ?? tick.Source, castName, tick.Time);
+ _active[key] = new DebuffState(
+ tick.Target, castName ?? tick.Source, tick.Source, Caster: "", IsMine: true,
+ LandedAt: tick.Time, LastTickAt: tick.Time,
+ ExpiresAt: at, Ticks: true, Certainty: howSure);
+```
+
+Replace `Record(DebuffState)` and `Expiry` so samples key on the displayed (ranked) name:
+
+```csharp
+ private void Record(DebuffState state)
+ {
+ if (state.LastTickAt <= state.LandedAt) return; // a single tick measures nothing
+ Record(state.Spell, (state.LastTickAt - state.LandedAt).TotalSeconds);
+ }
+
+ /// Measured samples first, catalog second, nothing third - the trust order the
+ /// whole panel rests on. Samples key on the RANKED name because ranks genuinely differ:
+ /// pooling Immolate I with Immolate V would corrupt both. A measurement is never adjusted
+ /// toward the catalog; Tepid Deeds keeps its measured 126s against a wiki 150.
+ ///
+ /// is null when no cast explained this effect, and then NOTHING
+ /// is derived. The rank lives on the cast line alone, so deriving from the base name would
+ /// silently assume tier 0 - reading 48s for a rank-V Immolate that runs 72s, and warning
+ /// early on every cast. An unknown rank is an unknown duration.
+ private (DateTime? At, DurationCertainty Certainty) Expiry(
+ string sampleKey, string? castName, DateTime from)
+ {
+ if (_samples.TryGetValue(sampleKey, out var samples) && samples.Count > 0)
+ return (from.AddSeconds(Consensus(samples)), DurationCertainty.Measured);
+ if (castName is not null && _catalog.Resolve(castName) is { } derived)
+ return (from.AddSeconds(derived.Seconds), DurationCertainty.Derived);
+ return (null, DurationCertainty.Unknown);
+ }
+```
+
+Finally, in `OnFade`, the lookup key becomes the base name — fade lines never carry the rank:
+
+```csharp
+ var key = (fade.Target, _catalog.BaseNameOf(fade.Spell));
+ if (!_active.Remove(key, out var state)) return;
+ if (_died.Contains(fade.Target)) return;
+
+ var measured = (fade.Time - state.LandedAt).TotalSeconds;
+ if (measured > 0) Record(state.Spell, measured); // ranked name: samples are per-rank
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release`
+
+Expected: PASS. Total 874 (867 + 7 new), 0 failed.
+
+Existing `DebuffState` constructions in tests will need the new `BaseName` argument. Where a test constructs one positionally, pass the same value as `Spell`. **Do not** change any existing assertion's expected value to make it pass — if a pre-existing behavioural test fails, stop and report it.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/EQBuddy.Core/DebuffTracker.cs tests/EQBuddy.Tests/DebuffTrackerTests.cs
+git commit -m "Derived durations, and the rank that only the cast line knows"
+```
+
+---
+
+### Task 5: Mark the estimate on the chip
+
+**Files:**
+- Modify: `src/EQBuddy.UI.Shared/DebuffChipPresentation.cs`
+- Test: `tests/EQBuddy.Tests/DebuffChipPresentationTests.cs`
+
+**Interfaces:**
+- Consumes: `DebuffState.Certainty`, `DurationCertainty` from Tasks 3–4
+- Produces: `DebuffChipPresentation.EstimatePrefix` (`"~"`)
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `tests/EQBuddy.Tests/DebuffChipPresentationTests.cs`. Note the local `State` helper needs the `BaseName` argument added from Task 4 — update it as shown:
+
+```csharp
+private static DebuffState State(string target, string spell, double? remaining,
+ DurationCertainty certainty = DurationCertainty.Measured) =>
+ new(target, spell, BaseName: spell, Caster: "", IsMine: true, LandedAt: T0, LastTickAt: T0,
+ ExpiresAt: remaining is { } r ? T0.AddSeconds(r) : null, Certainty: certainty);
+
+/// A derived countdown says it is one. Without the mark, a wiki estimate and a
+/// measurement read identically, and the panel exists to support "do I recast now".
+[Fact]
+public void ADerivedCountdownIsMarkedAsAnEstimate()
+{
+ var chips = DebuffChipPresentation.Chips(
+ [State("a sand giant", "Shiftless Deeds VI", 240, DurationCertainty.Derived)], T0, 10);
+
+ Assert.Equal("~4:00", Assert.Single(chips).CountdownText);
+}
+
+[Fact]
+public void AMeasuredCountdownIsNotMarked()
+{
+ var chips = DebuffChipPresentation.Chips(
+ [State("a sand giant", "Immolate", 48, DurationCertainty.Measured)], T0, 10);
+
+ Assert.Equal("0:48", Assert.Single(chips).CountdownText);
+}
+
+/// An estimate about to drop is still worth warning about - it is the best
+/// information available, and suppressing the warning would make the estimate pointless.
+[Fact]
+public void ADerivedCountdownStillWarnsWhenItIsAboutToDrop()
+{
+ var chips = DebuffChipPresentation.Chips(
+ [State("a sand giant", "Shiftless Deeds VI", 8, DurationCertainty.Derived)], T0, 10);
+
+ Assert.True(Assert.Single(chips).IsDue);
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release --filter "FullyQualifiedName~DebuffChipPresentationTests"`
+
+Expected: FAIL — `ADerivedCountdownIsMarkedAsAnEstimate` reports `Expected: "~4:00", Actual: "4:00"`.
+
+- [ ] **Step 3: Write the implementation**
+
+In `src/EQBuddy.UI.Shared/DebuffChipPresentation.cs`, add the constant beside `UnknownCountdown`:
+
+```csharp
+ /// Prefixes a countdown derived from the wiki's base duration rather than measured
+ /// from this log. One character, because the chip is narrow and the slider makes it
+ /// narrower - but the distinction has to survive a glance mid-fight, so it is in the text
+ /// rather than in an opacity a screenshot would lose.
+ public const string EstimatePrefix = "~";
+```
+
+Change the `CountdownText` argument in the `Select` to pass certainty:
+
+```csharp
+ CountdownText: Countdown(s.RemainingSeconds(now), s.Certainty),
+```
+
+And replace `Countdown`:
+
+```csharp
+ private static string Countdown(double? remaining, DurationCertainty certainty)
+ {
+ if (remaining is not { } seconds) return UnknownCountdown;
+ var whole = (int)Math.Round(seconds);
+ var prefix = certainty == DurationCertainty.Derived ? EstimatePrefix : "";
+ return $"{prefix}{whole / 60}:{whole % 60:00}";
+ }
+```
+
+- [ ] **Step 4: Run the full suite**
+
+Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release`
+
+Expected: PASS. Total 877 (874 + 3 new), 0 failed.
+
+- [ ] **Step 5: Run the Avalonia suite**
+
+First check nothing is running: `ps -C EQBuddy.Avalonia`. If a process is listed, stop — the running app holds the X11 hotkey grabs.
+
+Run: `dotnet test tests/EQBuddy.Avalonia.Tests/EQBuddy.Avalonia.Tests.csproj -c Release`
+
+Expected: PASS, 0 failed. Read the count, not the exit code.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/EQBuddy.UI.Shared/DebuffChipPresentation.cs tests/EQBuddy.Tests/DebuffChipPresentationTests.cs
+git commit -m "An estimate says that it is one"
+```
+
+---
+
+### Task 6: Verify in the real app
+
+Headless Avalonia tests cannot confirm this panel — replaying the real log has caught two bugs the suite could not see. This task has no unit test; its deliverable is evidence.
+
+**Files:** none modified unless a defect is found.
+
+**Interfaces:**
+- Consumes: everything above.
+- Produces: a report of what the panel showed, for the user.
+
+- [ ] **Step 1: Confirm settings are untouched by the test run**
+
+```bash
+stat -c '%y %n' ~/.config/EQBuddy/settings.json
+```
+
+The mtime must not have moved during the test runs above. `AppSettings.Load()` is a pure read and must stay one; if the mtime changed, **stop and report** — a test run has written to the live profile.
+
+- [ ] **Step 2: Publish**
+
+```bash
+dotnet publish src/EQBuddy.Avalonia/EQBuddy.Avalonia.csproj -c Release -r linux-x64 \
+ --self-contained -p:PublishSingleFile=true -o /tmp/claude-1000/eqbuddy-derived
+```
+
+Expected: build succeeds, no warnings about the missing embedded resource.
+
+- [ ] **Step 3: Replay the real log against an isolated profile**
+
+Do **not** point the app at `~/.config/EQBuddy`. Launch with a temp profile so the user's live settings cannot be touched:
+
+```bash
+mkdir -p /tmp/claude-1000/eqbuddy-profile
+EQBUDDY_APPDATA=/tmp/claude-1000/eqbuddy-profile \
+ setsid nohup /tmp/claude-1000/eqbuddy-derived/EQBuddy.Avalonia \
+ > /tmp/claude-1000/eqbuddy-run.log 2>&1 &
+```
+
+Point it at `tests/fixtures/eqlog_Daggo_freeport.txt` and open the DoT chip panel.
+
+- [ ] **Step 4: Check the panel and the error log**
+
+Screenshot the window (find its id via `xprop -root _NET_CLIENT_LIST`, match `_NET_WM_PID` to the pid you launched — **not** the window title, since the user's own app titles its window `EQBuddy` too):
+
+```bash
+import -window /tmp/claude-1000/eqbuddy-panel.png
+cat /tmp/claude-1000/eqbuddy-profile/error.log
+```
+
+Confirm: a `Shiftless Deeds IV` chip reads `~3:30` before any measurement lands; an `Immolate` chip reads `0:48` rather than `0:54` once measured; no chip shows a number where the spell is absent from the catalog. `error.log` must be empty or unchanged.
+
+- [ ] **Step 5: Close it gracefully and report**
+
+Close via a `WM_DELETE_WINDOW` ClientMessage scoped to the pid you launched (not `kill`, which skips `OnClosing`; not a broadcast, which would close the user's real app). Then report the screenshot and the three observations to the user.
+
+Do **not** install over `~/.local/share/EQBuddy-app/` — that is the user's call, and it wants the `.prev-` rollback copy made first.
+
+---
+
+## Self-Review
+
+**Spec coverage:** Part 1 anchoring → Task 1. Part 2 catalog → Task 3 (steps 1–3). Part 3 rank resolution → Tasks 2 and 3. Part 4 model + latent recast bug → Task 4. Part 5 presentation → Task 5. Testing section → tests in Tasks 1–5 plus real-app verification in Task 6. Out-of-scope items are not implemented anywhere.
+
+**Placeholders:** none — every code step carries the actual code.
+
+**Type consistency:** `DurationCertainty` and `ResolvedDuration` are defined in Task 3 and consumed with the same member names in Tasks 4 and 5. `SpellRank.Split`/`Scale` defined in Task 2, consumed in Task 3. `DebuffState.BaseName` and `.Certainty` added in Task 4, consumed in Task 5's test helper. `DebuffTracker.ServerTickSeconds` is deleted in Task 1 and referenced by no later task.
+
+**Known ripple:** Task 4's `DebuffState` gains a positional parameter, so existing positional constructions in `DebuffTrackerTests.cs` and `DebuffChipPresentationTests.cs` need `BaseName` supplied. Task 4 step 5 and Task 5 step 1 both call this out.
diff --git a/docs/superpowers/specs/2026-08-12-derived-durations-design.md b/docs/superpowers/specs/2026-08-12-derived-durations-design.md
new file mode 100644
index 00000000..c1d988b9
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-derived-durations-design.md
@@ -0,0 +1,197 @@
+# Derived durations, and the measurement that was one tick long
+
+**Date:** 2026-08-12
+**Status:** approved, ready for implementation
+**Follows:** `2026-08-10-debuff-tracking-design.md` (slices 1 and 2, shipped at `54e9cf1`)
+
+## Goal
+
+A DoT or debuff whose duration has never been measured should still show a countdown, drawn
+from the wiki's base duration scaled by the spell's rank — displayed as an estimate, and
+discarded the moment a real measurement lands.
+
+Along the way, fix the measurement path that reports every DoT one tick too long.
+
+## Three tiers of trust, in this order
+
+1. **Measured** — from the log. Authoritative. Always wins.
+2. **Derived** — wiki base × rank multiplier. Marked `~`. Superseded by any measurement.
+3. **Unknown** — `--`. Never a guessed number.
+
+Measurement is never "corrected" toward the wiki. Tepid Deeds measures ~126s across four clean
+samples while its wiki page says `2 Min 30 Sec` and contradicts itself in its own prose; the
+measurement stands.
+
+## The duration formula
+
+ duration = base × (1 + 0.10 × tier)
+
+`tier` is the trailing Roman numeral of the cast name. Additive, not compounding — `1.1^N`
+yields no integer tier for the observed values.
+
+Confirmed four ways, the last two reproduced from `spells.json` during design:
+
+| Evidence | Expected | Computed |
+|---|---|---|
+| Shiftless Deeds VI shows 4 min in game | 240s | 150 × 1.6 = **240** ✓ |
+| Wiki Spell Level slider at 6 reads "Duration +60%" | +60% | +60% ✓ |
+| Mesmerization rank V measures ~36s | 36s | 24 × 1.5 = **36** ✓ |
+| Shiftless Deeds IV | — | 150 × 1.4 = 210 |
+
+The slider's modifier table is applied client-side and is not in the page wikitext,
+`Template:Spellpage`, `Template:Spellpagesmart`, `MediaWiki:Common.js` or
+`MediaWiki:BlueprintLoader.js`. All were checked. The formula above removes the need for it.
+**Do not go looking for it again.**
+
+## Constraint: the rank appears on cast lines only
+
+Measured across the 690k-line log:
+
+| Line | Carries rank? | Evidence |
+|---|---|---|
+| `You begin casting .` | **yes** | `Mesmerization V` ×630, `Shiftless Deeds IV` ×155 |
+| ` has taken N damage from your .` | no | 0 of all tick lines carry a numeral |
+| `Your spell has worn off of .` | no | `Mesmerization` ×1197, unranked |
+| ` slows down.` | n/a | names no spell at all |
+
+So rank is metadata captured at cast and carried on the effect; every other line keys by base
+name. A tick with no cast to explain it has an unknown rank, and therefore shows `--` — not a
+tier-0 assumption, which would read 48s against a real 72s for a rank-V DoT and warn early
+every cast.
+
+## Part 1: the anchoring fix (do this first)
+
+The original brief proposed re-anchoring DoT timers on `SpellCastEvent`, on the theory that the
+first tick lands ~6s after the cast. **The log contradicts this.** Six DoTs with both fade lines
+and an exact wiki duration, medians over 138 completed casts:
+
+| Spell | wiki | tick1→fade | cast→fade | current `+6` |
+|---|---|---|---|---|
+| Immolate | 48 | **48** ✓ | 53 | 54 |
+| Drones of Doom | 48 | **48** ✓ | 53 | 54 |
+| Gasping Embrace | 48 | **48** ✓ | 54 | 54 |
+| Stinging Swarm | 54 | **54** ✓ | 59 | 60 |
+| Vengeance of the Wild | 30 | **31** ✓ | 38 | 37 |
+| Drifting Death | `1 minute` | 54 | 59 | 60 |
+
+Two conclusions:
+
+- **The first tick is the landing**, not one heartbeat after it. `cast→fade` matches nothing,
+ and is long by each spell's own cast time (Immolate 2.5s, Shiftless Deeds 6.0s) — which is
+ why the error is not a constant 6s.
+- **The fade line arrives at the last tick, not a tick later.** `fade − firstTick` equals the
+ wiki duration exactly for every spell whose wiki value is given in exact seconds or ticks.
+
+Drifting Death is the only miss and explains itself: its wiki value is the prose `1 minute`,
+rounded from 54s, while every spell that matches carries an exact `48 Sec` / `54 Sec` /
+`5 ticks`.
+
+**Change:** `DebuffTracker.Record(DebuffState)` computes
+`(LastTickAt − LandedAt) + ServerTickSeconds`. Drop the `+ ServerTickSeconds` — it is a phantom
+trailing tick, and it is the source of Immolate's 54s. `OnFade` already computes
+`fade − LandedAt` and is correct; after this change the two paths agree.
+
+`ServerTickSeconds` survives only if another caller needs it; otherwise it goes.
+
+## Part 2: the duration catalog
+
+`Data/SpellDurations.json`, an embedded resource loaded exactly as `MezSpells.json` is —
+**no runtime network**. A game overlay should not make an HTTP request mid-fight for a number
+that is only a fallback estimate, and the alternative brings four lookup states
+(LIVE/CACHED/STALE/Offline) into a panel read under pressure.
+
+Built by `scripts/harvests/eqlwiki/spells-promote.py`, alongside the existing
+`quests-promote.py`, from the already-harvested `spells.json` (1,929 spells; 1,589 with a
+parsed `duration_seconds`; 2,997 cached wikitext pages). The promote step reduces to
+`{ name → durationSeconds }` and **excludes**:
+
+- `Instant` / `duration_seconds == 0` — not a debuff.
+- The 337 entries with an unparsed raw duration, which are level-scaled ranges such as
+ `Cripple`'s `6.3 minutes @L53 to 7.0 minutes @L60`. These become **absent**, hence Unknown.
+ A level-scaled spell has no single base to multiply, and inventing one violates tier 3.
+
+Refreshed by re-running the harvest and the promote script, the same as every other catalog.
+
+## Part 3: rank resolution — the catalog is the authority on names
+
+121 catalog spells legitimately end in a Roman numeral: `Clarity II`, `Burnout IV`,
+`Cannibalize IV`, `Berserker Madness III`. These are distinct spell pages, not ranks. Stripping
+numerals naively would read `Clarity II` as tier-2 Clarity and scale a duration the catalog
+already knows exactly.
+
+Resolution order, given a cast name:
+
+1. **Exact catalog hit → tier 0, use the catalog value as-is.** `Clarity II` → its own 2100s.
+2. **Else strip a trailing Roman numeral; base must be in the catalog → Derived.**
+ `Shiftless Deeds IV` → `Shiftless Deeds` 150 × 1.4 = 210s.
+3. **Else Unknown.** `Heroic Leap I` — no base page, no guess.
+
+Verified against every ranked cast in the log: `Mesmerization V` → 36, `Shiftless Deeds IV` →
+210, `Beguile II` → 1152, `Efflorescing Heal III` → 31.2, `Charm III` → 1248, while
+`Clarity II`, `Burnout IV` and `Cannibalize IV` correctly resolve exact.
+
+Note that step 1 yields a *base* duration, which is still an estimate of what will happen on a
+mob — it is `Derived` certainty for display purposes. Only the log makes something `Measured`.
+
+## Part 4: model changes
+
+`DebuffState` gains:
+
+- `Rank` (int, 0 when unranked or unknown) and the resolved base name.
+- `Certainty { Measured, Derived, Unknown }`.
+
+`DebuffTracker.Expiry(spell, from)` consults, in order: per-rank samples → catalog-derived →
+null. Samples are keyed **per rank**, since ranks have genuinely different durations and mixing
+`Immolate I` with `Immolate V` samples would corrupt both.
+
+### Latent bug fixed here
+
+`_recastPending` stores the name from `SpellCastEvent` — which carries the rank — and is then
+looked up with `tick.Source`, which does not. For any ranked DoT, `_recastPending.Remove(...)`
+can never match, so recast detection silently never fires. That is exactly the failure its own
+comment documents ("the real log taught Immolate 115s against 54-60s"). The log escapes it only
+because none of the DoTs in it are ranked. Key `_recastPending` by base name.
+
+## Part 5: presentation
+
+`DebuffChipPresentation` renders a derived countdown with a tilde prefix: `~2:40` against a
+measured `2:40`, beside the existing `--` for unknown. One character, no new colour, legible at
+the smallest chip-slider size. Amber/red warning thresholds apply to derived countdowns exactly
+as to measured ones — an estimate that is about to drop is still worth showing.
+
+## Testing
+
+**Anchoring (Part 1)** — a completed DoT with a fade line measures `fade − firstTick`; the same
+DoT retired by tick silence measures the same value, which is the regression test for the
+dropped `+6`. Verbatim Immolate lines from the fixture, asserting 48s.
+
+**Catalog (Part 2)** — promote script output is checked in and asserted: `Shiftless Deeds` 150,
+`Immolate` 48, `Mesmerization` 24; `Cripple` and every `Instant` spell absent.
+
+**Rank resolution (Part 3)** — `Clarity II` resolves exact and is *not* scaled; `Shiftless Deeds
+IV` derives 210; `Heroic Leap I` is Unknown. These three are the whole contract.
+
+**Trust ordering (Part 4)** — a derived expiry is replaced by a measurement on the next cast and
+never the reverse; Tepid Deeds keeps 126s against a catalog 150s; samples for two ranks of one
+spell do not pool.
+
+**Presentation (Part 5)** — `~` appears only for `Derived`, `--` only for `Unknown`, and a
+derived countdown still turns amber at `DebuffWarnSeconds`.
+
+**Real-app verification** — headless Avalonia tests cannot confirm this panel. Publish, install,
+replay the real log, and read the DoT panel and `error.log`. Replaying the real log has caught
+two bugs the headless suite could not see.
+
+## Files
+
+- `scripts/harvests/eqlwiki/spells-promote.py` (new)
+- `src/EQBuddy.Core/Data/SpellDurations.json` (new, generated), `EQBuddy.Core.csproj`
+- `src/EQBuddy.Core/SpellDurationCatalog.cs` (new — load, rank resolution, derivation)
+- `src/EQBuddy.Core/DebuffTracker.cs` (anchoring fix, per-rank samples, certainty, recast key)
+- `src/EQBuddy.UI.Shared/DebuffChipPresentation.cs` (`~` marker)
+- tests alongside each
+
+## Out of scope
+
+Live wiki fetching for spells absent from the catalog; the Spell Level slider's modifier table;
+any change to slow/cripple detection or to which effects are tracked.
diff --git a/scripts/harvests/eqlwiki/spells-promote.py b/scripts/harvests/eqlwiki/spells-promote.py
new file mode 100755
index 00000000..d3a668b3
--- /dev/null
+++ b/scripts/harvests/eqlwiki/spells-promote.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""Promote the spell harvest into the embedded duration catalog (SpellDurations.json).
+
+Feeds the DoT/debuff panel's DERIVED durations - the cold-start fallback shown, marked as an
+estimate, until the log measures the real thing. Base durations come from the wiki's
+| duration = field, already parsed into duration_seconds by spells-harvest.py.
+
+Excluded on purpose:
+ - duration_seconds of 0 or None ("Instant") - not a debuff, nothing to count down.
+ - the ~337 entries whose raw duration is a LEVEL-SCALED RANGE that the harvest could not
+ reduce to a number ("6.3 minutes @L53 to 7.0 minutes @L60", Cripple). These have no single
+ base to multiply, so they are absent from the catalog and the panel shows "--". Inventing
+ a midpoint here would put a confident wrong number in front of the one decision the panel
+ exists to serve.
+
+Ranks are NOT expanded here. "Shiftless Deeds IV" is derived at runtime from the base entry
+(see SpellRank); pre-expanding would bloat the catalog and freeze the formula into data.
+
+Name collisions: two different wiki pages occasionally share a spell NAME with genuinely
+different durations - not a rank variant, just two spells the wiki happens to call the same
+thing. Last-wins (source array order) picks which value survives, same as a plain dict
+literal would. That is a real, silent decision, so it is logged rather than swallowed. Two
+known cases as of the 2026-08-06 harvest:
+ - "Rabies": page "Rabies" (2880.0s) vs page "Putrid Breath" (314.0s) -> keeps 314.0
+ - "Solon's Bravura": page "Solon's Bravura" (18.0s) vs page "Solon's Bewitching Bravura"
+ (60.0s) -> keeps 60.0
+A future refresh could flip either pick if the wiki reorders pages; the warning is what
+makes that visible in a refresh PR instead of silently changing behavior.
+
+Serialization matches quests-promote.py: sorted keys, compact separators, so knowledge-refresh
+PRs diff as DATA rather than formatting.
+"""
+
+import json
+import sys
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+SRC = HERE / "spells.json"
+OUT = HERE.parents[2] / "src" / "EQBuddy.Core" / "Data" / "SpellDurations.json"
+
+
+def promote(spells):
+ durations = {}
+ sources = {}
+ for s in spells:
+ seconds = s.get("duration_seconds")
+ if not isinstance(seconds, (int, float)) or seconds <= 0:
+ continue
+ name = s["name"].strip()
+ if not name:
+ continue
+ value = round(float(seconds), 1)
+ page = s.get("page_title") or name
+ if name in durations and durations[name] != value:
+ print(
+ f"warning: duration collision for {name!r}: "
+ f"page {sources[name]!r} = {durations[name]}, "
+ f"page {page!r} = {value} -> keeping {value}",
+ file=sys.stderr,
+ )
+ durations[name] = value
+ sources[name] = page
+ return durations
+
+
+def main():
+ spells = json.loads(SRC.read_text(encoding="utf-8"))
+ durations = promote(spells)
+ payload = {
+ "comment": (
+ "Base spell durations from eqlwiki.com's | duration = field, promoted by "
+ "spells-promote.py. Seconds, at BASE rank. Roman-numeral ranks add 10% per tier "
+ "and are derived at runtime (SpellRank). Level-scaled and Instant durations are "
+ "excluded, so an absent spell means unknown - never a guess."
+ ),
+ "durations": dict(sorted(durations.items())),
+ }
+ OUT.write_text(
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":"), indent=1) + "\n",
+ encoding="utf-8",
+ )
+ print(f"{len(durations)} durations -> {OUT}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/EQBuddy.Core/Data/SpellDurations.json b/src/EQBuddy.Core/Data/SpellDurations.json
new file mode 100644
index 00000000..be39f60b
--- /dev/null
+++ b/src/EQBuddy.Core/Data/SpellDurations.json
@@ -0,0 +1,685 @@
+{
+ "comment":"Base spell durations from eqlwiki.com's | duration = field, promoted by spells-promote.py. Seconds, at BASE rank. Roman-numeral ranks add 10% per tier and are derived at runtime (SpellRank). Level-scaled and Instant durations are excluded, so an absent spell means unknown - never a guess.",
+ "durations":{
+ "Aanya's Quickening":1440.0,
+ "Ab of Strength Recourse":360.0,
+ "Abduction of Strength":360.0,
+ "Accuracy":1800.0,
+ "Acumen":3780.0,
+ "Adorning Grace":4320.0,
+ "Adroitness":1800.0,
+ "Aegis":8640.0,
+ "Aegolism":9000.0,
+ "Affliction":84.0,
+ "Agility":3780.0,
+ "Agilmente's Aria of Eagles":18.0,
+ "Alacrity":660.0,
+ "Allure":960.0,
+ "Allure of the Wild":960.0,
+ "Alluring Aura":2700.0,
+ "Angstlich's Appalling Screech":18.0,
+ "Angstlich's Assonance":60.0,
+ "Anthem De Arms":18.0,
+ "Arch Lich":1140.0,
+ "Aria of Asceticism":18.0,
+ "Armor of Faith":3780.0,
+ "Asphyxiate":120.0,
+ "Assiduous Vision":9000.0,
+ "Asystole":42.0,
+ "Atol`s Spectral Shackles":150.0,
+ "Augment":2160.0,
+ "Augment Death":3600.0,
+ "Augmentation":1620.0,
+ "Augmentation of Death":1050.0,
+ "Aura of Antibody":1800.0,
+ "Aura of Cold":1800.0,
+ "Aura of Heat":1800.0,
+ "Aura of Marr":300.0,
+ "Aura of Purity":1800.0,
+ "Auspice":54.0,
+ "Avatar":360.0,
+ "Bane of Nife":42.0,
+ "Barrier of Combustion":900.0,
+ "Battery Vision":1620.0,
+ "Befriend Animal":960.0,
+ "Beguile":960.0,
+ "Beguile Animals":960.0,
+ "Beguile Plants":960.0,
+ "Beguile Undead":960.0,
+ "Berserker Spirit":300.0,
+ "Berserker Strength":180.0,
+ "Bind Sight":660.0,
+ "Blessing of Piety":2400.0,
+ "Blessing of Temperance":6000.0,
+ "Blessing of the Knight":3000.0,
+ "Blessing of the Squire":2400.0,
+ "Blinding Luminance":24.0,
+ "Blinding Step":60.0,
+ "Blood Claw":12.0,
+ "Blood Draw Strike":36.0,
+ "Blood Siphon Strike":36.0,
+ "Blood of Pain":36.0,
+ "Blooming Heal":24.0,
+ "Blossoming Heal":24.0,
+ "Bobbing Corpse":3780.0,
+ "Boil Blood":42.0,
+ "Bond of Death":54.0,
+ "Bonds of Force":120.0,
+ "Bonds of Tunare":108.0,
+ "Bone Melt":18.0,
+ "Boon of the Clear Mind":1620.0,
+ "Bounce":12.0,
+ "Bravery":2700.0,
+ "Breath of Ro":60.0,
+ "Breath of the Sea":60.0,
+ "Breeze":1626.0,
+ "Brell's Steadfast Aegis":9000.0,
+ "Brilliance":2400.0,
+ "Budding Heal":24.0,
+ "Bulwark of Faith":4860.0,
+ "Burning Arrow":6.0,
+ "Burnout":3600.0,
+ "Burnout II":3600.0,
+ "Burnout III":3600.0,
+ "Burnout IV":900.0,
+ "Burst of Strength":18.0,
+ "Cacophony":18.0,
+ "Cajole Undead":960.0,
+ "Cajoling Whispers":960.0,
+ "Calimony":360.0,
+ "Call of Earth":3600.0,
+ "Call of Fire":600.0,
+ "Call of the Predator":4524.0,
+ "Calm":42.0,
+ "Calm Animal":42.0,
+ "Calming Visage":36.0,
+ "Camouflage":1200.0,
+ "Can o' Whoop Ass":60.0,
+ "Cancelling of Life":60.0,
+ "Cantana of Replenishment":18.0,
+ "Cantana of Soothing":18.0,
+ "Cascading Darkness":96.0,
+ "Cassindra's Elegy":12.0,
+ "Cassindra's Insipid Ditty":18.0,
+ "Cast Sight":9000.0,
+ "Celerity":960.0,
+ "Celestial Cleansing":24.0,
+ "Celestial Elixir":24.0,
+ "Celestial Health":24.0,
+ "Celestial Remedy":24.0,
+ "Celestial Tranquility":18.0,
+ "Center":1620.0,
+ "Cessation of Cor":60.0,
+ "Cessation of Life":96.0,
+ "Chant of Battle":18.0,
+ "Charisma":3780.0,
+ "Charm":960.0,
+ "Charm Animals":960.0,
+ "Chase the Moon":36.0,
+ "Chill Bones":18.0,
+ "Chilling Embrace":36.0,
+ "Chloroplast":960.0,
+ "Choke":48.0,
+ "Chords of Dissonance":18.0,
+ "Cinda's Charismatic Carillon":12.0,
+ "Circle of Summer":2160.0,
+ "Circle of Winter":2160.0,
+ "Clarity":1620.0,
+ "Clarity II":2100.0,
+ "Clinging Darkness":48.0,
+ "Clockwork Poison":18.0,
+ "Cloud":2160.0,
+ "Cloud of Disempowerment":60.0,
+ "Clumsiness Strike":210.0,
+ "Cohesion":1800.0,
+ "Courage":1620.0,
+ "Creeping Crud":48.0,
+ "Creeping Vision":60.0,
+ "Crission's Pixie Strike":18.0,
+ "Curse":30.0,
+ "Curse of the Simple Mind":360.0,
+ "Dance of the Blade":150.0,
+ "Daring":2160.0,
+ "Dark Soul":30.0,
+ "Dawncall":36.0,
+ "Dazzle":96.0,
+ "Dead Man Floating":4320.0,
+ "Dead Men Floating":4320.0,
+ "Deadly Velium Poison":120.0,
+ "Death Pact":7200.0,
+ "Deftness":3240.0,
+ "Deliriously Nimble":4320.0,
+ "Demi Lich":1140.0,
+ "Denon's Bereavement":18.0,
+ "Denon's Disruptive Discord":18.0,
+ "Despair":360.0,
+ "Devouring Darkness":78.0,
+ "Dexterity":3780.0,
+ "Dexterous Aura":1620.0,
+ "Diamondskin":5400.0,
+ "Dictate":48.0,
+ "Disease":900.0,
+ "Disease Cloud":360.0,
+ "Disempower":120.0,
+ "Divine Barrier":18.0,
+ "Divine Favor":300.0,
+ "Divine Glory":3000.0,
+ "Divine Intervention":600.0,
+ "Divine Strength":3000.0,
+ "Divine Vigor":3000.0,
+ "Dominate Undead":960.0,
+ "Dooming Darkness":90.0,
+ "Dread of Night":48.0,
+ "Drifting Death":60.0,
+ "Drones of Doom":48.0,
+ "Drowsy":150.0,
+ "Dulsehound":180.0,
+ "Dyn's Dizzying Draught":12.0,
+ "Earth Elemental Attack":18.0,
+ "Ebbing Strength":360.0,
+ "Efflorescing Heal":24.0,
+ "Elemental Maelstrom":36.0,
+ "Elemental Rhythms":18.0,
+ "Elnerick's Entombment of Ice":48.0,
+ "Endure Cold":1620.0,
+ "Endure Disease":1620.0,
+ "Endure Fire":1620.0,
+ "Endure Magic":1620.0,
+ "Endure Poison":1620.0,
+ "Enduring Breath":1620.0,
+ "Enfeeblement":360.0,
+ "Engorging Roots":180.0,
+ "Engulfing Darkness":60.0,
+ "Engulfing Roots":180.0,
+ "Enlightenment":3600.0,
+ "Enslave Death":30.0,
+ "Ensnare":660.0,
+ "Ensnaring Roots":96.0,
+ "Enthrall":48.0,
+ "Entrance":72.0,
+ "Entrancing Lights":6.0,
+ "Entrapping Roots":180.0,
+ "Enveloping Roots":60.0,
+ "Envenomed Bolt":36.0,
+ "Envenomed Breath":42.0,
+ "Eternities Torment":126.0,
+ "Ethereal Cleansing":24.0,
+ "Everlasting Breath":1626.0,
+ "Expedience":720.0,
+ "Eye of Confusion":18.0,
+ "Eye of Tallon":60.0,
+ "Eye of Zomm":30.0,
+ "Fascination":36.0,
+ "Fear":18.0,
+ "Feckless Might":300.0,
+ "Feedback":900.0,
+ "Feet like Cat":2160.0,
+ "Fellspine":18.0,
+ "Feral Spirit":3600.0,
+ "Fetter":180.0,
+ "Fire":18.0,
+ "Fixation of Ro":600.0,
+ "Flame Lick":36.0,
+ "Flame Song of Ro":6.0,
+ "Flaming Arrow":6.0,
+ "Flash of Light":12.0,
+ "Fleeting Fury":18.0,
+ "Flowering Heal":24.0,
+ "Focus Death":3600.0,
+ "Focus of Spirit":6000.0,
+ "Form of Bleached Bone":7200.0,
+ "Form of Chilled Bone":7200.0,
+ "Form of the Bear":8640.0,
+ "Form of the Great Bear":8640.0,
+ "Form of the Howler":8640.0,
+ "Fortitude":8640.0,
+ "Frenzied Strength":180.0,
+ "Fufil's Curtailing Chant":18.0,
+ "Fungus Spores":300.0,
+ "Furious Strength":3240.0,
+ "Gasping Embrace":48.0,
+ "Gather Shadows":1200.0,
+ "Gaze":150.0,
+ "Ghoul Root":36.0,
+ "Gift of Brilliance":6000.0,
+ "Gift of Insight":4500.0,
+ "Gift of Magic":3600.0,
+ "Gift of Pure Thought":1980.0,
+ "Girdle of Karana":4320.0,
+ "Glamour":3240.0,
+ "Glamour of Kintaz":30.0,
+ "Glimpse":13.0,
+ "Goop Poison":120.0,
+ "Grasping Roots":48.0,
+ "Graveyard Dust":108.0,
+ "Greenmist":48.0,
+ "Grounding Strike":60.0,
+ "Group Resist Magic":2160.0,
+ "Guard":2700.0,
+ "Guard of Alendar":2160.0,
+ "Guard of Vie":2160.0,
+ "Guardian":3780.0,
+ "Guardian Rhythms":18.0,
+ "Harmony":120.0,
+ "Harmony of Nature":42.0,
+ "Harmshield":18.0,
+ "Harnessing of Spirit":4320.0,
+ "Harpy Voice":12.0,
+ "Haste":18.0,
+ "Haunting Visage":36.0,
+ "Haze":1620.0,
+ "Health":3240.0,
+ "Heart Flutter":36.0,
+ "Heat Blood":36.0,
+ "Heat Sight":1620.0,
+ "Heroic Bond":4320.0,
+ "Heroism":4320.0,
+ "Hobbling Strike":180.0,
+ "Holy Armor":1620.0,
+ "Hug":300.0,
+ "Hungry Earth":48.0,
+ "Hymn of Restoration":18.0,
+ "Ice":18.0,
+ "Ignite Blood":42.0,
+ "Ignite Bones":18.0,
+ "Illusion: Air Elemental":2160.0,
+ "Illusion: Barbarian":2166.0,
+ "Illusion: Dark Elf":2166.0,
+ "Illusion: Dry Bone":2160.0,
+ "Illusion: Dwarf":2166.0,
+ "Illusion: Earth Elemental":2160.0,
+ "Illusion: Erudite":2166.0,
+ "Illusion: Fire Elemental":2160.0,
+ "Illusion: Gnome":2166.0,
+ "Illusion: Half Elf":2166.0,
+ "Illusion: Halfling":2166.0,
+ "Illusion: High Elf":2166.0,
+ "Illusion: Human":2160.0,
+ "Illusion: Iksar":2166.0,
+ "Illusion: Ogre":2166.0,
+ "Illusion: Scaled Wolf":4320.0,
+ "Illusion: Skeleton":2160.0,
+ "Illusion: Spirit Wolf":2160.0,
+ "Illusion: Tree":2166.0,
+ "Illusion: Troll":2166.0,
+ "Illusion: Water Elemental":2160.0,
+ "Illusion: Werewolf":2160.0,
+ "Illusion: Wood Elf":2166.0,
+ "Immobilize":60.0,
+ "Immolate":48.0,
+ "Impart Strength":360.0,
+ "Impassivity":24.0,
+ "Improved Invis vs Undead":600.0,
+ "Improved Invisibility":600.0,
+ "Improved Invisibility to Undead":600.0,
+ "Improved Superior Camo":600.0,
+ "Improved Superior Camou":600.0,
+ "Incapacitate":300.0,
+ "Infectious Cloud":126.0,
+ "Inferno Shield":900.0,
+ "Infusion of Spirit":4320.0,
+ "Inner Fire":1620.0,
+ "Insidious Fever":660.0,
+ "Insidious Malady":660.0,
+ "Insidious Retrogression":96.0,
+ "Insight":2400.0,
+ "Insipid Weakness":180.0,
+ "Inspire Fear":30.0,
+ "Instill":96.0,
+ "Intellectual Advancement":1620.0,
+ "Intellectual Superiority":1620.0,
+ "Intensify Death":3600.0,
+ "Invigor":18.0,
+ "Invisibility":1200.0,
+ "Invisibility Versus Undead":1620.0,
+ "Invisibility to Undead":300.0,
+ "Invisibility versus Animals":2160.0,
+ "Invisibility versus Undead":1620.0,
+ "Invoke Fear":42.0,
+ "Jaxan's Jig o' Vigor":18.0,
+ "Jedah's Conservation":11700.0,
+ "Jedah's Greater Conservation":11700.0,
+ "Jedah's Lesser Conservation":11700.0,
+ "Jedah's Superior Conservation":11700.0,
+ "Jonthan's Inspiration":18.0,
+ "Jonthan's Provocation":18.0,
+ "Jonthan's Whistling Warsong":12.0,
+ "Kazumi's Note of Preservation":18.0,
+ "Kelin's Lucid Lullaby":18.0,
+ "Kelin's Lugubrious Lament":30.0,
+ "Languid Pace":150.0,
+ "Largo's Assonant Binding":18.0,
+ "Largo's Melodic Binding":18.0,
+ "Leach":54.0,
+ "Leatherskin":3240.0,
+ "Legacy of Spike":900.0,
+ "Leviathan Eyes":2160.0,
+ "Levitate":960.0,
+ "Levitation":960.0,
+ "Listless Power":300.0,
+ "Lower Element":300.0,
+ "Lull":120.0,
+ "Lull Animal":120.0,
+ "Lyssa's Solidarity of Vision":24.0,
+ "Lyssa's Veracious Concord":18.0,
+ "Magical Monologue":18.0,
+ "Magnify":150.0,
+ "Malaise":660.0,
+ "Malaria":72.0,
+ "Malisement":660.0,
+ "Malosi":660.0,
+ "Manasink":4320.0,
+ "Manaskin":7200.0,
+ "Mark of Karn":210.0,
+ "McVaxius' Berserker Crescendo":18.0,
+ "McVaxius' Rousing Rondo":18.0,
+ "Melodious Befuddlement":6.0,
+ "Mesmerization":24.0,
+ "Mesmerize":24.0,
+ "Mesmerizing Breath":18.0,
+ "Minor Illusion":2166.0,
+ "Mist":1620.0,
+ "Monkey Stun":20.0,
+ "Mortal Deftness":4320.0,
+ "Mud":12.0,
+ "Naltron's Mark":3240.0,
+ "Nature's Melody":150.0,
+ "Natureskin":4860.0,
+ "Negation of Life":90.0,
+ "Nillipus' March of the Wee":18.0,
+ "Nimble":3240.0,
+ "Niv's Harmonic":18.0,
+ "Niv's Melody of Preservation":18.0,
+ "Nonchalance":24.0,
+ "Null Aura":1800.0,
+ "Numb the Dead":120.0,
+ "O'Keils Radiation":180.0,
+ "O`Keil's Embers":180.0,
+ "O`Keil's Flickering Flame":300.0,
+ "O`Keils Flickering Flame":300.0,
+ "Obscure":2700.0,
+ "Obsidian Shatter":60.0,
+ "Odium":30.0,
+ "Overwhelming Splendor":4860.0,
+ "Pacify":42.0,
+ "Pack Chloroplast":660.0,
+ "Pack Regeneration":660.0,
+ "Pack Shrew":2160.0,
+ "Pack Spirit":2160.0,
+ "Pact of Shadow":24.0,
+ "Panic Animal":18.0,
+ "Panic the Dead":54.0,
+ "Paralyzing Earth":180.0,
+ "Phantom Armor":5400.0,
+ "Plague":78.0,
+ "Plainsight":4320.0,
+ "Poison":120.0,
+ "Poison Bolt":24.0,
+ "Poison Breath":30000.0,
+ "Power":1800.0,
+ "Pox of Bertoxxulous":108.0,
+ "Primal Essence":3780.0,
+ "Primal Remedy":30.0,
+ "Protect":2160.0,
+ "Protection of Diamond":3240.0,
+ "Protection of Rock":1620.0,
+ "Protection of Steel":2160.0,
+ "Protection of Wood":1620.0,
+ "Protection of the Glades":6000.0,
+ "Psalm of Cooling":18.0,
+ "Psalm of Mystic Shielding":18.0,
+ "Psalm of Purity":18.0,
+ "Psalm of Vitality":18.0,
+ "Psalm of Warmth":18.0,
+ "Purifying Rhythms":18.0,
+ "Pyrocruor":114.0,
+ "Quickness":660.0,
+ "Quivering Veil of Xarn":18.0,
+ "Rabies":314.0,
+ "Radiant Visage":2160.0,
+ "Raging Strength":2700.0,
+ "Rampage":300.0,
+ "Rapture":24.0,
+ "Reckless Health":180.0,
+ "Reckless Strength":180.0,
+ "Regeneration":960.0,
+ "Rejuvenation":120.0,
+ "Reoccurring Amnesia":24.0,
+ "Repulse Animal":48.0,
+ "Resist Cold":2160.0,
+ "Resist Disease":2160.0,
+ "Resist Fire":2160.0,
+ "Resist Magic":2160.0,
+ "Resist Poison":2160.0,
+ "Resolution":3780.0,
+ "Rest the Dead":180.0,
+ "Resurrection Effects":300.0,
+ "Riftwind's Protection":3600.0,
+ "Riotous Health":4320.0,
+ "Rising Dexterity":2700.0,
+ "Rizlona's Embers":12.0,
+ "Root":48.0,
+ "Rune I":2160.0,
+ "Rune II":3240.0,
+ "Rune III":4320.0,
+ "Rune IV":5400.0,
+ "Rune V":6600.0,
+ "Savage Spirit":3600.0,
+ "Scale Skin":1620.0,
+ "Scale of Wolf":2700.0,
+ "Scent of Darkness":660.0,
+ "Scent of Dusk":660.0,
+ "Scent of Shadow":660.0,
+ "Scorching Arrow":6.0,
+ "Scourge":72.0,
+ "Scream of Hate":600.0,
+ "Scream of Pain":600.0,
+ "Screaming Mace":18.0,
+ "Screaming Terror":18.0,
+ "Searing Arrow":6.0,
+ "See Invisible":1620.0,
+ "Selo's Accelerando":18.0,
+ "Selo's Accelerating Chorus":150.0,
+ "Selo's Assonant Strain":18.0,
+ "Selo's Chords of Cessation":18.0,
+ "Selo's Consonant Chain":18.0,
+ "Selo's Song of Travel":18.0,
+ "Sentinel":360.0,
+ "Sermon of the Righteous":30.0,
+ "Serpent Sight":1620.0,
+ "Sha's Lethargy":150.0,
+ "Shackle of Bone":150.0,
+ "Shackle of Spirit":150.0,
+ "Shade":4320.0,
+ "Shadow":5400.0,
+ "Shadow Compact":24.0,
+ "Shadow Vortex":360.0,
+ "Shadowbond":24.0,
+ "Shallow Breath":18.0,
+ "Share Wolf Form":2160.0,
+ "Shauri's Sonorous Clouding":18.0,
+ "Shield of Barbs":900.0,
+ "Shield of Brambles":900.0,
+ "Shield of Fire":900.0,
+ "Shield of Flame":900.0,
+ "Shield of Lava":900.0,
+ "Shield of Song":18.0,
+ "Shield of Spikes":900.0,
+ "Shield of Thistles":900.0,
+ "Shield of Thorns (Spell)":900.0,
+ "Shield of Words":4320.0,
+ "Shield of the Magi":5400.0,
+ "Shieldskin":2160.0,
+ "Shifting Shield":2700.0,
+ "Shifting Sight":960.0,
+ "Shiftless Deeds":150.0,
+ "Shroud of Death":1200.0,
+ "Shroud of Hate":600.0,
+ "Shroud of Pain":600.0,
+ "Shroud of Undeath":1200.0,
+ "Shroud of the Spirits":4320.0,
+ "Sicken":84.0,
+ "Sight":150.0,
+ "Sight Graft":1620.0,
+ "Sionachie's Dreams":18.0,
+ "Siphon Strength":360.0,
+ "Skin Like Diamond":3240.0,
+ "Skin Like Nature":4344.0,
+ "Skin Like Rock":1620.0,
+ "Skin Like Steel":2160.0,
+ "Skin Like Wood":1620.0,
+ "Sloths Healing":24.0,
+ "Slugs Healing":24.0,
+ "Snails Healing":24.0,
+ "Snare":180.0,
+ "Solon's Bravura":60.0,
+ "Solon's Charismatic Concord":18.0,
+ "Solon's Song of the Sirens":24.0,
+ "Song of Midnight":18.0,
+ "Song of Sustenance":90.0,
+ "Song of Twilight":18.0,
+ "Song of the Deep Seas":150.0,
+ "Song: Composition of Ervaj":18.0,
+ "Song: Melody of Ervaj":18.0,
+ "Song: Occlusion of Sound":18.0,
+ "Soothe":150.0,
+ "Speed of the Shissar":1800.0,
+ "Spirit Armor":2160.0,
+ "Spirit Quickening":3600.0,
+ "Spirit Sight":1620.0,
+ "Spirit Strength":2160.0,
+ "Spirit of Bear":2160.0,
+ "Spirit of Bih`Li":2160.0,
+ "Spirit of Cat":2700.0,
+ "Spirit of Cheetah":48.0,
+ "Spirit of Inferno":2640.0,
+ "Spirit of Lightning":2400.0,
+ "Spirit of Monkey":2160.0,
+ "Spirit of Oak":2160.0,
+ "Spirit of Ox":2700.0,
+ "Spirit of Scale":4320.0,
+ "Spirit of Snake":2160.0,
+ "Spirit of Vermin":2880.0,
+ "Spirit of Wolf":2160.0,
+ "Spirit of the Blizzard":2520.0,
+ "Spirit of the Puma":60.0,
+ "Spirit of the Scorpion":2760.0,
+ "Spirit of the Shrew":2160.0,
+ "Spiritual Brawn":4320.0,
+ "Spiritual Light":2700.0,
+ "Splurt":102.0,
+ "Spook the Dead":18.0,
+ "Sprouting Heal":24.0,
+ "Stability":1800.0,
+ "Stalwart Regeneration":60.0,
+ "Stamina":3780.0,
+ "Steelskin":4320.0,
+ "Stinging Swarm":54.0,
+ "Stoicism":24.0,
+ "Stone Spider Stun":20.0,
+ "Storm Strength":3246.0,
+ "Strength":3780.0,
+ "Strength of Earth":1620.0,
+ "Strength of Nature":3600.0,
+ "Strength of Stone":2160.0,
+ "Strengthen":1620.0,
+ "Strengthen Death":3600.0,
+ "Strong Disease":900.0,
+ "Suffocate":48.0,
+ "Suffocating Sphere":42.0,
+ "Sunbeam":18.0,
+ "Sunskin":1620.0,
+ "Superior Camouflage":1440.0,
+ "Surge of Enfeeblement":360.0,
+ "Swarm of Pain":60.0,
+ "Swift Like The Wind":960.0,
+ "Swift Spirit":18.0,
+ "Symbol of Marzin":3780.0,
+ "Symbol of Naltron":3240.0,
+ "Symbol of Pinzarn":2700.0,
+ "Symbol of Ryltan":2160.0,
+ "Symbol of Transal":1620.0,
+ "Sympathetic Aura":2160.0,
+ "Tagar's Insects":150.0,
+ "Tainted Breath":42.0,
+ "Talisman of Altuna":4320.0,
+ "Talisman of Jasinth":2160.0,
+ "Talisman of Kragg":4320.0,
+ "Talisman of Shadoo":2160.0,
+ "Talisman of Tnarg":4320.0,
+ "Talisman of the Beast":1620.0,
+ "Talisman of the Brute":3780.0,
+ "Talisman of the Cat":3780.0,
+ "Talisman of the Raptor":3780.0,
+ "Talisman of the Rhino":3780.0,
+ "Talisman of the Serpent":3780.0,
+ "Tangling Weeds":18.0,
+ "Tarew's Aquatic Ayre":24.0,
+ "Tashani":660.0,
+ "Tashania":660.0,
+ "Tashina":660.0,
+ "Telescope":18.0,
+ "Temperance":6000.0,
+ "Tepid Deeds":150.0,
+ "Terrorize Animal":54.0,
+ "The Unspoken Word":6.0,
+ "Togor's Insects":150.0,
+ "Torment of Argli":120.0,
+ "Tortoises Healing":24.0,
+ "Track Corpse":720.0,
+ "Treeform":2160.0,
+ "Trepidation":72.0,
+ "Tumultuous Strength":2760.0,
+ "Turning of the Unnatural":30.0,
+ "Turtle Skin":2160.0,
+ "Tuyen's Chant of Disease":12.0,
+ "Tuyen's Chant of Flame":18.0,
+ "Tuyen's Chant of Frost":18.0,
+ "Tuyen's Chant of Poison":12.0,
+ "Ultravision":2160.0,
+ "Umbra":5940.0,
+ "Unfailing Reverence":4320.0,
+ "Valiant Companion":210.0,
+ "Valor":3240.0,
+ "Vampiric Curse":54.0,
+ "Velocity":2160.0,
+ "Vengeance of the Glades":60.0,
+ "Vengeance of the Wild":30.0,
+ "Venom of the Snake":36.0,
+ "Verses of Victory":18.0,
+ "Vexing Mordinia":60.0,
+ "Vigor":1800.0,
+ "Vilia's Chorus of Celerity":18.0,
+ "Vilia's Verses of Celerity":18.0,
+ "Vision":660.0,
+ "Vision Shift":3600.0,
+ "Visions of Grandeur":2520.0,
+ "Voice Graft":1620.0,
+ "Voice of Darkness":600.0,
+ "Voice of Shadows":600.0,
+ "Walking Sleep":150.0,
+ "Wandering Mind":120.0,
+ "Ward of Alendar":2160.0,
+ "Ward of Calliav":2160.0,
+ "Ward of Calrena":2160.0,
+ "Ward of Vie":2160.0,
+ "Ward of the Divine":1200.0,
+ "Wave of Enfeeblement":240.0,
+ "Wave of Fear":18.0,
+ "Waves of the Deep Sea":150.0,
+ "Weak Poison":60.0,
+ "Weaken":360.0,
+ "Weakening Strike":210.0,
+ "Weakness":180.0,
+ "Whirlwind":6.0,
+ "Winged Death":60.0,
+ "Wrath of the Elements":30.0,
+ "Yaulp IV":24.0,
+ "Yekan's Quickening":3600.0,
+ "blessing of the grove":18.0,
+ "ice breath":60.0,
+ "mana shroud":18.0,
+ "mind cloud":60.0,
+ "siphon strength recourse":600.0
+ }
+}
diff --git a/src/EQBuddy.Core/DebuffTracker.cs b/src/EQBuddy.Core/DebuffTracker.cs
index 18794e18..675e07cc 100644
--- a/src/EQBuddy.Core/DebuffTracker.cs
+++ b/src/EQBuddy.Core/DebuffTracker.cs
@@ -3,7 +3,11 @@ namespace EQBuddy.Core;
/// One damage-over-time effect currently ticking on one target.
public sealed record DebuffState(
string Target,
+ /// What to show on the chip - the ranked name when a cast supplied one.
string Spell,
+ /// What to key on. Tick and fade lines never carry the rank, so tracking keys on
+ /// the base name while the chip displays the rank.
+ string BaseName,
string Caster,
bool IsMine,
DateTime LandedAt,
@@ -11,7 +15,9 @@ public sealed record DebuffState(
DateTime? ExpiresAt,
/// True for DoTs, which announce themselves every six seconds. A slow announces
/// itself once and then says nothing until it fades, so silence means nothing for it.
- bool Ticks = false)
+ bool Ticks = false,
+ /// Whether the countdown was measured, derived from the wiki, or is unknown.
+ DurationCertainty Certainty = DurationCertainty.Unknown)
{
/// Null when this spell's duration has never been measured. A null countdown is
/// the honest answer: the alternative is a number invented at the exact moment the user
@@ -29,11 +35,17 @@ public bool IsAboutToDrop(DateTime now, double warnSeconds) =>
///
/// Your own DoTs, timed so they can be refreshed before they fall off.
///
-/// The log never states a duration, but it does not have to. Ticks arrive every ~6 seconds
-/// naming the spell and target, so a completed cast measures itself: first tick to last tick,
-/// plus the tick that was already paid for. That measurement drives the NEXT cast of the same
-/// spell, which is why the first cast of anything shows no countdown and every one after it
-/// does.
+/// The log never states a duration, but it does not have to. The first tick IS the landing,
+/// ticks arrive every ~6 seconds naming the spell, and the last tick falls on the expiry - the
+/// fade line arrives in the same second, not one tick later. So a completed cast measures
+/// itself as first tick to last tick, and that measurement drives the NEXT cast of the same
+/// spell, which is why the first cast of anything shows no countdown and every one after does.
+///
+/// Measured across the 690k-line fixture (six DoTs, 138 completed casts): first-tick-to-fade
+/// equals the wiki duration exactly for every spell whose wiki value is given in exact seconds
+/// or ticks - Immolate 48, Drones of Doom 48, Gasping Embrace 48, Stinging Swarm 54. Anchoring
+/// on the CAST line instead matches none of them, running long by each spell's own cast time
+/// (Immolate 2.5s, Shiftless Deeds 6.0s), which is why the error is not a constant six seconds.
///
/// Third-party DoTs are deliberately ignored (). They were not
/// wanted, and in a real group log they are the overwhelming majority of tick lines.
@@ -57,21 +69,42 @@ public sealed class DebuffTracker
/// between two glances at the panel.
public static readonly TimeSpan ExpiryLinger = TimeSpan.FromSeconds(5);
- /// A DoT ticks on the six-second server heartbeat, and the first tick lands one
- /// heartbeat after the cast, so a cast's length is (last - first) + one tick.
- public const double ServerTickSeconds = 6;
-
/// Measurements kept per spell so a single odd cast cannot become the duration
/// for good. Capped: a long session would otherwise grow this without bound, and the
/// oldest samples say nothing the newest do not.
public static readonly int SampleCap = 16;
- private readonly Dictionary<(string Target, string Spell), DebuffState> _active = [];
+ /// How long after a cast a first TICK can still be attributed to it, and so
+ /// supply the rank. Wider than because the cast line precedes the
+ /// landing by the spell's own cast time, which reaches 6s (Shiftless Deeds) before the
+ /// first tick is even due. Measured across the fixture, cast-to-first-tick runs a median of
+ /// 5s and a 90th percentile of 8s. Still far below the shortest DoT duration (30s), so this
+ /// can never reach back and grab the PREVIOUS cast of the same spell.
+ public static readonly TimeSpan CastToTick = TimeSpan.FromSeconds(15);
+
+ /// How long a cast is remembered. The widest of the windows that read it, or the
+ /// wider window is fiction: pruning at alone meant a tick could
+ /// never see a cast older than 8s and 's 15s described a list that
+ /// could not contain them, silently losing the rank on a slow first tick.
+ private static readonly TimeSpan CastMemory =
+ CastToLand > CastToTick ? CastToLand : CastToTick;
+
+ private readonly SpellDurationCatalog _catalog;
+
+ private readonly Dictionary<(string Target, string BaseName), DebuffState> _active = [];
+
+ /// Effects whose chip has been retired but whose fade line has not arrived yet.
+ /// A derived duration is an estimate and the real spell can outlast it, so the panel stops
+ /// showing a chip long before the effect is safe to FORGET - see .
+ private readonly Dictionary<(string Target, string BaseName), DebuffState> _awaitingFade = [];
private readonly Dictionary> _samples = [];
private readonly HashSet _died = [];
private readonly HashSet _recastPending = [];
private readonly List<(DateTime Time, string Caster, string Spell, bool Mine)> _recentCasts = [];
+ public DebuffTracker(SpellDurationCatalog? catalog = null) =>
+ _catalog = catalog ?? SpellDurationCatalog.Embedded;
+
/// Lead time, in seconds, at which an effect counts as about to drop.
public double WarnSeconds { get; set; } = 10;
@@ -103,7 +136,7 @@ public void Apply(GameEvent evt)
// Nothing in the tick lines marks a recast - the ticks simply continue - so the
// cast line is the only evidence that the clock restarted.
case SpellCastEvent cast:
- _recastPending.Add(cast.Spell);
+ _recastPending.Add(_catalog.BaseNameOf(cast.Spell));
RememberCast(cast.Time, "", cast.Spell, mine: true);
break;
// Someone else's cast is worth remembering only because a slow landing names
@@ -125,7 +158,7 @@ public void Apply(GameEvent evt)
private void RememberCast(DateTime time, string caster, string spell, bool mine)
{
_recentCasts.Add((time, caster, spell, mine));
- _recentCasts.RemoveAll(c => time - c.Time > CastToLand);
+ _recentCasts.RemoveAll(c => time - c.Time > CastMemory);
}
/// A slow or cripple landing. The line names the mob and nothing else, so the
@@ -136,11 +169,13 @@ private void OnLanding(DebuffLandedEvent landed)
var cast = _recentCasts.LastOrDefault(c => landed.Time - c.Time <= CastToLand);
if (cast.Spell is null or "") return; // nobody we can see cast it: no spell, no chip
- var key = (landed.Target, cast.Spell);
+ var key = (Target: landed.Target, BaseName: _catalog.BaseNameOf(cast.Spell));
+ _awaitingFade.Remove(key); // a fresh landing supersedes whatever the old one measures
+ var (expires, certainty) = Expiry(cast.Spell, cast.Spell, landed.Time);
_active[key] = new DebuffState(
- landed.Target, cast.Spell, cast.Caster, cast.Mine,
+ landed.Target, cast.Spell, key.BaseName, cast.Caster, cast.Mine,
LandedAt: landed.Time, LastTickAt: landed.Time,
- ExpiresAt: Expiry(cast.Spell, landed.Time));
+ ExpiresAt: expires, Certainty: certainty);
}
/// Only YOUR spells announce a fade, so this both ends and measures your own
@@ -148,17 +183,34 @@ private void OnLanding(DebuffLandedEvent landed)
/// and shows no countdown until you have measured one.
private void OnFade(SpellWornOffEvent fade)
{
- var key = (fade.Target, fade.Spell);
- if (!_active.Remove(key, out var state)) return;
- if (_died.Contains(fade.Target)) return;
+ var key = (fade.Target, _catalog.BaseNameOf(fade.Spell));
+ // The chip may already be gone - a derived estimate that ran short retires it early -
+ // but the effect is only truly forgotten at UnknownCap, so the fade can still measure it.
+ if (!_active.Remove(key, out var state) && !_awaitingFade.Remove(key, out state)) return;
+ if (state is null || _died.Contains(fade.Target)) return;
var measured = (fade.Time - state.LandedAt).TotalSeconds;
- if (measured > 0) Record(state.Spell, measured);
+ if (measured > 0) Record(state.Spell, measured); // ranked name: samples are per-rank
}
private void OnTick(DamageDealtEvent tick)
{
- var key = (tick.Target, tick.Source);
+ // Every key goes through BaseNameOf - no exceptions. Real tick lines carry no numeral,
+ // so this is usually the identity; for a spell genuinely NAMED with one and missing from
+ // the catalog it is the difference between the tick key and the fade key agreeing and
+ // fade-ending silently ceasing to work.
+ var baseName = _catalog.BaseNameOf(tick.Source);
+ var key = (tick.Target, baseName);
+ // The rank is on the cast line and nowhere else, so a tick nobody cast has an unknown
+ // tier - and an unknown tier cannot be derived, only measured.
+ //
+ // The window matters. _recentCasts is pruned only when a new cast arrives, so an
+ // unbounded search would match a cast from ten minutes ago and silently become "the
+ // last rank I ever saw" - which is exactly the guess this design rejected.
+ var castName = _recentCasts
+ .LastOrDefault(c => c.Mine && tick.Time - c.Time <= CastToTick
+ && _catalog.BaseNameOf(c.Spell) == baseName).Spell;
+
if (_active.TryGetValue(key, out var existing) && tick.Time - existing.LastTickAt > TickGrace)
{
// The previous effect ended before this tick, and nobody was watching. Retirement
@@ -172,18 +224,27 @@ private void OnTick(DamageDealtEvent tick)
if (existing is not null && _active.ContainsKey(key))
{
- if (_recastPending.Remove(tick.Source))
+ if (_recastPending.Remove(baseName))
{
// A refresh restarts the clock. The interrupted first cast is NOT recorded:
// it was cut short by the recast, so it measures the gap between two casts
// rather than the spell's duration - the same reason a kill teaches nothing.
// Without the restart the two casts read as one long effect, which is how the
// real log taught Immolate 115s against 54-60s for every sibling druid DoT.
+ //
+ // The sample key and the displayed name fall back to the SAME name. Split them
+ // and a rank-N effect takes its countdown from tier-0 samples and shows it as
+ // Measured - the pooling this design forbids, arriving through the display
+ // rather than through the store.
+ var (refreshedAt, refreshedCertainty) =
+ Expiry(castName ?? existing.Spell, castName, tick.Time);
_active[key] = existing with
{
+ Spell = castName ?? existing.Spell,
LandedAt = tick.Time,
LastTickAt = tick.Time,
- ExpiresAt = Expiry(tick.Source, tick.Time),
+ ExpiresAt = refreshedAt,
+ Certainty = refreshedCertainty,
Ticks = true,
};
return;
@@ -192,13 +253,14 @@ private void OnTick(DamageDealtEvent tick)
return;
}
- _recastPending.Remove(tick.Source); // that cast explains THIS landing, not a refresh
+ _recastPending.Remove(baseName); // that cast explains THIS landing, not a refresh
_died.Remove(tick.Target); // a fresh cast on a name that died earlier
+ var (at, howSure) = Expiry(castName ?? tick.Source, castName, tick.Time);
_active[key] = new DebuffState(
- tick.Target, tick.Source, Caster: "", IsMine: true,
+ tick.Target, castName ?? tick.Source, baseName, Caster: "", IsMine: true,
LandedAt: tick.Time, LastTickAt: tick.Time,
- ExpiresAt: Expiry(tick.Source, tick.Time), Ticks: true);
+ ExpiresAt: at, Ticks: true, Certainty: howSure);
}
/// What is ticking now. Also the point at which effects whose ticks have stopped
@@ -219,11 +281,30 @@ public IReadOnlyList Active(DateTime now)
// A slow says nothing between landing and fading, so silence is not evidence.
// Yours ends at its fade line; a stranger's has no fade line at all, so it ends at
// the measured duration, or is eventually dropped rather than believed forever.
- var over = state.ExpiresAt is { } expiry
- ? now > expiry + ExpiryLinger
- : now - state.LandedAt > UnknownCap;
- if (over) _active.Remove(key);
+ //
+ // Two deadlines, and the sooner wins. The expiry says when to stop SHOWING a chip;
+ // UnknownCap says how long any unmeasured chip may be believed at all, and a derived
+ // duration must not defeat it - Valor's 3240s would hold a mis-attributed chip on the
+ // panel for 54 minutes, which is the exact thing the cap exists to prevent.
+ var cap = state.LandedAt + UnknownCap;
+ var retireAt = state.ExpiresAt is { } expiry && expiry + ExpiryLinger < cap
+ ? expiry + ExpiryLinger
+ : cap;
+ if (now <= retireAt) continue;
+
+ // Retired from the panel, not forgotten. A derived duration is an ESTIMATE and the
+ // real spell can outlast it - Shiftless Deeds IV measured 214.0s against a derived
+ // 210.0s in the user's own log - so dropping the effect outright would leave its fade
+ // line nothing to measure, and every later cast would re-derive the same estimate,
+ // pinning the spell at the guess for the rest of the session.
+ _active.Remove(key);
+ _awaitingFade[key] = state;
}
+
+ // Bounded, for the same reason the chip is: nothing announces a stranger's slow ending.
+ foreach (var (key, state) in _awaitingFade.ToList())
+ if (now - state.LandedAt > UnknownCap) _awaitingFade.Remove(key);
+
return _active.Values
.OrderBy(s => s.Target, StringComparer.OrdinalIgnoreCase)
.ThenBy(s => s.Spell, StringComparer.OrdinalIgnoreCase)
@@ -242,7 +323,7 @@ private void Learn(DebuffState state)
private void Record(DebuffState state)
{
if (state.LastTickAt <= state.LandedAt) return; // a single tick measures nothing
- Record(state.Spell, (state.LastTickAt - state.LandedAt).TotalSeconds + ServerTickSeconds);
+ Record(state.Spell, (state.LastTickAt - state.LandedAt).TotalSeconds);
}
private void Record(string spell, double measured)
@@ -253,8 +334,22 @@ private void Record(string spell, double measured)
_samples[spell] = samples;
}
- private DateTime? Expiry(string spell, DateTime from) =>
- _samples.TryGetValue(spell, out var samples) && samples.Count > 0
- ? from.AddSeconds(Consensus(samples))
- : null;
+ /// Measured samples first, catalog second, nothing third - the trust order the
+ /// whole panel rests on. Samples key on the RANKED name because ranks genuinely differ:
+ /// pooling Immolate I with Immolate V would corrupt both. A measurement is never adjusted
+ /// toward the catalog; Tepid Deeds keeps its measured 126s against a wiki 150.
+ ///
+ /// is null when no cast explained this effect, and then NOTHING
+ /// is derived. The rank lives on the cast line alone, so deriving from the base name would
+ /// silently assume tier 0 - reading 48s for a rank-V Immolate that runs 72s, and warning
+ /// early on every cast. An unknown rank is an unknown duration.
+ private (DateTime? At, DurationCertainty Certainty) Expiry(
+ string sampleKey, string? castName, DateTime from)
+ {
+ if (_samples.TryGetValue(sampleKey, out var samples) && samples.Count > 0)
+ return (from.AddSeconds(Consensus(samples)), DurationCertainty.Measured);
+ if (castName is not null && _catalog.Resolve(castName) is { } derived)
+ return (from.AddSeconds(derived.Seconds), DurationCertainty.Derived);
+ return (null, DurationCertainty.Unknown);
+ }
}
diff --git a/src/EQBuddy.Core/EQBuddy.Core.csproj b/src/EQBuddy.Core/EQBuddy.Core.csproj
index 43b32f40..ee5a0a8e 100644
--- a/src/EQBuddy.Core/EQBuddy.Core.csproj
+++ b/src/EQBuddy.Core/EQBuddy.Core.csproj
@@ -25,6 +25,7 @@
+
diff --git a/src/EQBuddy.Core/SpellDurationCatalog.cs b/src/EQBuddy.Core/SpellDurationCatalog.cs
new file mode 100644
index 00000000..62d5b5f7
--- /dev/null
+++ b/src/EQBuddy.Core/SpellDurationCatalog.cs
@@ -0,0 +1,91 @@
+using System.Reflection;
+using System.Text.Json;
+
+namespace EQBuddy.Core;
+
+/// How much to trust a countdown. The ordering is the feature: a measurement always
+/// beats an estimate, and an estimate always beats a guess - of which there are none.
+public enum DurationCertainty
+{
+ /// Nobody knows. The chip shows "--", never a number.
+ Unknown,
+ /// Wiki base duration, scaled for rank. Shown marked, and discarded the moment a
+ /// real measurement lands.
+ Derived,
+ /// Measured from this log. Authoritative - never adjusted toward the wiki.
+ Measured,
+}
+
+/// A cast name resolved against the catalog. is 0 when the
+/// spell is unranked or is genuinely named with a numeral.
+public sealed record ResolvedDuration(string BaseName, int Tier, double Seconds);
+
+///
+/// Base spell durations from eqlwiki, embedded rather than fetched (Data/SpellDurations.json).
+/// A game overlay should not make an HTTP request mid-fight for a number that is only a
+/// fallback estimate, and the harvest already on disk answers 679 spells offline.
+///
+/// The resolution order exists because of a trap worth stating plainly: some spells END in a
+/// roman numeral as their real name and are distinct spell pages, not ranks. 121 of them appear
+/// across the 1,929-spell wiki harvest; 10 survive into the shipped catalog ("Clarity II",
+/// "Burnout II/III/IV", "Rune I".."Rune V", "Yaulp IV"), and 4 of those - Burnout II/III/IV and
+/// Clarity II - also have a base entry, which is where the two readings actually collide. So the
+/// catalog is asked for the full name FIRST, and only a miss is reinterpreted as a rank. Read
+/// the other way round, "Clarity II" would scale a duration the catalog already knows exactly.
+///
+public sealed class SpellDurationCatalog
+{
+ private readonly IReadOnlyDictionary _durations;
+ private static SpellDurationCatalog? _embedded;
+
+ public SpellDurationCatalog(IReadOnlyDictionary? durations = null) =>
+ _durations = durations is null
+ ? LoadEmbedded()
+ : new Dictionary(durations, StringComparer.OrdinalIgnoreCase);
+
+ /// The shipped catalog, loaded once. 680 entries, 679 keys: the loader is
+ /// OrdinalIgnoreCase and the harvest ships both "Invisibility Versus Undead" and
+ /// "Invisibility versus Undead" (1620.0 either way, so the fold costs nothing).
+ public static SpellDurationCatalog Embedded => _embedded ??= new SpellDurationCatalog();
+
+ /// Base seconds for a cast name, scaled for rank - or null when the catalog
+ /// cannot answer, which the panel renders as "--".
+ public ResolvedDuration? Resolve(string castName)
+ {
+ var name = castName.Trim();
+ if (name.Length == 0) return null;
+
+ // Exact first: the catalog is the authority on what is a NAME and what is a rank.
+ if (_durations.TryGetValue(name, out var exact))
+ return new ResolvedDuration(name, 0, exact);
+
+ var (baseName, tier) = SpellRank.Split(name);
+ if (tier > 0 && _durations.TryGetValue(baseName, out var seconds))
+ return new ResolvedDuration(baseName, tier, SpellRank.Scale(seconds, tier));
+
+ return null;
+ }
+
+ /// The name that this spell's tick and fade lines will use. Those lines never
+ /// carry the rank - measured across the fixture, 0 of all tick lines have a numeral, and
+ /// "Your Mesmerization spell has worn off" appears 1197 times against 630 casts of
+ /// "Mesmerization V" - so a ranked cast has to collapse onto its base to be tracked at all.
+ /// A spell genuinely NAMED with a numeral keeps it, because its own tick lines will too.
+ public string BaseNameOf(string castName)
+ {
+ var name = castName.Trim();
+ return _durations.ContainsKey(name) ? name : SpellRank.Split(name).Base;
+ }
+
+ private static Dictionary LoadEmbedded()
+ {
+ using var stream = Assembly.GetExecutingAssembly()
+ .GetManifestResourceStream("EQBuddy.Core.Data.SpellDurations.json")
+ ?? throw new InvalidOperationException("SpellDurations.json missing from resources");
+ using var doc = JsonDocument.Parse(stream);
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var entry in doc.RootElement.GetProperty("durations").EnumerateObject())
+ result[entry.Name] = entry.Value.GetDouble();
+ return result;
+ }
+}
diff --git a/src/EQBuddy.Core/SpellRank.cs b/src/EQBuddy.Core/SpellRank.cs
new file mode 100644
index 00000000..1da2d517
--- /dev/null
+++ b/src/EQBuddy.Core/SpellRank.cs
@@ -0,0 +1,62 @@
+namespace EQBuddy.Core;
+
+///
+/// The rank suffix on a spell name. EverQuest Legends adds roman-numeral ranks to spells
+/// ("Shiftless Deeds IV"), and each tier adds 10% duration - additive, confirmed three ways:
+/// Shiftless Deeds VI shows 4 minutes in game against a 150s base (x1.6), the wiki's Spell
+/// Level slider reads "Duration +60%" at level 6, and Mesmerization V measures ~36s against a
+/// 24s base (x1.5). Compounding would give 1.1^6 = 1.77, which matches no observed value.
+///
+/// This splitter is deliberately naive about WHETHER the numeral is a rank. Some spells are
+/// genuinely named with a trailing numeral and are not ranks of anything - 121 such names across
+/// the 1,929-spell wiki harvest, of which 10 are in the shipped catalog ("Clarity II",
+/// "Burnout IV", "Yaulp IV"). Only the catalog can tell the two apart, so that call lives in
+/// .
+///
+public static class SpellRank
+{
+ /// Duration added per rank tier.
+ public const double PerTier = 0.10;
+
+ /// Splits a trailing roman numeral off a name. Returns tier 0 when there is none.
+ /// A single-word name is never split: the whole name would vanish, and "Ice" is a spell.
+ public static (string Base, int Tier) Split(string name)
+ {
+ var trimmed = name.Trim();
+ var space = trimmed.LastIndexOf(' ');
+ if (space <= 0) return (trimmed, 0);
+
+ var tier = ParseRoman(trimmed[(space + 1)..]);
+ return tier > 0 ? (trimmed[..space], tier) : (trimmed, 0);
+ }
+
+ public static double Scale(double baseSeconds, int tier) =>
+ baseSeconds * (1 + PerTier * tier);
+
+ /// I..XXXIX, or 0 for anything that is not a well-formed roman numeral. Only
+ /// I/V/X are accepted - L and beyond cannot be a spell rank, and "Cazic" should not be
+ /// read as a number because it starts with C.
+ private static int ParseRoman(string token)
+ {
+ if (token.Length is 0 or > 6) return 0;
+ var total = 0; var previous = 0;
+ for (var i = token.Length - 1; i >= 0; i--)
+ {
+ var value = token[i] switch { 'I' => 1, 'V' => 5, 'X' => 10, _ => 0 };
+ if (value == 0) return 0;
+ total += value < previous ? -value : value;
+ previous = Math.Max(previous, value);
+ }
+ // Round-trips only for canonical spellings, so "IIII" and "VV" are rejected.
+ return total is > 0 and < 40 && ToRoman(total) == token ? total : 0;
+ }
+
+ private static string ToRoman(int value)
+ {
+ var result = "";
+ foreach (var (number, symbol) in
+ (ReadOnlySpan<(int, string)>)[(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")])
+ while (value >= number) { result += symbol; value -= number; }
+ return result;
+ }
+}
diff --git a/src/EQBuddy.UI.Shared/DebuffChipPresentation.cs b/src/EQBuddy.UI.Shared/DebuffChipPresentation.cs
index 49d3a8d5..5ce54aea 100644
--- a/src/EQBuddy.UI.Shared/DebuffChipPresentation.cs
+++ b/src/EQBuddy.UI.Shared/DebuffChipPresentation.cs
@@ -13,6 +13,12 @@ public static class DebuffChipPresentation
/// reads as "it just dropped" rather than "nobody knows".
public const string UnknownCountdown = "--";
+ /// Prefixes a countdown derived from the wiki's base duration rather than measured
+ /// from this log. One character, because the chip is narrow and the slider makes it
+ /// narrower - but the distinction has to survive a glance mid-fight, so it is in the text
+ /// rather than in an opacity a screenshot would lose.
+ public const string EstimatePrefix = "~";
+
public static List Chips(
IReadOnlyList states, DateTime now, double warnSeconds) =>
states
@@ -25,17 +31,18 @@ public static List Chips(
.Select(s => new SpawnChip(
Zone: s.Target,
Name: s.Spell,
- CountdownText: Countdown(s.RemainingSeconds(now)),
+ CountdownText: Countdown(s.RemainingSeconds(now), s.Certainty),
IsDue: s.IsAboutToDrop(now, warnSeconds),
Detail: s.IsMine ? "" : s.Caster,
Icon: "☠",
Emphasis: s.IsMine))
.ToList();
- private static string Countdown(double? remaining)
+ private static string Countdown(double? remaining, DurationCertainty certainty)
{
if (remaining is not { } seconds) return UnknownCountdown;
var whole = (int)Math.Round(seconds);
- return $"{whole / 60}:{whole % 60:00}";
+ var prefix = certainty == DurationCertainty.Derived ? EstimatePrefix : "";
+ return $"{prefix}{whole / 60}:{whole % 60:00}";
}
}
diff --git a/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs b/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs
index e087c5aa..c7d97d47 100644
--- a/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs
+++ b/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs
@@ -12,9 +12,10 @@ public class DebuffChipPresentationTests
{
private static readonly DateTime T0 = new(2026, 8, 10, 20, 0, 0, DateTimeKind.Utc);
- private static DebuffState State(string target, string spell, double? remaining) =>
- new(target, spell, Caster: "", IsMine: true, LandedAt: T0, LastTickAt: T0,
- ExpiresAt: remaining is { } r ? T0.AddSeconds(r) : null);
+ private static DebuffState State(string target, string spell, double? remaining,
+ DurationCertainty certainty = DurationCertainty.Measured) =>
+ new(target, spell, BaseName: spell, Caster: "", IsMine: true, LandedAt: T0, LastTickAt: T0,
+ ExpiresAt: remaining is { } r ? T0.AddSeconds(r) : null, Certainty: certainty);
[Fact]
public void ACountdownIsShownAsMinutesAndSeconds()
@@ -28,7 +29,8 @@ public void ACountdownIsShownAsMinutesAndSeconds()
[Fact]
public void AnUnknownDurationShowsADashRatherThanZero()
{
- var chips = DebuffChipPresentation.Chips([State("a sand giant", "Ignite", null)], T0, 10);
+ var chips = DebuffChipPresentation.Chips(
+ [State("a sand giant", "Ignite", null, DurationCertainty.Unknown)], T0, 10);
var chip = Assert.Single(chips);
Assert.Equal("--", chip.CountdownText);
@@ -89,4 +91,35 @@ public void YourOwnEffectsAreEmphasised()
Assert.False(other.Emphasis);
Assert.Contains("Cognix", other.Detail, StringComparison.Ordinal);
}
+
+ /// A derived countdown says it is one. Without the mark, a wiki estimate and a
+ /// measurement read identically, and the panel exists to support "do I recast now".
+ [Fact]
+ public void ADerivedCountdownIsMarkedAsAnEstimate()
+ {
+ var chips = DebuffChipPresentation.Chips(
+ [State("a sand giant", "Shiftless Deeds VI", 240, DurationCertainty.Derived)], T0, 10);
+
+ Assert.Equal("~4:00", Assert.Single(chips).CountdownText);
+ }
+
+ [Fact]
+ public void AMeasuredCountdownIsNotMarked()
+ {
+ var chips = DebuffChipPresentation.Chips(
+ [State("a sand giant", "Immolate", 48, DurationCertainty.Measured)], T0, 10);
+
+ Assert.Equal("0:48", Assert.Single(chips).CountdownText);
+ }
+
+ /// An estimate about to drop is still worth warning about - it is the best
+ /// information available, and suppressing the warning would make the estimate pointless.
+ [Fact]
+ public void ADerivedCountdownStillWarnsWhenItIsAboutToDrop()
+ {
+ var chips = DebuffChipPresentation.Chips(
+ [State("a sand giant", "Shiftless Deeds VI", 8, DurationCertainty.Derived)], T0, 10);
+
+ Assert.True(Assert.Single(chips).IsDue);
+ }
}
diff --git a/tests/EQBuddy.Tests/DebuffTrackerTests.cs b/tests/EQBuddy.Tests/DebuffTrackerTests.cs
index 068141dc..c885b243 100644
--- a/tests/EQBuddy.Tests/DebuffTrackerTests.cs
+++ b/tests/EQBuddy.Tests/DebuffTrackerTests.cs
@@ -89,13 +89,48 @@ public void ADurationLearnedFromOneCastCountsDownTheNext()
tracker.Apply(Tick("a sand giant", "Drifting Death", second));
tracker.Active(T0.AddSeconds(70)); // ticks stopped: the cast is complete
- Assert.Equal(54, tracker.LearnedDurations["Drifting Death"], 0);
+ Assert.Equal(48, tracker.LearnedDurations["Drifting Death"], 0);
tracker.Apply(Tick("a dervish cutthroat", "Drifting Death", 100));
var state = Assert.Single(tracker.Active(T0.AddSeconds(100)));
Assert.NotNull(state.ExpiresAt);
- Assert.Equal(54, state.RemainingSeconds(T0.AddSeconds(100))!.Value, 0);
+ Assert.Equal(48, state.RemainingSeconds(T0.AddSeconds(100))!.Value, 0);
+ }
+
+ /// Immolate's wiki duration is 48s, and in the fixture its fade line arrives at the
+ /// last tick, not a tick after it: nine ticks spanning 48s, then "worn off" in the same second.
+ /// The tick-retired path used to add a phantom trailing tick and teach 54s - the number that
+ /// looked like a 6s anchoring error and is not one.
+ [Fact]
+ public void ATickRetiredDotMeasuresFirstTickToLastTick()
+ {
+ var tracker = new DebuffTracker();
+ for (var i = 0; i <= 48; i += 6)
+ tracker.Apply(Tick("a sand giant", "Immolate", i));
+
+ // Ticks stop; the effect retires once TickGrace has passed.
+ tracker.Active(T0.AddSeconds(48 + 13));
+
+ Assert.Equal(48, tracker.LearnedDurations["Immolate"]);
+ }
+
+ /// The fade path and the tick-retired path must agree. They measure the same event
+ /// by different evidence, so a disagreement means one of them is wrong.
+ [Fact]
+ public void TheFadePathAndTheTickPathMeasureTheSameDuration()
+ {
+ var faded = new DebuffTracker();
+ for (var i = 0; i <= 48; i += 6)
+ faded.Apply(Tick("a sand giant", "Immolate", i));
+ faded.Apply(new SpellWornOffEvent(T0.AddSeconds(48), "Immolate", "a sand giant"));
+
+ var ticked = new DebuffTracker();
+ for (var i = 0; i <= 48; i += 6)
+ ticked.Apply(Tick("a sand giant", "Immolate", i));
+ ticked.Active(T0.AddSeconds(48 + 13));
+
+ Assert.Equal(faded.LearnedDurations["Immolate"], ticked.LearnedDurations["Immolate"]);
}
[Fact]
@@ -139,9 +174,9 @@ public void AnEffectIsAboutToDropOnceItIsInsideTheWarningWindow()
for (var second = 100; second <= 148; second += 6)
tracker.Apply(Tick("a dervish cutthroat", "Drifting Death", second));
- // Learned 54s from a cast landing at 100, so it drops at 154.
- Assert.False(tracker.Active(T0.AddSeconds(142))[0].IsAboutToDrop(T0.AddSeconds(142), 10));
- Assert.True(tracker.Active(T0.AddSeconds(148))[0].IsAboutToDrop(T0.AddSeconds(148), 10));
+ // Learned 48s from a cast landing at 100, so it drops at 148.
+ Assert.False(tracker.Active(T0.AddSeconds(136))[0].IsAboutToDrop(T0.AddSeconds(136), 10));
+ Assert.True(tracker.Active(T0.AddSeconds(142))[0].IsAboutToDrop(T0.AddSeconds(142), 10));
}
/// Refreshing a DoT before it drops is the normal case, and the ticks continue
@@ -160,8 +195,8 @@ public void RecastingRestartsTheClockRatherThanExtendingIt()
tracker.Apply(Tick("a sand giant", "Immolate", second));
tracker.Active(T0.AddSeconds(120));
- // 36..90 is the second cast: 54s + the tick already paid for = 60, not 96.
- Assert.Equal(60, tracker.LearnedDurations["Immolate"], 0);
+ // 36..90 is the second cast: 54s, not 96. The last tick falls on the expiry.
+ Assert.Equal(54, tracker.LearnedDurations["Immolate"], 0);
}
/// One odd sample must not become the duration for good. A mob wandering out of
@@ -179,11 +214,11 @@ void Cast(string target, int from, int to)
tracker.Active(T0.AddSeconds(to + 30));
}
- Cast("mob one", 0, 48); // 54s
- Cast("mob two", 200, 248); // 54s again
- Cast("mob three", 400, 418); // 24s - the odd one out, and the most RECENT
+ Cast("mob one", 0, 48); // 48s
+ Cast("mob two", 200, 248); // 48s again
+ Cast("mob three", 400, 418); // 18s - the odd one out, and the most RECENT
- Assert.Equal(54, tracker.LearnedDurations["Ignite"], 0);
+ Assert.Equal(48, tracker.LearnedDurations["Ignite"], 0);
}
/// A gap between ticks ends the effect even if nothing asked for the active list
@@ -206,7 +241,7 @@ public void AGapBetweenTicksEndsTheEffectEvenWithoutAnActiveCall()
var state = Assert.Single(tracker.Active(T0.AddSeconds(120)));
Assert.Equal(T0.AddSeconds(120), state.LandedAt);
- Assert.Equal(12, tracker.LearnedDurations["Choke"], 0);
+ Assert.Equal(6, tracker.LearnedDurations["Choke"], 0);
}
// ---- slice 2: fades, slows and cripples ----
@@ -322,4 +357,226 @@ public void ASlowSurvivesLongerThanTheTickGapAndIsEndedByItsFade()
Assert.Empty(tracker.Active(T0.AddSeconds(61)));
Assert.Equal(60, tracker.LearnedDurations["Tepid Deeds"], 0);
}
+
+ // ---- derived durations: the catalog as a fallback ----
+
+ private static DebuffTracker WithCatalog() => new(new SpellDurationCatalog(
+ new Dictionary { ["Shiftless Deeds"] = 150, ["Immolate"] = 48 }));
+
+ /// Cold start: nothing has been measured, so the catalog answers - marked as an
+ /// estimate so the chip can say so.
+ [Fact]
+ public void AnUnmeasuredSpellFallsBackToTheDerivedDuration()
+ {
+ var tracker = WithCatalog();
+
+ tracker.Apply(new SpellCastEvent(T0, "Shiftless Deeds VI"));
+ tracker.Apply(new DebuffLandedEvent(T0.AddSeconds(6), "a sand giant", DebuffKind.Slow));
+
+ var state = Assert.Single(tracker.Active(T0.AddSeconds(6)));
+ Assert.Equal(DurationCertainty.Derived, state.Certainty);
+ Assert.Equal(240, state.RemainingSeconds(T0.AddSeconds(6))!.Value, precision: 3);
+ }
+
+ /// The trust order. Tepid Deeds measures ~126s while its wiki page says 150 - and
+ /// that page contradicts itself. The measurement wins and is never corrected toward the wiki.
+ [Fact]
+ public void AMeasurementSupersedesTheDerivedDuration()
+ {
+ var tracker = new DebuffTracker(new SpellDurationCatalog(
+ new Dictionary { ["Immolate"] = 48 }));
+
+ // First cast: nothing measured yet, so the estimate is shown.
+ tracker.Apply(new SpellCastEvent(T0, "Immolate"));
+ for (var i = 0; i <= 126; i += 6)
+ tracker.Apply(Tick("a sand giant", "Immolate", i));
+ tracker.Apply(new SpellWornOffEvent(T0.AddSeconds(126), "Immolate", "a sand giant"));
+
+ // Second cast on a fresh mob: the measured 126 is used, not the catalog's 48.
+ tracker.Apply(new SpellCastEvent(T0.AddSeconds(200), "Immolate"));
+ tracker.Apply(Tick("a griffon", "Immolate", 206));
+
+ var state = Assert.Single(tracker.Active(T0.AddSeconds(206)));
+ Assert.Equal(DurationCertainty.Measured, state.Certainty);
+ Assert.Equal(126, state.RemainingSeconds(T0.AddSeconds(206))!.Value, precision: 3);
+ }
+
+ /// Ranks have genuinely different durations, so their samples must not pool - a
+ /// rank-I measurement must never shorten a rank-V countdown.
+ [Fact]
+ public void SamplesForTwoRanksOfOneSpellDoNotPool()
+ {
+ var tracker = WithCatalog();
+
+ // Rank IV on one mob, measured at 100s.
+ tracker.Apply(new SpellCastEvent(T0, "Shiftless Deeds IV"));
+ tracker.Apply(new DebuffLandedEvent(T0.AddSeconds(1), "a sand giant", DebuffKind.Slow));
+ tracker.Apply(new SpellWornOffEvent(T0.AddSeconds(101), "Shiftless Deeds", "a sand giant"));
+
+ // Rank VI on another, measured at 130s. Both fade lines say "Shiftless Deeds".
+ tracker.Apply(new SpellCastEvent(T0.AddSeconds(200), "Shiftless Deeds VI"));
+ tracker.Apply(new DebuffLandedEvent(T0.AddSeconds(201), "a hill giant", DebuffKind.Slow));
+ tracker.Apply(new SpellWornOffEvent(T0.AddSeconds(331), "Shiftless Deeds", "a hill giant"));
+
+ Assert.Equal(100, tracker.LearnedDurations["Shiftless Deeds IV"]);
+ Assert.Equal(130, tracker.LearnedDurations["Shiftless Deeds VI"]);
+ // And nothing pooled into the base name the two fades share.
+ Assert.False(tracker.LearnedDurations.ContainsKey("Shiftless Deeds"));
+ }
+
+ /// The rank lives on the cast line and nowhere else, so a tick nobody cast has an
+ /// unknown tier. Assuming base rank would read 48s against a real 72s for a rank-V DoT and
+ /// warn early on every single cast.
+ [Fact]
+ public void ATickWithNoExplainingCastShowsNoDerivedDuration()
+ {
+ var tracker = WithCatalog();
+
+ tracker.Apply(Tick("a sand giant", "Immolate", 0));
+
+ var state = Assert.Single(tracker.Active(T0));
+ Assert.Equal(DurationCertainty.Unknown, state.Certainty);
+ Assert.Null(state.RemainingSeconds(T0));
+ }
+
+ /// A cast from long ago must not supply a rank. _recentCasts is pruned only when a
+ /// new cast arrives, so without a window this quietly becomes "the last rank I ever saw" -
+ /// the guess this design rejected.
+ [Fact]
+ public void AStaleCastDoesNotSupplyTheRank()
+ {
+ var tracker = WithCatalog();
+
+ tracker.Apply(new SpellCastEvent(T0, "Immolate III"));
+ // Two minutes later, a tick with no cast of its own to explain it.
+ tracker.Apply(Tick("a sand giant", "Immolate", 120));
+
+ var state = Assert.Single(tracker.Active(T0.AddSeconds(120)));
+ Assert.Equal("Immolate", state.Spell);
+ Assert.Equal(DurationCertainty.Unknown, state.Certainty);
+ }
+
+ /// The chip shows the rank you actually cast, while tracking keys on the base name
+ /// the tick and fade lines use.
+ [Fact]
+ public void TheChipShowsTheRankedNameButTracksByBaseName()
+ {
+ var tracker = WithCatalog();
+
+ tracker.Apply(new SpellCastEvent(T0, "Shiftless Deeds IV"));
+ tracker.Apply(new DebuffLandedEvent(T0.AddSeconds(1), "a sand giant", DebuffKind.Slow));
+
+ var state = Assert.Single(tracker.Active(T0.AddSeconds(1)));
+ Assert.Equal("Shiftless Deeds IV", state.Spell);
+ Assert.Equal("Shiftless Deeds", state.BaseName);
+ }
+
+ /// _recastPending held the RANKED cast name and was looked up with the UNRANKED tick
+ /// name, so recast detection could never fire for a ranked DoT - the exact failure the tracker
+ /// documents ("Immolate 115s against 54-60s for every sibling"). The fixture never caught it
+ /// because none of Daggo's DoTs are ranked.
+ [Fact]
+ public void ARecastOfARankedDotRestartsTheClock()
+ {
+ var tracker = new DebuffTracker(new SpellDurationCatalog(
+ new Dictionary { ["Immolate"] = 48 }));
+
+ tracker.Apply(new SpellCastEvent(T0, "Immolate III"));
+ tracker.Apply(Tick("a sand giant", "Immolate", 6));
+ tracker.Apply(Tick("a sand giant", "Immolate", 12));
+
+ // Refresh before it drops. The clock must restart from the new cast's first tick.
+ tracker.Apply(new SpellCastEvent(T0.AddSeconds(18), "Immolate III"));
+ tracker.Apply(Tick("a sand giant", "Immolate", 24));
+
+ var state = Assert.Single(tracker.Active(T0.AddSeconds(24)));
+ Assert.Equal(T0.AddSeconds(24), state.LandedAt);
+ }
+
+ /// A derived duration is an ESTIMATE, and the real spell can outlast it: replaying
+ /// the user's own log, Shiftless Deeds IV measured 214.0s against a derived 210.0s and
+ /// graduated with one second to spare. Retiring the chip must not also forget the effect -
+ /// otherwise the fade finds nothing, nothing is recorded, and every later cast re-derives the
+ /// same estimate, pinning the spell at the guess for the rest of the session.
+ [Fact]
+ public void ASlowOutlastingItsDerivedEstimateIsStillMeasuredWhenItFades()
+ {
+ var tracker = WithCatalog(); // Shiftless Deeds 150 base; rank IV derives 210s
+
+ tracker.Apply(new SpellCastEvent(T0, "Shiftless Deeds IV"));
+ tracker.Apply(new DebuffLandedEvent(T0.AddSeconds(1), "a sand giant", DebuffKind.Slow));
+
+ // The estimate and its linger run out while the effect is still on the mob: the chip
+ // goes away, as it should - a countdown that reached zero is not worth showing.
+ Assert.Empty(tracker.Active(T0.AddSeconds(220)));
+
+ // The truth arrives late, and is still the truth.
+ tracker.Apply(new SpellWornOffEvent(T0.AddSeconds(231), "Shiftless Deeds", "a sand giant"));
+
+ Assert.Equal(230, tracker.LearnedDurations["Shiftless Deeds IV"]);
+ }
+
+ /// UnknownCap exists so a mis-attributed chip cannot hold the panel forever. A
+ /// derived duration must not defeat it - Valor's 3240s would keep a wrong chip up for 54
+ /// minutes, which is the exact thing the cap was written to stop.
+ [Fact]
+ public void ALongDerivedDurationStillRetiresAtTheUnknownCap()
+ {
+ var tracker = new DebuffTracker(new SpellDurationCatalog(
+ new Dictionary { ["Valor"] = 3240 }));
+
+ tracker.Apply(new SpellCastEvent(T0, "Valor"));
+ tracker.Apply(new DebuffLandedEvent(T0.AddSeconds(1), "a sand giant", DebuffKind.Slow));
+
+ Assert.Single(tracker.Active(T0.AddSeconds(590)));
+ Assert.Empty(tracker.Active(T0.AddSeconds(700)));
+ }
+
+ /// What is remembered for a late fade is bounded too, or a mob three zones back
+ /// could still teach a duration an hour later.
+ [Fact]
+ public void AFadeLongAfterTheUnknownCapTeachesNothing()
+ {
+ var tracker = WithCatalog();
+
+ tracker.Apply(new SpellCastEvent(T0, "Shiftless Deeds IV"));
+ tracker.Apply(new DebuffLandedEvent(T0.AddSeconds(1), "a sand giant", DebuffKind.Slow));
+
+ Assert.Empty(tracker.Active(T0.AddSeconds(700)));
+ tracker.Apply(new SpellWornOffEvent(T0.AddSeconds(800), "Shiftless Deeds", "a sand giant"));
+
+ Assert.Empty(tracker.LearnedDurations);
+ }
+
+ /// A refresh must not read samples under a name it would never write to. The chip
+ /// keeps the ranked name, so the countdown has to come from the ranked name's samples: the
+ /// 30s measured for unranked Immolate belongs to tier 0 and must never surface on a rank-III
+ /// chip, least of all marked Measured.
+ [Fact]
+ public void ARefreshedRankedDotDoesNotBorrowTheBaseRanksMeasurement()
+ {
+ var tracker = new DebuffTracker(new SpellDurationCatalog(
+ new Dictionary { ["Immolate"] = 48 }));
+
+ // Tier 0, measured at 30s on another mob: ticks with no cast to name a rank.
+ foreach (var second in new[] { 0, 6, 12, 18, 24, 30 })
+ tracker.Apply(Tick("a hill giant", "Immolate", second));
+ Assert.Empty(tracker.Active(T0.AddSeconds(50)));
+ Assert.Equal(30, tracker.LearnedDurations["Immolate"]);
+
+ // Rank III on the sand giant, then a refresh. The bard's cast is what used to prune the
+ // recast line out of _recentCasts, leaving the refresh with no rank to work from and the
+ // sample lookup falling back to the tick's base name.
+ tracker.Apply(new SpellCastEvent(T0.AddSeconds(100), "Immolate III"));
+ foreach (var second in new[] { 102, 108, 114 })
+ tracker.Apply(Tick("a sand giant", "Immolate", second));
+ tracker.Apply(new SpellCastEvent(T0.AddSeconds(116), "Immolate III"));
+ tracker.Apply(new OtherCastEvent(T0.AddSeconds(125), "Kulwhip", "Chords of Dissonance"));
+ tracker.Apply(Tick("a sand giant", "Immolate", 126));
+
+ var state = Assert.Single(tracker.Active(T0.AddSeconds(126)));
+ Assert.Equal("Immolate III", state.Spell);
+ Assert.NotEqual(DurationCertainty.Measured, state.Certainty);
+ Assert.Equal(62.4, state.RemainingSeconds(T0.AddSeconds(126))!.Value, precision: 3);
+ }
}
diff --git a/tests/EQBuddy.Tests/SpellDurationCatalogTests.cs b/tests/EQBuddy.Tests/SpellDurationCatalogTests.cs
new file mode 100644
index 00000000..af11aedc
--- /dev/null
+++ b/tests/EQBuddy.Tests/SpellDurationCatalogTests.cs
@@ -0,0 +1,115 @@
+using EQBuddy.Core;
+using Xunit;
+
+namespace EQBuddy.Tests;
+
+///
+/// Resolving a cast name to a base duration. The whole contract is the ordering: an exact
+/// catalog hit beats a rank interpretation, because 121 wiki spells are genuinely NAMED with a
+/// trailing numeral and reading "Clarity II" as tier-2 Clarity would scale a duration the
+/// catalog already knows exactly.
+/// and both give the rank
+/// branch a real "Clarity"/"Burnout" base entry to wrongly match against, so a reversed
+/// (rank-before-exact) implementation produces a wrong NUMBER rather than merely missing -
+/// confirmed by temporarily swapping the two branches in Resolve and watching both
+/// fail (1944 vs 2100, 5040 vs 900), then reverting and watching both pass again.
+///
+public class SpellDurationCatalogTests
+{
+ private static readonly SpellDurationCatalog Catalog = new(new Dictionary
+ {
+ ["Shiftless Deeds"] = 150,
+ ["Mesmerization"] = 24,
+ ["Immolate"] = 48,
+ ["Clarity"] = 1620,
+ ["Clarity II"] = 2100,
+ });
+
+ /// The trap, made to actually bite. The fixture now has BOTH "Clarity" (1620) and
+ /// "Clarity II" (2100), so a reversed (rank-before-exact) implementation has something to
+ /// wrongly match: it would read "Clarity II" as tier-2 Clarity and return 1620 * 1.2 = 1944,
+ /// not 2100. With no "Clarity" entry at all, a reversed implementation's rank branch would
+ /// just miss and fall through to the same exact hit - passing for the wrong reason, which is
+ /// exactly what let this test through review the first time.
+ [Fact]
+ public void ASpellNamedWithANumeralResolvesExactlyAndIsNotScaled()
+ {
+ var resolved = Catalog.Resolve("Clarity II");
+
+ Assert.Equal(new ResolvedDuration("Clarity II", 0, 2100), resolved);
+ }
+
+ [Fact]
+ public void ARankedSpellDerivesFromItsBase()
+ {
+ Assert.Equal(new ResolvedDuration("Shiftless Deeds", 4, 210), Catalog.Resolve("Shiftless Deeds IV"));
+ Assert.Equal(new ResolvedDuration("Shiftless Deeds", 6, 240), Catalog.Resolve("Shiftless Deeds VI"));
+ Assert.Equal(new ResolvedDuration("Mesmerization", 5, 36), Catalog.Resolve("Mesmerization V"));
+ }
+
+ [Fact]
+ public void AnUnrankedSpellResolvesToItsOwnDuration()
+ {
+ Assert.Equal(new ResolvedDuration("Immolate", 0, 48), Catalog.Resolve("Immolate"));
+ }
+
+ /// No base page means no number. Heroic Leap has no wiki duration entry, so the
+ /// panel must say "--" rather than reach for something plausible.
+ [Fact]
+ public void AnUnknownSpellResolvesToNothing()
+ {
+ Assert.Null(Catalog.Resolve("Heroic Leap I"));
+ Assert.Null(Catalog.Resolve("Some Spell That Does Not Exist"));
+ }
+
+ /// The tracking key: ticks and fade lines never carry the rank, so a ranked cast
+ /// has to collapse onto the same key the tick lines will use.
+ [Fact]
+ public void TheBaseNameIsTheNameTickLinesWillUse()
+ {
+ Assert.Equal("Shiftless Deeds", Catalog.BaseNameOf("Shiftless Deeds IV"));
+ Assert.Equal("Immolate", Catalog.BaseNameOf("Immolate"));
+ // A spell genuinely named with a numeral keeps it - its tick lines carry it too.
+ Assert.Equal("Clarity II", Catalog.BaseNameOf("Clarity II"));
+ // Unknown to the catalog: fall back to the naive split rather than refuse to track.
+ Assert.Equal("Heroic Leap", Catalog.BaseNameOf("Heroic Leap I"));
+ }
+
+ /// Guards the shipped data, not the code. These four are the values the whole
+ /// feature was verified against.
+ [Fact]
+ public void TheEmbeddedCatalogCarriesTheVerifiedDurations()
+ {
+ var catalog = SpellDurationCatalog.Embedded;
+
+ Assert.Equal(150, catalog.Resolve("Shiftless Deeds")!.Seconds);
+ Assert.Equal(48, catalog.Resolve("Immolate")!.Seconds);
+ Assert.Equal(24, catalog.Resolve("Mesmerization")!.Seconds);
+ // Confirmed in game: Shiftless Deeds VI shows 4 minutes.
+ Assert.Equal(240, catalog.Resolve("Shiftless Deeds VI")!.Seconds);
+ }
+
+ /// The trap against the REAL data, where it is sharpest: the embedded catalog has
+ /// both a base entry and a numeral-named entry for these two families, so a reversed
+ /// (rank-before-exact) implementation would silently corrupt them rather than merely miss.
+ /// "Clarity" 1620s exists alongside "Clarity II" 2100s (reversed: 1620 * 1.2 = 1944, wrong).
+ /// "Burnout" 3600s exists alongside "Burnout IV" 900s (reversed: 3600 * 1.4 = 5040, a 5.6x
+ /// error). Exact-match-first is what keeps these two independent, unrelated durations from
+ /// being scaled off each other.
+ [Fact]
+ public void NumeralNamedSpellsWithARealBaseEntryStillResolveExactly()
+ {
+ var catalog = SpellDurationCatalog.Embedded;
+
+ Assert.Equal(2100, catalog.Resolve("Clarity II")!.Seconds);
+ Assert.Equal(900, catalog.Resolve("Burnout IV")!.Seconds);
+ }
+
+ /// Cripple's wiki duration is the level-scaled range "6.3 minutes @L53 to 7.0
+ /// minutes @L60". There is no single base to multiply, so it is absent by design.
+ [Fact]
+ public void ALevelScaledDurationIsAbsentRatherThanAveraged()
+ {
+ Assert.Null(SpellDurationCatalog.Embedded.Resolve("Cripple"));
+ }
+}
diff --git a/tests/EQBuddy.Tests/SpellRankTests.cs b/tests/EQBuddy.Tests/SpellRankTests.cs
new file mode 100644
index 00000000..947d46e5
--- /dev/null
+++ b/tests/EQBuddy.Tests/SpellRankTests.cs
@@ -0,0 +1,57 @@
+using EQBuddy.Core;
+using Xunit;
+
+namespace EQBuddy.Tests;
+
+///
+/// Splitting a rank off a spell name. This helper deliberately does NOT decide whether the
+/// numeral it found is a rank at all - 121 spells in the wiki catalog are genuinely NAMED with
+/// a trailing numeral ("Clarity II", "Burnout IV"), and only the catalog can tell them apart.
+/// See SpellDurationCatalog.
+///
+public class SpellRankTests
+{
+ [Theory]
+ [InlineData("Shiftless Deeds IV", "Shiftless Deeds", 4)]
+ [InlineData("Mesmerization V", "Mesmerization", 5)]
+ [InlineData("Shiftless Deeds VI", "Shiftless Deeds", 6)]
+ [InlineData("Beguile II", "Beguile", 2)]
+ [InlineData("Heroic Leap I", "Heroic Leap", 1)]
+ [InlineData("Efflorescing Heal III", "Efflorescing Heal", 3)]
+ public void ATrailingRomanNumeralIsSplitOff(string name, string expectedBase, int expectedTier)
+ {
+ Assert.Equal((expectedBase, expectedTier), SpellRank.Split(name));
+ }
+
+ [Theory]
+ [InlineData("Immolate")]
+ [InlineData("Drifting Death")]
+ [InlineData("Vengeance of the Wild")]
+ public void AnUnrankedNameIsTierZero(string name)
+ {
+ Assert.Equal((name, 0), SpellRank.Split(name));
+ }
+
+ /// Real spell words that happen to be Roman letters must not be eaten. "Ice" is a
+ /// spell Daggo casts 285 times in the fixture; "Mix" and "Dim" are Roman-parseable strings.
+ [Theory]
+ [InlineData("Ice")]
+ [InlineData("Mana Sieve")]
+ [InlineData("Chaos Flux")]
+ public void ASingleWordNameIsNeverTreatedAsARank(string name)
+ {
+ Assert.Equal((name, 0), SpellRank.Split(name));
+ }
+
+ /// The formula is additive, not compounding: 1.1^6 is 1.77, and Shiftless Deeds VI
+ /// shows exactly 4 minutes in game against a 150s base.
+ [Theory]
+ [InlineData(150, 6, 240)] // Shiftless Deeds VI - 4 min, confirmed in game
+ [InlineData(150, 4, 210)] // Shiftless Deeds IV
+ [InlineData(24, 5, 36)] // Mesmerization V - measured ~36s
+ [InlineData(48, 0, 48)] // unranked is untouched
+ public void DurationScalesTenPercentPerTier(double baseSeconds, int tier, double expected)
+ {
+ Assert.Equal(expected, SpellRank.Scale(baseSeconds, tier), precision: 6);
+ }
+}