From 77628a5d7d5d48ee75619ea4d5cabae594a5c04d Mon Sep 17 00:00:00 2001
From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com>
Date: Mon, 5 Oct 2026 12:51:45 +0000
Subject: [PATCH 1/5] Document and harden the Difficulty Ladder peer contract
(#13)
The difficulty glasses are Section Map's only cross-plugin dependency, and
neither the README nor the manifest said so. Pin the contract in screen.js,
CLAUDE.md and the README, and harden the handshake itself.
- Retry the capability check on every 200ms poller tick instead of deciding
once per player-screen mount. The old one-shot check relied on the Host
loading plugins alphabetically; core's session.js says plugins load
ASYNCHRONOUSLY "in whatever order the race settles", so a late or reloaded
Difficulty Ladder left the feature unsubscribed for the rest of the session.
There is no readiness event to subscribe to: the peer sets its marker at
top-level script execution and declares no capability domain.
- Require window._ddCapabilities.sectionDifficulty === true rather than the
mere existence of that global.
- Normalize payloads before rendering: drop entries whose fillPercentage is not
a finite number (the peer's arithmetic can produce NaN, which sailed through
the old typeof === 'number' check and reached the style attribute as
height:NaN%), and ignore a payload container that isn't an object so the last
good state survives. A well-formed map with nothing renderable still clears
the glasses.
- Allowlist glassSize against SM_GLASS_SIZES: it is the one payload field that
reaches markup unescaped, as data-size.
- Publish Difficulty Ladder v0.12.0 as the payload floor. The issue proposed
v0.9.13, but that version was never released; the peer's own README and
CHANGELOG call v0.12.0 the floor (its 0.12.0 entry is where
difficulty_ladder.sections.v2 is documented; the event itself shipped in
v0.2.0). Document it as a floor rather than a check: the capability marker
carries no version and predates v0.2.0, so a below-floor peer still renders,
it just computes fillPercentage with the pre-0.9.11 formula and can disagree
with Difficulty Ladder's own HUD.
- Declare the optional peer in plugin.json's capabilities block (roles,
observes, compatibility degrade-noop), which is the manifest surface the spec
defines for cross-plugin participation: there is no dependency key.
- Add 12 peer-contract tests covering an absent provider, a foreign
_ddCapabilities global, a late-loading provider, v0.12.0-shaped and current
(v3-advertising) payloads, malformed events, unreadable entries, glassSize
markup, show/hide re-subscription, and the no-unsubscribe-handle retry path.
v3 (difficulty:sections-updated-v3) is deliberately left unconsumed; #14 owns
that rework.
---
CLAUDE.md | 65 ++++++--
README.md | 36 +++++
plugin.json | 16 +-
screen.js | 226 +++++++++++++++++++--------
tests/screen.test.js | 355 +++++++++++++++++++++++++++++++++++++++++++
5 files changed, 625 insertions(+), 73 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 328f460..81150c1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -30,15 +30,62 @@ 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: the old one leaned on alphabetical plugin loading
+ (`difficulty_ladder` < `section_map`), which core does not actually promise —
+ `feedBack/static/js/session.js` is explicit that "plugins load ASYNCHRONOUSLY,
+ so the chain links up in whatever order the race settles". There is no
+ readiness event to subscribe to either — the peer sets its marker at top-level
+ script execution and declares no capability domain — so the 200 ms tick is the
+ retry. `_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..d6e9d10 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,144 @@ 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. The peer sets
+// its marker at top-level script execution and emits no readiness event, and
+// the Host does not order plugin loads — core's session.js is explicit that
+// "plugins load ASYNCHRONOUSLY, so [a wrapper chain] links up in whatever order
+// the race settles". _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 +203,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() {
@@ -244,6 +328,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 +462,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 +504,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 +515,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 +558,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..c4ce1a1 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);
+ if (!handlers.has(eventName)) handlers.set(eventName, []);
+ handlers.get(eventName).push(handler);
+ return () => {
+ const list = handlers.get(eventName) || [];
+ const i = list.indexOf(handler);
+ if (i >= 0) list.splice(i, 1);
+ };
+ },
+ 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('