diff --git a/CHANGELOG.md b/CHANGELOG.md index eb27462..95c5e0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed +- Retired the v2 section-difficulty surface (#159, tier rail 4/4, parent #131): + the `difficulty:sections-updated` event and its `difficulty_ladder.sections.v2` + payload (`fillPercentage` / `glassSize`), the legacy canvas overlay that drew + it (`drawHud` and its sizing/measurement helpers), and the retired pre-rename + setting key's one-time forward-migration. The render-neutral + `difficulty:sections-updated-v3` event (`difficulty_ladder.sections.v3`, + #156/#157) is now the only section-difficulty contract. This is a **breaking + change** for any third-party consumer still reading + `difficulty_ladder.sections.v2`: migrate to the v3 fields + (`sections[].top_tier` / `.current_tier` / `.has_chart_content`). Section Map + migrates to these fields in + [sectionmap#14](https://github.com/get-flashbacks/feedback-plugin-sectionmap/issues/14); + this removal ships only once that migration is in place. + The renamed-predecessor setting is gone: only `showDifficultyGuide` is read, + still defaulting to on. `screen.js`, `settings.html`, `tests/screen.test.js`, + `README.md`, `INTEGRATION.md`, `COMPLIANCE.md`, `PLAYER_CONTEXT.md`, + `MIGRATION.md` and `plugin.json` updated. Version 0.40.3 -> 0.41.0. + ### Fixed - Profile-aware Hosts that expose only `window.v3Profile` (no `playerContexts` capability) now resolve the main player's identity when the diff --git a/CLAUDE.md b/CLAUDE.md index 45013b8..9b8f998 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,8 +61,8 @@ Also worth knowing before poking around: - **`PLAYER_CONTEXT.md`** — the multi-player `(session_id, player_id)` contract shared with Split Screen, `note_detect`, karaoke, and Section Map; read this before touching anything that keys state by player. -- **`INTEGRATION.md`** — Section Map's phrase/glass-fill contract - (`difficulty:sections-updated`, schema `difficulty_ladder.sections.v2`). +- **`INTEGRATION.md`** — Section Map's section-difficulty contract + (`difficulty:sections-updated-v3`, schema `difficulty_ladder.sections.v3`). Note: as of this writing its title line still reads "Integration — feedBack-plugin-sectionmap", left over from an earlier doc it was adapted from — the content is this repo's real, current contract @@ -132,7 +132,7 @@ snapshot, this one included. ## Testing ```bash -node --test # JS: all tests/*.test.js (429 tests) +node --test # JS: all tests/*.test.js (419 tests) python3 -m pip install -r requirements-test.txt # Python test environment python3 -m pytest tests/test_dd_generation.py -q # Python generator tests — CI's own invocation node tools/settle_points.js # auto-adjust settle-point simulation, see README diff --git a/COMPLIANCE.md b/COMPLIANCE.md index e5eacb4..79060bf 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -56,14 +56,14 @@ spec is intentionally non-normative about exact mechanics. - [x] `standards: ["plugin-runtime-idempotent.v1"]` — verified true: `screen.js`'s singleton guard (`window.__feedBackDynamicDifficulty.installed`) returns unconditionally on any re-run, so a second execution installs zero additional listeners/timers/wrappers (§6.1). -- [x] rAF loops (`tickScoring`, `drawHud`) stop themselves when the player isn't active and restart +- [x] rAF loops (`tickScoring`) stop themselves when the player isn't active and restart on `highway:visibility` / `visibilitychange` (§6.4, rule 13). - [x] No `querySelector`/`querySelectorAll`/layout reads inside a per-frame path; DOM refs - (`_hudCanvas`, `_playerEl`) are cached and only re-resolved via a cheap `.isConnected` check + (`_playerEl`) are cached and only re-resolved via a cheap `.isConnected` check (rule 9). - [x] No `MutationObserver` anywhere in the plugin (rule 10). - [x] `localStorage` writes are debounced/change-gated (`_onMasteryApplied` skips the write when - the value is unchanged) and never happen inside `tickScoring`'s or `drawHud`'s per-frame body + the value is unchanged) and never happen inside `tickScoring`'s per-frame body — only from the `setMastery` wrapper, which fires on user/auto-adjust action, not per frame (rule 11). Confirmed this is the established, org-wide pattern: `localStorage` is used extensively by first-party bundled plugins (`achievements`, `career`, `highway_3d`, @@ -96,7 +96,7 @@ spec is intentionally non-normative about exact mechanics. declare a capability domain for this surface, which is *correct*, not a gap: best-practices rule 52 is explicit that declaring a capability you don't service is worse than declaring none. The actual cross-plugin surface for section-level difficulty is the plugin's own - `difficulty:sections-updated` event — see "Section-map integration assumptions" below for the + `difficulty:sections-updated-v3` event — see "Section-map integration assumptions" below for the current (post-#63) contract; this bullet's original text described a since-superseded getters-only architecture. (Separately, unrelated to section difficulty: the plugin *does* now participate in the Host's capability dispatch pipeline for player-scoped difficulty commands — @@ -131,24 +131,24 @@ spec is intentionally non-normative about exact mechanics. longer how this integration works.** This section originally documented issue #9's acceptance criteria against the state of the plugins at the time (both reading `window.highway` getters independently, no dedicated event). That's since changed: `difficulty_ladder` now emits a bespoke -`difficulty:sections-updated` event on `window.feedBack` (fired from +`difficulty:sections-updated-v3` event on `window.feedBack` (fired from `calculateAndEmitSectionDifficulties()`, throttled at approximately 150 ms — a trailing-edge throttle via `scheduleSectionDifficultiesEmit()`, not a debounce: it fires periodically during a continuous drag rather than only once at the end), and `section_map` no longer calls `highway.getPhrases()` / `hasPhraseData()` / `getMastery()` for section-difficulty data at all — it -renders whatever `fillPercentage`/`glassSize` the event payload carries per section, and nothing +renders whatever tiers, spans and content flags the event payload carries per section, and nothing else. See [`INTEGRATION.md`](INTEGRATION.md) for the authoritative, current contract (event shape, -fallback/timing behavior, and why the fill formula was unified with `drawHud()`'s own discrete -tiers in the same pass). Kept below for historical context only; do not treat the bullets as +fallback/timing behavior, and the shared discrete tier mapping used by the guide, the attempt log +and the v3 payload). Kept below for historical context only; do not treat the bullets as current: - ~~This plugin does **not** expose a bespoke API or event for section difficulty.~~ It does now - (`difficulty:sections-updated`). + (`difficulty:sections-updated-v3`). - ~~`feedBack-plugin-sectionmap` should therefore read `window.highway.getPhrases()` / `hasPhraseData()` / `getMastery()` directly...~~ It doesn't anymore; it's a pure event consumer. - No "difficulty maker" screen exists anywhere in the current feedBack codebase (checked `feedBack/plugins/` and `feedBack/static/` for any "maker" screen) — still true, and unaffected by the event-contract change above. -- Missing/delayed phrase data must degrade to "no glass HUD," never an error — still the case on - both sides: this plugin's own `drawHud()` fail-soft path is unchanged, and `section_map` simply - has nothing to render when no `difficulty:sections-updated` event has fired yet. +- Missing/delayed phrase data must degrade to "no section difficulty," never an error — still the + case on both sides: this plugin's own guide fail-soft path is unchanged, and `section_map` simply + has nothing to render when no `difficulty:sections-updated-v3` event has fired yet. diff --git a/INTEGRATION.md b/INTEGRATION.md index 0f27468..edc254e 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -2,7 +2,7 @@ > For the shared multi-player contract used by Split Screen, `note_detect`, > karaoke, and Section Map, see [`PLAYER_CONTEXT.md`](PLAYER_CONTEXT.md). -> This file keeps the Section Map-specific phrase/glass contract and formula. +> This file keeps the Section Map-specific section-difficulty contract. > **Superseded (issue #63).** This file originally tracked issue #8, written > against the Host-getters-only architecture `section_map` had at the time @@ -11,37 +11,36 @@ > instead of independently reading `highway` getters, which silently made > everything below the original section stale — it kept describing an > architecture the code had moved on from. Issue #63's audit caught this -> plus a second, independent bug it enabled (see "Fill formula" below). This +> plus a second, independent bug it enabled (see "Tier mapping" below). This > section describes the **current, real contract**; treat it as > authoritative over anything below it. ## The actual contract `difficulty_ladder` **is** the API `section_map` depends on: it emits a -`difficulty:sections-updated` event on `window.feedBack` (fired from +`difficulty:sections-updated-v3` event on `window.feedBack` (fired from `calculateAndEmitSectionDifficulties()`, throttled at approximately 150 ms via `scheduleSectionDifficultiesEmit()` — a trailing-edge throttle, not a debounce: it fires periodically during a continuous change rather than only once at the end. The throttle is also *event-driven*, not time-driven: it coalesces those state changes into at-most-one emit per 150 ms, it is not a heartbeat, and playback progress never schedules an emit of its own; see `screen.js`), and -`section_map`'s current `screen.js` (`_smUpdateDifficultyFills` / -`_smGetSectionDifficulty`) does nothing but render whatever -`fillPercentage` / `glassSize` that event's payload carries per section. It -does **not** independently call `highway.getPhrases()` / `getMastery()` for -this — that code path doesn't exist in `section_map` anymore. (`section_map` -still reads `highway.getSections()` / `getSongInfo()` / `getTime()` directly -for the minimap's own layout, timing and seeking — none of that is part of -this contract.) - -`difficulty_ladder`'s own player HUD (`drawHud()`) is a **separate**, +`section_map` renders whatever tiers, spans and content flags that event's +payload carries per section. It does **not** independently call +`highway.getPhrases()` / `getMastery()` for the difficulty values — that code +path doesn't exist in `section_map` anymore. (`section_map` still reads +`highway.getSections()` / `getSongInfo()` / `getTime()` directly for the +minimap's own layout, timing and seeking — none of that is part of this +contract.) + +`difficulty_ladder`'s own standalone guide is a **separate**, Host-getters-based consumer of the same `highway.getPhrases()` / -`getMastery()` data, for its own per-phrase glasses. It already knows to -defer to `section_map` when installed (`sectionMapOwnsGlasses` check — -`window.__slopsmithSectionMapHooksInstalled`), so in practice a user only -ever sees one glass renderer's output at a time. That mutual exclusion is -exactly why the fill-formula bug below went unnoticed for a while: the two -formulas' disagreement was never visible side by side, only as an +`getMastery()` data: since #157 it is an event-driven DOM tier rail rather than +a canvas overlay. It defers to `section_map` when installed (the +`window.__slopsmithSectionMapHooksInstalled` check), so in practice a user only +ever sees one section-difficulty renderer's output at a time. That mutual +exclusion is exactly why the tier-mapping bug below went unnoticed for a while: +the two consumers' disagreement was never visible side by side, only as an inconsistency in the *contract*. **Availability check note:** `section_map`'s `_smIsDynamicDifficultyAvailable()` @@ -60,22 +59,23 @@ reads to build the event) returns whatever phrase ladder the song's arrangement file has, whether generated by this plugin, hand-authored, or shipped with the pack. -Everything below describes v2 (`difficulty:sections-updated`), the contract -Section Map consumes today. A render-neutral replacement, -`difficulty:sections-updated-v3`, is emitted alongside it — see -["sections v3"](#sections-v3--the-render-neutral-payload-issue-156) and its -["Transition policy"](#transition-policy). +**v3 is the only contract.** Before #159 there were two events: the original +`difficulty:sections-updated` (v2) and the render-neutral +`difficulty:sections-updated-v3`. v2 carried presentation fields that made +every consumer inherit this producer's rendering decisions. v3 shipped +alongside it until Section Map +([sectionmap#14](https://github.com/get-flashbacks/feedback-plugin-sectionmap/issues/14)) +was migrated to v3; #159 then removed the v2 event and its fields. This file +documents v3, the sole current contract. -## Fill formula (the issue #63 bug) +## Tier mapping (the issue #63 bug) -Before this fix, `drawHud()`'s per-phrase glasses and the per-section event +Before this fix, the guide's per-phrase tier and the per-section event payload used **different formulas** for conceptually the same thing — -"how full is this glass at the current mastery": +"which tier does the current mastery reach here": -- `drawHud()`: a **discrete tier** — `idxLevel = min(max_difficulty, - floor(mastery * (max_difficulty + 1)))`, `fillFrac = idxLevel / - max_difficulty` (or `1` when `max_difficulty === 0` — no tier ladder to - climb). +- the guide: a **discrete tier** — `idxLevel = min(max_difficulty, + floor(mastery * (max_difficulty + 1)))`. - `calculateAndEmitSectionDifficulties()`: a **continuous** fraction — `mastery * maxSectionDifficulty / globalMaxDifficulty`. @@ -83,12 +83,13 @@ These disagree materially for a lower-depth phrase/section: e.g. mastery 0.6 against a max_difficulty-2 phrase/section (of a song-wide max of 4) lands on discrete tier 1 of 2 (50%), not the continuous formula's 30%. -**Fixed:** both now go through one shared helper, `_tierFillFrac(mastery, -maxDifficulty)` (`screen.js`), applied to the section's own aggregated +**Fixed:** every consumer now goes through one shared helper, `_tierFillFrac(mastery, +maxDifficulty, top_difficulty)` (`screen.js`), whose `idxLevel` is that +discrete tier. It is applied to the section's own aggregated `maxSectionDifficulty` (the pre-existing max-of-overlapping-phrases rule is unchanged) rather than scaled against the global max. `_presentedDifficultyLevel()` -(used by phrase-attempt logging) was refactored onto the same helper so -there is exactly one place this arithmetic is written. See +(used by phrase-attempt logging) and the rail's `current_tier` are built on the +same helper, so there is exactly one place this arithmetic is written. See `tests/screen.test.js`'s `_tierFillFrac` and `calculateAndEmitSectionDifficulties` tests. @@ -98,31 +99,24 @@ every phrase with a ladder, and a phrase that is complete early has sparse level numbers (see README → "One difficulty scale per song"). So `max_difficulty` no longer says how hard a phrase is. Core's `getPhrases()` also reports `top_difficulty`, the tier from which the phrase -plays in full, and the glasses use that instead +plays in full, and the guide uses that instead (`_phraseTopDifficulty`, falling back to `max_difficulty` on an older core): -- glass height and `glassSize` scale with `top_difficulty`, and the payload's - `maxDifficulty` / `avgDifficulty` are `top_difficulty` values; - `_tierFillFrac(mastery, max_difficulty, top_difficulty)` picks the tier on - the phrase's own scale exactly as before, then fills against the top: - `fillFrac = min(1, idxLevel / top_difficulty)` — an easy phrase is full as - soon as the slider reaches the tier it is complete at. With + the phrase's own scale exactly as before, then the guide and the v3 payload + clamp it to `top_difficulty` — an easy phrase is complete as soon as the + slider reaches the tier it is complete at. With `top_difficulty === max_difficulty` (every fully authored ladder) this is the formula above, unchanged. -- A section's fill uses its hardest overlapping phrase (by `top_difficulty`). +- A section's tier uses its hardest overlapping phrase (by `top_difficulty`). ## sections v3 — the render-neutral payload (issue #156) -The v2 payload above answers two questions a *glass* renderer needs: how full -is this section, and how big should the glass be. Both answers are presentation -decisions made here, in the producer, on Section Map's behalf — which is why -every consumer of `difficulty:sections-updated` inherits the glass metaphor -whether it wants one or not. v3 replaces those two fields with the facts they -were derived from, so a second renderer (a bar, a ring, a heat map, a -debug overlay) can be added without a third version of this contract. - -v3 is **additive here**: both payloads are emitted, on separate event names, -from the same call (see "Transition policy" below). +The v3 payload describes the song in facts a renderer can act on: where each +section and phrase sits, which tier the pane's mastery reaches, and whether the +span holds playable content. It carries no shape and no fraction of a shape, so +a renderer (a bar, a ring, a heat map, a debug overlay) picks those itself, +without a new version of this contract. ### The event @@ -130,7 +124,7 @@ from the same call (see "Transition policy" below). |---|---| | Event | `difficulty:sections-updated-v3` | | Schema | `difficulty_ladder.sections.v3` | -| Emitted by | `calculateAndEmitSectionDifficulties()`, on the same ~150 ms event-driven throttle as v2 (see "Freshness" below) | +| Emitted by | `calculateAndEmitSectionDifficulties()`, on a ~150 ms event-driven throttle (see "Freshness" below) | | Capability | `window._ddCapabilities.sectionsSchema === 'difficulty_ladder.sections.v3'` | ```json @@ -217,9 +211,9 @@ Top level: | Field | Meaning | |---|---| | `schema` | Always `difficulty_ladder.sections.v3`. | -| `player_context` | The full `player_context.v1` object for the pane this payload describes, or `null` for a legacy main-player emission while the Host is still resolving identity — identical to v2, per [`PLAYER_CONTEXT.md`](PLAYER_CONTEXT.md). | -| `mastery` | The pane's current mastery as a 0..1 number, or `null` when the highway reports nothing usable. This is what the host actually reported: a highway with no `getMastery` at all yields `null` here, where v2 still emits its long-standing `0.5` stand-in, so the two payloads legitimately disagree on that one case. | -| `max_tier` | Song-wide top of the tier ladder (`max_difficulty` over all phrases). 0 when no phrase has a ladder. Note this is the *ladder* top, not the same number as v2's `maxDifficulty`, which is a `top_difficulty` maximum kept for glass sizing. | +| `player_context` | The full `player_context.v1` object for the pane this payload describes, or `null` for a legacy main-player emission while the Host is still resolving identity, per [`PLAYER_CONTEXT.md`](PLAYER_CONTEXT.md). | +| `mastery` | The pane's current mastery as a 0..1 number, or `null` when the highway reports nothing usable. This is what the host actually reported: a highway with no `getMastery` at all yields `null` here rather than a fabricated stand-in. | +| `max_tier` | Song-wide top of the tier ladder (`max_difficulty` over all phrases). 0 when no phrase has a ladder. | | `current_phrase_index` | Index into `highway.getPhrases()` of the phrase covering playback right now, else `null`. As of the *last emit*, not live — see "Freshness" below. | | `sections` | One entry per section that has at least one overlapping phrase, in `highway.getSections()` order. | @@ -229,44 +223,42 @@ Each section entry, and each entry in its `phrases` array: |---|---|---|---| | `id` | ✓ | ✓ | `section:` for a section; for a phrase, the same `_phraseIdOf` string (`song::arrangement::index::start::end`) that `difficulty_ladder.phrase_attempt.v2` records are keyed by, so a consumer can join the two without a translation table. The `song::arrangement` prefix comes from the event's own `player_context`, not from the highway's song info — the two can disagree (a pane's `arrangement_id` vs the highway's `arrangement_index`) and only the context is what the attempt records were written with. Falls back to `phrase:` when no song identity is available at all (no context and no usable song info). | | `index` | ✓ | ✓ | Position in `getSections()` / `getPhrases()` respectively. | -| `start_time` / `end_time` | ✓ | ✓ | Half-open `[start_time, end_time)`, matching `drawHud()`'s and `tickScoring()`'s phrase cursor. `end_time` is `null` on a final, open-ended section (`Infinity` cannot be carried in a payload). | +| `start_time` / `end_time` | ✓ | ✓ | Half-open `[start_time, end_time)`, matching `tickScoring()`'s phrase cursor. `end_time` is `null` on a final, open-ended section (`Infinity` cannot be carried in a payload). | | `is_current` | ✓ | ✓ | Whether this entry covers the current playback time. All `false` when the highway reports no time — an unreported time is not read as "at the start of the song". A snapshot as of the last emit, so it goes stale between emits; see "Freshness" below. | | `current_tier` | ✓ | ✓ | The tier this pane's mastery currently maps to, from the same discrete ladder every other consumer here uses (`_tierFillFrac`), clamped to `top_tier` — above that tier the entry is already played in full, so there is no larger tier to report. `null` when `mastery` is `null`, and whenever the entry itself reports no ladder (`top_tier` or `max_tier` of `0`) — there is no tier for a measurement to land on. | -| `top_tier` | ✓ | ✓ | The tier from which this entry plays in full (`top_difficulty`, falling back to `max_difficulty` on an older core), clamped to `max_tier`. `0` means this entry reports *no ladder at all* (a single-level or hand-authored phrase), not "measured at the bottom tier" — the same reading v2 gives it, and the reason a 0/0 entry carries `current_tier: null` rather than a divided-by-zero result. | +| `top_tier` | ✓ | ✓ | The tier from which this entry plays in full (`top_difficulty`, falling back to `max_difficulty` on an older core), clamped to `max_tier`. `0` means this entry reports *no ladder at all* (a single-level or hand-authored phrase), not "measured at the bottom tier" — the reason a 0/0 entry carries `current_tier: null` rather than a divided-by-zero result. | | `max_tier` | ✓ | ✓ | The top of this entry's own tier ladder (`max_difficulty`). For a section: the largest `max_difficulty` among its overlapping phrases. `0` with `top_tier: 0` means "no ladder reported". | -| `avg_top_tier` | ✓ | — | Mean of the section's overlapping phrases' `top_tier`. This is v2's `avgDifficulty` only for a well-formed ladder: for a single-level section v2 averages its `max_difficulty` instead (its own fallback), so the two agree whenever the phrases report a ladder and can differ by one on a `top_tier: 0` section. | +| `avg_top_tier` | ✓ | — | Mean of the section's overlapping phrases' `top_tier`. | | `has_chart_content` | ✓ | ✓ | Whether the span holds playable notes or chords. Probed per section over the full chart; a single-level phrase (`top_tier: 0`) inherits its section's verdict, because probing every phrase's own span would cost `O(phrases × notes)` and tell a renderer nothing it can act on. A phrase with a ladder is always reported as having content. | | `phrases` | ✓ | — | The overlapping phrase entries, in `getPhrases()` order. | ### What it deliberately leaves out -- **No presentation field.** No `fillPercentage`, no `glassSize`, and nothing - else that names a shape or a fraction of a shape — a renderer picks those. - `current_tier` / `top_tier` / `max_tier` / `has_chart_content` carry every fact - v2's percentage was derived from: `fillPercentage = 100 × - (has_chart_content ? min(1, current_tier / top_tier) : 0)` for a section that - has a ladder, and `100` for one that doesn't (`top_tier: 0` with content — the - easy-but-playable case). v2's own fallback for a missing entry is a 40 % floor, - so rebuild that only if you want v2's exact glass rather than its inputs. +- **No presentation field.** No `fillPercentage`, no shape-size field, and + nothing else that names a shape or a fraction of a shape — a renderer picks + those. `current_tier` / `top_tier` / `max_tier` / `has_chart_content` carry + every fact a percentage was derived from: a consumer that wants a ratio can + compute, for a section with a ladder, `min(1, current_tier / top_tier)`, and + treat a content-bearing section with `top_tier: 0` as fully played. - **No entry for missing data.** A section with no overlapping phrase is absent from `sections` entirely, and a section whose phrases report no usable mastery gets `current_tier: null`. Absent/`null` means "nothing to show"; it never degrades into a zero tier that reads as a real measurement. - **No fabricated emptiness.** `has_chart_content` is what separates a silent - section from an easy one — both report `top_tier: 0`, and v2 had to express - that same distinction as 0% vs 100% fill (the PR #79 regression). It is - computed from the full chart's own notes, exactly as v2 computes it, and - only for entries where every overlapping phrase is single-level. - -Aggregation is unchanged from v2, so the two payloads describe the same song: -a section's `top_tier` is the hardest overlapping phrase's, its -`current_tier` is drawn on that phrase's ladder, and a section is + section from an easy one — both report `top_tier: 0`, and a percentage would + have had to express that same distinction as 0% vs 100% (the PR #79 + regression). It is computed from the full chart's own notes, and only for + entries where every overlapping phrase is single-level. + +Aggregation is unchanged from the pre-#159 producer, so the payload describes +the same song: a section's `top_tier` is the hardest overlapping phrase's, its +`current_tier` is drawn on that phrase's ladder, and a section spans `[sections[i].time, sections[i+1].time)`. The extra reads v3 adds are the ones -its new fields are *for* — the current time (for `is_current` / +its fields are *for* — the current time (for `is_current` / `current_phrase_index`) and the song identity (for the joinable phrase `id`), -both plain getters on a value v2 already holds. There is no per-note or -per-phrase scanning beyond the content probe v2 already does, so v3 costs one -extra payload build per (throttled) emit. +both plain getters on a value the producer already holds. There is no per-note +or per-phrase scanning beyond the content probe, so v3 costs one payload build +per (throttled) emit. ### Freshness: `is_current` is an emit-time snapshot @@ -297,70 +289,40 @@ answer for the whole payload when the host reports no time at all (where every `is_current` is `false` and `current_phrase_index` is `null`, so "derive it yourself" has nothing to derive from). -v2 never carried a playback time, so nothing a v2 consumer already relies on -changes shape; this is a note for v3 renderers, and the reason the fields stay -in the contract instead of being derived away — a payload that only arrives when -something changed is still the cheapest place to publish where playback was. - -### Transition policy - -v3 ships **alongside** v2, not instead of it, and the two are separated three -ways so neither plugin's upgrade order can break the other: - -1. **Separate event names.** v2 stays `difficulty:sections-updated`; - v3 is `difficulty:sections-updated-v3`. A subscriber can never be handed - the other version's payload and left to sniff `schema` — the payloads are - not interchangeable, and neither is a subset of the other. -2. **v2 is frozen, not re-derived.** Its name, schema string and field - semantics are unchanged, including the `fillPercentage` / `glassSize` - values. An un-upgraded Section Map keeps working with no change at all, - which is the whole point of emitting both from one call. -3. **The installed schema is advertised.** `window._ddCapabilities.sectionsSchema` - says which contract the running `difficulty_ladder` speaks, so an upgraded - Section Map picks v3 when it is there and stays on v2 when it isn't, - instead of guessing from a payload. Read it when you subscribe rather than - caching it forever — the global is set at this plugin's top-level script - execution, which is why the load-order note below is load-bearing. - -v2 (and `glassSize` with it) is removed in the follow-up that lands the v3 -consumer in Section Map ([sectionmap#14](https://github.com/get-flashbacks/feedback-plugin-sectionmap/issues/14)), -once the released Section Map no longer depends on it. Nothing in this section -should be read as a promise that v2 is permanent. - ## Fallback, timing, and stale-event behavior - **Missing/delayed phrase data:** `calculateAndEmitSectionDifficulties()` bails (emits nothing) when `highway.getPhrases()` is falsy/empty, or a section has no overlapping phrase (that section is simply absent from - `sectionDifficulties`, and from `sections` in the v3 payload). - `section_map` renders "no glass" for any section - index it never receives an entry for, and for a `fillPercentage` that - isn't a number — same "no glass, not a misleading zero-fill glass" - convention `drawHud()` follows when `hasPhraseData()` is false. + `sections`). + `section_map` renders nothing for any section + index it never receives an entry for, and for a `current_tier` that + isn't a number — the "no data, not a misleading zero" convention the + standalone guide follows when `hasPhraseData()` is false. - **`section_map` not installed:** `difficulty_ladder` doesn't know or - care — `fb.emit(...)` is a no-op if nothing's listening. `drawHud()`'s own - Host-getters path keeps working independently. + care — `fb.emit(...)` is a no-op if nothing's listening. The standalone + Host-getters tier rail keeps working independently. - **`difficulty_ladder` not installed:** `section_map`'s `_smIsDynamicDifficultyAvailable()` returns false (no `_ddCapabilities` - global was ever set), so it never subscribes to the event and never shows - glasses — matches the "works standalone" requirement in `section_map`'s - own `CLAUDE.md`. + global was ever set), so it never subscribes to the event and never renders + section difficulty — matches the "works standalone" requirement in + `section_map`'s own `CLAUDE.md`. - **Load-order / timing:** plugins load alphabetically, and `difficulty_ladder` < `section_map`, so `window._ddCapabilities.sectionDifficulty = true` (and - `sectionsSchema`, for the v3 transition above) is set (at + `sectionsSchema`) is set (at `difficulty_ladder`'s top-level script execution) before `section_map`'s `_smStartRealtimeHooks()` runs its one-time availability check. This ordering is load-bearing but undocumented in either repo before this pass — noted here and in `section_map`'s `CLAUDE.md`. -- **Stale events:** both section events are throttled but can still fire +- **Stale events:** the section event is throttled but can still fire repeatedly over a song (mastery changes, phrase generation, song load), and that throttle is event-driven rather than time-driven — playback progress never schedules an emit. `section_map` - applies whatever the *latest* event says, in place (`_smUpdateDifficultyFills`), - without diffing against a previous value — a strictly-newer event always + applies whatever the *latest* event says, in place, without diffing against a + previous value — a strictly-newer event always wins, so there's no meaningful "staleness" to detect (no timestamp/ordering guarantee is needed when the payload is just re-derived Host state, not an - independent fact that can go out of sync with it). The one exception is v3's + independent fact that can go out of sync with it). The one exception is `is_current` / `current_phrase_index`, which describe the playback position as of the last emit rather than re-deriving it at read time — see "Freshness" above. @@ -370,25 +332,25 @@ should be read as a promise that v2 is permanent. ## Original issue #8 record (superseded — kept for history) Tracked issue #8, "Verify integration with feedBack-plugin-sectionmap section difficulty -visualization," done after `feedBack-plugin-sectionmap#1` (glass-filling difficulty viz) was +visualization," done after `feedBack-plugin-sectionmap#1` (a section-fill difficulty viz) was implemented against this plugin's data contract (see this repo's `COMPLIANCE.md` for how that contract was established). At the time, `section_map` read `highway` getters directly -(`_smSectionDifficulty` / `_smComputeGlass`) rather than consuming an event — see "The actual -contract" above for what replaced this. +(`_smSectionDifficulty` and its section-fill helper) rather than consuming an event — see "The +actual contract" above for what replaced this. ### Acceptance-criteria checklist (as verified at the time, issue #8) - [x] **Dynamic difficulty plugin exposes/forwards section-level difficulty values required by section map.** Confirmed this plugin does not need to forward anything — the values are - already Host state both plugins read directly. Re-verified `screen.js`'s `drawHud()` and + already Host state both plugins read directly. Re-verified `screen.js`'s guide renderer and `tickScoring()` against the exact same three `highway` getters `section_map` now calls. - [x] **Section difficulty values map correctly to visualization fill percentages.** Both plugins use the same formula: `idxLevel = min(max_difficulty, floor(mastery * (max_difficulty + 1)))`, `fillFrac = idxLevel / max_difficulty` (or `1` when `max_difficulty === 0` — nothing to fill - toward). `section_map`'s `_smComputeGlass` implements this identically at the section level + toward). `section_map`'s section-fill helper implements this identically at the section level (aggregating over every phrase overlapping a section's time range via `_smSectionDifficulty`'s max-of-overlapping-phrases rule), verified by - `feedBack-plugin-sectionmap/tests/screen.test.js`'s glass-fill tests. + `feedBack-plugin-sectionmap/tests/screen.test.js`'s section-fill tests. - [x] **Verify behavior in both difficulty-maker and playback contexts.** No "difficulty maker" screen exists anywhere in the current feedBack codebase (checked `feedBack/plugins/` and `feedBack/static/` for any such screen) — see `COMPLIANCE.md`. Both plugins' difficulty @@ -396,10 +358,10 @@ contract" above for what replaced this. gap either plugin can close unilaterally. - [x] **Fallback behavior when section difficulty data is missing or delayed.** Both plugins degrade the same way: `hasPhraseData() === false`, or `getPhrases()` returning `null`/empty, - or a section with no phrase overlapping its time range, all render "no glass" rather than an - error or a misleading zero-fill glass. `section_map`'s `_smSectionDifficulty` returns `null` + or a section with no phrase overlapping its time range, all render nothing rather than an + error or a misleading zero-fill. `section_map`'s `_smSectionDifficulty` returns `null` per-section for exactly this case (own tests: "marks a section null when it has no - overlapping phrase, even if others do"); `difficulty_ladder`'s `drawHud()` hides its canvas + overlapping phrase, even if others do"); `difficulty_ladder`'s guide hides itself entirely under the same condition. - [x] **No regressions in dynamic-difficulty's existing flows.** This repo had no test harness before this pass. Added `tests/screen.test.js` (Node's built-in `node:test`, matching @@ -419,10 +381,10 @@ contract" above for what replaced this. - `node tests/screen.test.js` in this repo: 7/7 passing. - `node tests/screen.test.js` in `feedBack-plugin-sectionmap` (branch - `feature/glass-fill-difficulty-viz`): 25/25 passing, including 13 new tests added for the - glass-fill contract (`_smSectionDifficulty`, `_smComputeGlass`, `_smGlassHtml`, - `_smReadPhraseState`, and an end-to-end `_smUpdate` test asserting the glass slots actually get - populated from a stubbed `highway`). + `feature/section-fill-difficulty-viz`): 25/25 passing, including 13 new tests added for the + section-fill contract (`_smSectionDifficulty`, its fill helper, its markup helper, + `_smReadPhraseState`, and an end-to-end `_smUpdate` test asserting the section slots actually + get populated from a stubbed `highway`). ### Judgment calls flagged for the human reviewer (issue #8) diff --git a/MIGRATION.md b/MIGRATION.md index 160787d..56ee814 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -48,12 +48,12 @@ All keys live in `localStorage`, prefixed `difficulty_ladder.` (`LS_PREFIX`, | `difficulty_ladder.phraseAttempts.v2` | schema `difficulty_ladder.phrase_attempts.v2`, version 2 | live scoring | canonical; replaced `phraseAttempts.v1` | | `difficulty_ladder.songMastery` | legacy per-song map, `filename::arrangement` → numeric or `{mastery, instrument}` record | read by migration (and, only before a profile context exists, by the badge's fallback); **also written** by `_rememberSongInstrument` (`screen.js:545`) and **pruned** by stale-label cleanup `_clearStaleSongInstrument` (`screen.js:618`) | legacy migration source. Not strictly read-only: the difficulty value is migrated+retained, but its `instrument` label is rewritten on contact and an entry judged stale is deleted, so rollback is best-effort, not guaranteed (§7) | | `difficulty_ladder.phraseAttempts.v1` | legacy attempt array | read-only left shift during migration (`screen.js:98`) | legacy migration source; retained | -| `difficulty_ladder.autoAdjust`, `.dropResistance`, `.levelUpOnly`, `.sensitivity`, `.downStepRatio`, `.reactionSpeed`, `.minMastery`, `.maxMastery`, `.generateLevels`, `.showDifficultyGuide` | booleans / numbers | settings UI | current settings. `showGlasses` is the renamed predecessor of `showDifficultyGuide` and is migrated forward once (`_resolveDifficultyGuideSetting`, `screen.js:78`) | +| `difficulty_ladder.autoAdjust`, `.dropResistance`, `.levelUpOnly`, `.sensitivity`, `.downStepRatio`, `.reactionSpeed`, `.minMastery`, `.maxMastery`, `.generateLevels`, `.showDifficultyGuide` | booleans / numbers | settings UI | current settings. `showDifficultyGuide` defaults to on and is read by `_resolveDifficultyGuideSetting` (`screen.js:76`); its retired pre-rename predecessor key is no longer consulted (#159) | Not `localStorage` keys (schema/capability identifiers, listed so readers don't look for persisted data that is never written): `difficulty_ladder.player_context.v1` (`PLAYER_CONTEXT_SCHEMA`), -`difficulty_ladder.sections.v2` / `.v3`, `difficulty_ladder.difficulty_event.v1`, +`difficulty_ladder.sections.v3`, `difficulty_ladder.difficulty_event.v1`, `difficulty_ladder.difficulty_request.v1`. **External source keys (the separate plugin's own storage, never prefixed with @@ -285,12 +285,12 @@ separately-tracked removal timeline were gated on: - [x] #85 live settings reconciliation landed with regression tests. - [x] #215 source-store import landed with fixtures against the pinned schema (§11); the source keys stay read-only and unmatched records are deferred. -- [x] The v2 sections-event retirement (#159) is **out of scope for this - consolidation** — it belongs to the tier-rail track (#131), is tracked by - its own issue, and is blocked on the external Section Map consumer - migration (`feedback-plugin-sectionmap#14`, still open). This plugin keeps - the v2 event and the legacy read path until then, so Section Map behavior - is preserved. +- [x] The v2 sections-event retirement (#159) was **out of scope for this + consolidation** — it belongs to the tier-rail track (#131) and is tracked + by its own issue. That issue's removal of the v2 event, the legacy read + path, and all retired-overlay terminology is **merge-gated on the external + Section Map consumer migration** (`feedback-plugin-sectionmap#14`): it + lands only once Section Map reads the v3 fields. - [x] The **deprecation notice** requirement is **waived** (maintainer decision, 2026-10-16): no notice is published in the Song Mastery repository. The separate plugin is neither declared obsolete nor removed by this @@ -311,8 +311,8 @@ is an out-of-repo verification step, not a code gate. re-migrate (guard test present). - [ ] Legacy keys present after migration (`source_retained`). - [ ] No `dynamic_difficulty.*` key is read. -- [ ] `git grep -i glass` empty apart from the changelog (tier-rail removal, - #159) — not a gate for this issue. +- [ ] All retired-overlay terminology removed (`CHANGELOG.md` excepted; + tier-rail removal, #159) — a gate for #159, not for this issue. ## 10. Manual QA script diff --git a/PLAYER_CONTEXT.md b/PLAYER_CONTEXT.md index 2ce4f8b..96997cb 100644 --- a/PLAYER_CONTEXT.md +++ b/PLAYER_CONTEXT.md @@ -121,13 +121,12 @@ After a change, Difficulty Ladder emits `difficulty:player-changed`: } ``` -`difficulty:sections-updated` uses the payload schema -`difficulty_ladder.sections.v2` and also carries `player_context` when it is +`difficulty:sections-updated-v3` uses the payload schema +`difficulty_ladder.sections.v3` and carries `player_context` when it is generated for a concurrent panel. Section Map must use that context to update only the matching pane. A legacy main-player emission may have a null context -while an older Host is still loading identity. The render-neutral -`difficulty:sections-updated-v3` payload (`difficulty_ladder.sections.v3`, -issue #156) follows the same rule: one payload per player context, keyed by +while an older Host is still loading identity. The payload follows the rule of +one payload per player context, keyed by `session_id` / `player_id`, with no cross-panel state of any kind. See `INTEGRATION.md` → "sections v3". @@ -329,10 +328,10 @@ installed is in force for the committing player and phrase: ### Section Map -- Consume `difficulty:sections-updated` by `session_id` and `player_id` when - present, rendering glasses only in the matching player pane. +- Consume `difficulty:sections-updated-v3` by `session_id` and `player_id` when + present, rendering section difficulty only in the matching player pane. - Preserve the existing no-data behavior: absent or delayed phrase data means - no misleading zero/fully-filled glass. + no misleading zero tier. ## Compatibility boundary diff --git a/README.md b/README.md index 604bc45..ccf2d09 100644 --- a/README.md +++ b/README.md @@ -506,13 +506,14 @@ synchronous `notedetect:session` event, so no `MutationObserver` or polling is i (`highway.hasPhraseData() === false` — GP imports, legacy sloppak). **Difficulty guide** -- Renders upcoming sections in the player as bars — taller bar = a - harder section (scaled by that section's peak authored difficulty), fill - level = how much of that section's difficulty range the current - master-difficulty setting reaches. -- At the configured maximum mastery, three consecutive phrases at 95% - accuracy or better light a gold Mastery streak badge. Pausing or entering - or leaving a split-screen session resets it. +- Renders upcoming phrases in the player as a compact, **event-driven** segmented + tier rail — one cell per visible phrase, the first `currentTier + 1` segments + lit, and an outlined segment marking `topTier` (the full-detail threshold). + Difficulty is never encoded by changing a component's size (#157). +- At the configured maximum mastery, three consecutive phrases at 95% accuracy + or better build a mastery streak, tracked internally and exposed through the + plugin API (`masteryStreakStatus()`). Pausing or entering or leaving a + split-screen session resets it. - Purely a visualization; can be toggled independently of auto-adjust. - With Section Map installed the standalone overlay is suppressed and the guide renders in Section Map's own section bar instead. The *Show @@ -550,7 +551,7 @@ this documents the known source-level floors instead: | Capability | Core requirement | Evidence | |---|---|---| | Loading / generating ladders (`routes.py`) | `dlc_paths._resolve_dlc_path` (core `0dcc913`, Jul 10) and `sloppak.read_member_bytes` (core `d876ded`, Jul 13) | Direct imports in `routes.py`; feedBack core has no tags/releases and versions itself via a root `VERSION` file (currently `0.3.0-alpha.2`) — both commits postdate that version line, which is the load-bearing fact until a release-qualified audit exists | -| Correct phrase-tier semantics (mastery slider ↔ `getPhrases().top_difficulty`) | Core `e5339c0` (Sep 23) — switched mastery selection to tier-number semantics and exposed `top_difficulty` | `screen.js` consumes `top_difficulty` when present but only *falls back* when it's absent — that doesn't prove older-core rendering agrees with the HUD/attempt records | +| Correct phrase-tier semantics (mastery slider ↔ `getPhrases().top_difficulty`) | Core `e5339c0` (Sep 23) — switched mastery selection to tier-number semantics and exposed `top_difficulty` | `screen.js` consumes `top_difficulty` when present but only *falls back* when it's absent — that doesn't prove older-core rendering agrees with the tier-rail/attempt records | | Full concurrent-player support (`playerContexts`, `player-difficulty.v1`) | Core `7633211` (Sep 17) | Older hosts fall back to a legacy per-player-unaware compatibility path — see [`PLAYER_CONTEXT.md`](PLAYER_CONTEXT.md) | These are source/history findings (inspected against plugin revision @@ -570,8 +571,8 @@ lacks it (tiers are then derived from `max_difficulty` alone on those phrases, which cannot tell a collapsed single-level phrase from a full one — a mixed host is still a legacy host for those phrases), and `null` when there is no phrase data to judge. The three phrase-tier consumers -(the rendered DOM tier rail — the standalone guide since #157 replaced the -canvas "glass" HUD — the `difficulty_ladder.sections.v3` event, and the +(the rendered DOM tier rail — the standalone guide since #157 — the +`difficulty_ladder.sections.v3` event, and the phrase-attempt log) and their one intentional divergence on a collapsed ladder (`top_difficulty < max_difficulty`: the rail and attempt log report the slider's tier, which keeps climbing to `max_tier`, while v3 caps at full @@ -587,7 +588,7 @@ ladders — each is a feature-scoped optional dependency, not a hard one: | [Chordr](https://github.com/get-flashbacks/feedback-plugin-chordr) | The read-only chord-grouping preview at `POST /api/plugins/difficulty_ladder/analyze-chords` (`routes.py:460`), which calls `app.state.chordr_analyze_chart_chords_v1` (`routes.py:496`) | Required, for that route only | v0.5.0 (first version registering `app.state.chordr_analyze_chart_chords_v1`) | Route returns HTTP 503 when Chordr is absent. `staged_chords` (the chord-landmark generator stage, `GenerateIn.staged_chords`) is **not** Chordr-gated — it's a plugin-local option that re-derives chord identity from the arrangement's own templates and works identically with Chordr absent or missing | | [Note Detect](https://github.com/get-flashbacks/feedback-plugin-notedetect) | Split-screen adaptive scoring (`window.createNoteDetector`) | Optional | v1.15.2 for the factory/`ownSource` split-panel registration path at all; v1.33.0 for stable `player_context` propagation | Below 1.15.2: split-panel instances aren't registered for adaptive scoring. 1.15.2–1.33.0: works, but panel identity is unpersisted and per-highway rather than stable | | [Split Screen](https://github.com/get-flashbacks/feedback-plugin-splitscreen) | Per-panel difficulty state (`window.feedBackSplitscreen`/`window.slopsmithSplitscreen`) | Optional | v1.10.6 — the earliest auditable Split Screen version (the repo's history begins at a `Clean release snapshot` tagged 1.10.6) that already exposes the consumed `window.slopsmithSplitscreen` with `isActive()`. The `window.feedBackSplitscreen` alias is newer (v1.10.8), but the integration reads `window.feedBackSplitscreen \|\| window.slopsmithSplitscreen`, so 1.10.6 is sufficient at the source level | Feature-detected via a bare `typeof ss.isActive === 'function'` presence check — that catches a missing global or a missing `isActive` method, but not a present `isActive` whose contract moved underneath it, which is exactly the silent-death case splitscreen#47 (below) describes | -| [Section Map](https://github.com/get-flashbacks/feedback-plugin-sectionmap) | Glass-fill difficulty indicators via the `difficulty:sections-updated` event, rendering only its `sectionDifficulties[].fillPercentage`/`.glassSize` fields | Consumer of this plugin's event, not the other way around | Difficulty Ladder v0.12.0 — where `CHANGELOG.md` documents the `difficulty_ladder.sections.v2` payload contract (the event itself shipped in v0.2.0); Section Map does not actually inspect a `schema` field, so this floor is about when the current fill-percentage payload shape stabilized, not a version string Section Map validates | See `INTEGRATION.md` for the full contract | +| [Section Map](https://github.com/get-flashbacks/feedback-plugin-sectionmap) | Section-difficulty indicators via the `difficulty:sections-updated-v3` event, consuming its `sections[].top_tier`/`.current_tier` fields | Consumer of this plugin's event, not the other way around | Difficulty Ladder v0.41.0 — where the render-neutral `difficulty_ladder.sections.v3` payload replaced the retired v2 fill-percentage contract (`sectionmap#14` migrated Section Map to v3) | See `INTEGRATION.md` for the full contract | A `typeof` check alone doesn't catch a downstream contract change on an otherwise-present global — see diff --git a/plugin.json b/plugin.json index ae60a20..5448e41 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "id": "difficulty_ladder", "name": "Difficulty Ladder", - "version": "0.40.3", + "version": "0.41.0", "private": false, "description": "Live accuracy-driven difficulty auto-adjustment with a per-section difficulty guide.", "category": "practice", diff --git a/screen.js b/screen.js index 72819cb..09b3449 100644 --- a/screen.js +++ b/screen.js @@ -15,14 +15,11 @@ var PLUGIN_ID = 'difficulty_ladder'; var LS_PREFIX = 'difficulty_ladder.'; - // Section payload contracts (issue #156). v2 is what Section Map consumes - // today and stays byte-for-byte as it is until that plugin is on v3; - // v3 is the render-neutral replacement on its own event name, so a v3-only - // consumer can never be handed a v2 payload and an un-upgraded consumer - // never sees fields it doesn't know. See INTEGRATION.md -> "sections v3". - const SECTIONS_SCHEMA_V2 = 'difficulty_ladder.sections.v2'; + // Section payload contract (issue #156). v3 is the render-neutral payload + // on its own event name; the legacy render-bound v2 payload (the old + // `difficulty:sections-updated` event) was removed in #159 once Section Map + // migrated to v3. See INTEGRATION.md -> "sections v3". const SECTIONS_SCHEMA_V3 = 'difficulty_ladder.sections.v3'; - const SECTIONS_EVENT_V2 = 'difficulty:sections-updated'; const SECTIONS_EVENT_V3 = 'difficulty:sections-updated-v3'; // Best-mastery contract (#83): one event per NEW best, so a UI/integration @@ -34,20 +31,18 @@ const MASTERY_EVENT_V1 = 'difficulty:mastery-updated'; // Section Map's released integration probe predates this plugin's rename - // from dynamic_difficulty. It subscribes to our public - // `difficulty:sections-updated` event only after seeing this capability - // marker. Keep the compatibility surface deliberately minimal: section - // boundaries remain the host's canonical highway.getSections() data, and - // the event payload is indexed against that exact array. + // from dynamic_difficulty. Keep the compatibility surface deliberately + // minimal: section boundaries remain the host's canonical + // highway.getSections() data, and the emitted payload is indexed against + // that exact array. window._ddCapabilities = window._ddCapabilities || {}; window._ddCapabilities.sectionDifficulty = true; window._ddCapabilities.playerContext = 'difficulty_ladder.player_context.v1'; - // Which section-payload schema this build emits, so a consumer can pick - // v3 when present and fall back to v2 otherwise instead of sniffing - // payloads. Set here, at this plugin's top-level script execution, which - // always precedes section_map's (plugins load alphabetically and - // difficulty_ladder < section_map) — see INTEGRATION.md -> "Transition - // policy". + // Which section-payload schema this build emits, so a consumer can feature- + // detect it rather than sniffing payloads. Set here, at this plugin's + // top-level script execution, which always precedes section_map's (plugins + // load alphabetically and difficulty_ladder < section_map) — see + // INTEGRATION.md -> "sections v3 — the render-neutral payload". window._ddCapabilities.sectionsSchema = SECTIONS_SCHEMA_V3; function lsGet(key, def) { @@ -74,24 +69,13 @@ delete _pendingSettingWrites[key]; } - // Issue #158 (tier rail 3/4): the `showGlasses` setting was renamed to - // "Show difficulty guide", stored under `showDifficultyGuide`. The new - // key is read first; a value under the legacy key is migrated forward - // (persisted to the new key) so an existing user's on/off choice - // survives the upgrade instead of resetting to the default. Runs once - // at settings-load time — never from a gameplay-event handler — and is - // idempotent: once the new key exists the legacy key is never consulted - // again, and a fresh install (neither key present) takes the default - // without writing anything. + // Issue #158 (tier rail 3/4): guide visibility is stored under + // `showDifficultyGuide` and defaults to on. (#159 removed the one-time + // forward-migration from the pre-rename setting key along with the rest of + // the retired setting vocabulary.) function _resolveDifficultyGuideSetting() { var current = lsGet('showDifficultyGuide', null); if (current !== null) return current === true; - var legacy = lsGet('showGlasses', null); - if (legacy !== null) { - var migrated = legacy === true; - lsSet('showDifficultyGuide', migrated); - return migrated; - } return true; } @@ -2744,12 +2728,6 @@ // the settings-changed listener below can update settings.reactionSpeed // mid-song. let _masteryStreak = 0; // consecutive high-accuracy phrases at configured max mastery - // Forward-advancing cursors into the time-sorted notes/chords arrays — - // avoids an O(N) full-array rescan every rAF tick (CLAUDE.md's per-frame - // performance doctrine). Reset only on a backward seek (loop/rewind). - let _hudMaxDifficulty = null; - let _hudBadgeMeasureKey = null; - let _hudBadgeWidth = 0; function downStepRatio() { var ratio = Number(settings.downStepRatio); @@ -2789,10 +2767,6 @@ function resetPerSongState() { _resetMainScoreState(); _masteryStreak = 0; - _hudMaxDifficulty = null; - _hudBadgeMeasureKey = null; - _hudBadgeWidth = 0; - _hudPhraseIdx = -1; _railPhraseIdx = -1; } resetPerSongState(); @@ -2809,23 +2783,19 @@ ].join('::'); } - // Issue #63: the discrete difficulty-tier fill math, factored out so - // every glass renderer -- this plugin's own player HUD (drawHud) and the - // per-section aggregate it emits for feedBack-plugin-sectionmap - // (calculateAndEmitSectionDifficulties) -- presents the same tier for - // the same (mastery, max_difficulty) pair. Before this fix the two used - // different formulas (a discrete tier here vs. a continuous - // mastery-scaled fraction in the emitted event) that could disagree - // materially for a lower-depth phrase/section. + // Issue #63: the discrete difficulty-tier fill math. It maps (mastery, + // max_difficulty) to a discrete tier index; the DOM tier rail and the v3 + // section payload (calculateAndEmitSectionDifficulties) present the same + // tier for the same pair. The legacy render-bound payload, which once used + // a continuous mastery-scaled fraction instead, was removed in #159. // `maxDifficulty <= 0` means there's no tier ladder to climb -- nothing - // left to fill toward, so it's reported as fully filled (matches the - // pre-existing "no glass to fill toward" convention both call sites - // already followed for this case). + // to advance toward, so the caller's own "no ladder" convention applies + // (the rail reports no tier, the v3 payload reports top_tier 0). // // `topDifficulty` (optional, defaults to maxDifficulty) is the tier from // which the phrase plays in full -- core's getPhrases().top_difficulty. // Generated ladders share one arrangement-wide tier scale, so an easy - // phrase is complete well below max_difficulty; its glass is full from + // phrase is complete well below max_difficulty; its fill reaches full from // that tier on rather than only at the very top of the slider. function _tierFillFrac(mastery, maxDifficulty, topDifficulty) { if (!isFinite(maxDifficulty) || maxDifficulty <= 0) return { idxLevel: 0, fillFrac: 1 }; @@ -2851,8 +2821,8 @@ }); } - // How hard a phrase is, for glass sizing: the tier where it becomes - // complete. Falls back to max_difficulty on a core that predates + // The tier where a phrase becomes complete (core's top_difficulty). + // Falls back to max_difficulty on a core that predates // getPhrases().top_difficulty (identical for fully authored ladders). function _phraseTopDifficulty(phrase) { var top = Number(phrase && phrase.top_difficulty); @@ -3869,7 +3839,7 @@ provider_registered: !!provider, // Host-compatibility signal (#129): whether this host exposes // getPhrases().top_difficulty (core e5339c0+). false means the - // host predates tier-number phrase semantics and the HUD/attempt + // host predates tier-number phrase semantics and the rail/attempt // tiers are derived from max_difficulty alone. null = no phrases. host_reports_phrase_tiers: _hostReportsPhraseTiers(hw), // #161: the section-practice suspension is temporary — the @@ -3922,81 +3892,14 @@ return sections && sections.length > 0 && phrases && phrases.length > 0; } - function _maxPhraseDifficulty(phrases) { - var maxDiff = 1; - phrases.forEach(function (phrase) { - maxDiff = Math.max(maxDiff, _phraseTopDifficulty(phrase)); - }); - return maxDiff; - } - - // Aggregates one section's overlapping phrases into the discrete fill/ - // glass-size data both the sectionmap and the HUD tiers key off (issue - // #63): maxSectionDifficulty === 0 flips to the full-chart content probe - // so an empty section reads 0% while a bottom-tier-complete section reads - // 100%, exactly as drawHud renders its per-phrase glasses. - function _sectionDifficultyData(hw, mastery, nextSectionTime, maxDiff, section, hardestPhrase, inRange) { - var avg = inRange.reduce(function (a, b) { return a + b; }, 0) / inRange.length; - var maxSectionDifficulty = Math.max.apply(Math, inRange); - var fillPercentage; - if (maxSectionDifficulty > 0) { - fillPercentage = _tierFillFrac(mastery, Number(hardestPhrase.max_difficulty), - maxSectionDifficulty).fillFrac * 100; - } else { - fillPercentage = _chartHasContentIn(hw, section.time, nextSectionTime) ? 100 : 0; - } - var glassSize = 'medium'; - if (maxSectionDifficulty < maxDiff * 0.33) glassSize = 'small'; - else if (maxSectionDifficulty > maxDiff * 0.66) glassSize = 'large'; - return { - fillPercentage: fillPercentage, - glassSize: glassSize, - avgDifficulty: avg, - maxDifficulty: maxSectionDifficulty, - }; - } - - function _computeSectionDifficulties(sections, phrases, hw, mastery, maxDiff) { - var sectionDifficulties = {}; - for (var si = 0; si < sections.length; si++) { - var section = sections[si]; - var nextSectionTime = si < sections.length - 1 ? sections[si + 1].time : Infinity; - var inRange = []; - var hardestPhrase = null; - for (var pi = 0; pi < phrases.length; pi++) { - var phrase = phrases[pi]; - if (phrase.end_time > section.time && phrase.start_time < nextSectionTime) { - inRange.push(_phraseTopDifficulty(phrase)); - if (!hardestPhrase || _phraseTopDifficulty(phrase) > _phraseTopDifficulty(hardestPhrase)) hardestPhrase = phrase; - } - } - if (inRange.length > 0) { - sectionDifficulties[si] = _sectionDifficultyData( - hw, mastery, nextSectionTime, maxDiff, section, hardestPhrase, inRange); - } - } - return sectionDifficulties; - } - - // Both the render-bound v2 payload and the render-neutral v3 payload - // (issue #156) ride this same sections/phrases pass, so the twin costs one - // extra payload build per emit -- no extra cadence, no per-note scanning - // beyond the content probe above. v3's extra highway reads (getTime(), - // getSongInfo()) are for its is_current / current_phrase_index / joinable - // phrase id fields, and it reports null mastery for a highway that reports - // none at all. - function _emitSectionDifficulties(fb, context, sections, phrases, hw, reportsMastery, mastery, maxDiff, sectionDifficulties) { + // The render-neutral v3 payload (issue #156) rides one sections/phrases + // pass. Its extra highway reads (getTime(), getSongInfo()) are for its + // is_current / current_phrase_index / joinable phrase id fields, and it + // reports null mastery for a highway that reports none at all. + function _emitSectionDifficulties(fb, context, sections, phrases, hw, mastery) { var playerContextPayload = _contextEventPayload(context); - fb.emit(SECTIONS_EVENT_V2, { - schema: SECTIONS_SCHEMA_V2, - player_context: playerContextPayload, - sectionDifficulties: sectionDifficulties, - mastery: mastery, - maxDifficulty: maxDiff, - }); fb.emit(SECTIONS_EVENT_V3, - _sectionsV3Payload(sections, phrases, hw, - reportsMastery ? mastery : null, playerContextPayload)); + _sectionsV3Payload(sections, phrases, hw, mastery, playerContextPayload)); } // Calculate and emit section difficulty data for other plugins (e.g., sectionmap) @@ -4016,35 +3919,23 @@ // is still loading. if (!_sectionsLoaded(sections, phrases)) return; - var reportsMastery = typeof hw.getMastery === 'function'; - // 0.5 is v2's long-standing stand-in for a highway that reports no - // mastery at all; it is kept for v2 alone below so that frozen payload - // is unchanged. v3 gets what the host actually said (null), or the - // fallback would read as a real measurement — the same verdict - // _presentedDifficultyLevel() reaches for the HUD. - var mastery = reportsMastery ? hw.getMastery() : 0.5; - - // Calculate max difficulty across all phrases, then map each section - // to the difficulty data the sectionmap renders. - var maxDiff = _maxPhraseDifficulty(phrases); - var sectionDifficulties = _computeSectionDifficulties(sections, phrases, hw, mastery, maxDiff); + var mastery = typeof hw.getMastery === 'function' ? hw.getMastery() : null; // Emit event for sectionmap and other interested plugins - _emitSectionDifficulties(fb, context, sections, phrases, hw, reportsMastery, mastery, maxDiff, sectionDifficulties); + _emitSectionDifficulties(fb, context, sections, phrases, hw, mastery); } // ---- difficulty_ladder.sections.v3 (issue #156) ---------------------- - // The render-neutral twin of the v2 payload above: same aggregation over - // the same highway data, but every field states what the data *is* rather - // than how a glass should be drawn, so a consumer can render bars, rings, - // a heat map or nothing from one payload. See INTEGRATION.md -> + // The render-neutral section payload: every field states what the data + // *is* rather than how it should be drawn, so a consumer can render bars, + // rings, a heat map or nothing from one payload. See INTEGRATION.md -> // "sections v3" for the full contract; each section and phrase entry // carries: // id / index - stable key. A phrase's id is the same string // phrase_attempt.v2 records are keyed by, so a consumer // can join the two without a translation table. // start/end_time - half-open [start_time, end_time), matching - // drawHud()'s and tickScoring()'s phrase cursor. + // tickScoring()'s phrase cursor. // end_time is null on an open-ended final section. // current_tier - the tier this player's mastery currently maps to, or // null when mastery isn't reported. Never a fabricated @@ -4052,8 +3943,7 @@ // top_tier - the tier from which the entry plays in full // (core's top_difficulty, falling back to max_difficulty). // 0 means the phrase reports no ladder at all (a - // single-level or hand-authored phrase), which is the - // same reading v2 gives it. + // single-level or hand-authored phrase). // max_tier - the top of that entry's own tier ladder (max_difficulty). // is_current - whether the entry covers the playback time as of this // emit. Emits are event-driven, so this is a snapshot @@ -4064,7 +3954,7 @@ // has_chart_content - whether the span holds playable notes/chords: probed // per section, inherited by a single-level phrase from // its section (see the note at the assignment). - // A section with no overlapping phrase gets no entry at all, same as v2: + // A section with no overlapping phrase gets no entry at all: // absent means "nothing to show", never "tier 0". function _sectionsV3Payload(sections, phrases, hw, mastery, contextPayload) { // Everything the entries below read from the host, read once per emit. @@ -4092,9 +3982,9 @@ function _v3SectionEntries(sections, phrases, state) { var entries = []; for (var si = 0; si < sections.length; si++) { - // The last section has no successor to bound it; Infinity is the - // host's own convention here (v2 passes it the same way) but a - // payload cannot carry it, so it is emitted as null. + // The last section has no successor to bound it; a payload cannot + // carry Infinity, so an open-ended final section is emitted as + // null. var endTime = si < sections.length - 1 ? sections[si + 1].time : null; var entry = _v3SectionEntry(si, sections[si].time, endTime, phrases, state); if (entry) entries.push(entry); @@ -4103,8 +3993,8 @@ } // One section's entry, or null when no phrase overlaps it (the omission - // rule stated above). Same aggregation rule as v2 — hardest overlapping - // phrase by top_tier, mean of the section's top_tiers — restated as tiers. + // rule stated above). Aggregates the overlapping phrases as tiers: the + // hardest one, and the mean of the section's top_tiers. function _v3SectionEntry(index, startTime, endTime, phrases, state) { var endBoundary = endTime == null ? Infinity : endTime; var overlapping = _v3Overlapping(phrases, startTime, endBoundary); @@ -4115,8 +4005,7 @@ // top_tier === 0 means every overlapping phrase is single-level, // which is two different things: a section of easy phrases that is // played in full at every slider position, and a silent section. - // The full chart's own notes tell them apart — the same - // distinction v2 draws as 100% vs 0% fill (Sourcery, PR #79), + // The full chart's own notes tell them apart (Sourcery, PR #79), // expressed as data instead of as a percentage. var hasChartContent = topTier > 0 || _chartHasContentIn(state.hw, startTime, endBoundary); @@ -4951,205 +4840,25 @@ }); } - // ---- Standalone difficulty guide ---- - // #157 (tier rail 2/4): the standalone guide is now the event-driven, - // accessible DOM tier rail below. The legacy canvas "glass" HUD - // (ensureHudCanvas/drawHud + the GLASS_* constants) is retained, unreachable - // by default, so sub-issue 4 (#159) can delete it in one place; flip - // _USE_LEGACY_GLASS_HUD to run the old renderer while integrating. - var _USE_LEGACY_GLASS_HUD = false; - var _hudCanvas = null; - var _hudRafHandle = null; + // ---- Player element helpers shared by the guide and the scorer ---- + // #159 removed the legacy canvas HUD (its own draw loop and sizing + // constants) that these two helpers used to live beside; both are still + // needed — the scorer's rAF loop and the DOM tier rail below. var _playerEl = null; // cached — re-resolved only if disconnected, never per-frame-queried - var GLASS_W = 26, GLASS_GAP = 8, GLASS_MAX_H = 44, GLASS_MIN_H = 16, LOOKAHEAD = 5; - // Cached "which phrase is current" index for drawHud's own rAF loop, - // mirroring tickScoring's _curPhraseIdx/_noteCursor cursor pattern - // (CLAUDE.md: never redo O(N) work on a per-frame path). drawHud runs - // independently of tickScoring (it must keep drawing even when no - // scorer/provider is active), so it needs its own cached index rather - // than reusing _curPhraseIdx, which tickScoring only maintains while a - // provider is present. Reset on song change alongside the rest of - // resetPerSongState()'s per-song cursors. - var _hudPhraseIdx = -1; function getPlayerEl() { if (!_playerEl || !_playerEl.isConnected) _playerEl = document.getElementById('player'); return _playerEl; } - function ensureHudCanvas() { - if (_hudCanvas && _hudCanvas.isConnected) return _hudCanvas; - var player = getPlayerEl(); - if (!player) return null; - _hudCanvas = document.createElement('canvas'); - _hudCanvas.id = 'dynamic-difficulty-hud'; - _hudCanvas.style.cssText = - 'position:absolute;top:8px;left:50%;transform:translateX(-50%);' + - 'pointer-events:none;z-index:15;'; - player.appendChild(_hudCanvas); - return _hudCanvas; - } - function isPlayerActive() { var player = getPlayerEl(); return !!(player && player.classList.contains('active')); } - function _hudSuppressed(sectionMapOwnsGlasses, ss) { - return !settings.showDifficultyGuide || sectionMapOwnsGlasses - || (ss && typeof ss.isActive === 'function' && ss.isActive()); - } - - function _hudHighwayReady(hw) { - return hw && typeof hw.hasPhraseData === 'function' && hw.hasPhraseData(); - } - - function _phrasesReady(phrases) { - return phrases && phrases.length > 0; - } - - // Reuse the cached index across frames instead of an O(phrases.length) - // findIndex scan every rAF tick -- only rescans when the cached phrase no - // longer covers `t` (same cursor-caching idea tickScoring already applies - // to _curPhraseIdx above). A miss clamps to 0 so the row still renders. - function _hudFindPhraseIndex(phrases, t) { - var curIdx = _hudPhraseIdx; - if (curIdx < 0 || curIdx >= phrases.length || t < phrases[curIdx].start_time || t >= phrases[curIdx].end_time) { - curIdx = phrases.findIndex(function (p) { return t >= p.start_time && t < p.end_time; }); - } - _hudPhraseIdx = curIdx; - return curIdx < 0 ? 0 : curIdx; - } - - function _hudEnsureMaxDifficulty(phrases) { - if (_hudMaxDifficulty == null) { - _hudMaxDifficulty = 1; - phrases.forEach(function (phrase) { - _hudMaxDifficulty = Math.max(_hudMaxDifficulty, _phraseTopDifficulty(phrase)); - }); - } - return _hudMaxDifficulty; - } - - function _hudMastery(hw) { - return typeof hw.getMastery === 'function' ? hw.getMastery() : 0; - } - - // Reserve badge space before activation so the glass row does not jump; - // the backing store is only resized when the integer size actually moved. - function _sizeHudCanvas(canvas, w, h, dpr) { - var wantW = Math.round(w * dpr), wantH = Math.round(h * dpr); - if (canvas.width !== wantW || canvas.height !== wantH) { - canvas.width = wantW; - canvas.height = wantH; - canvas.style.width = w + 'px'; - canvas.style.height = h + 'px'; - } - } - - function _drawHudBadge(ctx, w) { - if (_masteryStreak < MASTERY_STREAK_PHRASES) return; - var badgeText = (w >= 90 ? '\u2605 Mastery ' : '\u2605 ') + _masteryStreak; - ctx.font = '600 11px system-ui, sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - var measureKey = badgeText + '|' + w; - if (_hudBadgeMeasureKey !== measureKey) { - _hudBadgeMeasureKey = measureKey; - _hudBadgeWidth = Math.min(w, Math.ceil(ctx.measureText(badgeText).width) + 14); - } - var badgeW = _hudBadgeWidth; - var badgeX = (w - badgeW) / 2; - ctx.fillStyle = 'rgba(34, 28, 8, 0.9)'; - ctx.strokeStyle = 'rgba(232, 192, 64, 0.9)'; - ctx.lineWidth = 1; - ctx.beginPath(); - if (typeof ctx.roundRect === 'function') ctx.roundRect(badgeX, 1, badgeW, 16, 8); - else ctx.rect(badgeX, 1, badgeW, 16); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = '#f4d35e'; - ctx.fillText(badgeText, w / 2, 9); - } - - function _drawHudGlasses(ctx, list, start, curIdx, maxDiff, mastery, h) { - list.forEach(function (p, i2) { - var pTop = _phraseTopDifficulty(p); - var sizeFrac = Math.max(0.3, pTop / maxDiff); - var glassH = GLASS_MIN_H + (GLASS_MAX_H - GLASS_MIN_H) * sizeFrac; - var fillFrac = _tierFillFrac(mastery, p.max_difficulty, pTop).fillFrac; - var x = i2 * (GLASS_W + GLASS_GAP); - var y = h - glassH - 4; - var isCurrent = (start + i2) === curIdx; - - ctx.lineWidth = isCurrent ? 2 : 1; - ctx.strokeStyle = isCurrent ? '#e8c040' : 'rgba(200,200,200,0.5)'; - ctx.beginPath(); - if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, GLASS_W, glassH, 4); - else ctx.rect(x, y, GLASS_W, glassH); - ctx.stroke(); - - var fillH = Math.max(0, glassH * fillFrac - 1); - if (fillH > 0) { - ctx.fillStyle = fillFrac > 0.8 ? 'rgba(224,80,80,0.55)' - : fillFrac > 0.4 ? 'rgba(232,192,64,0.55)' - : 'rgba(64,128,224,0.55)'; - ctx.fillRect(x + 1, y + glassH - fillH, GLASS_W - 2, fillH); - } - }); - } - - function drawHud() { - if (!isPlayerActive()) { - if (_hudCanvas) _hudCanvas.style.display = 'none'; - _hudRafHandle = null; - return; - } - _hudRafHandle = requestAnimationFrame(drawHud); - - // Section Map renders the same difficulty glasses in its section bar. - // Its idempotency marker is a stable capability signal, so this check - // does not query or mutate DOM on the animation path. - var sectionMapOwnsGlasses = !!window.__slopsmithSectionMapHooksInstalled; - var ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; - if (_hudSuppressed(sectionMapOwnsGlasses, ss)) { - if (_hudCanvas) _hudCanvas.style.display = 'none'; - return; - } - var hw = window.highway; - if (!_hudHighwayReady(hw)) { - if (_hudCanvas) _hudCanvas.style.display = 'none'; - return; - } - var canvas = ensureHudCanvas(); - if (!canvas) return; - - var phrases = hw.getPhrases(); - if (!_phrasesReady(phrases)) { canvas.style.display = 'none'; return; } - canvas.style.display = ''; - - var t = hw.getTime(); - var curIdx = _hudFindPhraseIndex(phrases, t); - var start = Math.max(0, curIdx - 1); - var list = phrases.slice(start, start + LOOKAHEAD); - var maxDiff = _hudEnsureMaxDifficulty(phrases); - var mastery = _hudMastery(hw); - - var w = Math.max(1, list.length * (GLASS_W + GLASS_GAP) - GLASS_GAP); - var h = GLASS_MAX_H + 30; - var dpr = window.devicePixelRatio || 1; - _sizeHudCanvas(canvas, w, h, dpr); - var ctx = canvas.getContext('2d'); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, w, h); - - _drawHudBadge(ctx, w); - _drawHudGlasses(ctx, list, start, curIdx, maxDiff, mastery, h); - } - // ---- Tier rail (#157): the standalone guide, event-driven + accessible ---- - // Replaces the per-frame canvas glass HUD as the active standalone - // renderer. Decisions the issue asked to make and state: + // The per-frame canvas guide was replaced by this renderer. Decisions the + // issue asked to make and state: // * Moved OFF the bare canvas onto DOM: an
    of phrase // cells, so hover, focus and an accessible name come from the // platform instead of a pointer-inert . @@ -5210,8 +4919,9 @@ return { hw: hw, phrases: phrases }; } - // The same gates the glass HUD used: player active, guide on, Section Map - // owns the guide, Split Screen owns the panels, and real phrase data. + // The same visibility gates the canvas guide used: player active, guide + // on, Section Map owns the guide, Split Screen owns the panels, and real + // phrase data. function _difficultyRailVisible() { return !!_railHighwaySnapshot(); } @@ -5251,8 +4961,7 @@ // loop: every caller is a discrete event (see the section comment). // The phrase scan runs only when no cached index still covers the event's // playback time — a phrase transition invalidates the cache; a mastery - // or settings event reuses it (same cursor-caching idea the retired - // glass HUD's `_hudPhraseIdx` used). + // or settings event reuses it. var _railPhraseIdx = -1; function _railCurrentIndex(phrases, t, changed) { @@ -5718,13 +5427,8 @@ function startRafLoops() { startMasteryLifecycleSubscriptions(); if (!_scoreRafHandle) tickScoring(); - // #157: the standalone guide is the event-driven rail. The legacy glass - // HUD (with its own rAF) only runs when explicitly forced on. - if (_USE_LEGACY_GLASS_HUD) { - if (!_hudRafHandle) drawHud(); - } else { - _syncDifficultyRail(); - } + // #157: the standalone guide is the event-driven rail, not a rAF loop. + _syncDifficultyRail(); } function onSongEvent() { @@ -5831,10 +5535,8 @@ stopSplitScreenHookSubscription(); stopPlayerContextSubscriptions(); if (_scoreRafHandle) { cancelAnimationFrame(_scoreRafHandle); _scoreRafHandle = null; } - if (_hudRafHandle) { cancelAnimationFrame(_hudRafHandle); _hudRafHandle = null; } _clearGenerateLabelTimer(); cancelSectionDifficultiesEmit(); - if (_hudCanvas) _hudCanvas.style.display = 'none'; _hideDifficultyRail(); if (_generateLabelTimer) { clearTimeout(_generateLabelTimer); _generateLabelTimer = null; } } @@ -5843,8 +5545,8 @@ // Safety net: if highway:visibility is not fired for every player-active // transition (e.g. pause/resume without a song change), the document - // visibilitychange event ensures we restart both loops whenever the tab - // returns to the foreground while the player is active. + // visibilitychange event ensures we restart the scoring loop and re-sync the + // rail whenever the tab returns to the foreground while the player is active. document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') startRafLoops(); else resetMasteryStreak(); @@ -5905,20 +5607,6 @@ if (e.key === PHRASE_ATTEMPTS_V2_LS_KEY) _phraseAttemptStore.invalidate(); if (e.key === SONG_MASTERY_LS_KEY) _songMasteryStore.invalidate(false); var short = e.key.slice(LS_PREFIX.length); - // Issue #158: a foreign tab running a pre-rename build may still - // write the legacy key. Treat it as the new setting (persisted - // forward), so a pre-upgrade tab can't silently lose the user's - // choice. - if (short === 'showGlasses') { - try { settings.showDifficultyGuide = JSON.parse(e.newValue) === true; } catch (_) { - settings.showDifficultyGuide = false; - } - lsSet('showDifficultyGuide', settings.showDifficultyGuide); - syncControlsUI(); - contributeDiagnostics(); - _syncDifficultyRail(); - return; - } if (Object.prototype.hasOwnProperty.call(settings, short)) { try { settings[short] = JSON.parse(e.newValue); } catch (_) { if (short === 'dropResistance' || short === 'levelUpOnly') settings[short] = false; @@ -6010,10 +5698,6 @@ // a browser. _syncDifficultyRail, _buildRailCell, _difficultyRailVisible, _hideDifficultyRail, ensureRailEl, - // Legacy canvas glass HUD (retired in #159): exported so a test - // can drive the real draw path and assert its fill reflects the - // phrase's pTop rather than max_difficulty (#199 review). - drawHud, // #83: the best-mastery write + emit path, exported so the event // contract (new best only, previous_best documented, dropped // without a bus) is testable without a host. diff --git a/settings.html b/settings.html index 792f5e1..13b00c7 100644 --- a/settings.html +++ b/settings.html @@ -280,14 +280,9 @@

    Ge var levelUpOnlyEl = document.getElementById('dd-level-up-only'); if (levelUpOnlyEl) levelUpOnlyEl.checked = lsGet('levelUpOnly', false) === true; - // Issue #158: read through the renamed key first, falling back to - // the legacy one; screen.js's own load migrates it forward, so - // this is only a display fallback for a panel opened before that - // migration has run. var guideEl = document.getElementById('dd-show-guide'); if (guideEl) { var guide = lsGet('showDifficultyGuide', null); - if (guide === null) guide = lsGet('showGlasses', true); guideEl.checked = guide !== false; } diff --git a/tests/scoring_pipelines.test.js b/tests/scoring_pipelines.test.js index 8c605e6..5cd5db2 100644 --- a/tests/scoring_pipelines.test.js +++ b/tests/scoring_pipelines.test.js @@ -228,8 +228,8 @@ function mainDriver(chart, env, opts = {}) { }, }; sandbox.window = sandbox; - // Section Map owning the glasses makes drawHud() skip all canvas work; the - // scoring tick is what is under test, not the HUD. + // Section Map owning the guide suppresses the tier rail; the scoring tick + // is what is under test, not the guide. sandbox.__slopsmithSectionMapHooksInstalled = true; sandbox.feedBack = { on: () => () => {}, off() {}, emit() {}, diff --git a/tests/screen.test.js b/tests/screen.test.js index b2c0bca..0acad6c 100644 --- a/tests/screen.test.js +++ b/tests/screen.test.js @@ -152,7 +152,7 @@ test('_presentedDifficultyLevel maps current mastery onto the phrase ladder leve assert.equal(mod._presentedDifficultyLevel({ getMastery: () => 'not-a-number' }, { max_difficulty: 3 }), null); }); -// ── Issue #63: one discrete tier formula shared by every glass renderer ──── +// ── Issue #63: one discrete tier formula shared by every tier consumer ───── test('_tierFillFrac matches the documented discrete tier ladder', () => { const mod = freshPlugin(); @@ -193,29 +193,6 @@ test('_phraseTopDifficulty prefers core top_difficulty and falls back to max_dif assert.equal(mod._phraseTopDifficulty({}), 0); }); -test('calculateAndEmitSectionDifficulties sizes and fills sections by the tier each phrase is complete at', () => { - const mod = freshPlugin(); - global.window.highway = stubHighwayForSectionDifficulty({ - sections: [{ time: 0, name: 'Verse' }, { time: 10, name: 'Solo' }], - phrases: [ - { start_time: 0, end_time: 10, max_difficulty: 3, top_difficulty: 1 }, - { start_time: 10, end_time: 20, max_difficulty: 3, top_difficulty: 3 }, - ], - mastery: 0.3, - }); - const emits = collectOneEmit(); - - mod.calculateAndEmitSectionDifficulties(); - - const [verse, solo] = [emits.v2().detail.sectionDifficulties[0], emits.v2().detail.sectionDifficulties[1]]; - assert.equal(verse.maxDifficulty, 1); - assert.equal(verse.fillPercentage, 100, 'the easy verse is already played in full at tier 1'); - assert.equal(verse.glassSize, 'medium'); - assert.equal(solo.maxDifficulty, 3); - assert.equal(solo.fillPercentage, (1 / 3) * 100); - assert.equal(solo.glassSize, 'large'); -}); - test('calculateAndEmitSectionDifficulties no-ops for a highway that exposes getSections but not getPhrases', () => { const mod = freshPlugin(); // A Host that has not shipped the phrase API must not throw: the section @@ -259,10 +236,8 @@ function stubHighwayForSectionDifficulty({ sections, phrases, mastery, notes, ch }; } -// The section emit publishes two events (v2 for the released Section Map -// integration, v3 for render-neutral consumers -- issue #156), so tests that -// assert one contract pick their event out of the recorded stream by name -// rather than reading whichever one landed last. +// The section emit publishes the render-neutral v3 payload on its own event +// (issue #156), so tests pick that event out of the recorded stream by name. function collectEmits() { const events = []; global.window.feedBack = { emit: (name, detail) => events.push({ name, detail }) }; @@ -280,48 +255,10 @@ function collectOneEmit() { const events = collectEmits(); return { events, - v2: () => lastEvent(events, 'difficulty:sections-updated'), v3: () => lastEvent(events, 'difficulty:sections-updated-v3'), }; } -// Every field name anywhere in a payload, so a contract test can assert a -// banned key is absent at any depth rather than only where it was looked for. -function collectFieldNames(value, found = new Set()) { - if (Array.isArray(value)) { - value.forEach(item => collectFieldNames(item, found)); - } else if (value && typeof value === 'object') { - Object.keys(value).forEach((key) => { found.add(key); collectFieldNames(value[key], found); }); - } - return found; -} - -test('a section of single-level phrases is full when it has notes and empty when it has none', () => { - const mod = freshPlugin(); - // Generated easy phrases collapse to one level (max_difficulty 0): they - // play in full at every slider position, so the section glass is full, - // matching drawHud. A silent section with the same shape stays at 0%. - global.window.highway = stubHighwayForSectionDifficulty({ - sections: [{ time: 0, name: 'Verse' }, { time: 10, name: 'Break' }, { time: 20, name: 'Solo' }], - phrases: [ - { start_time: 0, end_time: 10, max_difficulty: 0, top_difficulty: 0 }, - { start_time: 10, end_time: 20, max_difficulty: 0, top_difficulty: 0 }, - { start_time: 20, end_time: 30, max_difficulty: 3, top_difficulty: 3 }, - ], - notes: [{ t: 1, s: 0, f: 3 }, { t: 22, s: 2, f: 7 }], - chords: [{ t: 4, notes: [{ s: 0, f: 3 }] }], - mastery: 0.1, - }); - const emits = collectOneEmit(); - - mod.calculateAndEmitSectionDifficulties(); - - const sections = emits.v2().detail.sectionDifficulties; - assert.equal(sections[0].fillPercentage, 100, 'easy verse with notes is played in full'); - assert.equal(sections[1].fillPercentage, 0, 'a silent break is not shown as mastered'); - assert.equal(sections[2].fillPercentage, 0, 'the solo is at its bottom tier at 10%'); -}); - test('_chartHasContentIn checks notes and chords in a half-open window', () => { const mod = freshPlugin(); const hw = { getNotes: () => [{ t: 5 }], getChords: () => [{ t: 12 }] }; @@ -331,66 +268,12 @@ test('_chartHasContentIn checks notes and chords in a half-open window', () => { assert.equal(mod._chartHasContentIn({}, 0, Infinity), false, 'no getters, no content'); }); -test('calculateAndEmitSectionDifficulties fills each section using the same discrete tier drawHud() uses', () => { - const mod = freshPlugin(); - // A section whose only overlapping phrase caps at difficulty 2 (of a - // song-wide max of 4) is a real case the old continuous formula got - // wrong: mastery 0.6 against max_difficulty 2 lands on tier 1 (of 2), - // i.e. 50% -- not the 30% the old `mastery * maxSectionDifficulty / - // globalMaxDifficulty` formula would have emitted. - global.window.highway = stubHighwayForSectionDifficulty({ - sections: [{ time: 0, name: 'Verse' }, { time: 10, name: 'Chorus' }], - phrases: [ - { start_time: 0, end_time: 10, max_difficulty: 2 }, - { start_time: 10, end_time: 20, max_difficulty: 4 }, - ], - mastery: 0.6, - }); - const emits = collectOneEmit(); - - mod.calculateAndEmitSectionDifficulties(); - - assert.equal(emits.v2().name, 'difficulty:sections-updated'); - const verse = emits.v2().detail.sectionDifficulties[0]; - const expected = mod._tierFillFrac(0.6, 2); - assert.equal(verse.maxDifficulty, 2); - assert.equal(verse.fillPercentage, expected.fillFrac * 100); - assert.equal(verse.fillPercentage, 50); // tier 1 of 2, not the old 30% -}); - -test('calculateAndEmitSectionDifficulties reports 0% for a section whose only phrase has no difficulty ladder, even if other phrases in the song do', () => { - // Regression (Sourcery, PR #79): _tierFillFrac(mastery, 0) returns - // fillFrac 1 ("fully filled") for drawHud's per-phrase "no ladder" - // convention, but at the section level maxSectionDifficulty === 0 means - // "no difficulty content overlaps this section at all" (e.g. an empty - // section next to phrases that do have depth) -- reusing the per-phrase - // convention here would render an empty section as misleadingly "fully - // mastered". The old continuous formula always emitted 0% for this case. - const mod = freshPlugin(); - global.window.highway = stubHighwayForSectionDifficulty({ - sections: [{ time: 0, name: 'Intro' }, { time: 5, name: 'Verse' }], - phrases: [ - { start_time: 0, end_time: 5, max_difficulty: 0 }, - { start_time: 5, end_time: 20, max_difficulty: 4 }, - ], - mastery: 0.6, - }); - const emits = collectOneEmit(); - - mod.calculateAndEmitSectionDifficulties(); - - const intro = emits.v2().detail.sectionDifficulties[0]; - assert.equal(intro.maxDifficulty, 0); - assert.equal(intro.fillPercentage, 0); -}); - // ── difficulty_ladder.sections.v3 — the render-neutral payload (#156) ────── // -// The v2 payload above answers "how full is this glass, and how big"; these -// tests pin the v3 contract that replaces it: tiers, spans and "is this the -// current one", with no presentation field anywhere in it. +// These tests pin the v3 contract: tiers, spans and "is this the current +// one", with no presentation field anywhere in it. -test('the v3 payload reports tiers, spans and currency instead of glass presentation', () => { +test('the v3 payload reports tiers, spans and currency instead of presentation fields', () => { const mod = freshPlugin(); global.window.highway = stubHighwayForSectionDifficulty({ sections: [{ time: 0, name: 'Verse' }, { time: 10, name: 'Solo' }], @@ -444,7 +327,7 @@ test('the v3 payload reports tiers, spans and currency instead of glass presenta assert.equal(soloPhrase.is_current, true); }); -test('the v3 payload carries no presentation field at any depth', () => { +test('the v3 payload carries only documented fields, never a presentation decision', () => { const mod = freshPlugin(); global.window.highway = stubHighwayForSectionDifficulty({ sections: [{ time: 0, name: 'Verse' }, { time: 10, name: 'Solo' }], @@ -459,10 +342,14 @@ test('the v3 payload carries no presentation field at any depth', () => { mod.calculateAndEmitSectionDifficulties(); - const fields = collectFieldNames(emits.v3().detail); - for (const presentation of ['fillPercentage', 'glassSize']) { - assert.equal(fields.has(presentation), false, `${presentation} must stay out of the v3 payload`); - } + // Pinning the exact key sets at every level is the guard: a presentation + // field creaking back into the payload fails here. + const payload = emits.v3().detail; + assert.deepEqual(Object.keys(payload).sort(), + ['current_phrase_index', 'mastery', 'max_tier', 'player_context', 'schema', 'sections'].sort()); + assert.deepEqual(Object.keys(payload.sections[0].phrases[0]).sort(), + ['current_tier', 'end_time', 'has_chart_content', 'id', 'index', 'is_current', + 'max_tier', 'start_time', 'top_tier'].sort()); }); test('a v3 phrase entry carries the same id the phrase-attempt record is stored under', () => { @@ -598,11 +485,11 @@ test('v3 reports current_tier as null when the host reports no usable mastery', } }); -test('v3 reports an unreported mastery as null where v2 still emits its 0.5 fallback', () => { +test('v3 reports an unreported mastery as null instead of a fabricated fallback', () => { const mod = freshPlugin(); - // A highway with sections and phrases but no getMastery at all: v2's 0.5 - // stand-in is frozen and stays, but a real mastery in the v3 payload would - // put a non-null current_tier on the wire for a measurement nobody took. + // A highway with sections and phrases but no getMastery at all: a stand-in + // mastery would put a non-null current_tier on the wire for a measurement + // nobody took. const highway = stubHighwayForSectionDifficulty({ sections: [{ time: 0, name: 'Verse' }], phrases: [{ start_time: 0, end_time: 10, max_difficulty: 3 }], @@ -613,7 +500,6 @@ test('v3 reports an unreported mastery as null where v2 still emits its 0.5 fall mod.calculateAndEmitSectionDifficulties(); - assert.equal(emits.v2().detail.mastery, 0.5, "v2's fallback is unchanged"); const payload = emits.v3().detail; assert.equal(payload.mastery, null); assert.equal(payload.sections[0].current_tier, null); @@ -700,9 +586,9 @@ test('_v3CurrentTier never reports more detail than the entry plays in', () => { test('_v3CurrentTier reports no tier at all for an entry that has no ladder', () => { const mod = freshPlugin(); - // _tierFillFrac's zero-ladder convention is index 0 ("no glass to fill"), - // which on a 0/0 entry would be indistinguishable from mastery measured at - // the bottom tier — the reading top_tier/max_tier = 0 explicitly denies. + // _tierFillFrac's zero-ladder convention is index 0 ("nothing to + // advance"), which on a 0/0 entry would be indistinguishable from mastery + // measured at the bottom tier — the reading top_tier/max_tier = 0 denies. assert.equal(mod._v3CurrentTier(0.5, 0, 0), null); assert.equal(mod._v3CurrentTier(0.9, 3, 0), null, 'a top_tier of 0 reports no ladder either'); assert.equal(mod._v3CurrentTier(0.5, 0, 3), null); @@ -724,14 +610,14 @@ test('_hostReportsPhraseTiers reports the host tier-semantics signal (true/false ] }), false, 'a mixed host is legacy for the phrases that do not report the field'); }); -// Host-contract coverage (issue #129): the HUD glass, the render-neutral v3 -// event and the phrase-attempt log are three consumers of the SAME mastery -> -// tier mapping. These pin that they agree on a full ladder, and pin the one -// place they intentionally diverge on a collapsed ladder (top_tier < max_tier): -// v3 reports "detail" (capped at full detail, matching its contract), while the -// HUD/attempt log report the slider's tier, which keeps climbing to max_tier -// even though no extra content plays above top_tier. -test('host-contract: the HUD fill, the v3 tier and the attempt log agree on a full ladder', () => { +// Host-contract coverage (issue #129): the DOM tier rail, the render-neutral +// v3 event and the phrase-attempt log are three consumers of the SAME +// mastery -> tier mapping. These pin that they agree on a full ladder, and pin +// the one place they intentionally diverge on a collapsed ladder +// (top_tier < max_tier): v3 reports "detail" (capped at full detail, matching +// its contract), while the rail/attempt log report the slider's tier, which +// keeps climbing to max_tier even though no extra content plays above top_tier. +test('host-contract: the rail tier, the v3 tier and the attempt log agree on a full ladder', () => { const mod = freshPlugin(); const ctx = playerContext({ song_id: 'song.feedpak', arrangement_id: 'lead' }); const phrase = { start_time: 0, end_time: 10, max_difficulty: 3, top_difficulty: 3 }; @@ -754,7 +640,7 @@ test('host-contract: the HUD fill, the v3 tier and the attempt log agree on a fu assert.equal(fullV3.phrases[0].current_tier, 2, 'the v3 payload tier agrees on a full ladder'); assert.equal(fullV3.phrases[0].top_tier, 3); assert.equal(mod._v3CurrentTier(0.74, 3, 3), 2, 'v3 agrees on a full ladder'); - assert.equal(mod._tierFillFrac(0.74, 3, 3).idxLevel, presented, 'the HUD maps from the same index'); + assert.equal(mod._tierFillFrac(0.74, 3, 3).idxLevel, presented, 'the presented tier maps from the same index'); assert.equal(mod._tierFillFrac(0.74, 3, 3).fillFrac, 2 / 3); mod.recordPhraseAttempt(1, ctx, state, highway); @@ -782,7 +668,7 @@ test('host-contract: on a collapsed ladder v3 caps at full detail while the atte assert.equal(collapsedCollector.v3().detail.sections[0].phrases[0].current_tier, 1, 'the v3 payload reports the detail level, capped at full detail'); assert.equal(mod._v3CurrentTier(0.9, 3, 1), 1, 'v3 reports the detail level, capped at full detail'); - // Intermediate mastery is the discriminating HUD assertion: at 0.9 every + // Intermediate mastery is the discriminating rail assertion: at 0.9 every // fill formulation hits 1, while at 0.5 idxLevel = floor(0.5 * 4) = 2, // so the pTop-based fill is min(1, 2/1) = 1 but a max_difficulty-based // fill would be (2+1)/(3+1) = 3/4. The assertion reads the production @@ -792,63 +678,16 @@ test('host-contract: on a collapsed ladder v3 caps at full detail while the atte assert.equal(collapsedTop, 1); assert.equal(mod._tierFillFrac(0.5, 3, collapsedTop).fillFrac, 1); assert.notEqual(mod._tierFillFrac(0.5, 3, 3).fillFrac, mod._tierFillFrac(0.5, 3, collapsedTop).fillFrac, - 'the HUD fill is scaled by the collapsed top, not max_difficulty'); + 'the fill is scaled by the collapsed top, not max_difficulty'); mod.recordPhraseAttempt(1, ctx, state, highway); assert.equal(mod.loadPhraseAttempts(ctx)[0].presented_difficulty, presented, - 'the attempt log records the same uncapped tier the HUD maps from mastery'); -}); - -test('host-contract: drawHud fills a collapsed ladder from pTop, not max_difficulty', () => { - // The collapsed-ladder assertion above reads `_tierFillFrac` directly, so - // it would stay green if drawHud passed `max_difficulty` as the glass top. - // Drive the real draw path (Sourcery #199) and read the fill it emits. - const fills = []; - const canvas = { - isConnected: true, width: 0, height: 0, style: {}, - getContext: () => ({ - setTransform() {}, clearRect() {}, beginPath() {}, rect() {}, - roundRect() {}, stroke() {}, fill() {}, fillText() {}, - measureText: () => ({ width: 0 }), - fillRect(x, y, w, h) { fills.push(h); }, - }), - }; - const player = { isConnected: true, classList: { contains: () => true }, appendChild() {} }; - global.window = { addEventListener() {}, devicePixelRatio: 1 }; - global.document = { - addEventListener: () => {}, - getElementById: (id) => (id === 'player' ? player : null), - createElement: () => canvas, - }; - global.localStorage = { getItem: () => null, setItem() {} }; - global.requestAnimationFrame = () => 1; - const file = path.join(__dirname, '..', 'screen.js'); - delete require.cache[require.resolve(file)]; - const mod = require(file); - - const phrase = { start_time: 0, end_time: 10, max_difficulty: 3, top_difficulty: 1 }; - global.window.highway = { - hasPhraseData: () => true, - getPhrases: () => [phrase], - getTime: () => 1, - getMastery: () => 0.5, - }; - mod.settings.showDifficultyGuide = true; - - mod.drawHud(); - - // Single phrase, top === max -> glassH = GLASS_MAX_H (44). pTop = 1 at - // mastery 0.5 -> _tierFillFrac idxLevel 2, fillFrac min(1, 2/1) = 1 -> - // fillH = 44 - 1 = 43. A max_difficulty (3) regression -> fillFrac 2/3 -> - // fillH ~= 28. - assert.ok(fills.length >= 1, 'drawHud must fill the glass'); - assert.equal(Math.round(fills[0]), 43, - 'the drawn fill reflects pTop (full here), not max_difficulty'); + 'the attempt log records the same uncapped tier the rail maps from mastery'); }); -// The rendered guide is the event-driven DOM tier rail since #157, so the -// host-contract coverage above, which rides the retired glass HUD, is repeated -// here against the live renderer. The rail's lit segments (= currentTier), its +// The rendered guide is the event-driven DOM tier rail since #157; the +// host-contract coverage above rides the rail helpers, and this repeats it +// against the live DOM renderer. The rail's lit segments (= currentTier), its // full-detail notch (= topTier) and the accessible description must all read // from the same mastery -> tier mapping as the v3 emit and the attempt log — // including the one intentional collapse divergence (top_difficulty < @@ -1142,35 +981,7 @@ test('the split initializer carries and clears the same Elo observation field as assert.equal(split.lastElo, null); }); -test('the v2 event still emits byte-for-byte what Section Map consumes today', () => { - const mod = freshPlugin(); - global.window.highway = stubHighwayForSectionDifficulty({ - sections: [{ time: 0, name: 'Verse' }, { time: 10, name: 'Solo' }], - phrases: [ - { start_time: 0, end_time: 10, max_difficulty: 3, top_difficulty: 1 }, - { start_time: 10, end_time: 20, max_difficulty: 3, top_difficulty: 3 }, - ], - mastery: 0.3, - }); - const emits = collectOneEmit(); - - mod.calculateAndEmitSectionDifficulties(); - - // Locked shape, not just "it still fires": v2 stays the released contract - // for as long as an un-upgraded Section Map can be installed beside us. - assert.deepEqual(emits.v2().detail, { - schema: 'difficulty_ladder.sections.v2', - player_context: null, - sectionDifficulties: { - 0: { fillPercentage: 100, glassSize: 'medium', avgDifficulty: 1, maxDifficulty: 1 }, - 1: { fillPercentage: (1 / 3) * 100, glassSize: 'large', avgDifficulty: 3, maxDifficulty: 3 }, - }, - mastery: 0.3, - maxDifficulty: 3, - }); -}); - -test('the section emit publishes v2 and v3 on separate events in one call', () => { +test('the section emit publishes exactly one render-neutral v3 event per call', () => { const mod = freshPlugin(); global.window.highway = stubHighwayForSectionDifficulty({ sections: [{ time: 0, name: 'Verse' }], @@ -1182,8 +993,8 @@ test('the section emit publishes v2 and v3 on separate events in one call', () = mod.calculateAndEmitSectionDifficulties(); assert.deepEqual(emits.events.map(e => e.name), - ['difficulty:sections-updated', 'difficulty:sections-updated-v3'], - 'a v2-only subscriber must never be handed a v3 payload, or the reverse'); + ['difficulty:sections-updated-v3'], + 'only the v3 event is published; the legacy v2 event was removed in #159'); assert.equal(global.window._ddCapabilities.sectionsSchema, 'difficulty_ladder.sections.v3', 'consumers feature-detect the installed schema instead of sniffing payloads'); }); @@ -1594,7 +1405,6 @@ test('section difficulty events retain the player context for pane-local renderi mod.calculateAndEmitSectionDifficulties(ctx, highway); - assert.deepEqual(emits.v2().detail.player_context, mod._contextEventPayload(ctx)); assert.deepEqual(emits.v3().detail.player_context, mod._contextEventPayload(ctx)); }); @@ -2971,7 +2781,7 @@ test('split adaptive applies schedule pane-scoped section refresh events', async mod._applyDifficultyForContext(b, 70, highwayB, 'adaptive'); await new Promise(resolve => setTimeout(resolve, 200)); - const sectionEvents = events.filter(event => event.name === 'difficulty:sections-updated'); + const sectionEvents = events.filter(event => event.name === 'difficulty:sections-updated-v3'); assert.equal(sectionEvents.length, 2); const byPlayer = Object.fromEntries(sectionEvents.map(event => [ event.detail.player_context.player_id, event.detail.mastery, @@ -3004,7 +2814,7 @@ test('main song lifecycle does not cancel a pending split-pane section refresh', mod.onSongEvent(); await new Promise(resolve => setTimeout(resolve, 200)); - const splitEvents = events.filter(event => event.name === 'difficulty:sections-updated' + const splitEvents = events.filter(event => event.name === 'difficulty:sections-updated-v3' && event.detail.player_context?.player_id === 'side-player'); assert.equal(splitEvents.length, 1); assert.equal(splitEvents[0].detail.mastery, 0.65); @@ -3115,7 +2925,7 @@ test('ready split context emits initial scoped sections without saved difficulty mod.registerSplitHighway(highway, ctx); await new Promise(resolve => setTimeout(resolve, 200)); - const event = events.find(item => item.name === 'difficulty:sections-updated'); + const event = events.find(item => item.name === 'difficulty:sections-updated-v3'); assert.ok(event); assert.equal(event.detail.player_context.player_id, 'player-initial'); assert.equal(event.detail.mastery, 0.45); @@ -3143,7 +2953,7 @@ test('async main-profile readiness emits scoped sections without saved difficult await activation; await new Promise(resolve => setTimeout(resolve, 200)); - const sectionEvents = events.filter(item => item.name === 'difficulty:sections-updated'); + const sectionEvents = events.filter(item => item.name === 'difficulty:sections-updated-v3'); assert.equal(sectionEvents.length, 1); assert.equal(sectionEvents[0].detail.player_context.player_id, 'main'); assert.equal(sectionEvents[0].detail.player_context.profile_hash, 'main-profile-hash'); @@ -3179,7 +2989,7 @@ test('profile:changed recovers a main context whose v3Profile was still null at // unscoped Section Map output, no shared persistence key). mod.onSongEvent(); assert.equal( - events.filter(item => item.name === 'difficulty:sections-updated').length, + events.filter(item => item.name === 'difficulty:sections-updated-v3').length, 0, 'no unscoped sections may escape while v3Profile.get() is null' ); @@ -3189,7 +2999,7 @@ test('profile:changed recovers a main context whose v3Profile was still null at (handlers.get('profile:changed') || []).forEach(handler => handler({ detail: { ready: true } })); await new Promise(resolve => setTimeout(resolve, 200)); - const sectionEvents = events.filter(item => item.name === 'difficulty:sections-updated'); + const sectionEvents = events.filter(item => item.name === 'difficulty:sections-updated-v3'); assert.equal(sectionEvents.length, 1); assert.equal(sectionEvents[0].detail.player_context.player_id, 'main'); assert.equal(sectionEvents[0].detail.player_context.profile_hash, 'main-profile-hash'); @@ -3216,7 +3026,7 @@ test('profile readiness is left to the Host when the player-context service is p mod.resolveCompatibilityContextOnProfileSignal(); assert.equal(getActiveCalls, 0, 'v3Profile adapter must not query on a playerContexts Host'); - assert.equal(events.filter(item => item.name === 'difficulty:sections-updated').length, 0); + assert.equal(events.filter(item => item.name === 'difficulty:sections-updated-v3').length, 0); }); test('v3:profile-updated re-resolves the main context when the active profile changes', async () => { @@ -3248,7 +3058,7 @@ test('v3:profile-updated re-resolves the main context when the active profile ch (handlers.get('v3:profile-updated') || []).forEach(handler => handler({ detail: activeProfile })); await new Promise(resolve => setTimeout(resolve, 200)); - const latest = events.filter(item => item.name === 'difficulty:sections-updated').pop(); + const latest = events.filter(item => item.name === 'difficulty:sections-updated-v3').pop(); assert.ok(latest, 'a profile update must re-publish scoped sections'); assert.equal(latest.detail.player_context.profile_hash, 'bob-hash'); assert.equal(latest.detail.player_context.player_id, 'main'); @@ -3275,7 +3085,7 @@ test('song ready waits for profile readiness before its first Section Map emissi mod.onSongEvent(); assert.equal( - events.filter(item => item.name === 'difficulty:sections-updated').length, + events.filter(item => item.name === 'difficulty:sections-updated-v3').length, 0, 'no null-context event may escape while the profile promise is pending' ); @@ -3283,7 +3093,7 @@ test('song ready waits for profile readiness before its first Section Map emissi resolveProfile({ id: 'ready-profile', player_hash: 'ready-profile-hash' }); await new Promise(resolve => setTimeout(resolve, 200)); - const sectionEvents = events.filter(item => item.name === 'difficulty:sections-updated'); + const sectionEvents = events.filter(item => item.name === 'difficulty:sections-updated-v3'); assert.equal(sectionEvents.length, 1); assert.equal(sectionEvents[0].detail.player_context.player_id, 'main'); assert.equal(sectionEvents[0].detail.player_context.profile_hash, 'ready-profile-hash'); @@ -4409,28 +4219,12 @@ test('malformed dropResistance storage updates reset the setting to false', () = assert.equal(mod.settings.dropResistance, false); }); -// Issue #158 (tier rail 3/4): `showGlasses` was renamed to "Show difficulty -// guide", stored under `showDifficultyGuide`. An existing user's on/off -// choice migrates forward on first read instead of resetting to the -// default, and the migration is idempotent. +// Issue #159 (tier rail 4/4): the retired canvas-HUD visibility setting and its +// legacy read-time migration are gone with the canvas HUD. "Show difficulty +// guide" is stored under `showDifficultyGuide` and is the only key consulted. -test('a legacy-only showGlasses false keeps the guide off after upgrading', () => { - const mod = freshPlugin({ stored: { 'difficulty_ladder.showGlasses': JSON.stringify(false) } }); - assert.equal(mod.settings.showDifficultyGuide, false); - assert.equal(global.localStorage.getItem('difficulty_ladder.showDifficultyGuide'), 'false'); -}); - -test('a legacy-only showGlasses true keeps the guide on after upgrading', () => { - const mod = freshPlugin({ stored: { 'difficulty_ladder.showGlasses': JSON.stringify(true) } }); - assert.equal(mod.settings.showDifficultyGuide, true); - assert.equal(global.localStorage.getItem('difficulty_ladder.showDifficultyGuide'), 'true'); -}); - -test('an existing showDifficultyGuide value wins over a stale showGlasses value', () => { - const mod = freshPlugin({ stored: { - 'difficulty_ladder.showDifficultyGuide': JSON.stringify(false), - 'difficulty_ladder.showGlasses': JSON.stringify(true), - } }); +test('a stored showDifficultyGuide of false keeps the guide off', () => { + const mod = freshPlugin({ stored: { 'difficulty_ladder.showDifficultyGuide': JSON.stringify(false) } }); assert.equal(mod.settings.showDifficultyGuide, false); }); @@ -4441,32 +4235,6 @@ test('neither key present takes the on-by-default without writing storage', () = assert.ok(!written.includes('difficulty_ladder.showDifficultyGuide')); }); -test('the difficulty-guide migration is idempotent across reloads', () => { - const first = freshPlugin({ stored: { 'difficulty_ladder.showGlasses': JSON.stringify(false) } }); - assert.equal(first.settings.showDifficultyGuide, false); - const persisted = global.localStorage.getItem('difficulty_ladder.showDifficultyGuide'); - const second = freshPlugin({ stored: { - 'difficulty_ladder.showGlasses': JSON.stringify(false), - 'difficulty_ladder.showDifficultyGuide': persisted, - } }); - assert.equal(second.settings.showDifficultyGuide, false); - assert.equal(global.localStorage.getItem('difficulty_ladder.showDifficultyGuide'), 'false'); -}); - -test('a legacy-key storage event from a pre-rename tab is adopted, not dropped', () => { - const mod = freshPlugin(); - assert.equal(mod.settings.showDifficultyGuide, true); - - global.window.dispatchEvent({ - type: 'storage', - key: 'difficulty_ladder.showGlasses', - newValue: JSON.stringify(false), - }); - - assert.equal(mod.settings.showDifficultyGuide, false); - assert.equal(global.localStorage.getItem('difficulty_ladder.showDifficultyGuide'), 'false'); -}); - // Issue #64: minMastery/maxMastery are persisted independently by the two // settings.html number inputs, so an inverted pair (min > max) can reach // screen.js from a stale write, a manual localStorage edit, or a race @@ -6205,12 +5973,12 @@ test('currentTarget() returns null when there is no song loaded yet', () => { test('the phrase shape this plugin assumes matches the one sectionmap assumes (contract note)', () => { // getPhrases(): [{ index, start_time, end_time, max_difficulty }] const samplePhrase = { index: 0, start_time: 0, end_time: 10, max_difficulty: 3 }; - // Fields this plugin's drawHud()/tickScoring() reads: + // Fields this plugin's tickScoring() reads: for (const key of ['start_time', 'end_time', 'max_difficulty']) { assert.ok(key in samplePhrase, `difficulty_ladder reads phrase.${key}`); } - // Fields feedBack-plugin-sectionmap's _smSectionDifficulty/_smComputeGlass - // read (see that repo's screen.js): the same three, plus none extra — + // Fields feedBack-plugin-sectionmap's section-difficulty path reads (see + // that repo's screen.js): the same three, plus none extra — // confirming neither plugin depends on a field the other doesn't also see. for (const key of ['start_time', 'end_time', 'max_difficulty']) { assert.ok(key in samplePhrase, `sectionmap reads phrase.${key}`); diff --git a/tests/tier_rail.test.js b/tests/tier_rail.test.js index 3ed15a8..34b14fc 100644 --- a/tests/tier_rail.test.js +++ b/tests/tier_rail.test.js @@ -1,7 +1,6 @@ 'use strict'; -// Issue #157 (tier rail 2/4): the standalone difficulty guide is now an -// event-driven, accessible DOM tier rail instead of a per-frame canvas glass -// HUD. These tests pin the acceptance criteria: +// Issue #157 (tier rail 2/4): the standalone difficulty guide is an +// event-driven, accessible DOM tier rail rather than a per-frame canvas HUD. These tests pin the acceptance criteria: // * one cell per phrase, lit segments = currentTier, outlined = topTier, // and NO physical-size difficulty encoding (every segment is equal-sized); // * an accessible name reading current + upcoming tiers;