From 55124bee97c146ea7460473b5f47fe8edcb2c5ee Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:59:33 +0000 Subject: [PATCH 1/2] Replace section glasses with the shared difficulty tier rail (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section Map's per-section indicator was a glass whose fill percentage and glass size came from Difficulty Ladder's v2 payload. That metaphor encoded difficulty as size and could only show a fraction. Replace it with the semantic tier rail the peer's own guide draws: one fixed-size segment per tier, the first current+1 lit, and an outline on the tier a section plays in full. Difficulty is now shown by how many segments are lit, never by a segment's size. - Prefer the render-neutral difficulty:sections-updated-v3 payload (current_tier / top_tier / max_tier / has_chart_content), feature-detected via window._ddCapabilities.sectionsSchema === 'difficulty_ladder.sections.v3'. The payload's own schema field is ignored; the capability marker is the contract. - Keep the frozen v2 event as a bounded-transition fallback: reconstruct a coarse rail from fillPercentage (topTier = maxTier = maxDifficulty, currentTier = round(fill x topTier)) and discard glassSize entirely. The fallback is deleted once the peer's transition policy drops v2. - Normalize both payload shapes before rendering. Entries without finite numeric tiers (v3) or fillPercentage (v2) are dropped; a container that isn't readable is ignored so the last good state survives, while a well-formed payload with nothing renderable clears the rails. current_tier null is 'unknown' (an unlit rail), not a zero tier. - Collapse a block too narrow for its segments to a single marker; a resize listener invalidates the per-section signatures so the width-dependent form is re-decided. The readable detail 'Section name · tier X of Y · full detail at Z' is the same wording in the block title and aria-label, and only tier-bearing blocks are focus stops (managed in both the full-render and in-place-update paths). - Repaint rails in place from the peer's events via a per-section signature; no full _smRender() per event. - Delete the glass code (_smRenderGlassFilling, .sm-glass, SM_GLASS_SIZES, glassSize handling) and rename the state to _smSectionTiers. - Add tier-rail, responsive-collapse, a11y, late-provider, malformed-payload and narrow-section tests; bump to 1.4.0. --- CLAUDE.md | 99 ++++--- README.md | 46 +-- plugin.json | 8 +- screen.js | 503 ++++++++++++++++++++----------- tests/screen.test.js | 685 ++++++++++++++++++++++++++++--------------- 5 files changed, 891 insertions(+), 450 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b2dc53f..46b1b55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # Section Map — AI Agent Guide Draws a minimap bar of the current song's structure over the player, with -click/wheel seeking and a per-section "glass fill" difficulty indicator. +click/wheel seeking and a per-section difficulty "tier rail" indicator. Frontend-only — there's no `routes.py`; all logic lives in `screen.js`, reading state straight off the Host's `window.highway` object. @@ -23,21 +23,39 @@ reading state straight off the Host's `window.highway` object. feedBack#923/#924. Patching `window.showScreen` here would silently never fire for real navigation; this was an actual regression until fixed. - **Section-difficulty data comes from `difficulty_ladder`'s - `difficulty:sections-updated` event, not independent `highway` reads - (issue #63).** This plugin used to independently read `highway.getPhrases()` - / `hasPhraseData()` / `getMastery()` for section-difficulty data — that - changed when the glass-fill rendering was rewritten to consume - `difficulty_ladder`'s emitted event instead (`_smUpdateDifficultyFills` / - `_smGetSectionDifficulty` just render whatever `fillPercentage` / - `glassSize` the event's payload carries per section). This plugin still - works standalone with no ladder plugin installed — the glasses are its only - dependent feature, and `_smIsDynamicDifficultyAvailable()` gates the - subscription, so absent means no glasses rather than an error — but when a + `difficulty:sections-updated` / `difficulty:sections-updated-v3` events, not + independent `highway` reads (issue #63).** This plugin used to independently + read `highway.getPhrases()` / `hasPhraseData()` / `getMastery()` for + section-difficulty data — that changed when the rendering was rewritten to + consume `difficulty_ladder`'s emitted events instead. Issue #14 then replaced + the old glass-fill rendering (which consumed the v2 `fillPercentage` / + `glassSize` fields) with a semantic tier rail: `_smOnSectionsUpdatedV3` / + `_smNormalizeSectionTiers` / `_smGetSectionTier` carry each section's + `current_tier` / `top_tier` / `max_tier` / `has_chart_content`, and + `_smTierModel` / `_smRenderTierRail` / `_smUpdateTierRails` render it. This + plugin still works standalone with no ladder plugin installed — the rails are + its only dependent feature, and `_smIsDynamicDifficultyAvailable()` gates the + subscription, so absent means no rails rather than an error — but when a ladder plugin *is* installed, this is the API, not a coincidence of both sides reading the same Host state. See `difficulty_ladder`'s `INTEGRATION.md` - for the full contract (fill formula, fallback/timing behavior). The short + for the full contract (tier semantics, fallback/timing behavior). The short version is pinned as a comment block above `_smIsDynamicDifficultyAvailable()` in `screen.js`; keep the two in sync if either changes. +- **v3 is preferred; v2 is a bounded-transition fallback.** When + `_smPeerSpeaksV3()` sees `window._ddCapabilities.sectionsSchema === + 'difficulty_ladder.sections.v3'`, the subscription is to + `difficulty:sections-updated-v3` and `_smNormalizeSectionTiers` reads its + `sections` array directly. A peer that predates v3 (marker present, no + `sectionsSchema`) still gets `difficulty:sections-updated`, and + `_smNormalizeSectionTiersV2` reconstructs a coarse rail from `fillPercentage` + (`topTier = maxTier = maxDifficulty`, `currentTier = round(fill × topTier)`) + while discarding `glassSize` entirely. v3 is feature-detected, never + version-pinned — the marker carries no version. This is not the "don't sniff + `schema`" rule: we key off the capability marker the peer publishes, not the + payload's own `schema` field (which we deliberately ignore). The v2 fallback + is kept only while the peer's transition policy keeps emitting it; when that + policy drops v2, delete `_smNormalizeSectionTiersV2` / `_smOnSectionsUpdated` + and the v2 branch of `_smEnsureDifficultySubscription`. - **The peer handshake is re-checked, not decided once (issue #13).** `_smEnsureDifficultySubscription()` runs from `_smStartRealtimeHooks()` *and* from every `_smUpdate()` tick, so a Difficulty Ladder that loads or reloads @@ -72,28 +90,43 @@ reading state straight off the Host's `window.highway` object. `v0.12.0` the real floor, so that's the number this repo publishes. The floor is documentation, not enforcement, and shouldn't be restated as a check: the marker carries no version and predates v0.2.0, so a below-floor peer passes it - and still paints glasses. What actually changed below the floor is the + and still paints rails. What actually changed below the floor is the arithmetic behind `fillPercentage` (v0.9.11 aligned it with the peer's own - HUD), so an old peer's glasses can *disagree* with Difficulty Ladder's HUD - rather than fail to render. Keep the README table and the `screen.js` comment - block on the same number. -- **Payloads are normalized, not trusted (`_smNormalizeSectionDifficulties`).** - Entries without a finite numeric `fillPercentage` are dropped, which is what - keeps a malformed payload from painting a misleading near-empty glass — and - what stops the peer's NaN arithmetic (a NaN `mastery` or phrase difficulty) - from reaching the style attribute as `height:NaN%`. A container that isn't an - object is ignored entirely so the last good state survives. `null` return - means "ignore the event", `{}` means "clear the glasses" — don't collapse those - two. `glassSize` is the one payload field that reaches markup unescaped (as - `data-size`), so `_smGlassSize()` resolves it against `SM_GLASS_SIZES` before - either render path interpolates it. -- **v3 of the peer contract is deliberately not consumed.** - `difficulty:sections-updated-v3` is a render-neutral payload that - `difficulty_ladder` emits *alongside* v2. Consuming it means reworking the - glass rendering (v3 carries the facts, not the metaphor) — that's this repo's - issue #14, and the peer's own transition policy exists so an un-upgraded - Section Map keeps working on the frozen v2 event meanwhile. Don't sniff - `schema` to "upgrade" this plugin to v3 in passing. + HUD), so an old peer's rails can *disagree* with Difficulty Ladder's HUD + rather than fail to render. The v3 payload supersedes this arithmetic + entirely, which is why it is preferred. Keep the README table and the + `screen.js` comment block on the same number. +- **Payloads are normalized, not trusted (`_smNormalizeSectionTiers` / + `_smNormalizeSectionTiersV2`).** v3 entries without finite numeric `index`, + `max_tier` and `top_tier` are dropped, which keeps the peer's NaN arithmetic + from reaching the style attribute. v2 entries without a finite numeric + `fillPercentage` are dropped the same way. A container that isn't an object, + or a v3 payload whose `sections` isn't an array, is ignored entirely so the + last good state survives. `null` return means "ignore the event", `{}` means + "clear the rails" — don't collapse those two. `current_tier` is allowed to be + `null` (the peer's "no usable mastery" case): it renders an unlit rail, not a + zero tier. In v2 `glassSize` is discarded, and in v3 the payload's own `schema` + field is ignored (we key off the capability marker, not the payload). +- **The rail encodes difficulty semantically, never by size.** `_smTierModel` + produces `{ maxTier, topTier, currentTier, full }`: `top_tier` 0 with + `has_chart_content` is the easy-but-playable "full at every position" case + (`full: true`), `top_tier` 0 without content is silent (`null`, no rail), and + `current_tier` is clamped to `top_tier`. `_smRenderTierRail` draws + `maxTier + 1` fixed-size segments, lights the first `currentTier + 1`, and + outlines the `topTier` segment; `_smShouldCollapse` falls back to a single + marker on a block too narrow for the segments (a non-positive measured width + means "not laid out yet", where segments are kept). Colors are the literals + the peer's own guide uses (`SM_TIER_LIT` `#e8c040`, `SM_TIER_UNLIT` + `rgba(200,200,200,0.25)`, `SM_TIER_TOP` `#f4d35e`) — deliberately shared by + meaning, not Host CSS variables. The readable detail `Section name · tier X of + Y · full detail at Z` (`_smTierSuffix`) is the same wording in the block's + `title` and its `aria-label`; only tier-bearing blocks get a `tabindex`. + `_smUpdateTierRails` repaints in place, and a per-section signature + (`_smTierSignatures`) skips the rails whose data didn't change — no full + `_smRender()` per peer event. Because the signature tracks tier data, not + width, a `resize` listener (inside the hooks guard) calls `_smHandleResize`, + which clears the signatures and repaints so the width-dependent + segments-vs-marker choice is re-decided. - **Seeking must go through the Host's canonical funnel** (`window.feedBack.seek` / `window.slopsmith.seek`, wrapped by `_smSeek`), not by poking `audio.currentTime` directly — see the comment on `_smSeek` diff --git a/README.md b/README.md index 64eb78b..adee11d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A plugin for [feedBack](https://github.com/got-feedBack/feedBack) that shows a m - **Playback position** — white marker shows current position, active section highlighted - **Always visible** — sits between the HUD and the highway, doesn't obstruct notes - **Automatic** — appears when you play a song, disappears when you leave the player -- **Difficulty glasses (optional)** — each section fills a small glass with its current difficulty, when [Difficulty Ladder](https://github.com/get-flashbacks/feedback-plugin-difficulty-ladder) is installed. See below. +- **Difficulty tier rail (optional)** — each section shows a small rail of lit segments for its current difficulty tier, with an outline marking the tier it plays in full, when [Difficulty Ladder](https://github.com/get-flashbacks/feedback-plugin-difficulty-ladder) is installed. See below. ## Installation @@ -21,40 +21,52 @@ docker compose restart The section map automatically appears at the top of the player when you play a song. -## Difficulty glasses (optional) +## Difficulty tier rail (optional) Section Map is standalone: section colors, labels, click-to-seek and wheel nudging all work with no other plugin installed. -The per-section **difficulty glass** is the one optional feature, and it needs +The per-section **difficulty tier rail** is the one optional feature, and it needs [Difficulty Ladder](https://github.com/get-flashbacks/feedback-plugin-difficulty-ladder) installed alongside it. Difficulty Ladder works out how hard each section is and -publishes that as a fill level and a glass size; Section Map renders whatever it -hands over. Without it, the section map looks the same minus the glasses — no -errors, no placeholder. +publishes each section's current tier and the tier it plays in full; Section Map +renders that as a rail of segments — one per tier, the first `current + 1` lit, and +the full-detail tier outlined. A block too narrow for segments collapses to a +single lit marker. Without the peer, the section map looks the same minus the rails +— no errors, no placeholder. | Requirement | Value | |---|---| | Peer plugin | [Difficulty Ladder](https://github.com/get-flashbacks/feedback-plugin-difficulty-ladder), optional | -| Payload floor | **v0.12.0** — the first release documenting the payload shape below | -| Event consumed | `difficulty:sections-updated` (schema `difficulty_ladder.sections.v2`) | -| Feature-detected via | `window._ddCapabilities.sectionDifficulty` | +| Preferred event | `difficulty:sections-updated-v3` (schema `difficulty_ladder.sections.v3`) | +| Fallback event | `difficulty:sections-updated` (schema `difficulty_ladder.sections.v2`), floor **v0.12.0** | +| Feature-detected via | `window._ddCapabilities.sectionDifficulty` (v3 additionally via `window._ddCapabilities.sectionsSchema === 'difficulty_ladder.sections.v3'`) | Notes on compatibility: - "Difficulty Ladder is installed" is not on its own a compatibility statement. Section Map subscribes only once the peer advertises the capability marker - above; a build that doesn't is treated as not providing glasses. -- Older builds did emit the event (it shipped in v0.2.0), and Section Map cannot - read a peer's version, so they still render glasses — computed with the + above; a build that doesn't is treated as not providing rails. +- **v3 is preferred.** When the peer advertises the render-neutral + `difficulty_ladder.sections.v3` payload, Section Map consumes it and renders the + tiers directly. This is a feature detection (`sectionsSchema`), not a + version-pin; the peer emits no version on its marker. +- **v2 is the bounded-transition fallback.** A peer that predates v3 still emits + `difficulty:sections-updated`; Section Map reconstructs a coarse rail from its + `fillPercentage` so un-upgraded peers keep painting. v2 cannot show ladder depth + above the full-detail threshold the way v3 can, which is why v3 is preferred. + The v2 payload floor is **v0.12.0**, the first release documenting that shape — + and it is documentation, not enforcement (see below). +- Older v2 builds did emit the event (it shipped in v0.2.0), and Section Map + cannot read a peer's version, so they still render rails — computed with the formula that plugin used before v0.9.11, which can disagree with Difficulty Ladder's own HUD. That, not a rendering failure, is what the v0.12.0 floor is - about. If your glasses and Difficulty Ladder's HUD disagree, update it. + about. If your rail and Difficulty Ladder's HUD disagree, update it. - Any payload entry Section Map can't read is dropped rather than painted, so a - malformed or unexpected payload shows no glass instead of a wrong one. -- Difficulty Ladder also emits a newer, render-neutral - `difficulty:sections-updated-v3` payload. Section Map does not consume it yet — - it renders whatever v2 carries, which that plugin keeps emitting unchanged. + malformed or unexpected payload shows no rail instead of a wrong one. +- The rail is a focus stop: hovering or focusing a section with tier data reports + `Section name · tier X of Y · full detail at Z` in the same wording Difficulty + Ladder's own guide uses. ## License diff --git a/plugin.json b/plugin.json index 75ee277..abf1049 100644 --- a/plugin.json +++ b/plugin.json @@ -1,20 +1,20 @@ { "id": "section_map", "name": "Section Map", - "version": "1.3.0", + "version": "1.4.0", "private": false, "script": "screen.js", "category": "practice", - "description": "Visual song-section map and navigation. Optional: Difficulty Ladder v0.12.0+ adds per-section difficulty glasses.", + "description": "Visual song-section map and navigation. Optional: Difficulty Ladder adds a per-section difficulty tier rail.", "icon": "assets/thumb.svg", "capabilities": { "difficulty-ladder": { "roles": ["observer"], - "observes": ["difficulty:sections-updated"], + "observes": ["difficulty:sections-updated", "difficulty:sections-updated-v3"], "compatibility": "degrade-noop", "safety": "safe", "version": 1, - "description": "Optional peer plugin, used for the difficulty glasses only; section navigation, layout and seeking are standalone. Renders the peer's difficulty_ladder.sections.v2 sectionDifficulties payload (payload floor: Difficulty Ladder v0.12.0) behind window._ddCapabilities.sectionDifficulty. No peer, a peer that doesn't advertise the marker, or a payload entry we can't read all mean no glasses rather than an error." + "description": "Optional peer plugin, used for the difficulty tier rail only; section navigation, layout and seeking are standalone. Prefers the peer's render-neutral difficulty_ladder.sections.v3 payload (feature-detected via window._ddCapabilities.sectionsSchema), falling back to the frozen difficulty_ladder.sections.v2 sectionDifficulties payload (payload floor: Difficulty Ladder v0.12.0) behind window._ddCapabilities.sectionDifficulty. No peer, a peer that doesn't advertise the marker, or a payload entry we can't read all mean no rail rather than an error." } } } diff --git a/screen.js b/screen.js index ca48a97..194bb93 100644 --- a/screen.js +++ b/screen.js @@ -1,12 +1,12 @@ // Section Map plugin // Shows a minimap bar of the full song structure with clickable sections. -// Includes optional glass-filling difficulty visualization when the -// Difficulty Ladder plugin (difficulty_ladder) is installed. +// Includes an optional per-section difficulty tier rail when the Difficulty +// Ladder plugin (difficulty_ladder) is installed. let _smBar = null; let _smSections = []; let _smDuration = 0; -let _smSectionDifficulty = {}; // Map of section index to difficulty data +let _smSectionTiers = {}; // Map of section index to a normalized semantic tier entry let _smDynamicDifficultyAvailable = false; let _smPoller = null; let _smPlayerVisible = false; @@ -24,6 +24,10 @@ let _smBlockEls = []; // Index of the block last marked "active" (opacity 1), or -1 when unknown // (forces the next tick to (re)apply opacity instead of assuming default). let _smActiveIdx = -1; +// Per-section signature of the last rail paint, so a difficulty refresh only +// rebuilds the rails whose tier data actually changed. Aligned with +// _smSections by index; '' means "no tier rail for this section". +let _smTierSignatures = []; // Most-specific-first: 'pre' must come before 'chorus'/'verse' so a // "pre-chorus"/"pre-verse" section name (which contains both substrings) @@ -50,6 +54,24 @@ function _smGetColor(name) { return SM_COLORS.default; } +// The tier-rail tokens, shared by meaning with Difficulty Ladder's standalone +// guide (#157): gold is a lit tier, faint grey an unlit one, and the lighter +// gold outline marks `topTier`, the tier from which the entry plays in full. +// Deliberately the same literals the peer uses rather than Host CSS variables — +// the meaning is the peer's contract, and the two renderers must move together +// if either changes. +const SM_TIER_LIT = '#e8c040'; +const SM_TIER_UNLIT = 'rgba(200,200,200,0.25)'; +const SM_TIER_TOP = '#f4d35e'; +// A segment is a fixed size: difficulty is encoded by how many are lit, never +// by changing a segment's size (the flaw the glass metaphor had). +const SM_RAIL_SEGMENT_PX = 5; +const SM_RAIL_GAP_PX = 1; +// Space a segment rail needs beyond the segments themselves (rails are inset +// 2px per side). Below this a block collapses to a single marker instead of a +// fixed-width ornament crowding out its label. +const SM_RAIL_PAD_PX = 4; + function _smSubscribeFeedBackEvent(eventName, handler) { if (typeof window.feedBack === 'undefined' || typeof window.feedBack.on !== 'function') return null; const unsubscribe = window.feedBack.on(eventName, handler); @@ -59,47 +81,42 @@ function _smSubscribeFeedBackEvent(eventName, handler) { } // The Difficulty Ladder peer contract. Section Map is standalone: everything -// except the per-section "glass fill" indicator works with no peer plugin at -// all. Only the glasses depend on `difficulty_ladder` (formerly -// "dynamic-difficulty"), which owns the difficulty:sections-updated event this -// plugin renders. The full contract lives in that plugin's INTEGRATION.md; the -// parts this file depends on are: +// except the per-section difficulty rail works with no peer plugin at all. +// Only the rail depends on `difficulty_ladder` (formerly "dynamic-difficulty"), +// which owns the events this plugin renders. The full contract lives in that +// plugin's INTEGRATION.md; the parts this file depends on are: // // capability window._ddCapabilities.sectionDifficulty === true, set at that -// plugin's top-level script execution. (`_ddCapabilities` predates -// its rename from `dynamic_difficulty` to `difficulty_ladder` and -// is kept as-is: it is the established marker between the two -// plugins.) -// event 'difficulty:sections-updated' (schema difficulty_ladder.sections.v2) -// payload event.detail.sectionDifficulties -- an object map keyed by section -// index, each entry { fillPercentage, glassSize, avgDifficulty, -// maxDifficulty }. A section with no overlapping phrase is simply -// absent from the map, which renders as "no glass". -// floor Difficulty Ladder v0.12.0, the release whose CHANGELOG documents -// the v2 payload shape above. The event itself shipped in v0.2.0, -// but "Difficulty Ladder is installed" is not a compatibility -// statement on its own. Note what the floor is and isn't: the -// marker below has existed since well before v0.2.0 and carries no -// version, so a below-floor peer still passes it and still paints -// glasses. What changed below the floor is the arithmetic behind -// fillPercentage (v0.9.11 aligned it with the peer's own HUD), so -// an old peer renders a glass that can disagree with Difficulty -// Ladder's HUD — not one this plugin can detect. Hence a -// documented floor rather than an enforced version check. -// -// v3 (`difficulty:sections-updated-v3`, a render-neutral payload) is emitted -// alongside v2 on a separate event name and is deliberately NOT consumed yet: -// _smRenderGlassFilling/_smUpdateDifficultyFills render glass metaphors, which is -// exactly what v3 stops prescribing. That is also why the peer's -// `sectionsSchema` advertisement is not a gate below — it exists for a consumer -// choosing between v2 and v3, and the peer keeps emitting v2 unchanged -// alongside v3 either way. +// plugin's top-level script execution. A build that also speaks +// the render-neutral payload additionally advertises +// window._ddCapabilities.sectionsSchema === +// 'difficulty_ladder.sections.v3'. +// event v3 'difficulty:sections-updated-v3' (schema +// difficulty_ladder.sections.v3). Preferred when advertised. +// event v2 'difficulty:sections-updated' (schema +// difficulty_ladder.sections.v2). Kept only as the bounded- +// transition fallback for a peer that does not advertise v3 yet. +// It carries the glass presentation fields (fillPercentage, +// glassSize); this plugin reads fillPercentage to reconstruct a +// coarse rail and ignores glassSize entirely. +// payload v3 event.detail = { schema, player_context, mastery, max_tier, +// current_phrase_index, sections: [ { id, index, start_time, +// end_time, is_current, current_tier, top_tier, max_tier, +// avg_top_tier, has_chart_content, phrases } ] }. Sections are +// present in `highway.getSections()` order, one entry per section +// that has an overlapping phrase; the facts rendered here are +// current_tier, top_tier, max_tier and has_chart_content. +// floor v3 is feature-detected via `sectionsSchema`, not version-pinned. +// v2's documented floor is Difficulty Ladder v0.12.0, the release +// whose CHANGELOG documents that payload shape. const _SM_DD_SECTIONS_EVENT = 'difficulty:sections-updated'; +const _SM_DD_SECTIONS_V3_EVENT = 'difficulty:sections-updated-v3'; +const _SM_DD_SECTIONS_SCHEMA_V3 = 'difficulty_ladder.sections.v3'; // Check whether the Difficulty Ladder peer is installed AND speaking the // contract above. A bare `_ddCapabilities` global is not enough -- require the // named marker, so a foreign plugin that happens to use that global (or a stub -// left behind by an earlier session) leaves the glasses off rather than +// left behind by an earlier session) leaves the rail off rather than // subscribing to an event whose payload shape we cannot render. function _smIsDynamicDifficultyAvailable() { if (typeof window.feedBack === 'undefined') return false; @@ -107,63 +124,139 @@ function _smIsDynamicDifficultyAvailable() { return !!caps && typeof caps === 'object' && caps.sectionDifficulty === true; } -// Validate one `difficulty_ladder.sections.v2` payload's section map. +// Whether the peer advertises the render-neutral v3 payload. Read at subscribe +// time (and re-read on every retry tick until subscribed), never cached +// forever: the marker appears at the peer's script execution, so a peer that +// loads after this plugin is picked up on a later tick. +function _smPeerSpeaksV3() { + const caps = window._ddCapabilities; + return !!caps && typeof caps === 'object' && caps.sectionsSchema === _SM_DD_SECTIONS_SCHEMA_V3; +} + +// Validate a `difficulty_ladder.sections.v3` payload's section array. // -// null the container is not an object -- an unusable payload. Callers must -// keep whatever they already have rather than repaint from noise. -// {} a well-formed container with nothing renderable in it, which clears -// the glasses (the "no glass, not a stale one" convention). +// null the payload, or its `sections`, is not the shape we can read. The +// caller keeps the last good state rather than repainting from noise. +// {} a well-formed payload with no renderable section in it, which clears +// the rails (the "no rail, not a stale one" convention). // -// Entries are dropped unless they carry a finite numeric `fillPercentage`. The -// peer computes it arithmetically, so it is NaN whenever mastery or the phrase -// difficulty isn't a number — and a NaN sails through the `typeof === 'number'` -// check the renderer used to do, painting `height:NaN%` and a "Difficulty: NaN%" -// title. The same check drops anything from a payload that isn't the v2 shape at -// all, so an entry we can't read renders no glass instead of a misleading -// near-empty one. -function _smNormalizeSectionDifficulties(raw) { +// Entries are dropped unless they carry finite numeric `index`, `max_tier` and +// `top_tier`. `current_tier` is allowed to be null (the peer emits null when the +// host reports no usable mastery, or the entry has no ladder) -- that renders an +// unlit rail with only the full-detail outline, which is honest, not a zero +// tier. A NaN from the peer's own arithmetic never reaches the markup. +function _smNormalizeSectionTiers(payload) { + if (!payload || typeof payload !== 'object') return null; + const sections = payload.sections; + if (!Array.isArray(sections)) return null; + const normalized = {}; + for (const entry of sections) { + if (!entry || typeof entry !== 'object') continue; + if (!Number.isFinite(entry.index)) continue; + if (!Number.isFinite(entry.max_tier) || entry.max_tier < 0) continue; + if (!Number.isFinite(entry.top_tier) || entry.top_tier < 0) continue; + normalized[entry.index] = { + index: entry.index, + max_tier: Math.floor(entry.max_tier), + top_tier: Math.floor(entry.top_tier), + current_tier: Number.isFinite(entry.current_tier) ? Math.floor(entry.current_tier) : null, + has_chart_content: entry.has_chart_content === true, + }; + } + return normalized; +} + +// Validate a frozen `difficulty_ladder.sections.v2` section map (the bounded- +// transition fallback). Same null/{}/entry contract as the v3 normalizer. +// +// v2 carries presentation decisions, not tiers: `fillPercentage` is the lit +// fraction of a glass and `maxDifficulty` is the entry's top-difficulty (the +// tier it plays in full from). To keep one renderer, reconstruct a coarse rail: +// `topTier = maxTier = maxDifficulty`, `currentTier = round(fill * topTier)`. +// That cannot show ladder depth above the full-detail threshold the way v3 can, +// which is exactly why v3 is preferred; `glassSize` is discarded entirely. +function _smNormalizeSectionTiersV2(raw) { if (!raw || typeof raw !== 'object') return null; const normalized = {}; for (const key of Object.keys(raw)) { const entry = raw[key]; if (!entry || typeof entry !== 'object') continue; if (!Number.isFinite(entry.fillPercentage)) continue; - normalized[key] = entry; + const fill = Math.max(0, Math.min(100, entry.fillPercentage)); + const topTier = Number.isFinite(entry.maxDifficulty) + ? Math.max(0, Math.floor(entry.maxDifficulty)) + : 0; + normalized[key] = { + index: Number(key), + max_tier: topTier, + top_tier: topTier, + current_tier: topTier === 0 + ? (fill >= 100 ? 0 : null) + : Math.max(0, Math.min(topTier, Math.round((fill / 100) * topTier))), + has_chart_content: topTier > 0 || fill > 0, + }; } return normalized; } -// Get difficulty data for a section from the Difficulty Ladder peer -function _smGetSectionDifficulty(sectionIndex) { +// Get tier data for a section from the Difficulty Ladder peer +function _smGetSectionTier(sectionIndex) { if (!_smDynamicDifficultyAvailable) return null; - // Populated by difficulty_ladder's difficulty:sections-updated event. - return _smSectionDifficulty[sectionIndex] || null; + // Populated by the peer's difficulty:sections-updated[-v3] event. + return _smSectionTiers[sectionIndex] || null; +} + +// Collapse a normalized entry into the values the rail paints. Returns null +// when there is nothing to show: no entry at all, or a section whose phrases +// report no ladder and no chart content (silent, not "easy"). A `top_tier` of 0 +// with content is the easy-but-playable case -- played in full at every slider +// position -- rendered as a single lit, outlined segment. +function _smTierModel(entry) { + if (!entry) return null; + const maxTier = Math.max(0, Math.floor(entry.max_tier) || 0); + const topTier = Math.min(Math.max(0, Math.floor(entry.top_tier) || 0), maxTier); + if (topTier === 0) { + if (!entry.has_chart_content) return null; + return { maxTier: 0, topTier: 0, currentTier: 0, full: true }; + } + const currentTier = entry.current_tier == null + ? null + : Math.max(0, Math.min(topTier, Math.floor(entry.current_tier))); + return { maxTier, topTier, currentTier, full: currentTier != null && currentTier >= topTier }; } -// Initialize difficulty data listener if the Difficulty Ladder peer is available +// Initialize the difficulty availability flags if the peer is present. function _smInitializeDifficultyListener() { _smDynamicDifficultyAvailable = _smIsDynamicDifficultyAvailable(); } -// difficulty:sections-updated handler. -function _smOnSectionsUpdated(event) { - const normalized = _smNormalizeSectionDifficulties(event && event.detail && event.detail.sectionDifficulties); - if (normalized === null) return; - _smSectionDifficulty = normalized; - // Difficulty refreshes change only the per-section glass fill, not - // colors/labels/positions — update those in place rather than paying for a - // full _smRender() (which tears down and rebuilds every block's DOM) on - // every event, which can fire repeatedly over the course of a song. Falls - // back to a full render if the cached blocks don't match the current - // section list (bar not built for these sections yet). +// Shared tail of both event handlers: repaint the rails in place when the bar +// already matches the current sections, otherwise rebuild the bar. +function _smApplyTierData() { if (_smBar && _smSections.length > 0 && _smBlockEls.length === _smSections.length) { - _smUpdateDifficultyFills(); + _smUpdateTierRails(); } else { _smRender(); } } -// Subscribe to the peer's section-difficulty event if it is available yet. +// difficulty:sections-updated-v3 handler. +function _smOnSectionsUpdatedV3(event) { + const normalized = _smNormalizeSectionTiers(event && event.detail); + if (normalized === null) return; + _smSectionTiers = normalized; + _smApplyTierData(); +} + +// difficulty:sections-updated (v2) handler -- bounded-transition fallback only. +function _smOnSectionsUpdated(event) { + const normalized = _smNormalizeSectionTiersV2(event && event.detail && event.detail.sectionDifficulties); + if (normalized === null) return; + _smSectionTiers = normalized; + _smApplyTierData(); +} + +// Subscribe to the peer's section event if it is available yet. // // This re-runs rather than deciding once per mount on purpose. When both // plugins come back 'ready' in one /api/plugins response, core injects screen.js @@ -183,8 +276,8 @@ function _smOnSectionsUpdated(event) { // short-circuiting on a single boolean once registered. // // One-directional on purpose: a peer that vanishes mid-song leaves its last -// glasses in place rather than tearing down mid-render, and the contract is -// that an absent peer means no glasses, never an error. +// rails in place rather than tearing down mid-render, and the contract is that +// an absent peer means no rails, never an error. function _smEnsureDifficultySubscription() { if (_smDifficultySubscribed) return; _smInitializeDifficultyListener(); @@ -196,7 +289,11 @@ function _smEnsureDifficultySubscription() { // unsubscribe handle, and re-registering every 200ms because it didn't // would leak a handler per tick. _smDifficultySubscribed = true; - _smDifficultyUnsubscribe = _smSubscribeFeedBackEvent(_SM_DD_SECTIONS_EVENT, _smOnSectionsUpdated); + if (_smPeerSpeaksV3()) { + _smDifficultyUnsubscribe = _smSubscribeFeedBackEvent(_SM_DD_SECTIONS_V3_EVENT, _smOnSectionsUpdatedV3); + } else { + _smDifficultyUnsubscribe = _smSubscribeFeedBackEvent(_SM_DD_SECTIONS_EVENT, _smOnSectionsUpdated); + } } function _smStartRealtimeHooks() { @@ -206,7 +303,7 @@ function _smStartRealtimeHooks() { _smSongReadyUnsubscribe = _smSubscribeFeedBackEvent('song:ready', () => { _smSections = []; _smDuration = 0; - _smSectionDifficulty = {}; + _smSectionTiers = {}; _smUpdate(); }); _smSongReadySubscribed = typeof _smSongReadyUnsubscribe === 'function'; @@ -272,6 +369,7 @@ function _smRemove() { _smMarkerEl = null; _smBlockEls = []; _smActiveIdx = -1; + _smTierSignatures = []; } // The playback clock, across whichever backend is driving audio. getTime() is @@ -340,7 +438,7 @@ function _smOnWheel(e) { function _smUpdate() { // Retry the Difficulty Ladder handshake before anything else: the peer may - // have loaded (or reloaded) after this plugin did, and glasses are the one + // have loaded (or reloaded) after this plugin did, and the rail is the one // thing that can't wait for the next song. No-op once subscribed. _smEnsureDifficultySubscription(); @@ -374,11 +472,12 @@ function _smUpdate() { if (_smBar.innerHTML !== '') { _smSections = []; _smDuration = 0; - _smSectionDifficulty = {}; + _smSectionTiers = {}; _smBar.innerHTML = ''; _smMarkerEl = null; _smBlockEls = []; _smActiveIdx = -1; + _smTierSignatures = []; } return; } @@ -420,10 +519,83 @@ function _smUpdate() { } } +// The on-bar label drops a trailing digit (no room for "Verse 2" in a +// ~9px-tall strip), but the hover title keeps it — otherwise two "Verse" +// blocks are indistinguishable on mouseover. +function _smSectionLabel(sec) { + const ordinalMatch = sec.name.match(/(\d+)$/); + let label = sec.name.replace(/\d+$/, '').trim(); + label = label.charAt(0).toUpperCase() + label.slice(1); + return { label, titleLabel: ordinalMatch ? `${label} ${ordinalMatch[1]}` : label }; +} + +// The readable tier detail shared by the hover title and the accessible name: +// `tier X of Y · full detail at Z`. `Y` is maxTier, `Z` is topTier; the +// full-detail clause is omitted when there is no ladder to climb (maxTier 0). +// currentTier null is "tier unknown", not a zero tier. +function _smTierSuffix(model) { + if (!model) return ''; + if (model.currentTier == null) { + return model.maxTier > 0 ? `tier unknown · full detail at ${model.topTier}` : 'tier unknown'; + } + const tierText = `tier ${model.currentTier} of ${model.maxTier}`; + return model.maxTier > 0 ? `${tierText} · full detail at ${model.topTier}` : tierText; +} + +// A rail cell that fits: one fixed-size segment per tier (maxTier + 1), the +// first currentTier + 1 lit, and the segment at topTier outlined. +function _smRailSegmentsMarkup(model) { + let html = ''; + for (let tier = 0; tier <= model.maxTier; tier++) { + const lit = model.currentTier != null && tier <= model.currentTier; + const top = tier === model.topTier; + html += ``; + } + return html; +} + +// Degraded form for a block too narrow for segments: a single marker that is +// lit once a tier is known and outlined when the entry is already played in +// full. The readable detail still travels on the block's label. +function _smRailCollapsedMarkup(model) { + const lit = model.currentTier != null; + return ``; +} + +// Whether the segment rail would not fit in `blockPx`. A non-positive width +// means "could not measure" (the bar isn't laid out yet), where the segments are +// kept: the rail is overflow-hidden, so it degrades gracefully instead of us +// guessing wrong. +function _smShouldCollapse(blockPx, model) { + if (!(blockPx > 0)) return false; + const segCount = model.maxTier + 1; + const needed = segCount * SM_RAIL_SEGMENT_PX + (segCount - 1) * SM_RAIL_GAP_PX; + return blockPx < needed + SM_RAIL_PAD_PX; +} + +function _smRailInner(model, blockPx) { + return _smShouldCollapse(blockPx, model) + ? _smRailCollapsedMarkup(model) + : _smRailSegmentsMarkup(model); +} + +// The rail container: anchored to the bottom edge of the section block so it +// never competes with the centered label. aria-hidden because the block's own +// aria-label carries the readable detail; the segments are its visual encoding. +function _smRenderTierRail(model, blockPx) { + const style = `position:absolute;left:2px;right:2px;bottom:1px;display:flex;gap:${SM_RAIL_GAP_PX}px;justify-content:center;align-items:flex-end;pointer-events:none;overflow:hidden;`; + return ``; +} + +function _smTierSignature(model) { + return model ? `${model.maxTier}|${model.topTier}|${model.currentTier}|${model.full ? 1 : 0}` : ''; +} + function _smRender() { if (!_smBar || !_smSections.length || !_smDuration) return; let html = ''; + const barWidth = _smBar.getBoundingClientRect ? _smBar.getBoundingClientRect().width : 0; for (let i = 0; i < _smSections.length; i++) { const sec = _smSections[i]; @@ -432,30 +604,33 @@ function _smRender() { const widthPct = ((nextTime - sec.time) / _smDuration) * 100; const color = _smGetColor(sec.name); - // Clean up section name for display - const ordinalMatch = sec.name.match(/(\d+)$/); - let label = sec.name.replace(/\d+$/, '').trim(); - label = label.charAt(0).toUpperCase() + label.slice(1); - // The on-bar label drops the trailing digit (no room for "Verse 2" in - // a ~9px-tall strip), but the hover title keeps it — otherwise two - // "Verse" blocks are indistinguishable on mouseover. - const titleLabel = ordinalMatch ? `${label} ${ordinalMatch[1]}` : label; - - // Get difficulty data if available - const difficulty = _smGetSectionDifficulty(i); - const difficultyContent = _smDynamicDifficultyAvailable && difficulty - ? _smRenderGlassFilling(difficulty) - : ''; - + const { label, titleLabel } = _smSectionLabel(sec); const safeLabel = _smEscapeHtml(label); const safeTitle = _smEscapeHtml(`${titleLabel} (${_smFmt(sec.time)})`); + + // Semantic tier data, when the peer is present. blockPx is an estimate + // from the bar's laid-out width; it only decides whether the rail draws + // segments or collapses to a marker. + const model = _smTierModel(_smGetSectionTier(i)); + const blockPx = barWidth > 0 ? (widthPct / 100) * barWidth : 0; + const railContent = model ? _smRenderTierRail(model, blockPx) : ''; + const tierSuffix = _smTierSuffix(model); + const fullTitle = tierSuffix + ? `${safeTitle} · ${_smEscapeHtml(tierSuffix)}` + : safeTitle; + // One focus stop per section with tier data, carrying the readable + // detail so keyboard/AT users are not limited to the hover title. + const ariaAttr = model + ? ` tabindex="0" aria-label="${_smEscapeHtml(`${titleLabel} · ${tierSuffix}`)}"` + : ''; + // opacity starts at 0.5 (the "inactive" state) so _smUpdate's per-tick // highlight pass only ever needs to touch the blocks whose active // state actually changed, instead of rewriting every block's opacity // on every poll. html += `
- ${difficultyContent} + title="${fullTitle}"${ariaAttr}> + ${railContent} ${safeLabel}
`; } @@ -471,87 +646,76 @@ function _smRender() { _smMarkerEl = _smBar.querySelector ? _smBar.querySelector('#sm-marker') : null; _smBlockEls = _smBar.querySelectorAll ? Array.from(_smBar.querySelectorAll('.sm-block')) : []; _smActiveIdx = -1; + _smTierSignatures = _smSections.map((_, i) => _smTierSignature(_smTierModel(_smGetSectionTier(i)))); } -// Size buckets for the glass, keyed by the difficulty_ladder.sections.v2 -// `glassSize` value. Module-level because two call sites need it: the renderer -// below and _smUpdateDifficultyFills()'s size-change comparison. -const SM_GLASS_SIZES = { - small: 'width:12px;height:12px;', - medium: 'width:16px;height:16px;', - large: 'width:20px;height:20px;', -}; - -// The one place a peer's `glassSize` enters our markup: it lands unescaped in -// the `data-size` attribute, so resolve it against the allowlist first rather -// than trusting the string. Anything unrecognized (including markup) becomes -// `medium`, matching the style fallback. -function _smGlassSize(difficulty) { - const size = difficulty && difficulty.glassSize; - return Object.prototype.hasOwnProperty.call(SM_GLASS_SIZES, size) ? size : 'medium'; -} - -// Render glass-filling visualization for section difficulty -function _smRenderGlassFilling(difficulty) { - // isFinite, not typeof: the peer derives this arithmetically and a NaN - // mastery or phrase difficulty yields NaN, which is typeof 'number' and - // would otherwise reach the style attribute as `height:NaN%`. - if (!difficulty || !Number.isFinite(difficulty.fillPercentage)) return ''; - - const fillPct = Math.max(0, Math.min(100, difficulty.fillPercentage)); - const glassSize = _smGlassSize(difficulty); - const glassStyle = SM_GLASS_SIZES[glassSize]; - - // Classed (and size-tagged) so _smUpdateDifficultyFills() can update an - // existing glass's fill height/title in place on a difficulty refresh, - // instead of the caller having to rebuild the whole section-map bar just - // to redraw it. - return `
-
-
`; -} - -// Refresh only the per-section difficulty "glass fill" indicators in place -// (fill height + title) instead of rebuilding the whole section-map bar. -// Called from the 'difficulty:sections-updated' event, which can fire -// repeatedly while a song plays as difficulty_ladder re-derives each section's -// difficulty — none of that data changes section colors/labels/positions, so a -// full _smRender() on every event would tear down and rebuild every block's DOM -// just to repaint a handful of small fill bars. -function _smUpdateDifficultyFills() { +// Refresh only the per-section tier rails in place (segments + readable label) +// instead of rebuilding the whole section-map bar. Called from the peer's +// section-difficulty events, which can fire repeatedly while a song plays as +// difficulty_ladder re-derives each section's tiers — none of that data changes +// section colors/labels/positions, so a full _smRender() on every event would +// tear down and rebuild every block's DOM just to repaint a few small rails. +// A per-section signature skips the rails whose tier data did not change. +function _smUpdateTierRails() { if (!_smBar || !_smBlockEls.length) return; - for (let i = 0; i < _smBlockEls.length; i++) { + const barWidth = _smBar.getBoundingClientRect ? _smBar.getBoundingClientRect().width : 0; + for (let i = 0; i < _smSections.length; i++) { + const model = _smTierModel(_smGetSectionTier(i)); + const signature = _smTierSignature(model); + if (_smTierSignatures[i] === signature) continue; + _smTierSignatures[i] = signature; + const block = _smBlockEls[i]; if (!block || typeof block.querySelector !== 'function') continue; - const difficulty = _smGetSectionDifficulty(i); - const glass = block.querySelector('.sm-glass'); - if (_smDynamicDifficultyAvailable && difficulty && Number.isFinite(difficulty.fillPercentage)) { - const fillPct = Math.max(0, Math.min(100, difficulty.fillPercentage)); - const glassSize = _smGlassSize(difficulty); - const sizeChanged = glass && glass.getAttribute && glass.getAttribute('data-size') !== glassSize; - if (glass && !sizeChanged) { - // Same glass already present (the common case while a song - // plays) — just update its fill height and title. - glass.title = `Difficulty: ${fillPct.toFixed(0)}%`; - const fillEl = glass.querySelector('.sm-glass-fill'); - if (fillEl) fillEl.style.height = fillPct + '%'; + + const sec = _smSections[i]; + const nextTime = i < _smSections.length - 1 ? _smSections[i + 1].time : _smDuration; + const widthPct = _smDuration > 0 ? ((nextTime - sec.time) / _smDuration) * 100 : 0; + const blockPx = barWidth > 0 ? (widthPct / 100) * barWidth : 0; + + const rail = block.querySelector('.sm-rail'); + if (model) { + if (rail) rail.innerHTML = _smRailInner(model, blockPx); + else block.insertAdjacentHTML('afterbegin', _smRenderTierRail(model, blockPx)); + } else if (rail) { + rail.remove(); + } + + const { titleLabel } = _smSectionLabel(sec); + const tierSuffix = _smTierSuffix(model); + const baseTitle = `${titleLabel} (${_smFmt(sec.time)})`; + block.title = tierSuffix ? `${baseTitle} · ${tierSuffix}` : baseTitle; + // Mirror _smRender's attributes exactly, in both directions: a block + // that gains tier data becomes a focus stop, and one that loses it + // stops being one (otherwise tabindex lingers on a bare, unnamed div). + if (typeof block.setAttribute === 'function' && typeof block.removeAttribute === 'function') { + if (model) { + block.setAttribute('aria-label', `${titleLabel} · ${tierSuffix}`); + block.setAttribute('tabindex', '0'); } else { - // No glass yet, or its size bucket changed — (re)build just - // this one glass element rather than the whole bar. - if (glass) glass.remove(); - block.insertAdjacentHTML('afterbegin', _smRenderGlassFilling(difficulty)); + block.removeAttribute('aria-label'); + block.removeAttribute('tabindex'); } - } else if (glass) { - glass.remove(); } } } +// A window/bar resize changes how many pixels each block has, which is what +// decides between the segment rail and the collapsed marker. The signature +// deliberately excludes width (tier data is what it tracks), so a resize has to +// invalidate the signatures and repaint for that width-dependent form to be +// re-decided. Without this, a block that crossed the collapse threshold on +// resize would keep the other form until its tier data or the section list next +// changed. Cheap: one repaint per resize, none per _smUpdate tick. +function _smHandleResize() { + if (!_smBar) return; + _smTierSignatures = []; + _smUpdateTierRails(); +} + function _smFmt(s) { return Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0'); } - function _smEscapeHtml(value) { return String(value) .replace(/&/g, '&') @@ -567,20 +731,22 @@ if (typeof module !== 'undefined' && module.exports) { module.exports = { _smGetColor, _smFmt, _smCreate, _smRemove, _smUpdate, _smRender, _smOnClick, _smOnWheel, _smEscapeHtml, - _smIsDynamicDifficultyAvailable, _smGetSectionDifficulty, _smRenderGlassFilling, - _smUpdateDifficultyFills, - _smNormalizeSectionDifficulties, _smOnSectionsUpdated, _smEnsureDifficultySubscription, + _smIsDynamicDifficultyAvailable, _smPeerSpeaksV3, _smGetSectionTier, + _smTierModel, _smTierSuffix, _smRenderTierRail, _smUpdateTierRails, _smHandleResize, + _smNormalizeSectionTiers, _smNormalizeSectionTiersV2, + _smOnSectionsUpdated, _smOnSectionsUpdatedV3, _smEnsureDifficultySubscription, _smInitializeDifficultyListener, _smStartRealtimeHooks, _smStopRealtimeHooks, _smSetPlayerVisible, _getState: () => ({ - bar: _smBar, sections: _smSections, duration: _smDuration, sectionDifficulty: _smSectionDifficulty, + bar: _smBar, sections: _smSections, duration: _smDuration, sectionTiers: _smSectionTiers, ddAvailable: _smDynamicDifficultyAvailable, markerEl: _smMarkerEl, blockEls: _smBlockEls, activeIdx: _smActiveIdx, + tierSignatures: _smTierSignatures, songReadySubscribed: _smSongReadySubscribed, difficultySubscribed: _smDifficultySubscribed, }), _setState(next) { if ('sections' in next) _smSections = next.sections; if ('duration' in next) _smDuration = next.duration; if ('bar' in next) _smBar = next.bar; - if ('sectionDifficulty' in next) _smSectionDifficulty = next.sectionDifficulty; + if ('sectionTiers' in next) _smSectionTiers = next.sectionTiers; if ('ddAvailable' in next) _smDynamicDifficultyAvailable = next.ddAvailable; if ('poller' in next) _smPoller = next.poller; if ('playerVisible' in next) _smPlayerVisible = next.playerVisible; @@ -591,6 +757,7 @@ if (typeof module !== 'undefined' && module.exports) { if ('markerEl' in next) _smMarkerEl = next.markerEl; if ('blockEls' in next) _smBlockEls = next.blockEls; if ('activeIdx' in next) _smActiveIdx = next.activeIdx; + if ('tierSignatures' in next) _smTierSignatures = next.tierSignatures; }, }; } else { @@ -611,7 +778,7 @@ if (typeof module !== 'undefined' && module.exports) { _smRemove(); _smSections = []; _smDuration = 0; - _smSectionDifficulty = {}; + _smSectionTiers = {}; await origPlaySong(filename, arrangement); if (_smPlayerVisible) _smCreate(); }; @@ -627,6 +794,12 @@ if (typeof module !== 'undefined' && module.exports) { _smSubscribeFeedBackEvent('screen:changed', (event) => { _smSetPlayerVisible((event.detail && event.detail.id) === 'player'); }); + + // Re-decide the width-dependent rail form on resize. Installed inside the + // same idempotency guard as every other top-level listener/timer. + window.addEventListener('resize', () => { + if (_smPlayerVisible) _smHandleResize(); + }); })(); } diff --git a/tests/screen.test.js b/tests/screen.test.js index 9c74dd5..5d410d7 100644 --- a/tests/screen.test.js +++ b/tests/screen.test.js @@ -1,6 +1,7 @@ 'use strict'; // Coverage for pure/DOM-light helpers in screen.js: section color lookup, -// time formatting, render HTML shape, click/wheel seek math. +// time formatting, render HTML shape, click/wheel seek math, and the +// Difficulty Ladder tier-rail contract (v3 preferred, v2 during transition). // Runs under the org reusable CI as `node tests/screen.test.js`. const test = require('node:test'); const assert = require('node:assert/strict'); @@ -438,148 +439,259 @@ test('_smUpdate clears the bar even when song:ready already zeroed _smSections f } }); -// _smUpdateDifficultyFills updates an existing glass in place (no DOM -// rebuild) on a difficulty refresh, only rebuilding the one glass element -// when its size bucket changes, per its own doc comment. +// Tier semantics (issue #14): the rail is driven by v3's semantic fields +// (current_tier / top_tier / max_tier / has_chart_content), never by a size. -class FakeGlassFill { - constructor() { this.style = {}; } -} +test('_smTierModel reads current/top/max tiers and treats top_tier 0 with content as full detail', () => { + const mod = freshPlugin(); + assert.equal(mod._smTierModel(null), null); -class FakeGlass { - constructor(size, fillPct) { - this._size = size; - this.title = `Difficulty: ${fillPct}%`; - this.removed = false; - this._fill = new FakeGlassFill(); - this._fill.style.height = fillPct + '%'; - } - getAttribute(name) { return name === 'data-size' ? this._size : null; } - querySelector(sel) { return sel === '.sm-glass-fill' ? this._fill : null; } + const normal = mod._smTierModel({ max_tier: 4, top_tier: 3, current_tier: 2, has_chart_content: true }); + assert.deepEqual(normal, { maxTier: 4, topTier: 3, currentTier: 2, full: false }); + + // current_tier is clamped to top_tier: above that tier the entry is already + // played in full, so there is no larger tier to report. + const over = mod._smTierModel({ max_tier: 4, top_tier: 2, current_tier: 4, has_chart_content: true }); + assert.equal(over.currentTier, 2); + assert.equal(over.full, true); + + // top_tier 0 with content: easy but playable, full at every position. + const easy = mod._smTierModel({ max_tier: 0, top_tier: 0, current_tier: 0, has_chart_content: true }); + assert.deepEqual(easy, { maxTier: 0, topTier: 0, currentTier: 0, full: true }); + + // top_tier 0 without content: silent, not "easy" -- no rail at all. + assert.equal(mod._smTierModel({ max_tier: 0, top_tier: 0, current_tier: null, has_chart_content: false }), null); + + // A null current_tier keeps the rail (with the outline) but lights nothing. + const unknown = mod._smTierModel({ max_tier: 3, top_tier: 3, current_tier: null, has_chart_content: true }); + assert.equal(unknown.currentTier, null); + assert.equal(unknown.full, false); +}); + +test('_smTierSuffix renders the shared readable detail, omitting the full-detail clause with no ladder', () => { + const mod = freshPlugin(); + assert.equal(mod._smTierSuffix(null), ''); + assert.equal( + mod._smTierSuffix({ maxTier: 4, topTier: 3, currentTier: 2, full: false }), + 'tier 2 of 4 · full detail at 3', + ); + assert.equal( + mod._smTierSuffix({ maxTier: 0, topTier: 0, currentTier: 0, full: true }), + 'tier 0 of 0', + ); + assert.equal( + mod._smTierSuffix({ maxTier: 3, topTier: 3, currentTier: null, full: false }), + 'tier unknown · full detail at 3', + ); +}); + +test('_smRenderTierRail lights current+1 segments and outlines the full-detail threshold', () => { + const mod = freshPlugin(); + const html = mod._smRenderTierRail({ maxTier: 3, topTier: 3, currentTier: 1, full: false }, 500); + assert.equal((html.match(/class="sm-rail-seg"/g) || []).length, 4, 'maxTier + 1 segments'); + assert.equal((html.match(/#e8c040/g) || []).length, 2, 'tiers 0 and 1 are lit'); + assert.equal((html.match(/rgba\(200,200,200,0\.25\)/g) || []).length, 2, 'tiers 2 and 3 are unlit'); + assert.equal((html.match(/#f4d35e/g) || []).length, 1, 'exactly one outline marks topTier'); + assert.ok(!html.includes('sm-rail-marker'), 'a wide block draws segments, not a marker'); + + // The easy-but-playable case (maxTier 0) is a single segment, still + // outlined — the same semantic the collapsed form draws. + const easy = mod._smRenderTierRail({ maxTier: 0, topTier: 0, currentTier: 0, full: true }, 500); + assert.equal((easy.match(/class="sm-rail-seg/g) || []).length, 1); + assert.equal((easy.match(/#e8c040/g) || []).length, 1, 'the single segment is lit'); + assert.equal((easy.match(/#f4d35e/g) || []).length, 1, 'and outlined'); +}); + +test('_smRenderTierRail collapses to a single marker when the block cannot fit segments', () => { + const mod = freshPlugin(); + // maxTier 3 -> 4 segments * 5px + 3 gaps = 23px needed; 10px cannot fit. + const narrow = mod._smRenderTierRail({ maxTier: 3, topTier: 3, currentTier: 1, full: false }, 10); + assert.equal((narrow.match(/sm-rail-marker/g) || []).length, 1); + assert.equal((narrow.match(/class="sm-rail-seg/g) || []).length, 1, 'only the marker is drawn'); + + // An unmeasurable width (bar not laid out) keeps the segments. + const unmeasured = mod._smRenderTierRail({ maxTier: 3, topTier: 3, currentTier: 1, full: false }, 0); + assert.ok(!unmeasured.includes('sm-rail-marker')); + assert.equal((unmeasured.match(/class="sm-rail-seg"/g) || []).length, 4); +}); + +test('_smRender attaches a rail plus a focusable, readable detail to a section with tier data', () => { + const mod = freshPlugin(); + const bar = new FakeBar(); + mod._setState({ + bar, + sections: [{ name: 'verse2', time: 0 }, { name: 'Chorus', time: 1 }], + duration: 100, + ddAvailable: true, + sectionTiers: { + 0: { index: 0, max_tier: 4, top_tier: 3, current_tier: 2, has_chart_content: true }, + 1: { index: 1, max_tier: 2, top_tier: 2, current_tier: 2, has_chart_content: true }, + }, + }); + mod._smRender(); + assert.ok(bar.innerHTML.includes('class="sm-rail"'), 'rail container rendered'); + assert.ok(bar.innerHTML.includes('tabindex="0"'), 'tier-bearing block is focusable'); + assert.ok( + bar.innerHTML.includes('aria-label="Verse 2 · tier 2 of 4 · full detail at 3"'), + 'accessible name carries the shared detail', + ); + assert.ok( + bar.innerHTML.includes('title="Verse 2 (0:00) · tier 2 of 4 · full detail at 3"'), + 'hover title carries the same detail', + ); +}); + +test('_smRender collapses a very narrow section to a single marker without dropping its label', () => { + const mod = freshPlugin(); + const bar = new FakeBar(); + bar.width = 500; + // Intro is 1% of a 500px bar = 5px -> a 4-segment rail cannot fit. + mod._setState({ + bar, + sections: [{ name: 'Intro', time: 0 }, { name: 'Verse 1', time: 1 }], + duration: 100, + ddAvailable: true, + sectionTiers: { + 0: { index: 0, max_tier: 3, top_tier: 3, current_tier: 1, has_chart_content: true }, + 1: { index: 1, max_tier: 3, top_tier: 3, current_tier: 1, has_chart_content: true }, + }, + }); + mod._smRender(); + assert.equal((bar.innerHTML.match(/sm-rail-marker/g) || []).length, 1, 'narrow Intro collapses'); + assert.ok(bar.innerHTML.includes('>Intro<'), 'label survives the collapse'); +}); + +test('_smRender leaves a section without tier data without a rail or tab stop', () => { + const mod = freshPlugin(); + const bar = new FakeBar(); + mod._setState({ + bar, + sections: [{ name: 'Intro', time: 0 }], + duration: 10, + ddAvailable: true, + sectionTiers: {}, // no overlapping phrase -> absent entry + }); + mod._smRender(); + assert.ok(!bar.innerHTML.includes('class="sm-rail"')); + assert.ok(!bar.innerHTML.includes('tabindex')); +}); + +// _smUpdateTierRails updates an existing rail in place (no DOM rebuild) on a +// difficulty refresh, and only for the sections whose tier data changed. + +class FakeRail { + constructor(html) { this._innerHTML = html || ''; this.writes = 0; this.removed = false; } + get innerHTML() { return this._innerHTML; } + set innerHTML(v) { this._innerHTML = v; this.writes++; } remove() { this.removed = true; } } -class FakeDifficultyBlock { - constructor(glass) { - this._glass = glass || null; - this.insertedHTML = []; - } - querySelector(sel) { return sel === '.sm-glass' ? this._glass : null; } +class FakeRailBlock { + constructor(rail) { this._rail = rail || null; this.title = ''; this._attrs = {}; this.inserted = []; } + querySelector(sel) { return sel === '.sm-rail' ? this._rail : null; } insertAdjacentHTML(_pos, html) { - this.insertedHTML.push(html); - const sizeMatch = html.match(/data-size="(\w+)"/); - const fillMatch = html.match(/height:([\d.]+)%/); - const titleMatch = html.match(/title="([^"]*)"/); - const glass = new FakeGlass(sizeMatch ? sizeMatch[1] : 'medium', fillMatch ? fillMatch[1] : '0'); - if (titleMatch) glass.title = titleMatch[1]; - this._glass = glass; + this.inserted.push(html); + const inner = html.replace(/^]*>/, '').replace(/<\/div>$/, ''); + this._rail = new FakeRail(inner); } + setAttribute(name, value) { this._attrs[name] = value; } + removeAttribute(name) { delete this._attrs[name]; } } -test('_smUpdateDifficultyFills updates an existing glass fill/title in place without rebuilding it', () => { +test('_smUpdateTierRails rebuilds only the rails whose tier data changed', () => { const mod = freshPlugin(); - const glass = new FakeGlass('medium', 10); - const block = new FakeDifficultyBlock(glass); + const sections = [{ name: 'Intro', time: 0 }, { name: 'Verse 1', time: 50 }]; + const block0 = new FakeRailBlock(new FakeRail('old')); + const block1 = new FakeRailBlock(new FakeRail('old')); + const bar = new FakeBar(); mod._setState({ - bar: new FakeBar(), - blockEls: [block], - sectionDifficulty: [{ fillPercentage: 40, glassSize: 'medium' }], + bar, + sections, + duration: 100, + blockEls: [block0, block1], ddAvailable: true, + tierSignatures: ['3|3|1|0', '3|3|1|0'], + sectionTiers: { + 0: { index: 0, max_tier: 3, top_tier: 3, current_tier: 1, has_chart_content: true }, + 1: { index: 1, max_tier: 3, top_tier: 3, current_tier: 1, has_chart_content: true }, + }, }); - mod._smUpdateDifficultyFills(); - - assert.equal(block._glass, glass, 'same glass element reused, not rebuilt'); - assert.equal(glass.removed, false); - assert.equal(block.insertedHTML.length, 0); - assert.equal(glass._fill.style.height, '40%'); - assert.equal(glass.title, 'Difficulty: 40%'); + mod._smUpdateTierRails(); + assert.equal(block0._rail.writes, 0, 'unchanged section is not repainted'); + assert.equal(block1._rail.writes, 0); + + // Only section 0's tier advances. + mod._setState({ sectionTiers: { + 0: { index: 0, max_tier: 3, top_tier: 3, current_tier: 2, has_chart_content: true }, + 1: { index: 1, max_tier: 3, top_tier: 3, current_tier: 1, has_chart_content: true }, + } }); + mod._smUpdateTierRails(); + assert.equal(block0._rail.writes, 1, 'changed section repaints in place'); + assert.equal(block1._rail.writes, 0, 'unrelated section is left alone'); + assert.ok(block0._rail.innerHTML.includes('sm-rail-seg')); + assert.equal(block0._attrs['aria-label'], 'Intro · tier 2 of 3 · full detail at 3'); }); -test('_smUpdateDifficultyFills rebuilds the glass when its size bucket changes', () => { +test('_smUpdateTierRails creates a rail when tier data appears and removes it when it goes away', () => { const mod = freshPlugin(); - const glass = new FakeGlass('small', 10); - const block = new FakeDifficultyBlock(glass); + const sections = [{ name: 'Intro', time: 0 }]; + const block = new FakeRailBlock(null); mod._setState({ bar: new FakeBar(), + sections, + duration: 10, blockEls: [block], - sectionDifficulty: [{ fillPercentage: 75, glassSize: 'large' }], ddAvailable: true, + sectionTiers: {}, }); - mod._smUpdateDifficultyFills(); - - assert.equal(glass.removed, true, 'old size-mismatched glass is discarded'); - assert.equal(block.insertedHTML.length, 1); - assert.notEqual(block._glass, glass, 'a fresh glass element replaces it'); - assert.equal(block._glass.title, 'Difficulty: 75%'); + mod._smUpdateTierRails(); + assert.equal(block.inserted.length, 0, 'no rail while there is no data'); + assert.equal(block._attrs.tabindex, undefined, 'no focus stop without tier data'); + + mod._setState({ sectionTiers: { 0: { index: 0, max_tier: 2, top_tier: 2, current_tier: 1, has_chart_content: true } } }); + mod._smUpdateTierRails(); + assert.equal(block.inserted.length, 1, 'rail inserted when data arrives'); + assert.ok(block.inserted[0].includes('class="sm-rail"')); + assert.equal(block._attrs.tabindex, '0', 'block becomes a focus stop when data arrives'); + assert.equal(block._attrs['aria-label'], 'Intro · tier 1 of 2 · full detail at 2'); + + mod._setState({ sectionTiers: {}, tierSignatures: ['2|2|1|0'] }); + mod._smUpdateTierRails(); + assert.equal(block._rail.removed, true, 'stale rail removed when data disappears'); + assert.equal(block._attrs.tabindex, undefined, 'phantom focus stop must not linger'); + assert.equal(block._attrs['aria-label'], undefined); }); -test('_smUpdateDifficultyFills removes a stale glass once difficulty data is no longer available', () => { +test('_smHandleResize re-decides the width-dependent rail form', () => { const mod = freshPlugin(); - const glass = new FakeGlass('medium', 10); - const block = new FakeDifficultyBlock(glass); + const bar = new FakeBar(); + const block = new FakeRailBlock(new FakeRail('stale')); mod._setState({ - bar: new FakeBar(), + bar, + sections: [{ name: 'Intro', time: 0 }], + duration: 100, blockEls: [block], - sectionDifficulty: [], + sectionTiers: { 0: { index: 0, max_tier: 3, top_tier: 3, current_tier: 1, has_chart_content: true } }, ddAvailable: true, + tierSignatures: ['3|3|1|0'], }); - mod._smUpdateDifficultyFills(); - - assert.equal(glass.removed, true); -}); - -test('_smSetPlayerVisible retries event subscription after feedBack becomes available', () => { - const mod = freshPlugin(); - const originalSetInterval = global.setInterval; - const originalClearInterval = global.clearInterval; - const originalHighway = global.highway; - const originalFeedBack = global.window.feedBack; - let intervalId = 0; - let onCalls = 0; - let unsubscribeCalls = 0; - - global.highway = { - getSections: () => [], - getSongInfo: () => ({ duration: 0 }), - getTime: () => 0, - }; - delete global.window.feedBack; - global.setInterval = () => ++intervalId; - global.clearInterval = () => {}; - - try { - mod._smSetPlayerVisible(true); - global.window.feedBack = { - on: () => { - onCalls++; - return () => { unsubscribeCalls++; }; - }, - }; - - mod._smSetPlayerVisible(true); - assert.equal(onCalls, 1); + bar.width = 10; // 100% of 10px -> a 4-segment rail cannot fit + mod._smHandleResize(); + assert.ok(block._rail.innerHTML.includes('sm-rail-marker'), 'collapses on a narrow bar'); - mod._smSetPlayerVisible(false); - assert.equal(unsubscribeCalls, 1); - } finally { - mod._smSetPlayerVisible(false); - global.setInterval = originalSetInterval; - global.clearInterval = originalClearInterval; - if (typeof originalHighway === 'undefined') delete global.highway; - else global.highway = originalHighway; - if (typeof originalFeedBack === 'undefined') delete global.window.feedBack; - else global.window.feedBack = originalFeedBack; - } + bar.width = 500; // now it fits + mod._smHandleResize(); + assert.ok(!block._rail.innerHTML.includes('sm-rail-marker'), 'expands back to segments'); + assert.ok(block._rail.innerHTML.includes('sm-rail-seg')); }); - -// Difficulty Ladder peer contract (issue #13): absent, late-loaded, minimum, and -// current providers, plus malformed and older payloads. The glasses are the only -// feature that depends on the peer, so every one of these has to leave base -// section navigation working. +// Difficulty Ladder peer contract (issue #14): absent, late-loaded, v3 and +// legacy-v2 providers, plus malformed payloads. The rail is the only feature +// that depends on the peer, so every one of these has to leave base section +// navigation working. // Minimal stand-in for a host event bus: records (event, handler) so a test can // fire the peer's event exactly as the host would, and count subscriptions. @@ -604,9 +716,36 @@ function fakeEventBus() { }; } -// A difficulty_ladder.sections.v2 payload, as difficulty_ladder emits it: an -// object map keyed by section index, one entry per section that has an -// overlapping phrase (sections with none are simply absent). +// A difficulty_ladder.sections.v3 section entry, as difficulty_ladder emits it. +function v3Section(index, overrides) { + return Object.assign({ + id: 'section:' + index, + index, + start_time: index * 10, + end_time: index * 10 + 10, + is_current: false, + current_tier: 1, + top_tier: 3, + max_tier: 3, + avg_top_tier: 3, + has_chart_content: true, + phrases: [], + }, overrides || {}); +} + +function v3Payload(sections, overrides) { + return Object.assign({ + schema: 'difficulty_ladder.sections.v3', + player_context: null, + mastery: 0.5, + max_tier: 4, + current_phrase_index: 0, + sections, + }, overrides || {}); +} + +// A legacy difficulty_ladder.sections.v2 payload: an object map keyed by +// section index, one entry per section with an overlapping phrase. function v2Payload(entries) { return { schema: 'difficulty_ladder.sections.v2', @@ -616,7 +755,7 @@ function v2Payload(entries) { }; } -test('peer contract: no provider means no subscription and no glasses', () => { +test('peer contract: no provider means no subscription and no rails', () => { const mod = freshPlugin(); const bus = fakeEventBus(); global.window.feedBack = bus; @@ -627,8 +766,8 @@ test('peer contract: no provider means no subscription and no glasses', () => { assert.equal(mod._getState().ddAvailable, false); assert.deepEqual(bus.calls, [], 'must not subscribe without the capability marker'); - mod._setState({ sectionDifficulty: { 0: { fillPercentage: 50 } } }); - assert.equal(mod._smGetSectionDifficulty(0), null, 'cached data stays unreachable while the peer is absent'); + mod._setState({ sectionTiers: { 0: { max_tier: 3, top_tier: 3, current_tier: 1, has_chart_content: true } } }); + assert.equal(mod._smGetSectionTier(0), null, 'cached data stays unreachable while the peer is absent'); }); test('peer contract: a bare _ddCapabilities global is not a provider', () => { @@ -636,7 +775,7 @@ test('peer contract: a bare _ddCapabilities global is not a provider', () => { const bus = fakeEventBus(); global.window.feedBack = bus; // Some other plugin using the same global name, or a ladder build from - // before the v2 contract existed. Either way, no sectionDifficulty marker. + // before the sectionDifficulty contract existed. Either way, no marker. global.window._ddCapabilities = { somethingElse: true }; try { assert.equal(mod._smIsDynamicDifficultyAvailable(), false); @@ -647,173 +786,198 @@ test('peer contract: a bare _ddCapabilities global is not a provider', () => { } }); -test('peer contract: a late-loading provider is picked up by the next _smUpdate tick', () => { - // The original handshake decided once, at player-screen mount, which relied - // on the Host loading plugins alphabetically (difficulty_ladder < - // section_map). A deferred or reloaded plugin load left the feature - // unsubscribed for the rest of the session; the retry is the poller. +test('peer contract: a v3-capable provider subscribes to the render-neutral event', () => { const mod = freshPlugin(); - const originalHighway = global.highway; const bus = fakeEventBus(); global.window.feedBack = bus; - global.highway = { - getSections: () => [], - getSongInfo: () => ({ duration: 0 }), - getTime: () => 0, + global.window._ddCapabilities = { + sectionDifficulty: true, + sectionsSchema: 'difficulty_ladder.sections.v3', }; try { - mod._setState({ bar: new FakeBar() }); - mod._smUpdate(); // player already visible, peer not loaded yet - assert.equal(bus.count('difficulty:sections-updated'), 0); - - global.window._ddCapabilities = { sectionDifficulty: true }; - mod._smUpdate(); - assert.equal(bus.count('difficulty:sections-updated'), 1, 'subscribed on the next tick'); - - mod._smUpdate(); - mod._smUpdate(); - assert.equal(bus.count('difficulty:sections-updated'), 1, 'subscribed exactly once'); + assert.equal(mod._smPeerSpeaksV3(), true); + mod._smEnsureDifficultySubscription(); + assert.equal(bus.count('difficulty:sections-updated-v3'), 1); + assert.equal(bus.count('difficulty:sections-updated'), 0, 'v2 is not also subscribed'); } finally { delete global.window._ddCapabilities; - if (typeof originalHighway === 'undefined') delete global.highway; - else global.highway = originalHighway; } }); -test('peer contract: a minimum (v0.12.0) provider payload renders glasses', () => { - // v0.12.0 is the floor: the first release documenting the v2 payload shape. - // It advertises sectionDifficulty and no sectionsSchema (v3 came later). +test('peer contract: a v3 payload populates tier state per section', () => { const mod = freshPlugin(); const bus = fakeEventBus(); global.window.feedBack = bus; - global.window._ddCapabilities = { sectionDifficulty: true }; + global.window._ddCapabilities = { + sectionDifficulty: true, + sectionsSchema: 'difficulty_ladder.sections.v3', + }; try { mod._smEnsureDifficultySubscription(); assert.equal(mod._getState().ddAvailable, true); - bus.fire('difficulty:sections-updated', { detail: v2Payload({ - 0: { fillPercentage: 40, glassSize: 'small', avgDifficulty: 2, maxDifficulty: 2 }, - 1: { fillPercentage: 100, glassSize: 'large', avgDifficulty: 4, maxDifficulty: 4 }, - }) }); + bus.fire('difficulty:sections-updated-v3', { detail: v3Payload([ + v3Section(0, { current_tier: 2, top_tier: 3, max_tier: 3 }), + v3Section(1, { current_tier: 3, top_tier: 3, max_tier: 3 }), + ]) }); - const state = mod._getState(); - assert.equal(state.sectionDifficulty[0].fillPercentage, 40); - assert.equal(state.sectionDifficulty[1].glassSize, 'large'); - // A section the peer reported nothing for is "no glass", not a zero fill. - assert.equal(mod._smGetSectionDifficulty(2), null); + assert.equal(mod._smGetSectionTier(0).current_tier, 2); + assert.equal(mod._smGetSectionTier(1).current_tier, 3); + // A section the peer reported nothing for is "no rail", not a zero tier. + assert.equal(mod._smGetSectionTier(2), null); } finally { delete global.window._ddCapabilities; } }); -test('peer contract: a current provider payload renders in place without a full re-render', () => { +test('peer contract: a v3 payload updates rails in place without a full re-render', () => { const mod = freshPlugin(); const bus = fakeEventBus(); global.window.feedBack = bus; - // Current builds also advertise sectionsSchema, and emit v2 alongside v3. global.window._ddCapabilities = { sectionDifficulty: true, sectionsSchema: 'difficulty_ladder.sections.v3', }; try { mod._smEnsureDifficultySubscription(); - const bar = new FakeBar(); - const block = new FakeDifficultyBlock(new FakeGlass('medium', 10)); + const block = new FakeRailBlock(new FakeRail('old')); mod._setState({ - bar, + bar: new FakeBar(), sections: [{ name: 'Intro', time: 0 }], duration: 10, blockEls: [block], - sectionDifficulty: {}, + sectionTiers: {}, }); - bus.fire('difficulty:sections-updated', { detail: v2Payload({ - 0: { fillPercentage: 60, glassSize: 'medium' }, - }) }); + bus.fire('difficulty:sections-updated-v3', { detail: v3Payload([ + v3Section(0, { current_tier: 2 }), + ]) }); - assert.equal(block.insertedHTML.length, 0, 'existing glass updated in place'); - assert.equal(block._glass._fill.style.height, '60%'); - assert.equal(block._glass.title, 'Difficulty: 60%'); + assert.equal(block.inserted.length, 0, 'existing rail updated in place'); + assert.ok(block._rail.innerHTML.includes('#e8c040')); } finally { delete global.window._ddCapabilities; } }); -test('peer contract: an event with no detail leaves the last good state alone', () => { +test('peer contract: a late-loading provider is picked up by the next _smUpdate tick', () => { + // The handshake re-runs every tick, so a peer that loads (or reloads) after + // this plugin does is still picked up mid-session. const mod = freshPlugin(); + const originalHighway = global.highway; const bus = fakeEventBus(); global.window.feedBack = bus; - global.window._ddCapabilities = { sectionDifficulty: true }; + global.highway = { + getSections: () => [], + getSongInfo: () => ({ duration: 0 }), + getTime: () => 0, + }; try { - mod._smEnsureDifficultySubscription(); - bus.fire('difficulty:sections-updated', { detail: v2Payload({ 0: { fillPercentage: 25, glassSize: 'small' } }) }); + mod._setState({ bar: new FakeBar() }); + mod._smUpdate(); // player already visible, peer not loaded yet + assert.equal(bus.count('difficulty:sections-updated-v3'), 0); - bus.fire('difficulty:sections-updated', undefined); - bus.fire('difficulty:sections-updated', { detail: {} }); - bus.fire('difficulty:sections-updated', { detail: { sectionDifficulties: 'nope' } }); + global.window._ddCapabilities = { + sectionDifficulty: true, + sectionsSchema: 'difficulty_ladder.sections.v3', + }; + mod._smUpdate(); + assert.equal(bus.count('difficulty:sections-updated-v3'), 1, 'subscribed on the next tick'); - assert.equal(mod._getState().sectionDifficulty[0].fillPercentage, 25, 'malformed payloads must not clear good data'); + mod._smUpdate(); + mod._smUpdate(); + assert.equal(bus.count('difficulty:sections-updated-v3'), 1, 'subscribed exactly once'); } finally { delete global.window._ddCapabilities; + if (typeof originalHighway === 'undefined') delete global.highway; + else global.highway = originalHighway; } }); -test('peer contract: an unreadable payload entry paints no glass', () => { - // The peer computes fillPercentage arithmetically, so it is NaN whenever - // mastery or the phrase difficulty isn't a number -- and NaN is typeof - // 'number', which used to sail through the renderer's own check and paint - // `height:NaN%`. A payload that isn't the v2 shape at all is dropped the - // same way: an entry we can't read renders no glass rather than a - // misleading near-empty one. +test('peer contract: a legacy v2-only provider is still supported during the transition', () => { + // No sectionsSchema -> the peer predates v3. Keep consuming its frozen v2 + // event and reconstruct a coarse rail from fillPercentage, ignoring + // glassSize entirely. const mod = freshPlugin(); const bus = fakeEventBus(); global.window.feedBack = bus; global.window._ddCapabilities = { sectionDifficulty: true }; try { mod._smEnsureDifficultySubscription(); - mod._setState({ sectionDifficulty: { 0: { fillPercentage: 25, glassSize: 'small' } } }); - - bus.fire('difficulty:sections-updated', { detail: { - sectionDifficulties: { - 0: { fillPercentage: NaN }, // peer arithmetic went NaN - 1: { fillPercentage: '50' }, // string, not a number - 2: { fillPercentage: null }, - 3: null, // not even an object - 4: { glassSize: 'small' }, // no fill field at all - }, - } }); + assert.equal(bus.count('difficulty:sections-updated'), 1); + assert.equal(bus.count('difficulty:sections-updated-v3'), 0); - const state = mod._getState(); - assert.deepEqual(Object.keys(state.sectionDifficulty), []); - assert.equal(mod._smGetSectionDifficulty(0), null, 'unreadable entries render no glass'); - assert.equal(mod._smRenderGlassFilling({ fillPercentage: NaN }), '', 'NaN fill renders nothing at all'); + bus.fire('difficulty:sections-updated', { detail: v2Payload({ + 0: { fillPercentage: 50, glassSize: 'large', avgDifficulty: 2, maxDifficulty: 4 }, + }) }); + + const entry = mod._smGetSectionTier(0); + assert.equal(entry.max_tier, 4); + assert.equal(entry.top_tier, 4); + assert.equal(entry.current_tier, 2, 'fill 50% of a 4-tier ladder lights 2'); + assert.equal(entry.has_chart_content, true); + assert.equal('glassSize' in entry, false, 'glassSize is discarded, not carried through'); } finally { delete global.window._ddCapabilities; } }); -test('_smRenderGlassFilling only ever writes an allowlisted glassSize into markup', () => { - // glassSize is the one payload field that reaches an HTML attribute - // unescaped (data-size), so an unrecognized value must resolve to `medium` - // rather than being interpolated verbatim. +test('peer contract: an event with no detail leaves the last good state alone', () => { const mod = freshPlugin(); - for (const size of ['small', 'medium', 'large']) { - assert.ok( - mod._smRenderGlassFilling({ fillPercentage: 50, glassSize: size }).includes(`data-size="${size}"`), - `${size} renders its own bucket`, - ); + const bus = fakeEventBus(); + global.window.feedBack = bus; + global.window._ddCapabilities = { + sectionDifficulty: true, + sectionsSchema: 'difficulty_ladder.sections.v3', + }; + try { + mod._smEnsureDifficultySubscription(); + bus.fire('difficulty:sections-updated-v3', { detail: v3Payload([v3Section(0, { current_tier: 1 })]) }); + + bus.fire('difficulty:sections-updated-v3', undefined); + bus.fire('difficulty:sections-updated-v3', { detail: {} }); + bus.fire('difficulty:sections-updated-v3', { detail: { sections: 'nope' } }); + + assert.equal(mod._smGetSectionTier(0).current_tier, 1, 'malformed payloads must not clear good data'); + } finally { + delete global.window._ddCapabilities; } - for (const bogus of ['huge', '">', undefined, null]) { - const html = mod._smRenderGlassFilling({ fillPercentage: 50, glassSize: bogus }); - assert.ok(html.includes('data-size="medium"'), `${bogus} falls back to medium`); - assert.ok(!html.includes(' { + // The peer derives tiers arithmetically, so current_tier can be null and + // max/top tiers can go NaN. An entry we can't read is dropped; a payload + // whose sections array is unusable is ignored, keeping the last good state. + const mod = freshPlugin(); + const bus = fakeEventBus(); + global.window.feedBack = bus; + global.window._ddCapabilities = { + sectionDifficulty: true, + sectionsSchema: 'difficulty_ladder.sections.v3', + }; + try { + mod._smEnsureDifficultySubscription(); + mod._setState({ sectionTiers: { 0: { max_tier: 3, top_tier: 3, current_tier: 1, has_chart_content: true } } }); + + bus.fire('difficulty:sections-updated-v3', { detail: v3Payload([ + v3Section(1, { max_tier: NaN }), // peer arithmetic went NaN + v3Section(2, { top_tier: '3' }), // string, not a number + { index: 3 }, // no tier fields at all + null, // not even an object + ]) }); + + const state = mod._getState(); + assert.deepEqual(Object.keys(state.sectionTiers), []); + assert.equal(mod._smGetSectionTier(1), null, 'unreadable entries render no rail'); + } finally { + delete global.window._ddCapabilities; } }); test('peer contract: hiding the player detaches the difficulty subscription and re-show re-attaches one', () => { // _smDifficultySubscribed is written from two places (the mount path and the // per-tick retry) and reset on hide; a double-subscribe here would double - // every difficulty repaint for the rest of the session. + // every rail repaint for the rest of the session. const mod = freshPlugin(); const originalSetInterval = global.setInterval; const originalClearInterval = global.clearInterval; @@ -821,7 +985,10 @@ test('peer contract: hiding the player detaches the difficulty subscription and const bus = fakeEventBus(); global.window.feedBack = bus; - global.window._ddCapabilities = { sectionDifficulty: true }; + global.window._ddCapabilities = { + sectionDifficulty: true, + sectionsSchema: 'difficulty_ladder.sections.v3', + }; global.highway = { getSections: () => [], getSongInfo: () => ({ duration: 0 }), @@ -832,18 +999,18 @@ test('peer contract: hiding the player detaches the difficulty subscription and try { mod._smSetPlayerVisible(true); - assert.equal(bus.count('difficulty:sections-updated'), 1); + assert.equal(bus.count('difficulty:sections-updated-v3'), 1); mod._smSetPlayerVisible(false); - assert.equal(bus.count('difficulty:sections-updated'), 0, 'hide must detach'); + assert.equal(bus.count('difficulty:sections-updated-v3'), 0, 'hide must detach'); mod._smSetPlayerVisible(true); - assert.equal(bus.count('difficulty:sections-updated'), 1, 're-show re-subscribes exactly once'); + assert.equal(bus.count('difficulty:sections-updated-v3'), 1, 're-show re-subscribes exactly once'); // And a tick must not add a second handler on top. mod._smUpdate(); mod._smUpdate(); - assert.equal(bus.count('difficulty:sections-updated'), 1); + assert.equal(bus.count('difficulty:sections-updated-v3'), 1); } finally { mod._smSetPlayerVisible(false); global.setInterval = originalSetInterval; @@ -865,7 +1032,10 @@ test('peer contract: the per-tick retry never re-registers when the bus hands ba global.window.feedBack = { on() { registrations++; return undefined; }, }; - global.window._ddCapabilities = { sectionDifficulty: true }; + global.window._ddCapabilities = { + sectionDifficulty: true, + sectionsSchema: 'difficulty_ladder.sections.v3', + }; global.highway = { getSections: () => [], getSongInfo: () => ({ duration: 0 }), @@ -887,7 +1057,10 @@ test('peer contract: the retry waits for the host bus when the peer is already u // retrying rather than marking the subscription done. const mod = freshPlugin(); const originalHighway = global.highway; - global.window._ddCapabilities = { sectionDifficulty: true }; + global.window._ddCapabilities = { + sectionDifficulty: true, + sectionsSchema: 'difficulty_ladder.sections.v3', + }; global.highway = { getSections: () => [], getSongInfo: () => ({ duration: 0 }), @@ -901,7 +1074,7 @@ test('peer contract: the retry waits for the host bus when the peer is already u const bus = fakeEventBus(); global.window.feedBack = bus; mod._smUpdate(); - assert.equal(bus.count('difficulty:sections-updated'), 1); + assert.equal(bus.count('difficulty:sections-updated-v3'), 1); } finally { delete global.window._ddCapabilities; if (typeof originalHighway === 'undefined') delete global.highway; @@ -909,23 +1082,73 @@ test('peer contract: the retry waits for the host bus when the peer is already u } }); -test('_smNormalizeSectionDifficulties separates an unusable payload from an empty one', () => { - const mod = freshPlugin(); - // null: not an object at all -> "ignore the event", keep current state. - assert.equal(mod._smNormalizeSectionDifficulties(undefined), null); - assert.equal(mod._smNormalizeSectionDifficulties(null), null); - assert.equal(mod._smNormalizeSectionDifficulties(7), null); - // {}: a well-formed map with nothing renderable -> "clear the glasses". - assert.deepEqual(mod._smNormalizeSectionDifficulties({}), {}); - assert.deepEqual(mod._smNormalizeSectionDifficulties({ 0: 'nope' }), {}); - // A 0% fill is real data (a section with no chart content), not a missing one. - const zeroFill = mod._smNormalizeSectionDifficulties({ 0: { fillPercentage: 0, glassSize: 'small' } }); - assert.equal(zeroFill[0].fillPercentage, 0); - // Surrounding keys are not the contract: the producer keys by section index, - // and only the entries we can render survive. +test('_smSetPlayerVisible retries the song:ready subscription after feedBack becomes available', () => { + // The host bus may not exist when the player first mounts; _smSongReadySubscribed + // stays false so the next _smSetPlayerVisible(true) retries it. + const mod = freshPlugin(); + const originalSetInterval = global.setInterval; + const originalClearInterval = global.clearInterval; + const originalHighway = global.highway; + const originalFeedBack = global.window.feedBack; + global.setInterval = () => 1; + global.clearInterval = () => {}; + global.highway = { + getSections: () => [], + getSongInfo: () => ({ duration: 0 }), + getTime: () => 0, + }; + try { + delete global.window.feedBack; + mod._smSetPlayerVisible(true); + assert.equal(mod._getState().songReadySubscribed, false); + + const bus = fakeEventBus(); + global.window.feedBack = bus; + mod._smSetPlayerVisible(true); + assert.equal(bus.count('song:ready'), 1); + assert.equal(mod._getState().songReadySubscribed, true); + } finally { + mod._smSetPlayerVisible(false); + global.setInterval = originalSetInterval; + global.clearInterval = originalClearInterval; + if (typeof originalHighway === 'undefined') delete global.highway; + else global.highway = originalHighway; + if (typeof originalFeedBack === 'undefined') delete global.window.feedBack; + else global.window.feedBack = originalFeedBack; + } +}); + +test('_smNormalizeSectionTiers separates an unusable payload from an empty one', () => { + const mod = freshPlugin(); + // null: not an object, or no usable sections array -> "ignore the event". + assert.equal(mod._smNormalizeSectionTiers(undefined), null); + assert.equal(mod._smNormalizeSectionTiers(null), null); + assert.equal(mod._smNormalizeSectionTiers(7), null); + assert.equal(mod._smNormalizeSectionTiers({ sections: 'nope' }), null); + // {}: a well-formed payload with nothing renderable -> "clear the rails". + assert.deepEqual(mod._smNormalizeSectionTiers({ sections: [] }), {}); + assert.deepEqual(mod._smNormalizeSectionTiers(v3Payload([{ index: 0 }])), {}); + // A null current_tier is real data (unknown, not zero) and survives. + const unknown = mod._smNormalizeSectionTiers(v3Payload([v3Section(0, { current_tier: null })])); + assert.equal(unknown[0].current_tier, null); + // Surrounding keys are not the contract: the producer keys by section index. assert.deepEqual( - Object.keys(mod._smNormalizeSectionDifficulties({ 0: { fillPercentage: 1 }, schema: 'x' })), + Object.keys(mod._smNormalizeSectionTiers(v3Payload([v3Section(0)], { schema: 'x' }))), ['0'], ); }); +test('_smNormalizeSectionTiersV2 separates an unusable payload from an empty one', () => { + const mod = freshPlugin(); + assert.equal(mod._smNormalizeSectionTiersV2(undefined), null); + assert.equal(mod._smNormalizeSectionTiersV2(null), null); + assert.equal(mod._smNormalizeSectionTiersV2(7), null); + assert.deepEqual(mod._smNormalizeSectionTiersV2({}), {}); + assert.deepEqual(mod._smNormalizeSectionTiersV2({ 0: 'nope' }), {}); + // A 0% fill with no ladder and no content is a silent section: no rail. + assert.equal(mod._smNormalizeSectionTiersV2({ 0: { fillPercentage: 0, maxDifficulty: 0 } })[0].has_chart_content, false); + // A 100% fill with no ladder is the easy-but-playable case: a full rail. + const easy = mod._smNormalizeSectionTiersV2({ 0: { fillPercentage: 100, maxDifficulty: 0 } })[0]; + assert.equal(easy.has_chart_content, true); + assert.equal(easy.current_tier, 0); +}); From d324285f48ab540a4cbb6f66845486487a88b663 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:08:16 +0000 Subject: [PATCH 2/2] Split _smUpdateTierRails into rail/attribute helpers (CodeFactor) --- screen.js | 59 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/screen.js b/screen.js index 194bb93..9607246 100644 --- a/screen.js +++ b/screen.js @@ -656,6 +656,37 @@ function _smRender() { // section colors/labels/positions, so a full _smRender() on every event would // tear down and rebuild every block's DOM just to repaint a few small rails. // A per-section signature skips the rails whose tier data did not change. +function _smUpdateBlockRail(block, model, blockPx) { + if (!block || typeof block.querySelector !== 'function') return; + const rail = block.querySelector('.sm-rail'); + if (model) { + if (rail) rail.innerHTML = _smRailInner(model, blockPx); + else block.insertAdjacentHTML('afterbegin', _smRenderTierRail(model, blockPx)); + } else if (rail) { + rail.remove(); + } +} + +function _smUpdateBlockAttributes(block, sec, model) { + if (!block || typeof block.querySelector !== 'function') return; + const { titleLabel } = _smSectionLabel(sec); + const tierSuffix = _smTierSuffix(model); + const baseTitle = `${titleLabel} (${_smFmt(sec.time)})`; + block.title = tierSuffix ? `${baseTitle} · ${tierSuffix}` : baseTitle; + // Mirror _smRender's attributes exactly, in both directions: a block + // that gains tier data becomes a focus stop, and one that loses it + // stops being one (otherwise tabindex lingers on a bare, unnamed div). + if (typeof block.setAttribute === 'function' && typeof block.removeAttribute === 'function') { + if (model) { + block.setAttribute('aria-label', `${titleLabel} · ${tierSuffix}`); + block.setAttribute('tabindex', '0'); + } else { + block.removeAttribute('aria-label'); + block.removeAttribute('tabindex'); + } + } +} + function _smUpdateTierRails() { if (!_smBar || !_smBlockEls.length) return; const barWidth = _smBar.getBoundingClientRect ? _smBar.getBoundingClientRect().width : 0; @@ -666,37 +697,15 @@ function _smUpdateTierRails() { _smTierSignatures[i] = signature; const block = _smBlockEls[i]; - if (!block || typeof block.querySelector !== 'function') continue; + if (!block) continue; const sec = _smSections[i]; const nextTime = i < _smSections.length - 1 ? _smSections[i + 1].time : _smDuration; const widthPct = _smDuration > 0 ? ((nextTime - sec.time) / _smDuration) * 100 : 0; const blockPx = barWidth > 0 ? (widthPct / 100) * barWidth : 0; - const rail = block.querySelector('.sm-rail'); - if (model) { - if (rail) rail.innerHTML = _smRailInner(model, blockPx); - else block.insertAdjacentHTML('afterbegin', _smRenderTierRail(model, blockPx)); - } else if (rail) { - rail.remove(); - } - - const { titleLabel } = _smSectionLabel(sec); - const tierSuffix = _smTierSuffix(model); - const baseTitle = `${titleLabel} (${_smFmt(sec.time)})`; - block.title = tierSuffix ? `${baseTitle} · ${tierSuffix}` : baseTitle; - // Mirror _smRender's attributes exactly, in both directions: a block - // that gains tier data becomes a focus stop, and one that loses it - // stops being one (otherwise tabindex lingers on a bare, unnamed div). - if (typeof block.setAttribute === 'function' && typeof block.removeAttribute === 'function') { - if (model) { - block.setAttribute('aria-label', `${titleLabel} · ${tierSuffix}`); - block.setAttribute('tabindex', '0'); - } else { - block.removeAttribute('aria-label'); - block.removeAttribute('tabindex'); - } - } + _smUpdateBlockRail(block, model, blockPx); + _smUpdateBlockAttributes(block, sec, model); } }