diff --git a/CLAUDE.md b/CLAUDE.md index 328f460..b2dc53f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,15 +30,70 @@ reading state straight off the Host's `window.highway` object. `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 — `_smIsDynamicDifficultyAvailable()` - gates the subscription on `window._ddCapabilities`, absent means no - glasses are shown, not 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). Load order matters: plugins load - alphabetically, so `difficulty_ladder` (< `section_map`) sets - `window._ddCapabilities` before this plugin's one-time availability check - runs. + 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 + 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 + version is pinned as a comment block above `_smIsDynamicDifficultyAvailable()` + in `screen.js`; keep the two in sync if either changes. +- **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 + after this plugin is still picked up mid-session. Don't reintroduce a + one-shot check, and don't reintroduce the *wrong reason* for removing it + either. The accurate version: when both plugins are 'ready' in one + `/api/plugins` response, core's `static/js/plugin-loader.js` injects `screen.js` + files one at a time in sorted order (`name || id`, ties broken by id), so + `difficulty_ladder` does sort ahead of `section_map` and a one-shot check + happened to work. That ordering is a property of that one response, not a + promise — the loader skips any plugin whose status isn't `'ready'`, refetches + on `plugin-registered` startup events, and the backend "clears its plugin + registry at the start of `load_plugins()` and repopulates it incrementally + while HTTP stays up, so every backend restart serves a window of partial (even + empty) responses". A mid-install peer therefore lands on a later refetch, after + our script body has run, and our script does not re-run either (the loader's + `loadedScripts` guard), so the 200 ms tick is the only retry. The peer emits no + readiness event to subscribe to — it just sets its marker at top-level script + execution and declares no capability domain. `_smIsDynamicDifficultyAvailable()` + requires `window._ddCapabilities.sectionDifficulty === true`, not merely that + some `_ddCapabilities` global exists, so a foreign user of that global can't + unlock a subscription we can't render. That function must stay + reachable-before-bus: it returns early when `feedBack.on` isn't a function yet, + and once it has registered a handler `_smDifficultySubscribed` stays true even + if the bus returned no unsubscribe handle — otherwise the tick would + re-register one handler per 200ms. +- **The peer compatibility floor is Difficulty Ladder v0.12.0, not v0.2.0.** + v0.2.0 is when the *event* first shipped; v0.12.0 is where that plugin's + CHANGELOG documents the `difficulty_ladder.sections.v2` payload shape this + plugin renders. Issue #13 proposed publishing `v0.9.13` as the floor, but that + version was never released — the peer's own README and CHANGELOG call + `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 + 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. - **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 d8b95bb..64eb78b 100644 --- a/README.md +++ b/README.md @@ -9,6 +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. ## Installation @@ -20,6 +21,41 @@ docker compose restart The section map automatically appears at the top of the player when you play a song. +## Difficulty glasses (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 +[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. + +| 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` | + +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 + 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. +- 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. + ## License MIT diff --git a/plugin.json b/plugin.json index 04e89e9..75ee277 100644 --- a/plugin.json +++ b/plugin.json @@ -1,10 +1,20 @@ { "id": "section_map", "name": "Section Map", - "version": "1.2.7", + "version": "1.3.0", "private": false, "script": "screen.js", "category": "practice", - "description": "Visual song-section map and navigation.", - "icon": "assets/thumb.svg" + "description": "Visual song-section map and navigation. Optional: Difficulty Ladder v0.12.0+ adds per-section difficulty glasses.", + "icon": "assets/thumb.svg", + "capabilities": { + "difficulty-ladder": { + "roles": ["observer"], + "observes": ["difficulty:sections-updated"], + "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." + } + } } diff --git a/screen.js b/screen.js index cfc1062..ca48a97 100644 --- a/screen.js +++ b/screen.js @@ -1,6 +1,7 @@ // Section Map plugin // Shows a minimap bar of the full song structure with clickable sections. -// Includes optional glass-filling difficulty visualization when dynamic-difficulty is installed. +// Includes optional glass-filling difficulty visualization when the +// Difficulty Ladder plugin (difficulty_ladder) is installed. let _smBar = null; let _smSections = []; @@ -49,40 +50,153 @@ function _smGetColor(name) { return SM_COLORS.default; } -// Check if the difficulty_ladder plugin (formerly "dynamic-difficulty") is -// installed and available. `window._ddCapabilities` is the compatibility -// marker it sets at load time (see difficulty_ladder's screen.js and -// INTEGRATION.md, issue #63) -- the marker name predates that plugin's -// rename from `dynamic_difficulty` to `difficulty_ladder`, kept as-is since -// it's the established capability-marker contract between the two plugins. -// (A second check here used to also probe `window.feedBackViz_dynamic_difficulty`, -// a viz-factory-naming-convention global difficulty_ladder never actually -// set even before the rename -- dead since this function was written.) +function _smSubscribeFeedBackEvent(eventName, handler) { + if (typeof window.feedBack === 'undefined' || typeof window.feedBack.on !== 'function') return null; + const unsubscribe = window.feedBack.on(eventName, handler); + if (typeof unsubscribe === 'function') return unsubscribe; + if (typeof window.feedBack.off === 'function') return () => window.feedBack.off(eventName, handler); + return null; +} + +// 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: +// +// 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. +const _SM_DD_SECTIONS_EVENT = 'difficulty:sections-updated'; + +// 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 +// subscribing to an event whose payload shape we cannot render. function _smIsDynamicDifficultyAvailable() { if (typeof window.feedBack === 'undefined') return false; - if (typeof window._ddCapabilities !== 'undefined') return true; - return false; + const caps = window._ddCapabilities; + return !!caps && typeof caps === 'object' && caps.sectionDifficulty === true; } -// Get difficulty data for a section from dynamic-difficulty plugin +// Validate one `difficulty_ladder.sections.v2` payload's section map. +// +// 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). +// +// 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) { + 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; + } + return normalized; +} + +// Get difficulty data for a section from the Difficulty Ladder peer function _smGetSectionDifficulty(sectionIndex) { if (!_smDynamicDifficultyAvailable) return null; - // This would be populated by a capability or event from dynamic-difficulty - // For now, return from cache if available + // Populated by difficulty_ladder's difficulty:sections-updated event. return _smSectionDifficulty[sectionIndex] || null; } -// Initialize difficulty data listener if dynamic-difficulty is available +// Initialize difficulty data listener if the Difficulty Ladder peer is available function _smInitializeDifficultyListener() { _smDynamicDifficultyAvailable = _smIsDynamicDifficultyAvailable(); } -function _smSubscribeFeedBackEvent(eventName, handler) { - if (typeof window.feedBack === 'undefined' || typeof window.feedBack.on !== 'function') return null; - const unsubscribe = window.feedBack.on(eventName, handler); - if (typeof unsubscribe === 'function') return unsubscribe; - if (typeof window.feedBack.off === 'function') return () => window.feedBack.off(eventName, handler); - return null; +// 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). + if (_smBar && _smSections.length > 0 && _smBlockEls.length === _smSections.length) { + _smUpdateDifficultyFills(); + } else { + _smRender(); + } +} + +// Subscribe to the peer's section-difficulty 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 +// files one at a time in sorted order (`name || id`, ties broken by id), so +// difficulty_ladder sorts ahead of section_map and the old one-shot check +// happened to work. That ordering is a property of that one response, not a +// guarantee: static/js/plugin-loader.js skips any plugin whose status isn't +// 'ready', and the backend "clears its plugin registry at the start of +// load_plugins() and repopulates it incrementally while HTTP stays up, so every +// backend restart serves a window of partial (even empty) responses". A peer +// that is mid-install then arrives on a later refetch (the loader refetches on +// `plugin-registered` startup events) — after our script body has already run, +// and our script does not run again either, so nothing else would ever retry. +// The peer emits no readiness event to subscribe to either; it just sets its +// marker at top-level script execution. So _smUpdate() calls this on every +// 200ms tick while the player is visible: one property read per tick, +// 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. +function _smEnsureDifficultySubscription() { + if (_smDifficultySubscribed) return; + _smInitializeDifficultyListener(); + if (!_smDynamicDifficultyAvailable) return; + const bus = window.feedBack; + if (!bus || typeof bus.on !== 'function') return; // host bus not up yet -- retry next tick + // From here a handler IS registered, so _smDifficultySubscribed means + // "registered", not "removable": the bus isn't required to return an + // 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); } function _smStartRealtimeHooks() { @@ -98,28 +212,7 @@ function _smStartRealtimeHooks() { _smSongReadySubscribed = typeof _smSongReadyUnsubscribe === 'function'; } - _smInitializeDifficultyListener(); - if (_smDynamicDifficultyAvailable && !_smDifficultySubscribed) { - _smDifficultyUnsubscribe = _smSubscribeFeedBackEvent('difficulty:sections-updated', (event) => { - const { sectionDifficulties } = event.detail || {}; - if (sectionDifficulties) { - _smSectionDifficulty = sectionDifficulties; - // 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). - if (_smBar && _smSections.length > 0 && _smBlockEls.length === _smSections.length) { - _smUpdateDifficultyFills(); - } else { - _smRender(); - } - } - }); - _smDifficultySubscribed = typeof _smDifficultyUnsubscribe === 'function'; - } + _smEnsureDifficultySubscription(); } function _smStopRealtimeHooks() { @@ -136,9 +229,11 @@ function _smStopRealtimeHooks() { if (typeof _smDifficultyUnsubscribe === 'function') { _smDifficultyUnsubscribe(); + _smDifficultyUnsubscribe = null; + _smDifficultySubscribed = false; } - _smDifficultyUnsubscribe = null; - _smDifficultySubscribed = false; + // If there's no disposer, keep _smDifficultySubscribed true to prevent + // accumulating handlers on hide/show cycles (bus returned no unsubscribe handle). } function _smSetPlayerVisible(isVisible) { @@ -244,6 +339,11 @@ 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 + // thing that can't wait for the next song. No-op once subscribed. + _smEnsureDifficultySubscription(); + if (!_smBar) return; const sections = highway.getSections(); const info = highway.getSongInfo(); @@ -373,21 +473,34 @@ function _smRender() { _smActiveIdx = -1; } +// 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) { - if (!difficulty || typeof difficulty.fillPercentage !== 'number') return ''; + // 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 = difficulty.glassSize || 'medium'; // small, medium, large - - // Size classes for the glass - const sizeStyles = { - small: 'width:12px;height:12px;', - medium: 'width:16px;height:16px;', - large: 'width:20px;height:20px;', - }; - - const glassStyle = sizeStyles[glassSize] || sizeStyles.medium; + 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, @@ -402,10 +515,10 @@ function _smRenderGlassFilling(difficulty) { // 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 dynamic-difficulty tracks accuracy — 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. +// 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() { if (!_smBar || !_smBlockEls.length) return; for (let i = 0; i < _smBlockEls.length; i++) { @@ -413,9 +526,9 @@ function _smUpdateDifficultyFills() { if (!block || typeof block.querySelector !== 'function') continue; const difficulty = _smGetSectionDifficulty(i); const glass = block.querySelector('.sm-glass'); - if (_smDynamicDifficultyAvailable && difficulty && typeof difficulty.fillPercentage === 'number') { + if (_smDynamicDifficultyAvailable && difficulty && Number.isFinite(difficulty.fillPercentage)) { const fillPct = Math.max(0, Math.min(100, difficulty.fillPercentage)); - const glassSize = difficulty.glassSize || 'medium'; + 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 @@ -456,10 +569,12 @@ if (typeof module !== 'undefined' && module.exports) { _smOnClick, _smOnWheel, _smEscapeHtml, _smIsDynamicDifficultyAvailable, _smGetSectionDifficulty, _smRenderGlassFilling, _smUpdateDifficultyFills, + _smNormalizeSectionDifficulties, _smOnSectionsUpdated, _smEnsureDifficultySubscription, _smInitializeDifficultyListener, _smStartRealtimeHooks, _smStopRealtimeHooks, _smSetPlayerVisible, _getState: () => ({ bar: _smBar, sections: _smSections, duration: _smDuration, sectionDifficulty: _smSectionDifficulty, ddAvailable: _smDynamicDifficultyAvailable, markerEl: _smMarkerEl, blockEls: _smBlockEls, activeIdx: _smActiveIdx, + songReadySubscribed: _smSongReadySubscribed, difficultySubscribed: _smDifficultySubscribed, }), _setState(next) { if ('sections' in next) _smSections = next.sections; diff --git a/tests/screen.test.js b/tests/screen.test.js index 463f31f..9c74dd5 100644 --- a/tests/screen.test.js +++ b/tests/screen.test.js @@ -574,3 +574,358 @@ test('_smSetPlayerVisible retries event subscription after feedBack becomes avai else global.window.feedBack = originalFeedBack; } }); + + +// 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. + +// 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. +function fakeEventBus() { + const handlers = new Map(); + return { + calls: [], + on(eventName, handler) { + this.calls.push(eventName); + // Replace the list rather than mutating it: fire() captures the list + // when it starts, so a handler that subscribes or unsubscribes + // mid-dispatch can't disturb the dispatch already in progress. + handlers.set(eventName, (handlers.get(eventName) || []).concat(handler)); + return () => { + handlers.set(eventName, (handlers.get(eventName) || []).filter((h) => h !== handler)); + }; + }, + count(eventName) { return (handlers.get(eventName) || []).length; }, + fire(eventName, event) { + for (const handler of handlers.get(eventName) || []) handler(event); + }, + }; +} + +// 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). +function v2Payload(entries) { + return { + schema: 'difficulty_ladder.sections.v2', + sectionDifficulties: entries, + mastery: 0.5, + maxDifficulty: 4, + }; +} + +test('peer contract: no provider means no subscription and no glasses', () => { + const mod = freshPlugin(); + const bus = fakeEventBus(); + global.window.feedBack = bus; + assert.equal(mod._smIsDynamicDifficultyAvailable(), false); + + mod._smEnsureDifficultySubscription(); + mod._smEnsureDifficultySubscription(); + + 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'); +}); + +test('peer contract: a bare _ddCapabilities global is not a provider', () => { + const mod = freshPlugin(); + 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. + global.window._ddCapabilities = { somethingElse: true }; + try { + assert.equal(mod._smIsDynamicDifficultyAvailable(), false); + mod._smEnsureDifficultySubscription(); + assert.deepEqual(bus.calls, []); + } finally { + delete global.window._ddCapabilities; + } +}); + +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. + const mod = freshPlugin(); + const originalHighway = global.highway; + const bus = fakeEventBus(); + global.window.feedBack = bus; + global.highway = { + getSections: () => [], + getSongInfo: () => ({ duration: 0 }), + getTime: () => 0, + }; + 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'); + } 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). + const mod = freshPlugin(); + const bus = fakeEventBus(); + global.window.feedBack = bus; + global.window._ddCapabilities = { sectionDifficulty: true }; + 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 }, + }) }); + + 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); + } finally { + delete global.window._ddCapabilities; + } +}); + +test('peer contract: a current provider payload renders 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)); + mod._setState({ + bar, + sections: [{ name: 'Intro', time: 0 }], + duration: 10, + blockEls: [block], + sectionDifficulty: {}, + }); + + bus.fire('difficulty:sections-updated', { detail: v2Payload({ + 0: { fillPercentage: 60, glassSize: 'medium' }, + }) }); + + 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%'); + } finally { + delete global.window._ddCapabilities; + } +}); + +test('peer contract: an event with no detail leaves the last good state alone', () => { + const mod = freshPlugin(); + const bus = fakeEventBus(); + global.window.feedBack = bus; + global.window._ddCapabilities = { sectionDifficulty: true }; + try { + mod._smEnsureDifficultySubscription(); + bus.fire('difficulty:sections-updated', { detail: v2Payload({ 0: { fillPercentage: 25, glassSize: 'small' } }) }); + + bus.fire('difficulty:sections-updated', undefined); + bus.fire('difficulty:sections-updated', { detail: {} }); + bus.fire('difficulty:sections-updated', { detail: { sectionDifficulties: 'nope' } }); + + assert.equal(mod._getState().sectionDifficulty[0].fillPercentage, 25, 'malformed payloads must not clear good data'); + } finally { + delete global.window._ddCapabilities; + } +}); + +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. + 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 + }, + } }); + + 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'); + } 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. + 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`, + ); + } + 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(' { + // _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. + const mod = freshPlugin(); + const originalSetInterval = global.setInterval; + const originalClearInterval = global.clearInterval; + const originalHighway = global.highway; + const bus = fakeEventBus(); + + global.window.feedBack = bus; + global.window._ddCapabilities = { sectionDifficulty: true }; + global.highway = { + getSections: () => [], + getSongInfo: () => ({ duration: 0 }), + getTime: () => 0, + }; + global.setInterval = () => 1; + global.clearInterval = () => {}; + + try { + mod._smSetPlayerVisible(true); + assert.equal(bus.count('difficulty:sections-updated'), 1); + + mod._smSetPlayerVisible(false); + assert.equal(bus.count('difficulty:sections-updated'), 0, 'hide must detach'); + + mod._smSetPlayerVisible(true); + assert.equal(bus.count('difficulty:sections-updated'), 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); + } finally { + mod._smSetPlayerVisible(false); + global.setInterval = originalSetInterval; + global.clearInterval = originalClearInterval; + delete global.window._ddCapabilities; + if (typeof originalHighway === 'undefined') delete global.highway; + else global.highway = originalHighway; + } +}); + +test('peer contract: the per-tick retry never re-registers when the bus hands back no unsubscribe', () => { + // The bus is not required to return an unsubscribe handle (a plain + // EventTarget's on/off returns undefined). Registering once and keeping the + // handler is the only correct outcome -- re-registering every 200ms would + // leak a handler per tick. + const mod = freshPlugin(); + const originalHighway = global.highway; + let registrations = 0; + global.window.feedBack = { + on() { registrations++; return undefined; }, + }; + global.window._ddCapabilities = { sectionDifficulty: true }; + global.highway = { + getSections: () => [], + getSongInfo: () => ({ duration: 0 }), + getTime: () => 0, + }; + try { + mod._setState({ bar: new FakeBar() }); + for (let i = 0; i < 5; i++) mod._smUpdate(); + assert.equal(registrations, 1); + } finally { + delete global.window._ddCapabilities; + if (typeof originalHighway === 'undefined') delete global.highway; + else global.highway = originalHighway; + } +}); + +test('peer contract: the retry waits for the host bus when the peer is already up', () => { + // Peer present, host event bus not: nothing to subscribe to yet, so keep + // retrying rather than marking the subscription done. + const mod = freshPlugin(); + const originalHighway = global.highway; + global.window._ddCapabilities = { sectionDifficulty: true }; + global.highway = { + getSections: () => [], + getSongInfo: () => ({ duration: 0 }), + getTime: () => 0, + }; + try { + mod._setState({ bar: new FakeBar() }); + mod._smUpdate(); + assert.equal(mod._getState().difficultySubscribed, false); + + const bus = fakeEventBus(); + global.window.feedBack = bus; + mod._smUpdate(); + assert.equal(bus.count('difficulty:sections-updated'), 1); + } finally { + delete global.window._ddCapabilities; + if (typeof originalHighway === 'undefined') delete global.highway; + else global.highway = originalHighway; + } +}); + +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. + assert.deepEqual( + Object.keys(mod._smNormalizeSectionDifficulties({ 0: { fillPercentage: 1 }, schema: 'x' })), + ['0'], + ); +}); +