From d089304f9ed33d28f97e760277894c157971a018 Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 11:13:19 -0400 Subject: [PATCH 01/13] Derived durations, and the measurement that was one tick long The brief asked to re-anchor DoT timers on the cast line. The log says no: across six DoTs with both fade lines and an exact wiki duration, cast->fade matches nothing and runs long by each spell's own cast time. fade->firstTick matches the wiki exactly. The first tick is the landing. The real defect is a phantom trailing tick in the no-fade measurement path, which is where Immolate's 54s against a wiki 48s came from. Also settled: base durations come from the harvest already on disk rather than a runtime fetch, and rank resolution asks the catalog first, because 121 spells legitimately end in a Roman numeral and Clarity II is not tier-2 Clarity. --- .../2026-08-12-derived-durations-design.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-12-derived-durations-design.md 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. From 61f28541b5ac147f1465e7dcca44061c8d16f0f4 Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 11:28:04 -0400 Subject: [PATCH 02/13] Implementation plan for derived durations Six tasks, TDD throughout: drop the phantom tick, parse ranks, promote the catalog, wire the three-tier lookup into the tracker, mark the estimate, then replay the real log in the real app. Writing it surfaced one thing the spec did not cover: _recentCasts is pruned only when a new cast arrives, so pairing a tick to a cast without a window would quietly become "the last rank I ever saw" - the guess the design rejected. Bounded at 15s, with a test that pins it. --- .../plans/2026-08-12-derived-durations.md | 1128 +++++++++++++++++ 1 file changed, 1128 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-12-derived-durations.md 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..928151f4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-derived-durations.md @@ -0,0 +1,1128 @@ +# 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 at `54e9cf1` is 923 passing. +- **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. Read the total — it must be 925 (923 baseline + 2 new), with 0 failed. If any pre-existing test now fails, it was asserting the phantom tick; report which before changing it. + +- [ ] **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. + +- [ ] **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: ~1580 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. ~1,580 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 948 (925 + 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, 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, 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, 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. + private (DateTime? At, DurationCertainty Certainty) Expiry(string spell, DateTime from) + { + if (_samples.TryGetValue(spell, out var samples) && samples.Count > 0) + return (from.AddSeconds(Consensus(samples)), DurationCertainty.Measured); + if (_catalog.Resolve(spell) 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 955 (948 + 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 958 (955 + 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. From d6c210733d2bc0da6e4075070a1aade4bfd0e031 Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 11:55:32 -0400 Subject: [PATCH 03/13] Correct the plan's test counts to the per-suite baseline The plan quoted 923 as the baseline for EQBuddy.Tests, but 923 is both suites combined. Measured on branch: 842 Core, 81 Avalonia. --- .../plans/2026-08-12-derived-durations.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-08-12-derived-durations.md b/docs/superpowers/plans/2026-08-12-derived-durations.md index 928151f4..1077f6af 100644 --- a/docs/superpowers/plans/2026-08-12-derived-durations.md +++ b/docs/superpowers/plans/2026-08-12-derived-durations.md @@ -18,7 +18,7 @@ - `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 at `54e9cf1` is 923 passing. +- **`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`. @@ -125,7 +125,7 @@ Then correct the class docstring, which currently states the wrong model. Replac Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release` -Expected: PASS. Read the total — it must be 925 (923 baseline + 2 new), with 0 failed. If any pre-existing test now fails, it was asserting the phantom tick; report which before changing it. +Expected: PASS. Read the total — it must be 844 (842 baseline + 2 new), with 0 failed. If any pre-existing test now fails, it was asserting the phantom tick; report which before changing it. - [ ] **Step 6: Commit** @@ -293,7 +293,7 @@ public static class SpellRank Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release --filter "FullyQualifiedName~SpellRankTests"` -Expected: PASS, 16 tests. +Expected: PASS, 16 tests (4 theories, 16 cases). - [ ] **Step 5: Commit** @@ -622,7 +622,7 @@ public sealed class SpellDurationCatalog Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release` -Expected: PASS. Total 948 (925 + Task 2's 16 theory cases + 7 new), 0 failed. +Expected: PASS. Total 867 (844 + Task 2's 16 theory cases + 7 new), 0 failed. - [ ] **Step 8: Commit** @@ -934,7 +934,7 @@ Finally, in `OnFade`, the lookup key becomes the base name — fade lines never Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release` -Expected: PASS. Total 955 (948 + 7 new), 0 failed. +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. @@ -1039,7 +1039,7 @@ And replace `Countdown`: Run: `dotnet test tests/EQBuddy.Tests/EQBuddy.Tests.csproj -c Release` -Expected: PASS. Total 958 (955 + 3 new), 0 failed. +Expected: PASS. Total 877 (874 + 3 new), 0 failed. - [ ] **Step 5: Run the Avalonia suite** From 503d0cd097338fb1f873120891edc46c905cf092 Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 11:57:31 -0400 Subject: [PATCH 04/13] Pre-flight fixes to the plan Two defects found scanning the plan against the existing tests before any dispatch: Expiry() fell back to the base tick name when no cast explained an effect, so it would have derived a tier-0 duration for exactly the case the design says must read '--'. The unknown-rank test in the plan would have failed against the plan's own implementation. castName is now separate and nullable, and nothing derives without it. Task 1 breaks four existing tests that encode the phantom tick. Naming them, with their new values and the four fade-path tests that must NOT move, so the implementer corrects the evidence rather than guessing at it. --- .../plans/2026-08-12-derived-durations.md | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-08-12-derived-durations.md b/docs/superpowers/plans/2026-08-12-derived-durations.md index 1077f6af..8b540c5d 100644 --- a/docs/superpowers/plans/2026-08-12-derived-durations.md +++ b/docs/superpowers/plans/2026-08-12-derived-durations.md @@ -125,7 +125,24 @@ Then correct the class docstring, which currently states the wrong model. Replac 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. If any pre-existing test now fails, it was asserting the phantom tick; report which before changing it. +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** @@ -845,7 +862,7 @@ Replace `OnLanding`'s body after the cast lookup: ```csharp var key = (landed.Target, _catalog.BaseNameOf(cast.Spell)); - var (expires, certainty) = Expiry(cast.Spell, landed.Time); + 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, @@ -873,7 +890,8 @@ In `OnTick`, replace the key and both construction sites. The key is already the Then in the recast branch, replace the `with` expression: ```csharp - var (refreshedAt, refreshedCertainty) = Expiry(castName ?? tick.Source, tick.Time); + var (refreshedAt, refreshedCertainty) = + Expiry(castName ?? tick.Source, castName, tick.Time); _active[key] = existing with { Spell = castName ?? existing.Spell, @@ -889,7 +907,7 @@ Then in the recast branch, replace the `with` expression: And the new-effect branch at the end: ```csharp - var (at, howSure) = Expiry(castName ?? tick.Source, tick.Time); + 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, @@ -908,12 +926,18 @@ Replace `Record(DebuffState)` and `Expiry` so samples key on the displayed (rank /// 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. - private (DateTime? At, DurationCertainty Certainty) Expiry(string spell, DateTime from) + /// 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(spell, out var samples) && samples.Count > 0) + if (_samples.TryGetValue(sampleKey, out var samples) && samples.Count > 0) return (from.AddSeconds(Consensus(samples)), DurationCertainty.Measured); - if (_catalog.Resolve(spell) is { } derived) + if (castName is not null && _catalog.Resolve(castName) is { } derived) return (from.AddSeconds(derived.Seconds), DurationCertainty.Derived); return (null, DurationCertainty.Unknown); } From d978b6a53c250517de736819c7cc8dcf912438d0 Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 11:59:50 -0400 Subject: [PATCH 05/13] The DoT measurement that was one tick long --- src/EQBuddy.Core/DebuffTracker.cs | 22 +++++---- tests/EQBuddy.Tests/DebuffTrackerTests.cs | 59 ++++++++++++++++++----- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/src/EQBuddy.Core/DebuffTracker.cs b/src/EQBuddy.Core/DebuffTracker.cs index 18794e18..8e264f2e 100644 --- a/src/EQBuddy.Core/DebuffTracker.cs +++ b/src/EQBuddy.Core/DebuffTracker.cs @@ -29,11 +29,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,10 +63,6 @@ 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. @@ -242,7 +244,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) diff --git a/tests/EQBuddy.Tests/DebuffTrackerTests.cs b/tests/EQBuddy.Tests/DebuffTrackerTests.cs index 068141dc..3e9fde53 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 ---- From ab2ecb9e8d990a34ec87d4164b9b3ab68187cbbc Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 12:04:01 -0400 Subject: [PATCH 06/13] Ranks add ten percent each, and Ice is not a numeral Co-Authored-By: Claude Opus 5 (1M context) --- src/EQBuddy.Core/SpellRank.cs | 61 +++++++++++++++++++++++++++ tests/EQBuddy.Tests/SpellRankTests.cs | 57 +++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/EQBuddy.Core/SpellRank.cs create mode 100644 tests/EQBuddy.Tests/SpellRankTests.cs diff --git a/src/EQBuddy.Core/SpellRank.cs b/src/EQBuddy.Core/SpellRank.cs new file mode 100644 index 00000000..d661c5aa --- /dev/null +++ b/src/EQBuddy.Core/SpellRank.cs @@ -0,0 +1,61 @@ +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; + } +} 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); + } +} From 28cd8e0fb231ca07443108c605ed35a2520fedc8 Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 12:10:44 -0400 Subject: [PATCH 07/13] Correct the catalog size gate: 680, not ~1580 The gate quoted the 1,589 spells that have a parsed duration_seconds without subtracting the 896 that are Instant/zero and the 340 level-scaled ones the promote script correctly excludes. 693 positive durations collapse to 680 unique names. The Task 3 implementer stopped at the gate exactly as told to, and its number was the right one. --- docs/superpowers/plans/2026-08-12-derived-durations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-12-derived-durations.md b/docs/superpowers/plans/2026-08-12-derived-durations.md index 8b540c5d..b378da1d 100644 --- a/docs/superpowers/plans/2026-08-12-derived-durations.md +++ b/docs/superpowers/plans/2026-08-12-derived-durations.md @@ -426,7 +426,7 @@ print('Cripple present?', 'Cripple' in d) " ``` -Expected: ~1580 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`. +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. @@ -589,7 +589,7 @@ public sealed class SpellDurationCatalog ? LoadEmbedded() : new Dictionary(durations, StringComparer.OrdinalIgnoreCase); - /// The shipped catalog, loaded once. ~1,580 spells. + /// 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 From 3c7c774715257c53510a517325812ef145defcd1 Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 12:12:17 -0400 Subject: [PATCH 08/13] Base durations, shipped rather than fetched Promotes the eqlwiki spell harvest into an embedded 680-entry duration catalog (SpellDurations.json) and resolves cast names against it. The catalog is the authority on names: an exact hit always beats a rank interpretation, since 121 wiki spells are genuinely named with a trailing roman numeral (Clarity II, Burnout IV) rather than being ranks of something else. Level-scaled and Instant durations are excluded on purpose, so an absent spell means unknown, never a guess. --- scripts/harvests/eqlwiki/spells-promote.py | 63 ++ src/EQBuddy.Core/Data/SpellDurations.json | 685 ++++++++++++++++++ src/EQBuddy.Core/EQBuddy.Core.csproj | 1 + src/EQBuddy.Core/SpellDurationCatalog.cs | 87 +++ .../SpellDurationCatalogTests.cs | 90 +++ 5 files changed, 926 insertions(+) create mode 100755 scripts/harvests/eqlwiki/spells-promote.py create mode 100644 src/EQBuddy.Core/Data/SpellDurations.json create mode 100644 src/EQBuddy.Core/SpellDurationCatalog.cs create mode 100644 tests/EQBuddy.Tests/SpellDurationCatalogTests.cs diff --git a/scripts/harvests/eqlwiki/spells-promote.py b/scripts/harvests/eqlwiki/spells-promote.py new file mode 100755 index 00000000..4e84f972 --- /dev/null +++ b/scripts/harvests/eqlwiki/spells-promote.py @@ -0,0 +1,63 @@ +#!/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() 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/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..b5251b2c --- /dev/null +++ b/src/EQBuddy.Core/SpellDurationCatalog.cs @@ -0,0 +1,87 @@ +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 680 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; + } +} diff --git a/tests/EQBuddy.Tests/SpellDurationCatalogTests.cs b/tests/EQBuddy.Tests/SpellDurationCatalogTests.cs new file mode 100644 index 00000000..3a7cb178 --- /dev/null +++ b/tests/EQBuddy.Tests/SpellDurationCatalogTests.cs @@ -0,0 +1,90 @@ +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")); + } +} From 8439296db22e1be314bfbc97a26e91eb08868044 Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 12:20:02 -0400 Subject: [PATCH 09/13] Fix Task 3 review findings: log collisions, sharpen the ordering test promote(): the 693 -> 680 name collapse silently picked last-wins on 13 collisions; two (Rabies, Solon's Bravura) are genuinely different spells with different durations, not duplicates. Now logged to stderr, naming both pages and which value survives, so a knowledge refresh can't flip the pick unnoticed. Behavior unchanged - output JSON is byte-identical. SpellDurationCatalogTests: the numeral-vs-rank ordering test had no "Clarity" entry in its fixture, so a reversed (rank-before-exact) implementation would simply miss the rank branch and fall through to the same exact hit - passing for the wrong reason. Added a real "Clarity" base entry to the fixture and a new test against the embedded catalog's real Clarity/Burnout collision, both of which now fail under a reversed Resolve() (1944 vs 2100, 5040 vs 900) and pass under the correct one - verified by temporarily swapping the two branches. --- scripts/harvests/eqlwiki/spells-promote.py | 28 ++++++++++++++-- .../SpellDurationCatalogTests.cs | 33 ++++++++++++++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/scripts/harvests/eqlwiki/spells-promote.py b/scripts/harvests/eqlwiki/spells-promote.py index 4e84f972..d3a668b3 100755 --- a/scripts/harvests/eqlwiki/spells-promote.py +++ b/scripts/harvests/eqlwiki/spells-promote.py @@ -16,11 +16,23 @@ 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 @@ -30,13 +42,25 @@ 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 name: - durations[name] = round(float(seconds), 1) + 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 diff --git a/tests/EQBuddy.Tests/SpellDurationCatalogTests.cs b/tests/EQBuddy.Tests/SpellDurationCatalogTests.cs index 3a7cb178..af11aedc 100644 --- a/tests/EQBuddy.Tests/SpellDurationCatalogTests.cs +++ b/tests/EQBuddy.Tests/SpellDurationCatalogTests.cs @@ -7,7 +7,12 @@ 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. +/// 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 { @@ -16,12 +21,16 @@ public class SpellDurationCatalogTests ["Shiftless Deeds"] = 150, ["Mesmerization"] = 24, ["Immolate"] = 48, + ["Clarity"] = 1620, ["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. + /// 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() { @@ -80,6 +89,22 @@ public void TheEmbeddedCatalogCarriesTheVerifiedDurations() 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] From 8d4100bbf472ae5cc3fef8a745e5895486375f32 Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 12:27:03 -0400 Subject: [PATCH 10/13] Derived durations, and the rank that only the cast line knows Wire SpellDurationCatalog into DebuffTracker: effects are keyed by base name (tick/fade lines never carry the rank) while the chip displays the ranked name, samples are recorded per-rank, and Expiry falls back to the catalog's derived duration only when a cast supplied the rank. Trust order stays Measured > Derived > Unknown; a measurement is never adjusted toward the catalog. Co-Authored-By: Claude Opus 5 (1M context) --- src/EQBuddy.Core/DebuffTracker.cs | 77 +++++++++-- .../DebuffChipPresentationTests.cs | 2 +- tests/EQBuddy.Tests/DebuffTrackerTests.cs | 127 ++++++++++++++++++ 3 files changed, 191 insertions(+), 15 deletions(-) diff --git a/src/EQBuddy.Core/DebuffTracker.cs b/src/EQBuddy.Core/DebuffTracker.cs index 8e264f2e..0cda564d 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 @@ -68,12 +74,25 @@ public sealed class DebuffTracker /// oldest samples say nothing the newest do not. public static readonly int SampleCap = 16; + /// 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; + private readonly Dictionary<(string Target, string Spell), DebuffState> _active = []; 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; @@ -105,7 +124,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 @@ -138,11 +157,12 @@ 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 = (landed.Target, _catalog.BaseNameOf(cast.Spell)); + 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.Item2, 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 @@ -150,17 +170,27 @@ 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); + 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); + if (measured > 0) Record(state.Spell, measured); // ranked name: samples are per-rank } 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; + if (_active.TryGetValue(key, out var existing) && tick.Time - existing.LastTickAt > TickGrace) { // The previous effect ended before this tick, and nobody was watching. Retirement @@ -181,11 +211,15 @@ private void OnTick(DamageDealtEvent tick) // 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. + 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 = Expiry(tick.Source, tick.Time), + ExpiresAt = refreshedAt, + Certainty = refreshedCertainty, Ticks = true, }; return; @@ -197,10 +231,11 @@ private void OnTick(DamageDealtEvent tick) _recastPending.Remove(tick.Source); // 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, tick.Source, 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 @@ -255,8 +290,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/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs b/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs index e087c5aa..bcca79cd 100644 --- a/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs +++ b/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs @@ -13,7 +13,7 @@ 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, + new(target, spell, BaseName: spell, Caster: "", IsMine: true, LandedAt: T0, LastTickAt: T0, ExpiresAt: remaining is { } r ? T0.AddSeconds(r) : null); [Fact] diff --git a/tests/EQBuddy.Tests/DebuffTrackerTests.cs b/tests/EQBuddy.Tests/DebuffTrackerTests.cs index 3e9fde53..975ddf58 100644 --- a/tests/EQBuddy.Tests/DebuffTrackerTests.cs +++ b/tests/EQBuddy.Tests/DebuffTrackerTests.cs @@ -357,4 +357,131 @@ 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(); + + 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); + } } From 3ad42de3f6cca62b547a717b7b11887fc27d6464 Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 12:34:40 -0400 Subject: [PATCH 11/13] An estimate says that it is one --- .../DebuffChipPresentation.cs | 13 +++++-- .../DebuffChipPresentationTests.cs | 36 +++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) 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 bcca79cd..01e0d9cc 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) => + 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); + ExpiresAt: remaining is { } r ? T0.AddSeconds(r) : null, Certainty: certainty); [Fact] public void ACountdownIsShownAsMinutesAndSeconds() @@ -89,4 +90,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); + } } From d9c3d6391abbd2d8ef642d31eda61e021b230aac Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 13:05:55 -0400 Subject: [PATCH 12/13] A retired chip is not a forgotten effect 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. Retiring the chip at the estimate also dropped the effect, so the fade found nothing, nothing was recorded, and every later cast re-derived the same value: the spell pinned at the guess for the rest of the session, one second of margin away from happening for real. So retirement is now two separate things. Active() moves a retired non-ticking effect into _awaitingFade instead of dropping it, OnFade measures from whichever store still remembers it, and _awaitingFade is pruned at LandedAt + UnknownCap so it cannot grow without bound. A fresh landing on the same key supersedes what is waiting there. The chip itself is now bounded too. A derived expiry defeated UnknownCap - a mis-attributed Valor would have held the panel for 54 minutes, which is the exact thing that cap was written to stop - so a non-ticking chip retires at min(ExpiresAt + ExpiryLinger, LandedAt + UnknownCap). Ticking DoTs still retire on TickGrace, which was already right. Two smaller things the refresh path was doing wrong. It read _samples under the tick's BASE name while displaying the RANKED one, so a rank-N effect could take its countdown from tier-0 samples and show it as Measured - the pooling this design forbids, arriving through the display rather than through the store. And _recentCasts was pruned at CastToLand (8s) while OnTick read it at CastToTick (15s), so the wider window described a list that could not contain anything that old; it now prunes at the wider of the two. With that fixed the sample-key bug turns out to be unreachable, since a live effect's next tick is always within TickGrace of the cast that set the recast flag - but reading a key you will never write to is worth not doing anyway. Co-Authored-By: Claude Opus 5 (1M context) --- src/EQBuddy.Core/DebuffTracker.cs | 76 ++++++++++++++---- tests/EQBuddy.Tests/DebuffTrackerTests.cs | 97 ++++++++++++++++++++++- 2 files changed, 156 insertions(+), 17 deletions(-) diff --git a/src/EQBuddy.Core/DebuffTracker.cs b/src/EQBuddy.Core/DebuffTracker.cs index 0cda564d..675e07cc 100644 --- a/src/EQBuddy.Core/DebuffTracker.cs +++ b/src/EQBuddy.Core/DebuffTracker.cs @@ -82,9 +82,21 @@ public sealed class DebuffTracker /// 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 Spell), DebuffState> _active = []; + 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 = []; @@ -146,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 @@ -157,10 +169,11 @@ 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, _catalog.BaseNameOf(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, key.Item2, cast.Caster, cast.Mine, + landed.Target, cast.Spell, key.BaseName, cast.Caster, cast.Mine, LandedAt: landed.Time, LastTickAt: landed.Time, ExpiresAt: expires, Certainty: certainty); } @@ -171,8 +184,10 @@ private void OnLanding(DebuffLandedEvent landed) private void OnFade(SpellWornOffEvent fade) { var key = (fade.Target, _catalog.BaseNameOf(fade.Spell)); - if (!_active.Remove(key, out var state)) return; - if (_died.Contains(fade.Target)) return; + // 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); // ranked name: samples are per-rank @@ -180,7 +195,12 @@ private void OnFade(SpellWornOffEvent fade) 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. // @@ -189,7 +209,7 @@ private void OnTick(DamageDealtEvent tick) // 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; + && _catalog.BaseNameOf(c.Spell) == baseName).Spell; if (_active.TryGetValue(key, out var existing) && tick.Time - existing.LastTickAt > TickGrace) { @@ -204,15 +224,20 @@ 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 ?? tick.Source, castName, tick.Time); + Expiry(castName ?? existing.Spell, castName, tick.Time); _active[key] = existing with { Spell = castName ?? existing.Spell, @@ -228,12 +253,12 @@ 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, castName ?? tick.Source, tick.Source, Caster: "", IsMine: true, + tick.Target, castName ?? tick.Source, baseName, Caster: "", IsMine: true, LandedAt: tick.Time, LastTickAt: tick.Time, ExpiresAt: at, Ticks: true, Certainty: howSure); } @@ -256,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) diff --git a/tests/EQBuddy.Tests/DebuffTrackerTests.cs b/tests/EQBuddy.Tests/DebuffTrackerTests.cs index 975ddf58..c885b243 100644 --- a/tests/EQBuddy.Tests/DebuffTrackerTests.cs +++ b/tests/EQBuddy.Tests/DebuffTrackerTests.cs @@ -408,12 +408,20 @@ 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.False(tracker.LearnedDurations.ContainsKey("Shiftless Deeds VI")); + 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 @@ -484,4 +492,91 @@ public void ARecastOfARankedDotRestartsTheClock() 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); + } } From b99c3dfc2c9aec3b5f8d53c25c672f30b867758f Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Wed, 12 Aug 2026 13:06:17 -0400 Subject: [PATCH 13/13] Every key through BaseNameOf, and counts that count what they say OnTick was the one place a key skipped BaseNameOf: tick.Source went in raw. The three keys agreed only because real tick lines never carry a numeral - for a spell genuinely NAMED with one and missing from the catalog, the tick key and the fade key diverge and fade-ending silently stops working. The _recastPending lookups had the same split, written with the base name and read with the raw one, so recast detection could not fire for such a spell either. Both now go through BaseNameOf; the chip still displays what the log actually said. _active's tuple field was called Spell while every call site puts a base name in it. It is BaseName now - that naming confusion is what made the sample-key bug possible to write. The doc comments claimed 121 catalog spells end in a roman numeral. True of the 1,929-spell harvest; the shipped catalog has 10, and only 4 of those (Burnout II/III/IV, Clarity II) also carry a base entry, which is where the two readings actually collide. The rule and the reasoning were right, so only the counts and the examples changed - "Cannibalize IV" and "Berserker Madness III" do not ship. "680 spells" is 679 keys at runtime: the OrdinalIgnoreCase loader folds the two spellings of Invisibility Versus Undead, both 1620.0. And a test that built a Measured state with no expiry - a combination production never produces - now asks for Unknown, so the file has one genuine Unknown state in it. Co-Authored-By: Claude Opus 5 (1M context) --- src/EQBuddy.Core/SpellDurationCatalog.cs | 14 +++++++++----- src/EQBuddy.Core/SpellRank.cs | 9 +++++---- tests/EQBuddy.Tests/DebuffChipPresentationTests.cs | 3 ++- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/EQBuddy.Core/SpellDurationCatalog.cs b/src/EQBuddy.Core/SpellDurationCatalog.cs index b5251b2c..62d5b5f7 100644 --- a/src/EQBuddy.Core/SpellDurationCatalog.cs +++ b/src/EQBuddy.Core/SpellDurationCatalog.cs @@ -23,11 +23,13 @@ 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 680 spells offline. +/// fallback estimate, and the harvest already on disk answers 679 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 +/// 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. /// @@ -41,7 +43,9 @@ public SpellDurationCatalog(IReadOnlyDictionary? durations = nul ? LoadEmbedded() : new Dictionary(durations, StringComparer.OrdinalIgnoreCase); - /// The shipped catalog, loaded once. 680 spells. + /// 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 diff --git a/src/EQBuddy.Core/SpellRank.cs b/src/EQBuddy.Core/SpellRank.cs index d661c5aa..1da2d517 100644 --- a/src/EQBuddy.Core/SpellRank.cs +++ b/src/EQBuddy.Core/SpellRank.cs @@ -7,10 +7,11 @@ namespace EQBuddy.Core; /// 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 . +/// 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 { diff --git a/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs b/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs index 01e0d9cc..c7d97d47 100644 --- a/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs +++ b/tests/EQBuddy.Tests/DebuffChipPresentationTests.cs @@ -29,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);