diff --git a/AGENTS.md b/AGENTS.md index fc7a44db..99ab37a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,9 +244,10 @@ window.feedBackViz_my_viz = function () { // `bundle` is a snapshot with: currentTime, songInfo, isReady, // notes, chords, anchors (all difficulty-filter-aware), // beats, sections, chordTemplates, stringCount, lyrics, - // toneChanges, toneBase, mastery, hasPhraseData, inverted, - // lefty, renderScale, lyricsVisible, the 2D coordinate - // helpers project and fretX, and getNoteState (see below). + // toneChanges, toneBase, mastery, difficultyOverride, + // hasPhraseData, inverted, lefty, renderScale, lyricsVisible, + // the 2D coordinate helpers project and fretX, and + // getNoteState (see below). // The bundle OBJECT is reused across frames (mutated in // place — no per-frame allocation): never cache it or // compare its identity between frames; field values are @@ -367,7 +368,9 @@ Plugins that add a layer on top of whichever visualization is active — HUDs, f - `highway.getFilteredNotes()` / `highway.getFilteredChords()` — difficulty-filtered variants. Returns the master-difficulty-filtered arrays when the song has phrase-level data (slider active); falls through to the raw arrays for songs with a single difficulty level (slider disabled). Plugins that process only the notes the player is currently expected to play should use these instead of `getNotes()` / `getChords()` - `highway.hasPhraseData()` — returns `true` only when at least one phrase actually has more than one difficulty level (i.e. the mastery slider can change what's rendered somewhere in the song, and `getFilteredNotes()` / `getFilteredChords()` return a filtered subset). Use this to gate logic that only makes sense when difficulty filtering is available. **Not** the same as "phrase data exists" — see `getPhrases()` below. - `highway.getPhrases()` — phrase timing windows `[{ index, start_time, end_time, max_difficulty, top_difficulty }]` for the current song. `max_difficulty` is the phrase's tier scale (the slider picks tier `floor(mastery * (max_difficulty + 1))` and the phrase plays its last level whose `difficulty` is ≤ that tier); `top_difficulty` is the tier from which the phrase plays in full — equal to `max_difficulty` for a fully authored ladder, lower when duplicate tiers were collapsed or a generated phrase is complete early. Returns `null` only when the source has no phrase data at all (GP imports, single-difficulty charts). A chart whose declared difficulty levels never actually differ (e.g. an unsimplified CDLC arrangement) still returns real timing windows here — each phrase's ladder just collapses to one level server-side — so `getPhrases()` can be non-null even when `hasPhraseData()` is `false`. Don't assume the two are equivalent; pair `hasPhraseData()` with logic that needs a real ladder, and use `getPhrases()` on its own for phrase-boundary-only needs (e.g. Section Practice's phrase-sized looping). Read-only; do not mutate. -- `highway.getMastery()` — current master-difficulty slider value as a fraction `0..1`. Reflects the same value the mastery slider is set to; meaningful only when `hasPhraseData()` is true. +- `highway.getMastery()` — current master-difficulty slider value as a fraction `0..1`. Reflects the same value the mastery slider is set to; meaningful only when `hasPhraseData()` is true. **Not** affected by a practice override — see `getEffectiveMastery()` below. +- `highway.getEffectiveMastery(time)` — the difficulty fraction actually in force at a chart `time` (or at the current playback time when omitted). Equal to `getMastery()` everywhere except inside an active practice override's half-open `[start_time, end_time)` window. Use this, not `getMastery()`, when you need the difficulty the player is currently seeing. +- `highway.getDifficultyOverride()` — the active practice override as `{ startTime, endTime, fraction }`, or `null`. Set only via the `practice-difficulty` capability domain (feedBack#136); see [docs/capability-recipes.md](docs/capability-recipes.md). Read-only. - `highway.getChordTemplates()` — chord shape lookup table; index by `chord.id` from `getChords()` to get `{ name, fingers, frets }`. `fingers` and `frets` are per-string arrays (length matches the tuning's string count); within `fingers`, `-1` = unused, `0` = open string, `n > 0` = finger number. arrangement XML sources populate real fingerings; GP imports currently emit all `-1` since pre-import sources don't carry finger data. Not filter-aware: templates are static metadata, every `chord_id` referenced by `getChords()` is guaranteed valid - `highway.getSongInfo()` — tuning, arrangement, capo - `highway.getStringCount()` — number of strings on the active arrangement (4 for bass, 6 for guitar, 7+ for extended-range GP imports). Derived server-side as `max(notes-max-string + 1, name-based fallback, len(tuning))` where the tuning length only contributes when it isn't the arrangement XML padded 6-string form (sloppak / GP-imported sources carry trimmed tuning lengths). The name-based fallback is 4 for arrangements containing "bass" (case-insensitive) and 6 otherwise. This combination handles partial-string-usage charts (a 6-string lead that never plays string 5), extended-range GP imports (5-string bass, 7-string guitar), and sloppaks that explicitly encode the instrument range — without requiring plugins to do their own arrangement-name matching diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2db8ae..349f6e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Practice difficulty override capability.** New core-owned `practice-difficulty` domain (#136) lets a practice plugin play part of a chart at a different difficulty than the rest of the song — a slowed verse, an isolated tricky bar — via `activate` / `clear` against the player's current context. The override is a half-open `[start_time, end_time)` window installed on that player's highway, with `difficulty_pct` clamped to 0–100; it is complementary to the song-wide `player-difficulty.v1` slider and deliberately never rewrites it, so `getMastery()` keeps reporting the slider while the new `getEffectiveMastery(time)` / `getDifficultyOverride()` (and `bundle.difficultyOverride` for renderers) expose the value actually in force. One override is active per player context and conflicts resolve incumbent-wins, so competing practice surfaces can't stack or fight. State is released automatically on clear, explicit unregister, song replacement, and context replacement/departure; diagnostics are count-only and redact all identity. - **3D Highway: general per-panel zoom.** A new `cameraZoom` setting (Zoom In ↔ Out, 0.5 = unchanged) scales the camera distance in every camera mode and appears first in Split Screen's Viz ⚙ popover. Previously the only zoom control was "Locked zoom", which does nothing unless "Lock camera at frets 1–12" is on and the visible notes are at or below fret 12, so a small split panel could not be brought closer in the normal auto-framed view. Default is neutral; existing layouts are unchanged. `window.h3dBgSetCameraZoom(v)` sets the global value. - **Host compatibility matrix (`docs/compatibility.md`).** A maintained table of the host APIs current plugins depend on — `dlc_paths._resolve_dlc_path`, `sloppak.read_member_bytes`, the `chart-transform` capability, the `/ws/sync/{session_id}` relay, `renderFrameAt`/`renderFrame`/`setExternalFrameDriver`, `playerContexts`/`player-difficulty.v1`, and phrase-tier `getPhrases().top_difficulty` — each row pinned to an exact commit, the plugins and features that need it, whether the need is required or optional, and whether the commit is upstream or fork-only. Written because the version string cannot express any of it: `VERSION` has read `0.3.0-alpha.2` since 2026-08-10 across 83 commits, so a version comparison cannot separate a build that has `renderFrameAt` from one that does not. Documentation only — no plugin loading, capability, or `minHost` behaviour changes. Every row is source-audited, not runtime-certified; the document says so, and records the host and plugin revisions it was verified against (#131). - **Keys Highway 3D: per-panel hand filter.** The plugin declares a `handFilter` setting (Both / LH / RH) in `capabilities.visualization.settings`, and each renderer instance implements `applySetting` / `getSetting`. Split Screen can now show a different hand in each panel (feedBack-plugin-splitscreen#66). An instance with no override follows the global Settings value as before. Re-saving the *same* global hand value no longer rebuilds the notes or resets the current score mid-song. diff --git a/CLAUDE.md b/CLAUDE.md index 056125fe..24a5d37f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,9 +235,10 @@ window.feedBackViz_my_viz = function () { // `bundle` is a snapshot with: currentTime, songInfo, isReady, // notes, chords, anchors (all difficulty-filter-aware), // beats, sections, chordTemplates, stringCount, lyrics, - // toneChanges, toneBase, mastery, hasPhraseData, inverted, - // lefty, renderScale, lyricsVisible, the 2D coordinate - // helpers project and fretX, and getNoteState (see below). + // toneChanges, toneBase, mastery, difficultyOverride, + // hasPhraseData, inverted, lefty, renderScale, lyricsVisible, + // the 2D coordinate helpers project and fretX, and + // getNoteState (see below). // The bundle OBJECT is reused across frames (mutated in // place — no per-frame allocation): never cache it or // compare its identity between frames; field values are @@ -358,7 +359,9 @@ Plugins that add a layer on top of whichever visualization is active — HUDs, f - `highway.getFilteredNotes()` / `highway.getFilteredChords()` — difficulty-filtered variants. Returns the master-difficulty-filtered arrays when the song has phrase-level data (slider active); falls through to the raw arrays for songs with a single difficulty level (slider disabled). Plugins that process only the notes the player is currently expected to play should use these instead of `getNotes()` / `getChords()` - `highway.hasPhraseData()` — returns `true` only when at least one phrase actually has more than one difficulty level (i.e. the mastery slider can change what's rendered somewhere in the song, and `getFilteredNotes()` / `getFilteredChords()` return a filtered subset). Use this to gate logic that only makes sense when difficulty filtering is available. **Not** the same as "phrase data exists" — see `getPhrases()` below. - `highway.getPhrases()` — phrase timing windows `[{ index, start_time, end_time, max_difficulty, top_difficulty }]` for the current song. `max_difficulty` is the phrase's tier scale (the slider picks tier `floor(mastery * (max_difficulty + 1))` and the phrase plays its last level whose `difficulty` is ≤ that tier); `top_difficulty` is the tier from which the phrase plays in full — equal to `max_difficulty` for a fully authored ladder, lower when duplicate tiers were collapsed or a generated phrase is complete early. Returns `null` only when the source has no phrase data at all (GP imports, single-difficulty charts). A chart whose declared difficulty levels never actually differ (e.g. an unsimplified CDLC arrangement) still returns real timing windows here — each phrase's ladder just collapses to one level server-side — so `getPhrases()` can be non-null even when `hasPhraseData()` is `false`. Don't assume the two are equivalent; pair `hasPhraseData()` with logic that needs a real ladder, and use `getPhrases()` on its own for phrase-boundary-only needs (e.g. Section Practice's phrase-sized looping). Read-only; do not mutate. -- `highway.getMastery()` — current master-difficulty slider value as a fraction `0..1`. Reflects the same value the mastery slider is set to; meaningful only when `hasPhraseData()` is true. +- `highway.getMastery()` — current master-difficulty slider value as a fraction `0..1`. Reflects the same value the mastery slider is set to; meaningful only when `hasPhraseData()` is true. **Not** affected by a practice override — see `getEffectiveMastery()` below. +- `highway.getEffectiveMastery(time)` — the difficulty fraction actually in force at a chart `time` (or at the current playback time when omitted). Equal to `getMastery()` everywhere except inside an active practice override's half-open `[start_time, end_time)` window. Use this, not `getMastery()`, when you need the difficulty the player is currently seeing. +- `highway.getDifficultyOverride()` — the active practice override as `{ startTime, endTime, fraction }`, or `null`. Set only via the `practice-difficulty` capability domain (feedBack#136); see [docs/capability-recipes.md](docs/capability-recipes.md). Read-only. - `highway.getChordTemplates()` — chord shape lookup table; index by `chord.id` from `getChords()` to get `{ name, fingers, frets }`. `fingers` and `frets` are per-string arrays (length matches the tuning's string count); within `fingers`, `-1` = unused, `0` = open string, `n > 0` = finger number. arrangement XML sources populate real fingerings; GP imports currently emit all `-1` since pre-import sources don't carry finger data. Not filter-aware: templates are static metadata, every `chord_id` referenced by `getChords()` is guaranteed valid - `highway.getSongInfo()` — tuning, arrangement, capo - `highway.getStringCount()` — number of strings on the active arrangement (4 for bass, 6 for guitar, 7+ for extended-range GP imports). Derived server-side as `max(notes-max-string + 1, name-based fallback, len(tuning))` where the tuning length only contributes when it isn't the arrangement XML padded 6-string form (sloppak / GP-imported sources carry trimmed tuning lengths). The name-based fallback is 4 for arrangements containing "bass" (case-insensitive) and 6 otherwise. This combination handles partial-string-usage charts (a 6-string lead that never plays string 5), extended-range GP imports (5-string bass, 7-string guitar), and sloppaks that explicitly encode the instrument range — without requiring plugins to do their own arrangement-name matching diff --git a/docs/capability-domains.md b/docs/capability-domains.md index 2b1096a8..077edfc3 100644 --- a/docs/capability-domains.md +++ b/docs/capability-domains.md @@ -169,6 +169,22 @@ A context carries profile identity/readiness plus song, arrangement, instrument, The companion `player-difficulty.v1` command accepts the complete context and a 0–100 target. Core resolves every current identity dimension before applying it to the privately bound highway, so stale arrangement/profile requests and vocal targets cannot change another panel. Diagnostics use `feedBack.player_identity.diagnostics.v1` and contain active/ready counts only—never session, player, profile, song, or arrangement identifiers, and never highway/DOM objects. +## Practice-Difficulty Domain + +The practice-difficulty slice (#136) is a core-owned provider-coordinator implemented by [static/capabilities/practice-difficulty.js](../static/capabilities/practice-difficulty.js). It owns **one time-scoped difficulty override per player context**: `register-participant`, `unregister-participant`, `activate`, `clear`, and read-only `inspect`; the provider operation is `practice.difficulty`. + +It is **complementary to `player-difficulty.v1`, not a duplicate.** `player-difficulty.v1` drives the song-wide mastery slider; `practice-difficulty` drives a transient practice window (slow a section down, isolate a tricky bar) and never rewrites the slider. Both can be active at once — the override wins inside its half-open `[start_time, end_time)` window and the slider wins everywhere else. + +An override request carries the full `difficulty_ladder.player_context.v1` snapshot plus `difficulty_pct` (finite, clamped to 0–100), `start_time`, `end_time`, and optional `phrase_indices` / `label`. Core resolves the context through `window.feedBack.playerContexts.getHighway()` — the same private routing `player-difficulty.v1` uses — so a split-screen panel receives only its own override. Registrant identity comes from the dispatch caller, never the payload, so a caller cannot claim another's slot. The window is keyed by a hash of the nine player-identity dimensions and exported only as a redacted `context_ref` (`context-`). + +Conflicts are resolved **incumbent-wins**: the registrant that first held a slot keeps it until it clears, unregisters, or its player context/song goes away; a second registrant's `activate` is refused with `denied` and an `override-rejected` event naming `held_by`. Re-activating from the *same* registrant updates in place. Overrides never stack — and because a highway carries a single override slot, a request whose context resolves to a highway another live record already owns is refused too, rather than silently overwriting that window while both records claim to be in force. + +The synchronous `highway.setDifficultyOverride()` data-plane hook validates and installs the window and rebuilds the phrase filter; it is called on activate/clear, not per frame. `_rebuildMasteryFilter()` resolves each phrase's level from `_effectiveMasteryAt(p.start_time)` rather than reading `hwState._mastery`, which is what makes the override section-scoped. `getMastery()` keeps reporting the song-wide slider so an adaptive consumer cannot mistake an override for a slider move; `getEffectiveMastery(time)` and the renderer's `bundle.difficultyOverride` expose the effective view (a reused object, so the allocation-free bundle contract holds). Lifecycle reconciliation runs on the player-identity and song bus events: a record whose context no longer resolves is released, a record whose highway was rebuilt mid-song is re-installed, and a record whose highway was taken over by another context is released rather than left stranded. + +**Practice-mode exit is the registrant's `clear`, not a Host subscription.** #136 lists "clears on … practice-mode exit" among the lifecycle conditions, and this domain implements that condition at the *producer* boundary rather than in core. Core has no consumer-agnostic practice lifecycle to observe, and the one core feature that toggles a practice mode — Section Practice's loop checkbox — drives its own A/B loop rather than the difficulty slot, so treating that toggle as "practice mode exited" would clear overrides belonging to other registrants. A producer therefore signals its own exit by dispatching `clear` (with no `player_context`, which releases every slot that caller holds) or `unregister-participant` from its teardown path; the domain additionally releases state when a registrant disappears at runtime (`unregistered`). See [capability-recipes.md](capability-recipes.md#practice-difficulty-override) for the consumer contract. + +Diagnostics use `feedBack.practice_difficulty.diagnostics.v1` and contain counts plus one bounded outcome string only — never context refs, labels, timestamps, or session/player/profile/song identity. The redaction boundary is the **diagnostics** surface specifically: `dispatch` broadcasts its full result — including registrant-supplied `label` text and window bounds — on `practice-difficulty:dispatched` and the shared `capability:event` bus, so those payloads are public to any in-page listener by design, the same way `playback` echoes target titles. The domain has no compatibility shim; a consumer's request against an older Host degrades to a non-`handled` outcome, which producers treat as "no override available". + ## MIDI-Input Domain The MIDI-input slice (spec 012, issues #873/#880) promotes `midi-input` as a **core-owned** provider-coordinator implemented by [static/capabilities/midi-input.js](../static/capabilities/midi-input.js) — the MIDI analog of `audio-input`. It is deliberately separate from `audio-input` (whose source/`open` contract is audio-frame-centric: channel shapes, sample buffers) because MIDI carries discrete messages, not audio; and it is **not** owned by any feature plugin, so the device-access boundary outlives the input-setup wizard (exactly as `audio-input` is `core.audio.session`-owned). Consumers — the `input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and (as a follow-up, #881) note-detection's Web-MIDI provider — converge here on ONE device-access boundary: one permission prompt, one source list, one redaction boundary, retiring private per-plugin `navigator.requestMIDIAccess()` calls. diff --git a/docs/capability-recipes.md b/docs/capability-recipes.md index e9e3afe7..f4c2175e 100644 --- a/docs/capability-recipes.md +++ b/docs/capability-recipes.md @@ -550,6 +550,86 @@ await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: Transforms run at chart ready, mastery recompute, and explicit `refresh`, never per frame. Selection persists by provider id. `getSongInfo()` retains original metadata; effective metadata is available through the renderer bundle and `getStringCount()`, `getTuning()`, `getCapo()`, and `getCentOffset()`. +## Practice Difficulty Override + +Practice plugins that need to play a stretch of a chart at a different difficulty than the rest of the song — a slow section, an isolated tricky bar, a step-through passage — drive the `practice-difficulty` domain (#136). One override is active per player context, installed on that context's highway. + +`practice-difficulty` is **not** a second difficulty slider. `player-difficulty.v1` moves the song-wide mastery slider; `practice-difficulty` sets a time window in which phrases render at a different difficulty, leaving the slider where the player left it. Use `player-difficulty.v1` for "play the whole song at X%", `practice-difficulty` for "play *this part* at X%". + +```json +{ + "id": "my_practice", + "name": "My Practice", + "standards": ["capability-pipelines.v1"], + "capabilities": { + "practice-difficulty": { + "roles": ["provider"], + "operations": ["practice.difficulty"], + "events": ["override-activated", "override-cleared"], + "mode": "active", + "compatibility": "none", + "ownership": "multi-provider", + "safety": "safe", + "version": 1 + } + } +} +``` + +```js +const api = window.feedBack.capabilities; + +// Optional, but it makes the plugin visible in the Capability Inspector. +// Always pass `source` — dispatch falls back to the literal 'dispatch' +// when you omit it, which would merge every source-less plugin into one +// shared registrant. +await api.dispatch({ capability: 'practice-difficulty', command: 'register-participant', + source: 'my_practice', payload: { label: 'My Practice' } }); + +const player_context = window.feedBack.playerContexts.getActive('main'); + +await api.dispatch({ capability: 'practice-difficulty', command: 'activate', + source: 'my_practice', + payload: { + player_context, + difficulty_pct: 40, // finite, clamped to 0..100 + start_time: 12.5, // half-open window: [start_time, end_time) + end_time: 24.0, + phrase_indices: [3], // optional, descriptive only + label: 'Verse 2 · slow', // optional, bounded + redacted + }, +}); + +// On leaving practice mode, and again on teardown, so the next song is never +// stuck slowed down. There is no core practice-mode hook to hang this on — the +// Host cannot see when *your* practice surface ends — so this call is the exit +// signal (see the rules below). +// Omit player_context deliberately: it releases every override you own, which +// is what you want here, because a snapshot captured earlier may already be +// stale (an arrangement switch or a new song changes its key). +await api.dispatch({ capability: 'practice-difficulty', command: 'clear', + source: 'my_practice', payload: {} }); + +// If you lose a slot to another registrant, wait for the incumbent to leave. +window.feedBack.on('practice-difficulty:override-cleared', (event) => { + const cleared = event.detail && event.detail.payload; + console.log('practice slot free:', cleared && cleared.context_ref); +}); +``` + +The `player_context` must be the **current** snapshot. Core resolves it through `window.feedBack.playerContexts.getHighway()` — the same private routing `player-difficulty.v1` uses — so a splitscreen panel gets only its own override, and a stale snapshot comes back as `no-target` without touching any highway. + +Rules worth knowing before you build on this: + +- **Outcomes.** `handled` (applied), `no-target` (the context is stale or unmatched — nothing was recorded, so retry on the next context event), `denied` (non-finite `difficulty_pct`, `end_time <= start_time`, a vocal player context, or a conflict). Against a Host without this domain the outcome is not `handled`; treat any non-`handled` result as "no override available" and carry on. +- **One override per context, never stacked.** The first registrant to hold a slot keeps it until it clears, unregisters, or its context/song goes away. A second registrant's `activate` is refused (`denied`) and emits `override-rejected` with `held_by`. Re-activating from the *same* registrant updates in place. If your plugin is competing with another practice surface, retry after `practice-difficulty:override-cleared` rather than trying to take the slot. +- **One override per highway too.** A highway holds a single override slot, so if two different contexts ever resolve to the same panel the second registrant is refused rather than silently overwriting the first. +- **`clear` with no context releases everything you own.** Supplying a context only narrows the release to that slot — and a stale one answers `no-target` instead of pretending it cleared something. +- **You never have to release on song change.** Core drops the override on song replacement, on context replacement/departure, and re-installs it if a panel's highway is rebuilt mid-song. Clearing on teardown is still correct: it is the only path that also covers your plugin's screen being torn down mid-session, since the Host does not notify a disabled plugin. +- **Leaving practice mode is your `clear` call.** #136 lists "practice-mode exit" among the lifecycle conditions, and this domain deliberately implements it as the *registrant's* `clear` rather than as a Host subscription. Core has no consumer-agnostic practice lifecycle to observe, and the one core feature that toggles a practice mode (Section Practice's loop checkbox) owns its own A/B loop rather than the difficulty slot — treating that toggle as "practice mode exited" would clear overrides belonging to other registrants. So dispatch `clear` from the path that ends your practice session and from teardown; `unregister-participant` is the stronger form if your plugin is done with the domain entirely. +- **`getMastery()` does not move.** It keeps reporting the song-wide slider. Read `highway.getEffectiveMastery(time)` for the value in force at a moment, and `highway.getDifficultyOverride()` (or `bundle.difficultyOverride` in a renderer) for the active window. +- **Nothing renders differently without phrase data.** An override only changes which level a phrase plays; a chart with a single difficulty level has nothing to switch between, so the window has no visible effect (`highway.hasPhraseData()` is `false`). + ## Future Expansion Domains Some domain names are reserved for expected future contracts, but they are not registered in the runtime graph yet. For example, `ui.player-panels` is documented as a likely panel-host surface, but FeedBack does not currently expose a capability command for panel contributions. See [capability-roadmap.md](capability-roadmap.md) for the PR1 domain set and deferred-domain checklist. diff --git a/docs/capability-roadmap.md b/docs/capability-roadmap.md index edda2293..d431f83b 100644 --- a/docs/capability-roadmap.md +++ b/docs/capability-roadmap.md @@ -100,6 +100,12 @@ The player-identity slice (#82) promotes `player-identity` as an active, core-ow `player-difficulty.v1` is the narrow command plane for applying one player's difficulty to one current highway. The Host validates the full context and target range; stale, incomplete, or vocal requests cannot fall through to a global setter. There is no compatibility shim because player-scoped identity did not previously exist. Older single-player Hosts continue through Difficulty Ladder's existing feature-detected compatibility adapter. +## Practice Difficulty Slice + +The practice-difficulty slice (#136) promotes `practice-difficulty` as an active, core-owned provider-coordinator. It owns one time-scoped difficulty override per player context — `register-participant` / `unregister-participant` / `activate` / `clear` / `inspect`, with `override-activated` / `override-cleared` / `override-rejected` events — installed on that context's privately bound highway. Registrant identity comes from the dispatch caller, conflicts are incumbent-wins and never stack, `difficulty_pct` clamps to 0–100, and the window is half-open `[start_time, end_time)`. + +It is deliberately **not** a second difficulty slider: `player-difficulty.v1` owns the song-wide mastery value and this domain never rewrites it. The synchronous `highway.setDifficultyOverride` hook makes the override section-scoped by resolving each phrase's level from `_effectiveMasteryAt(p.start_time)` instead of the slider, while `getMastery()` keeps reporting the song-wide value so an adaptive consumer cannot mistake an override for a slider move. State is released on clear, unregister, song replacement, and context replacement/departure. No compatibility shim is needed — no earlier override surface exists, and a consumer's request against an older Host simply degrades to a non-`handled` outcome. + ## Deferred Domains These domains are planned but should stay out of the runtime graph until a host workflow exists: diff --git a/docs/capability-safety-matrix.md b/docs/capability-safety-matrix.md index c9088bff..252f3d88 100644 --- a/docs/capability-safety-matrix.md +++ b/docs/capability-safety-matrix.md @@ -24,6 +24,7 @@ Core domains also have a review scope. **Active contract** domains are wired to | midi-input | provider-coordinator | sensitive | inspect, list-sources, discover, select-source, open-source, close-source | source.enumerate, source.describe, source.open, source.close | Core-owned MIDI device control plane (spec 012), the MIDI analog of `audio-input`. Inspect/list/select are prompt-free; `discover` is the Web-MIDI permission boundary (`requestMIDIAccess()` gates the whole input list) and records denied/unavailable outcomes; `open-source` attaches a shared listener session and never re-prompts. Selection persists by redaction-safe `logicalSourceKey`. Diagnostics redact device labels and never include raw MIDI messages or live handles. | | player-identity | exclusive-owner | safe | inspect | none | Stable local `(session_id, player_id)` contexts, profile readiness, and lifecycle events. Public snapshots omit highway objects; diagnostics contain counts only and redact player, profile, song, and arrangement identity. | | player-difficulty.v1 | exclusive-owner | safe | set | none | Accepts a complete current player context and routes mastery to its privately bound highway. Stale, incomplete, vocal, or out-of-range requests fail without touching another player. | +| practice-difficulty | provider-coordinator | safe | inspect, register-participant, unregister-participant, activate, clear | practice.difficulty | One time-scoped difficulty override per player context (#136), installed per highway without rewriting the song-wide mastery slider. Registrant identity comes from the dispatch caller, never the payload; conflicts are incumbent-wins and never stack; `difficulty_pct` clamps to 0–100 and non-finite or inverted windows are refused. State is released on clear, unregister, song replacement, and context replacement/departure (and re-installed if a panel highway is rebuilt mid-song). Diagnostics contain counts and one bounded outcome string — never context refs, labels, timestamps, or session/player/profile/song identity. | Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers. diff --git a/static/capabilities.js b/static/capabilities.js index 5cdd67bc..ae7aa628 100644 --- a/static/capabilities.js +++ b/static/capabilities.js @@ -131,6 +131,7 @@ 'chart-transform': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with persisted selection, refresh, and fixed-reason failure attribution (#952).' }), 'player-identity': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Provides stable local session/player identity, profile readiness, lifecycle events, and private highway bindings for concurrent players.' }), 'player-difficulty.v1': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Routes a context-scoped difficulty request to exactly one current player highway.' }), + 'practice-difficulty': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Owns one time-scoped difficulty override per player context, applied per highway without rewriting the song-wide mastery value (#136).' }), }); const EXPECTED_COMPATIBILITY_SHIMS = Object.freeze({}); diff --git a/static/capabilities/practice-difficulty.js b/static/capabilities/practice-difficulty.js new file mode 100644 index 00000000..525da66b --- /dev/null +++ b/static/capabilities/practice-difficulty.js @@ -0,0 +1,543 @@ +// Practice difficulty override (feedBack#136): one deterministic, +// time-scoped difficulty override per player context. +// +// Complements player-difficulty.v1 rather than duplicating it: that +// command drives the song-wide mastery slider, this one drives a +// transient section/practice window and never mutates the slider. The +// override is installed on the highway bound to the supplied player +// context, so split-screen panels each get their own. +(function () { + 'use strict'; + + window.feedBack = window.feedBack || {}; + const capabilities = window.feedBack.capabilities; + if (!capabilities || capabilities.version !== 1) return; + if (window.feedBack.practiceDifficultyDomain && window.feedBack.practiceDifficultyDomain.version === 1) return; + + const DOMAIN = 'practice-difficulty'; + const OWNER_ID = 'core.practice-difficulty'; + const DIAG_SCHEMA = 'feedBack.practice_difficulty.diagnostics.v1'; + const STATE_SCHEMA = 'feedBack.practice_difficulty.state.v1'; + const OVERRIDE_SCHEMA = 'feedBack.practice_difficulty.override.v1'; + const PARTICIPANT_SCHEMA = 'feedBack.practice_difficulty.participant.v1'; + const MAX_LABEL = 80; + // Mirrors player-identity's `fields`: exactly the dimensions its + // getHighway() matches on, so two contexts that hash to the same key + // are guaranteed to resolve to the same highway. session_id is + // included for completeness even though it is constant per page. + const CONTEXT_FIELDS = ['session_id', 'player_id', 'profile_id', 'profile_hash', + 'song_id', 'arrangement_id', 'instrument', 'role', 'skill']; + + // contextKey → { contextKey, contextRef, context, source, difficultyPct, + // startTime, endTime, phraseIndices, label, clamped, + // activatedAt, highwayRef } + const overrides = new Map(); + // pluginId → { pluginId, label, registeredAt } + const participants = new Map(); + const _hasWeakRef = typeof WeakRef === 'function'; + let lastOutcome = null; + + function _text(value) { return value == null ? '' : String(value); } + + function _number(value) { + const n = typeof value === 'number' ? value : Number(value); + return Number.isFinite(n) ? n : null; + } + + // FNV-1a, matching playback.js. Used so the public surface can expose a + // stable per-context reference without ever shipping the raw identity + // fields (song/profile/player ids) into events, snapshots or diagnostics. + function _hash(value) { + let h = 2166136261; + for (let i = 0; i < value.length; i += 1) { + h ^= value.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0).toString(36); + } + + function _contextKey(context) { + return CONTEXT_FIELDS.map(field => `${field}=${_text(context[field])}`).join('|'); + } + + function _contextRef(contextKey) { return `context-${_hash(contextKey)}`; } + + // Registrant-supplied free text is a user-facing practice description + // ("Verse 2 · slow"), so it is echoed back on the public snapshot the + // way playback.js exports target titles — but paths, URLs and + // credentials are stripped before it can ever be exported, and it + // never reaches diagnostics. + function _label(value) { + return _text(value) + .replace(/(?:\/Users\/|\/home\/|\/root\b\/?|[A-Za-z]:\\)[^\r\n\t"',;(){}\[\]<>|]*/g, '[path]') + .replace(/https?:\/\/[^\s?#]+[^\s]*/gi, '[url]') + .replace(/\b(token|secret|password|api[_-]?key)=([^\s&]+)/gi, '$1=[redacted]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, MAX_LABEL); + } + + function _handled(payload = {}) { return { outcome: 'handled', payload }; } + function _denied(reason, payload = {}) { return { outcome: 'denied', reason, payload }; } + function _noTarget(reason) { return { outcome: 'no-target', reason }; } + + function _done(result) { + lastOutcome = result.outcome; + _contributeDiagnostics(); + return result; + } + + function _callerId(ctx = {}) { + // Dispatch sets requester from the caller. A payload can never claim + // another registrant's slot — identity comes from here only. + return _text(ctx.requester || ctx.source || 'unknown') || 'unknown'; + } + + function _payload(ctx = {}) { + return ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {}; + } + + function _publicOverride(record) { + return { + schema: OVERRIDE_SCHEMA, + context_ref: record.contextRef, + source: record.source, + difficulty_pct: record.difficultyPct, + start_time: record.startTime, + end_time: record.endTime, + phrase_indices: record.phraseIndices.slice(), + label: record.label, + clamped: record.clamped, + activated_at: record.activatedAt, + }; + } + + function _publicParticipant(record) { + return { + schema: PARTICIPANT_SCHEMA, + participant_ref: record.pluginId, + label: record.label, + registered_at: record.registeredAt, + }; + } + + function _snapshot(extra = {}) { + return { + schema: STATE_SCHEMA, + available: true, + // Sorted so repeated reads of an unchanged domain are + // byte-stable (diagnostics diffing, Inspector re-renders). + active_overrides: [...overrides.values()] + .map(_publicOverride) + .sort((a, b) => a.context_ref.localeCompare(b.context_ref)), + participants: [...participants.values()] + .map(_publicParticipant) + .sort((a, b) => a.participant_ref.localeCompare(b.participant_ref)), + ...extra, + }; + } + + function _emit(name, detail) { + try { capabilities.emitEvent(DOMAIN, name, detail || {}); } + catch (_) { /* eventing must not break the host */ } + } + + // Counts and one bounded outcome string only — no context refs, labels, + // timestamps, or identity fields (docs/capability-review-preflight.md). + function _diagnostics() { + return { + schema: DIAG_SCHEMA, + available: true, + active_overrides: overrides.size, + participants: participants.size, + last_outcome: lastOutcome, + }; + } + + function _contributeDiagnostics() { + const diagnostics = window.feedBack && window.feedBack.diagnostics; + if (!diagnostics || typeof diagnostics.contribute !== 'function') return; + try { diagnostics.contribute('practice-difficulty', _diagnostics()); } + catch (_) { /* diagnostics must not break the host */ } + } + + function _playerContexts() { + const contexts = window.feedBack && window.feedBack.playerContexts; + return contexts && contexts.version === 1 && typeof contexts.getHighway === 'function' + ? contexts + : null; + } + + function _resolveHighway(context) { + const contexts = _playerContexts(); + if (!contexts) return null; + try { return contexts.getHighway(context) || null; } + catch (_) { return null; } + } + + function _capable(highway) { + return !!(highway && typeof highway.setDifficultyOverride === 'function'); + } + + function _install(record, highway) { + try { + highway.setDifficultyOverride({ + startTime: record.startTime, + endTime: record.endTime, + fraction: record.difficultyPct / 100, + }); + } catch (_) { + // One broken surface must not take the whole slot with it. + return false; + } + return true; + } + + function _installedOn(record, highway) { + // Weak identity test — the highway holds exactly ONE override + // slot, so two contexts resolving to the same instance would + // silently overwrite each other's window while both records + // claimed to be live. + let current; + try { current = highway.getDifficultyOverride(); } + catch (_) { return false; } + return !!current + && current.startTime === record.startTime + && current.endTime === record.endTime + && current.fraction === record.difficultyPct / 100; + } + + // Is any OTHER live record already installed on this highway? + function _highwayOwner(highway, exceptKey) { + for (const record of overrides.values()) { + if (record.contextKey === exceptKey) continue; + const ref = record.highwayRef; + if (ref == null) continue; + const other = _hasWeakRef ? ref.deref() : ref; + if (other && other === highway) return record; + } + return null; + } + + function _uninstall(record) { + // Detach from the highway we actually installed on, NOT from + // whatever getHighway(record.context) resolves to now. By the + // time a stale record is released the context is already gone or + // replaced, so re-resolving would return null and leave the + // override stranded on the live highway. Held weakly so a closed + // split-screen panel can still be collected; the strong fallback + // only applies in minimal/test environments without WeakRef. + const ref = record.highwayRef; + const highway = ref == null ? null : (_hasWeakRef ? ref.deref() : ref); + if (!_capable(highway)) return; + try { highway.setDifficultyOverride(null); } + catch (_) { /* best-effort */ } + } + + function _release(record, reason) { + if (!overrides.delete(record.contextKey)) return false; + _uninstall(record); + _emit('override-cleared', { + context_ref: record.contextRef, + source: record.source, + reason, + }); + return true; + } + + // Reconcile every live record against the highway it resolved to. + // This is the single cleanup path for song replacement + // (player-identity leaves the context on song:loading), arrangement + // switches, and profile changes — none of which need this module to + // know the specific lifecycle rule. + // + // It re-installs as well as releases: the highway drops its own + // override slot on init()/reconnect() (its song is being rebuilt), + // so a same-song rebind would otherwise leave a record that inspect + // reports as active, that still blocks competitors, and that is no + // longer in force. A record whose context no longer resolves at all + // is released instead. + function _reconcile(reason) { + let released = 0; + let reinstalled = 0; + for (const record of [...overrides.values()]) { + const highway = _resolveHighway(record.context); + if (!_capable(highway)) { + if (_release(record, reason)) released += 1; + continue; + } + // One override slot per highway instance: refuse to install + // over another live record's window. + const owner = _highwayOwner(highway, record.contextKey); + if (owner) { + if (_release(record, 'highway-reused')) released += 1; + continue; + } + if (!_installedOn(record, highway)) { + if (_install(record, highway)) reinstalled += 1; + } + } + if (released || reinstalled) _contributeDiagnostics(); + return released; + } + + function _releaseParticipant(pluginId, reason) { + if (!pluginId) return 0; + let released = 0; + for (const record of [...overrides.values()]) { + if (record.source === pluginId && _release(record, reason)) released += 1; + } + if (participants.delete(pluginId)) _emit('participant-unregistered', { participant_ref: pluginId, reason }); + return released; + } + + function _registerParticipant(ctx = {}) { + const pluginId = _callerId(ctx); + if (pluginId === OWNER_ID) { + return _done(_denied('The capability owner cannot register as a registrant', _snapshot())); + } + const label = _label(_payload(ctx).label); + const existing = participants.get(pluginId); + if (!existing) { + participants.set(pluginId, { + pluginId, + label, + registeredAt: new Date().toISOString(), + }); + try { + capabilities.registerParticipant(pluginId, { + [DOMAIN]: { + roles: ['provider'], + operations: ['practice.difficulty'], + events: ['override-activated', 'override-cleared'], + mode: 'active', + compatibility: 'none', + safety: 'safe', + runtime: true, + description: 'Drives time-scoped practice difficulty overrides for its own player contexts.', + provider_policy: { providerIds: [pluginId] }, + }, + }); + } catch (_) { /* Inspector visibility is best-effort */ } + _emit('participant-registered', { participant_ref: pluginId, label }); + } else if (label && label !== existing.label) { + existing.label = label; + } + return _done(_handled({ ..._snapshot(), participant_ref: pluginId })); + } + + function _unregisterParticipant(ctx = {}) { + const pluginId = _callerId(ctx); + const released = _releaseParticipant(pluginId, 'participant-unregistered'); + // Mirror chart-transform: only drop the capability participant when + // it holds no other role on this domain. + if (typeof capabilities.inspect === 'function') { + const live = capabilities.inspect(DOMAIN); + const participant = ((live && live.participants) || []) + .find(p => p.pluginId === pluginId); + const roles = participant && Array.isArray(participant.roles) ? participant.roles : []; + const providerOnly = roles.length <= 1 && roles[0] === 'provider'; + if (!participant || providerOnly) { + try { capabilities.unregisterParticipant(pluginId, DOMAIN); } + catch (_) { /* participant cleanup is best-effort */ } + } + } + return _done(_handled({ ..._snapshot(), released, participant_ref: pluginId })); + } + + function _phraseIndices(value) { + if (!Array.isArray(value)) return []; + const out = []; + for (const entry of value.slice(0, 64)) { + const index = _number(entry); + if (index !== null) out.push(index); + } + return out; + } + + function _activate(ctx = {}) { + const payload = _payload(ctx); + const context = payload.player_context && typeof payload.player_context === 'object' + ? payload.player_context + : null; + if (!context) return _done(_noTarget('An override requires a player_context')); + + const source = _callerId(ctx); + const contextKey = _contextKey(context); + + // Conflict policy: incumbent wins. The registrant that first held + // the slot keeps it until it clears, unregisters, or its context / + // song goes away. A second registrant is refused rather than + // stacked or silently allowed to steal the slot. + const incumbent = overrides.get(contextKey); + if (incumbent && incumbent.source !== source) { + _emit('override-rejected', { + context_ref: incumbent.contextRef, + held_by: incumbent.source, + rejected: source, + reason: 'conflict', + }); + return _done(_denied( + `Player context is already overridden by ${incumbent.source}`, + { ..._snapshot(), held_by: incumbent.source }, + )); + } + + const requestedPct = _number(payload.difficulty_pct); + if (requestedPct === null) { + return _done(_denied('An override requires a finite numeric difficulty_pct')); + } + const difficultyPct = Math.max(0, Math.min(100, requestedPct)); + const startTime = _number(payload.start_time); + const endTime = _number(payload.end_time); + if (startTime === null || endTime === null) { + return _done(_denied('An override requires finite start_time and end_time')); + } + // Half-open [start_time, end_time). A zero-length or inverted + // window would never cover a phrase start and would silently do + // nothing, so refuse it rather than record a no-op. + if (endTime <= startTime) { + return _done(_denied('An override requires end_time greater than start_time')); + } + // Same gate the sibling player-difficulty.v1 command applies: a + // vocal context has no notes to make harder or easier, so an + // override there could only be a mis-routed request. + if (_text(context.role) === 'karaoke') { + return _done(_denied('Practice difficulty overrides do not apply to a vocal player context')); + } + + const highway = _resolveHighway(context); + if (!_capable(highway)) { + // Never record state we could not install — a consumer that + // retries on the next context event then gets a clean attempt + // instead of a slot it believes is live. + return _done(_noTarget('No current player context matches this override')); + } + // The highway carries ONE override slot. Two different contexts + // that resolve to the same instance (a rebind that kept the + // context fields but swapped the panel's highway, a host that + // reused an instance) would otherwise silently overwrite each + // other while both records stayed live — the loser's window + // would be gone but it would still block competitors and report + // itself as in force via inspect. + const owner = _highwayOwner(highway, contextKey); + if (owner) { + return _done(_denied( + `Highway for this player context already carries ${owner.contextRef}`, + { ..._snapshot(), held_by: owner.source }, + )); + } + + const record = { + contextKey, + contextRef: _contextRef(contextKey), + context: { ...context }, + source, + difficultyPct, + startTime, + endTime, + phraseIndices: _phraseIndices(payload.phrase_indices), + label: _label(payload.label), + clamped: difficultyPct !== requestedPct, + activatedAt: new Date().toISOString(), + highwayRef: _hasWeakRef ? new WeakRef(highway) : highway, + }; + if (!_install(record, highway)) { + return _done(_noTarget('Player highway rejected the override')); + } + overrides.set(contextKey, record); + _emit('override-activated', _publicOverride(record)); + return _done(_handled({ ..._snapshot(), override: _publicOverride(record) })); + } + + function _clear(ctx = {}) { + const payload = _payload(ctx); + const source = _callerId(ctx); + const context = payload.player_context && typeof payload.player_context === 'object' + ? payload.player_context + : null; + + // Omitting player_context releases every slot the caller holds — the + // right call in a teardown path, where the captured context may + // already be stale (an arrangement switch or a new song changes + // its key). Supplying one only narrows the release. + if (!context) { + // No context → release every slot this registrant holds. A + // registrant never clears another registrant's override. + let released = 0; + for (const record of [...overrides.values()]) { + if (record.source === source && _release(record, 'command-clear')) released += 1; + } + return _done(_handled({ ..._snapshot(), released })); + } + + const contextKey = _contextKey(context); + const incumbent = overrides.get(contextKey); + // A snapshot captured before an arrangement switch or a new song + // matches nothing, so the naive answer would be a silent + // `released: false` with the real override still installed. Say + // `no-target` instead so a consumer can fall back to the + // context-less clear above. + if (!incumbent) return _done(_noTarget('No active override for this player context')); + if (incumbent.source !== source) { + return _done(_denied( + `Player context is overridden by ${incumbent.source}`, + { ..._snapshot(), held_by: incumbent.source }, + )); + } + _release(incumbent, 'command-clear'); + return _done(_handled({ ..._snapshot(), released: true })); + } + + capabilities.registerOwner(DOMAIN, { + pluginId: OWNER_ID, + kind: 'provider-coordinator', + safety: 'safe', + commands: ['inspect', 'register-participant', 'unregister-participant', 'activate', 'clear'], + operations: ['practice.difficulty'], + events: ['participant-registered', 'participant-unregistered', 'override-activated', 'override-cleared', 'override-rejected'], + description: 'Owns one time-scoped practice difficulty override per player context, applied per highway without touching the song-wide mastery value.', + handlers: { + inspect: () => _done(_handled(_snapshot())), + 'register-participant': (ctx) => _registerParticipant(ctx), + 'unregister-participant': (ctx) => _unregisterParticipant(ctx), + activate: (ctx) => _activate(ctx), + clear: (ctx) => _clear(ctx), + }, + }); + + // Runtime participant disappearance (plugin disabled, screen torn down) + // must release state — not just explicit unregister-participant. + if (typeof capabilities.subscribe === 'function') { + try { + capabilities.subscribe('unregistered', (detail) => { + const info = detail || {}; + if (info.capability !== DOMAIN || !info.pluginId) return; + if (_releaseParticipant(_text(info.pluginId), 'participant-unregistered')) { + _contributeDiagnostics(); + } + }); + } catch (_) { /* runtime cleanup is best-effort */ } + } + + const bus = window.feedBack; + if (typeof bus.on === 'function') { + try { + // Context lifecycle: left / changed / ready all cover song + // replacement (player-identity leaves the context on + // song:loading), arrangement switches and profile refreshes. + for (const event of ['player-context:left', 'player-context:changed', 'player-context:ready']) { + bus.on(event, () => _reconcile('context-replaced')); + } + bus.on('song:loading', () => _reconcile('song-replaced')); + bus.on('song:loaded', () => _reconcile('song-replaced')); + } catch (_) { /* bus mirroring is best-effort */ } + } + + window.feedBack.practiceDifficultyDomain = { + version: 1, + snapshot: _snapshot, + diagnostics: _diagnostics, + }; + _contributeDiagnostics(); +})(); diff --git a/static/highway.js b/static/highway.js index 54275509..cf84d708 100644 --- a/static/highway.js +++ b/static/highway.js @@ -313,6 +313,19 @@ function createHighway() { // state) so multiple createHighway() instances stay truly // per-instance — no shared localStorage key to race on. hwState._mastery = 1; + // Time-scoped practice override (feedBack#136): { startTime, endTime, + // fraction } or null. Never merged into _mastery — getMastery() keeps + // reporting the song-wide slider so the Difficulty Ladder adaptive + // controller stays consistent; only _rebuildMasteryFilter() consults + // the override, and only for phrases that start inside its window. + // + // _overrideView is the object the render bundle hands out. The bundle + // is documented as mutated-in-place with no per-frame allocation, so + // this is updated by _setOverrideState() rather than re-spread every + // frame; its identity only changes when the override goes absent. + hwState._difficultyOverride = null; + const _overrideView = { startTime: 0, endTime: 0, fraction: 0 }; + let _overrideViewActive = false; hwState._filteredNotes = null; hwState._filteredChords = null; hwState._filteredAnchors = null; @@ -649,6 +662,12 @@ function createHighway() { // Master-difficulty (feedBack#48) b.mastery = hwState._mastery; b.hasPhraseData = _hasRealLadder(); + // Live summary of an active practice override (feedBack#136), so a + // renderer never has to guess why `mastery` above disagrees with + // the notes it is being handed. null when no override is active. + // A reused view object (see _setOverrideState) — never a per-frame + // allocation, so a renderer may safely hold the reference. + b.difficultyOverride = _overrideViewActive ? _overrideView : null; // When phrase data authored ANY handshape, respect the filtered // list strictly (even when this difficulty leaves it empty) — // otherwise low-mastery levels would surface arp hints that @@ -1577,10 +1596,41 @@ function createHighway() { )); } + // Single writer for the override so the state, the bundle view and + // the filter rebuild cannot drift apart. Every reset path + // (song replacement, reconnect, explicit clear) goes through here. + function _setOverrideState(next) { + hwState._difficultyOverride = next; + if (next) { + _overrideView.startTime = next.startTime; + _overrideView.endTime = next.endTime; + _overrideView.fraction = next.fraction; + _overrideViewActive = true; + } else { + _overrideViewActive = false; + } + } + + // Effective difficulty fraction at a chart time (feedBack#136). The + // practice override wins inside its half-open window [startTime, + // endTime); everywhere else (and for every non-finite time — the + // render loop's pre-song frame, a caller passing nothing) the + // song-wide slider value applies. Deliberately does NOT touch + // hwState._mastery: the slider and the Difficulty Ladder adaptive + // controller must keep seeing the song-wide value. + function _effectiveMasteryAt(time) { + const override = hwState._difficultyOverride; + if (!override) return hwState._mastery; + if (!Number.isFinite(time)) return hwState._mastery; + if (time < override.startTime || time >= override.endTime) return hwState._mastery; + return override.fraction; + } + // Rebuild the mastery-filtered note/chord arrays from _phrases + - // _mastery. Called on `ready` and on every setMastery(). When - // _phrases is null (slider-disabled source), we clear the filtered - // arrays — drawNotes/drawChords fall through to the flat arrays. + // _mastery. Called on `ready`, on every setMastery(), and on every + // setDifficultyOverride(). When _phrases is null (slider-disabled + // source), we clear the filtered arrays — drawNotes/drawChords fall + // through to the flat arrays. // // Output arrays are pre-sorted by time because phrase iterations // arrive in chronological order and within each level the notes/ @@ -1616,8 +1666,11 @@ function createHighway() { const n = p.levels.length; if (n === 0) continue; // Map slider fraction to a level via the phrase's tier scale - // (see phraseLevelIndexForMastery). - const lv = p.levels[phraseLevelIndexForMastery(p.levels, p.max_difficulty, hwState._mastery)]; + // (see phraseLevelIndexForMastery). A phrase picks its level + // from the effective difficulty at its own start time, which + // is what makes a practice override section-scoped rather + // than song-wide (feedBack#136). + const lv = p.levels[phraseLevelIndexForMastery(p.levels, p.max_difficulty, _effectiveMasteryAt(p.start_time))]; for (const x of lv.notes) outNotes.push(x); for (const x of lv.chords) outChords.push(x); // Anchors drive the fret zoom / pan. Keeping max-mastery @@ -1790,8 +1843,12 @@ function createHighway() { // persists across arrangement switches — the slider's // position stays put. Filter rebuilds on the next `ready` // once the new arrangement's phrases arrive (or stays - // disabled if the new source has no phrase data). + // disabled if the new source has no phrase data). A + // practice override is song-scoped, so it does NOT persist: + // the section it referred to belongs to the chart that is + // being left (feedBack#136). hwState._phrases = null; + _setOverrideState(null); hwState._filteredNotes = null; hwState._filteredChords = null; hwState._filteredAnchors = null; @@ -1941,6 +1998,47 @@ function createHighway() { _rebuildMasteryFilter(); }, getMastery() { return hwState._mastery; }, + // Practice difficulty override (feedBack#136). Per-instance, + // like _mastery, but time-scoped and never merged into the + // song-wide value: getMastery() keeps reporting the slider. + // + // `override` is `{ startTime, endTime, fraction }` (fractions, + // not percentages) or null to clear. Validation is deliberately + // forgiving-then-ignoring like setMastery: a caller that passes + // garbage keeps the previous override rather than having the + // chart blanked. Callers that need the request rejected should + // validate first — the practice-difficulty capability domain does. + setDifficultyOverride(override) { + if (override == null) { + if (!hwState._difficultyOverride) return; + _setOverrideState(null); + _rebuildMasteryFilter(); + return; + } + if (typeof override !== 'object') return; + const startTime = Number(override.startTime); + const endTime = Number(override.endTime); + const fraction = Number(override.fraction); + if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return; + if (!Number.isFinite(fraction)) return; + // Half-open window — a zero-length or inverted range would + // never match a phrase start and would silently do nothing. + if (endTime <= startTime) return; + _setOverrideState({ + startTime, + endTime, + fraction: Math.max(0, Math.min(1, fraction)), + }); + _rebuildMasteryFilter(); + }, + getDifficultyOverride() { + return hwState._difficultyOverride ? { ...hwState._difficultyOverride } : null; + }, + // Effective difficulty at a chart time, or at the current + // playback time when `time` is omitted. Always a 0..1 fraction. + getEffectiveMastery(time) { + return _effectiveMasteryAt(time === undefined ? hwState.chartTime : time); + }, // Single source of truth shared with the ready-bundle field above // (see _hasRealLadder) — true only when some phrase actually has // more than one level, not merely when phrase timing exists. @@ -2928,8 +3026,11 @@ function createHighway() { // persists across arrangement switches — the slider's // position stays put. Filter rebuilds on the next `ready` // once the new arrangement's phrases arrive (or stays - // disabled if the new source has no phrase data). + // disabled if the new source has no phrase data). A + // practice override is song-scoped, so it does NOT persist + // (feedBack#136). hwState._phrases = null; + _setOverrideState(null); hwState._filteredNotes = null; hwState._filteredChords = null; hwState._filteredAnchors = null; diff --git a/static/v3/index.html b/static/v3/index.html index 74b38f5e..15de1288 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -136,6 +136,9 @@ + + diff --git a/tests/js/highway_difficulty_override.test.js b/tests/js/highway_difficulty_override.test.js new file mode 100644 index 00000000..737f710f --- /dev/null +++ b/tests/js/highway_difficulty_override.test.js @@ -0,0 +1,173 @@ +// feedBack#136 — the per-highway difficulty-override seam. +// +// The createHighway closure is too heavy for a Node sandbox, so the pure +// helpers are extracted and run directly (the same approach as +// highway_phrase_tiers.test.js / highway_chart_transform.test.js) and the +// wiring is pinned by source inspection. +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function extractFunction(src, name) { + const start = src.indexOf(`function ${name}(`); + assert.ok(start >= 0, `${name} present`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth += 1; + else if (src[i] === '}') { + depth -= 1; + if (depth === 0) return src.slice(start, i + 1); + } + } + assert.fail(`${name} body is balanced`); +} + +// Shorthand object-method form (`setDifficultyOverride(override) {`), not a +// `function` declaration — same brace-balancing walk, different marker. +function extractMethod(src, signature) { + const start = src.indexOf(`${signature} {`); + assert.ok(start >= 0, `${signature} present`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth += 1; + else if (src[i] === '}') { + depth -= 1; + if (depth === 0) return src.slice(start, i + 1); + } + } + assert.fail(`${signature} body is balanced`); +} + +// A fixed, repo-relative file resolved by the module system from a literal +// specifier; no input reaches this path. highway.js is only read, not loaded. +const src = fs.readFileSync(require.resolve('../../static/highway.js'), 'utf8'); // nosemgrep + +const sandbox = { hwState: { _mastery: 1, _difficultyOverride: null, chartTime: 0 } }; +vm.runInNewContext([ + extractFunction(src, 'phraseLevelTiers'), + extractFunction(src, 'phraseLevelIndexForMastery'), + extractFunction(src, 'phraseTopDifficulty'), + extractFunction(src, '_effectiveMasteryAt'), +].join('\n'), sandbox); +const { phraseLevelIndexForMastery, _effectiveMasteryAt } = sandbox; + +function withOverride(override) { + sandbox.hwState._mastery = 1; + sandbox.hwState._difficultyOverride = override; +} + +test('without an override the effective difficulty is always the song-wide slider', () => { + withOverride(null); + for (const time of [0, 12.5, 24, 1e6, NaN, undefined, null]) { + assert.equal(_effectiveMasteryAt(time), 1, `time ${time}`); + } + sandbox.hwState._mastery = 0.4; + assert.equal(_effectiveMasteryAt(12.5), 0.4); +}); + +test('the override window is half-open: [start_time, end_time)', () => { + withOverride({ startTime: 12.5, endTime: 24, fraction: 0.4 }); + assert.equal(_effectiveMasteryAt(11.9), 1, 'before the window the slider applies'); + assert.equal(_effectiveMasteryAt(12.5), 0.4, 'start_time is inside the window'); + assert.equal(_effectiveMasteryAt(23.999), 0.4, 'the last moment inside the window'); + assert.equal(_effectiveMasteryAt(24), 1, 'end_time is outside the window'); + assert.equal(_effectiveMasteryAt(40), 1, 'after the window the slider applies'); +}); + +test('a non-finite query falls back to the slider rather than the override', () => { + withOverride({ startTime: 12.5, endTime: 24, fraction: 0.4 }); + // The render loop's pre-song frame, and a caller that passed nothing. + assert.equal(_effectiveMasteryAt(NaN), 1); + assert.equal(_effectiveMasteryAt(undefined), 1); +}); + +test('an override picks phrase levels without touching the song-wide mastery value', () => { + // The whole point of the seam: hwState._mastery keeps reporting the + // slider (so Difficulty Ladder's adaptive controller and the slider UI + // stay consistent) while phrases inside the window render at the + // override difficulty. + sandbox.hwState._mastery = 0.9; + sandbox.hwState._difficultyOverride = { startTime: 12.5, endTime: 24, fraction: 0.4 }; + const levels = [{ difficulty: 0 }, { difficulty: 1 }, { difficulty: 2 }, { difficulty: 3 }]; + + assert.equal(phraseLevelIndexForMastery(levels, 3, _effectiveMasteryAt(0)), 3, + 'a phrase before the window plays at the slider difficulty'); + assert.equal(phraseLevelIndexForMastery(levels, 3, _effectiveMasteryAt(13)), 1, + 'a phrase inside the window plays at the override difficulty'); + assert.equal(phraseLevelIndexForMastery(levels, 3, _effectiveMasteryAt(30)), 3, + 'a phrase after the window plays at the slider difficulty'); + + assert.equal(sandbox.hwState._mastery, 0.9, 'the song-wide mastery value is never rewritten'); +}); + +// --- Source-level wiring ------------------------------------------------- + +test('the public API exposes the override getters/setter', () => { + assert.match(src, /setDifficultyOverride\(override\)\s*\{/); + assert.match(src, /getDifficultyOverride\(\)\s*\{/); + assert.match(src, /getEffectiveMastery\(time\)\s*\{\s*return _effectiveMasteryAt\(time === undefined \? hwState\.chartTime : time\);\s*\}/, + 'getEffectiveMastery() with no argument reads the live playback time'); +}); + +test('getMastery still reports hwState._mastery, not the effective value', () => { + // Regression guard: folding the override into getMastery() would make + // an adaptive consumer believe the slider itself moved. + assert.match(src, /getMastery\(\)\s*\{\s*return hwState\._mastery;\s*\}/); +}); + +test('no override code path ever writes hwState._mastery', () => { + // Stronger than asserting getMastery() reads one field: the override + // helpers must not reach the slider at all, so the adaptive + // controller can never observe a slider move it did not cause. + const bodies = [ + extractFunction(src, '_effectiveMasteryAt'), + extractFunction(src, '_setOverrideState'), + extractMethod(src, 'setDifficultyOverride(override)'), + extractMethod(src, 'getDifficultyOverride()'), + extractMethod(src, 'getEffectiveMastery(time)'), + ]; + for (const body of bodies) { + assert.doesNotMatch(body, /hwState\._mastery\s*=(?!=)/, + `override code must not assign the slider:\n${body.slice(0, 80)}`); + } +}); + +test('setDifficultyOverride refuses garbage instead of blanking the chart', () => { + const fn = extractMethod(src, 'setDifficultyOverride(override)'); + assert.match(fn, /if \(typeof override !== 'object'\) return;/, + 'a non-object leaves the previous override in place'); + assert.match(fn, /if \(!Number\.isFinite\(startTime\) \|\| !Number\.isFinite\(endTime\)\) return;/); + assert.match(fn, /if \(!Number\.isFinite\(fraction\)\) return;/); + assert.match(fn, /if \(endTime <= startTime\) return;/, + 'a zero-length or inverted window would silently never match'); + assert.match(fn, /Math\.max\(0, Math\.min\(1, fraction\)\)/, 'the fraction is clamped to 0..1'); + assert.match(fn, /override == null/, 'null clears'); + assert.match(fn, /_rebuildMasteryFilter\(\)/, 'installing rebuilds the phrase filter'); +}); + +test('every song-reset path drops the override through the single writer', () => { + // _setOverrideState() is the only writer, so a reset path that nulled + // _difficultyOverride directly would silently leave the render bundle's + // view object reporting a stale window. + assert.equal(extractFunction(src, '_setOverrideState').includes('hwState._difficultyOverride = next'), true); + const directWrites = src.match(/hwState\._difficultyOverride\s*=(?!=)(?!\s*next;)\s*null;/g) || []; + assert.deepEqual(directWrites, ['hwState._difficultyOverride = null;'], + 'only the initial declaration may assign the field directly'); + + const resets = src.match(/hwState\._phrases = null;(?:\s*\n\s*_setOverrideState\(null\);)/g) || []; + // init() and reconnect() both reset the ladder; each must drop the + // song-scoped override on the way. + assert.equal(resets.length, 2, `expected 2 reset paths, found ${resets.length}`); +}); + +test('the renderer bundle exposes the override so mastery cannot be misread', () => { + assert.match(src, /b\.mastery = hwState\._mastery;/, + 'bundle.mastery stays the song-wide value'); + assert.match(src, /b\.difficultyOverride = _overrideViewActive \? _overrideView : null;/, + 'the bundle hands out a reused view object'); + assert.doesNotMatch(src, /b\.difficultyOverride = \{ \.\.\./, + 'the bundle is documented as allocation-free; never re-spread the override per frame'); +}); \ No newline at end of file diff --git a/tests/js/highway_phrase_tiers.test.js b/tests/js/highway_phrase_tiers.test.js index 2b6629fb..d2b9be01 100644 --- a/tests/js/highway_phrase_tiers.test.js +++ b/tests/js/highway_phrase_tiers.test.js @@ -81,5 +81,9 @@ test('phraseTopDifficulty reports the tier the phrase is complete at', () => { test('the mastery filter selects levels through the tier helper', () => { const fn = extractFunction(src, '_rebuildMasteryFilter'); - assert.match(fn, /phraseLevelIndexForMastery\(p\.levels, p\.max_difficulty, hwState\._mastery\)/); + // The fraction is resolved per phrase via _effectiveMasteryAt(p.start_time), + // not read straight off hwState._mastery — that is what makes a + // time-scoped practice override (feedBack#136) affect only the phrases + // inside its window. + assert.match(fn, /phraseLevelIndexForMastery\(p\.levels, p\.max_difficulty, _effectiveMasteryAt\(p\.start_time\)\)/); }); diff --git a/tests/js/practice_difficulty_domain.test.js b/tests/js/practice_difficulty_domain.test.js new file mode 100644 index 00000000..507ad535 --- /dev/null +++ b/tests/js/practice_difficulty_domain.test.js @@ -0,0 +1,552 @@ +// feedBack#136 — practice-difficulty capability domain. +// +// Runs the real capabilities.js runtime + the real domain module in a vm +// window, the same way player_identity_capability.test.js does, so these +// exercise dispatch attribution, participant lifecycle and the private +// player-context → highway routing rather than a hand-rolled double. +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createWindow, ROOT } = require('./capabilities_test_harness'); + +const CAPABILITIES = fs.readFileSync(path.join(ROOT, 'static', 'capabilities.js'), 'utf8'); +const PLAYER_IDENTITY = fs.readFileSync(path.join(ROOT, 'static', 'capabilities', 'player-identity.js'), 'utf8'); +const SOURCE = fs.readFileSync(path.join(ROOT, 'static', 'capabilities', 'practice-difficulty.js'), 'utf8'); + +// Values produced inside the vm carry that realm's Array/Object prototypes, +// which node:assert/strict's deepStrictEqual rejects even when the contents +// match. Normalize every cross-realm value through JSON on the way out. +function plain(value) { + if (value === undefined) return undefined; + return JSON.parse(JSON.stringify(value)); +} + +// A highway double that records what it was handed. `setMastery` records +// separately so a test can prove the domain never touches the song-wide +// slider value (the real assertion about mastery lives in +// highway_difficulty_override.test.js). +function fakeHighway() { + return { + override: 'untouched', + masteryCalls: [], + setMastery(value) { this.masteryCalls.push(value); }, + setDifficultyOverride(value) { this.override = value === null ? null : plain(value); }, + // Mirrors the real highway's read-back, which is how the domain + // notices a chart rebuild that dropped the slot behind its back. + getDifficultyOverride() { + return this.override === 'untouched' ? null : this.override; + }, + }; +} + +function load() { + const window = createWindow(); + const context = vm.createContext(window); + vm.runInContext(CAPABILITIES, context, { filename: 'capabilities.js' }); + vm.runInContext(PLAYER_IDENTITY, context, { filename: 'player-identity.js' }); + vm.runInContext(SOURCE, context, { filename: 'practice-difficulty.js' }); + return window; +} + +function makeContext(window, overrides = {}, highway) { + return window.feedBack.playerContexts.upsert({ + player_id: 'player-1', + profile_id: 'alex', + profile_hash: 'hash-alex', + song_id: 'song.feedpak', + arrangement_id: 'lead', + instrument: 'guitar', + role: 'lead', + skill: 'overall', + ...overrides, + }, highway, 'plugin.splitscreen'); +} + +function dispatch(window, command, source, args = {}) { + return window.feedBack.capabilities.dispatch({ + capability: 'practice-difficulty', command, source, args, + }).then(plain); +} + +test('a domain snapshot is available and reports no overrides before any are activated', async () => { + const window = load(); + const result = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.equal(result.outcome, 'handled'); + assert.equal(result.payload.schema, 'feedBack.practice_difficulty.state.v1'); + assert.deepEqual(result.payload.active_overrides, []); + assert.deepEqual(result.payload.participants, []); +}); + +test('activate installs one clamped override on the bound highway and leaves mastery alone', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + + const result = await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, + difficulty_pct: 140, + start_time: 12.5, + end_time: 24, + phrase_indices: [3, 'x', -1], + label: ' Verse two ', + }); + + assert.equal(result.outcome, 'handled'); + const override = result.payload.override; + assert.equal(override.schema, 'feedBack.practice_difficulty.override.v1'); + assert.equal(override.difficulty_pct, 100, '140% clamps to 100%'); + assert.equal(override.clamped, true); + assert.equal(override.start_time, 12.5); + assert.equal(override.end_time, 24); + assert.deepEqual(override.phrase_indices, [3, -1], 'non-numeric entries are dropped'); + assert.equal(override.label, 'Verse two'); + assert.equal(override.source, 'plugin.section_practice', 'attribution comes from the dispatch caller'); + + assert.deepEqual(highway.override, { startTime: 12.5, endTime: 24, fraction: 1 }, + 'the highway receives a 0..1 fraction, not a percentage'); + assert.deepEqual(highway.masteryCalls, [], 'the domain must never mutate the song-wide mastery slider'); +}); + +test('difficulty_pct clamps low and reports when no clamping was needed', async () => { + const window = load(); + const context = makeContext(window, {}, fakeHighway()); + + const low = await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: -25, start_time: 0, end_time: 5, + }); + assert.equal(low.payload.override.difficulty_pct, 0); + assert.equal(low.payload.override.clamped, true); + + const exact = await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 62.5, start_time: 0, end_time: 5, + }); + assert.equal(exact.payload.override.difficulty_pct, 62.5); + assert.equal(exact.payload.override.clamped, false); +}); + +test('one active override per player context: a second registrant is refused and the incumbent keeps the slot', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + const rejected = []; + window.feedBack.on('capability:event', (event) => { + const detail = event.detail || {}; + if (detail.capability === 'practice-difficulty' && detail.event === 'override-rejected') { + rejected.push(detail.payload); + } + }); + + await dispatch(window, 'register-participant', 'plugin.section_practice'); + const first = await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 12.5, end_time: 24, + }); + assert.equal(first.outcome, 'handled'); + + await dispatch(window, 'register-participant', 'plugin.step_mode'); + const second = await dispatch(window, 'activate', 'plugin.step_mode', { + player_context: context, difficulty_pct: 10, start_time: 12.5, end_time: 24, + }); + assert.equal(second.outcome, 'denied'); + assert.equal(second.payload.held_by, 'plugin.section_practice', + 'the conflict names the incumbent deterministically'); + assert.deepEqual(highway.override, { startTime: 12.5, endTime: 24, fraction: 0.4 }, + 'the refused request must not touch the highway'); + + assert.equal(rejected.length, 1); + assert.equal(rejected[0].held_by, 'plugin.section_practice'); + assert.equal(rejected[0].rejected, 'plugin.step_mode'); + + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.equal(snapshot.payload.active_overrides.length, 1, 'overrides never stack'); +}); + +test('the same registrant re-activating updates its own override in place', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 12.5, end_time: 24, + }); + const again = await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 55, start_time: 13, end_time: 26, + }); + + assert.equal(again.outcome, 'handled', 'self-update is not a conflict'); + assert.equal(again.payload.override.difficulty_pct, 55); + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.equal(snapshot.payload.active_overrides.length, 1); + assert.deepEqual(highway.override, { startTime: 13, endTime: 26, fraction: 0.55 }); +}); + +test('two different player contexts hold independent overrides', async () => { + const window = load(); + const first = fakeHighway(); + const second = fakeHighway(); + const contextA = makeContext(window, { player_id: 'player-a' }, first); + const contextB = makeContext(window, { player_id: 'player-b' }, second); + + const a = await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: contextA, difficulty_pct: 30, start_time: 0, end_time: 10, + }); + const b = await dispatch(window, 'activate', 'plugin.step_mode', { + player_context: contextB, difficulty_pct: 70, start_time: 0, end_time: 10, + }); + + assert.equal(a.outcome, 'handled'); + assert.equal(b.outcome, 'handled'); + assert.notEqual(a.payload.override.context_ref, b.payload.override.context_ref); + assert.deepEqual(first.override, { startTime: 0, endTime: 10, fraction: 0.3 }); + assert.deepEqual(second.override, { startTime: 0, endTime: 10, fraction: 0.7 }); + + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.equal(snapshot.payload.active_overrides.length, 2); +}); + +test('a second context resolving to the same highway is refused, not silently merged', async () => { + // The highway carries ONE override slot. Two contexts bound to the same + // panel instance would otherwise overwrite each other's window while + // both records claimed to be in force — the loser's record would still + // block competitors and inspect would report it as active. + const window = load(); + const shared = fakeHighway(); + const contextA = makeContext(window, { player_id: 'player-a' }, shared); + const contextB = makeContext(window, { player_id: 'player-b' }, shared); + + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: contextA, difficulty_pct: 30, start_time: 0, end_time: 10, + }); + const clash = await dispatch(window, 'activate', 'plugin.step_mode', { + player_context: contextB, difficulty_pct: 70, start_time: 0, end_time: 10, + }); + + assert.equal(clash.outcome, 'denied'); + assert.equal(clash.payload.held_by, 'plugin.section_practice'); + assert.deepEqual(shared.override, { startTime: 0, endTime: 10, fraction: 0.3 }, + 'the first window stays in force on the shared highway'); + + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.equal(snapshot.payload.active_overrides.length, 1); +}); + +test('a record whose highway was rebuilt mid-song is re-installed, not left stranded', async () => { + // highway.init()/reconnect() null their own override slot because the + // chart is being rebuilt. If the domain only checked "does this context + // still resolve?", the record would survive as live-but-uninstalled: + // inspect would report it active and it would still block competitors + // while the chart rendered at the slider. + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 12.5, end_time: 24, + }); + + // The panel rebuilds its highway against the same context. + highway.override = null; + window.feedBack.emit('player-context:changed', { detail: {} }); + + assert.deepEqual(highway.override, { startTime: 12.5, endTime: 24, fraction: 0.4 }, + 'the override is put back on the highway that still owns the context'); + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.equal(snapshot.payload.active_overrides.length, 1); +}); + +test('a vocal player context is denied, matching the sibling player-difficulty.v1 gate', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, { role: 'karaoke', skill: 'vocal' }, highway); + + const result = await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 0, end_time: 10, + }); + assert.equal(result.outcome, 'denied'); + assert.equal(highway.override, 'untouched'); + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.deepEqual(snapshot.payload.active_overrides, []); +}); + +test('invalid ranges and non-finite difficulty are denied without touching the highway', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + const base = { player_context: context, difficulty_pct: 50 }; + + const empty = await dispatch(window, 'activate', 'plugin.section_practice', { + ...base, start_time: 10, end_time: 10, + }); + assert.equal(empty.outcome, 'denied', 'a zero-length window is refused, not recorded as a no-op'); + + const inverted = await dispatch(window, 'activate', 'plugin.section_practice', { + ...base, start_time: 30, end_time: 20, + }); + assert.equal(inverted.outcome, 'denied'); + + const nan = await dispatch(window, 'activate', 'plugin.section_practice', { + ...base, difficulty_pct: Number.NaN, start_time: 0, end_time: 10, + }); + assert.equal(nan.outcome, 'denied', 'a non-finite difficulty has nothing sensible to clamp to'); + + const missingTimes = await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 50, + }); + assert.equal(missingTimes.outcome, 'denied'); + + assert.equal(highway.override, 'untouched', 'no rejected request may reach the highway'); + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.deepEqual(snapshot.payload.active_overrides, [], 'a denied request records no state'); +}); + +test('an override against a stale player context returns no-target and records nothing', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + + // The arrangement changed underneath us — the captured snapshot no + // longer matches any current context. + window.feedBack.playerContexts.updateActive('player-1', { arrangement_id: 'rhythm' }); + + const result = await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 50, start_time: 0, end_time: 10, + }); + assert.equal(result.outcome, 'no-target'); + assert.equal(highway.override, 'untouched'); + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.deepEqual(snapshot.payload.active_overrides, []); +}); + +test('clear releases the calling registrant override; it cannot clear another registrant slot', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 12.5, end_time: 24, + }); + + const foreign = await dispatch(window, 'clear', 'plugin.step_mode', { player_context: context }); + assert.equal(foreign.outcome, 'denied'); + assert.equal(foreign.payload.held_by, 'plugin.section_practice'); + assert.notEqual(highway.override, null, 'a refused clear must leave the override installed'); + + const mine = await dispatch(window, 'clear', 'plugin.section_practice', { player_context: context }); + assert.equal(mine.outcome, 'handled'); + assert.equal(mine.payload.released, true); + assert.equal(highway.override, null); + + // Clearing an already-released slot reports no-target rather than a + // misleading `released: false`, so a consumer can tell "nothing to do" + // apart from "I cleared something". + const again = await dispatch(window, 'clear', 'plugin.section_practice', { player_context: context }); + assert.equal(again.outcome, 'no-target'); +}); + +test('clear with a stale player_context reports no-target instead of a fake release', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 12.5, end_time: 24, + }); + + // The consumer's captured snapshot goes stale (arrangement switch). + // Core's lifecycle reconciliation already released the slot, so the + // context-scoped clear matches nothing — and must say so rather than + // report a `handled released: false` that reads like "still there, + // nothing to do" or, worse, like it is still in force. + window.feedBack.playerContexts.updateActive('player-1', { arrangement_id: 'rhythm' }); + assert.equal(highway.override, null, 'core already released it on the context change'); + + const stale = await dispatch(window, 'clear', 'plugin.section_practice', { player_context: context }); + assert.equal(stale.outcome, 'no-target'); + assert.equal(stale.payload, undefined, 'no-target carries no handled snapshot'); + assert.equal(stale.reason, 'No active override for this player context'); +}); + +test('clear without a player_context releases every override the caller owns', async () => { + const window = load(); + const highwayA = fakeHighway(); + const highwayB = fakeHighway(); + const highwayC = fakeHighway(); + const contextA = makeContext(window, { player_id: 'player-a' }, highwayA); + const contextB = makeContext(window, { player_id: 'player-b' }, highwayB); + const contextC = makeContext(window, { player_id: 'player-c' }, highwayC); + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: contextA, difficulty_pct: 30, start_time: 0, end_time: 10, + }); + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: contextB, difficulty_pct: 70, start_time: 0, end_time: 10, + }); + // A third context, because one override per context means two + // registrants need two contexts to both hold a slot. + await dispatch(window, 'activate', 'plugin.step_mode', { + player_context: contextC, difficulty_pct: 10, start_time: 20, end_time: 30, + }); + + const result = await dispatch(window, 'clear', 'plugin.section_practice'); + assert.equal(result.outcome, 'handled'); + assert.equal(result.payload.released, 2); + assert.equal(highwayA.override, null); + assert.equal(highwayB.override, null); + assert.deepEqual(highwayC.override, { startTime: 20, endTime: 30, fraction: 0.1 }, + 'another registrant keeps its own slot'); +}); + +test('unregister-participant releases that registrant state and participant record', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + await dispatch(window, 'register-participant', 'plugin.section_practice', { label: 'Section Practice' }); + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 0, end_time: 10, + }); + + const result = await dispatch(window, 'unregister-participant', 'plugin.section_practice'); + assert.equal(result.outcome, 'handled'); + assert.equal(result.payload.released, 1); + assert.equal(highway.override, null); + assert.deepEqual(result.payload.active_overrides, []); + assert.deepEqual(result.payload.participants, []); +}); + +test('runtime participant disappearance releases state without an explicit unregister', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + await dispatch(window, 'register-participant', 'plugin.section_practice'); + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 0, end_time: 10, + }); + assert.notEqual(highway.override, null); + + // The host tears the plugin down at runtime. + window.feedBack.capabilities.unregisterParticipant('plugin.section_practice', 'practice-difficulty'); + + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.deepEqual(snapshot.payload.active_overrides, [], 'runtime disappearance must not leak a stuck override'); + assert.deepEqual(snapshot.payload.participants, []); + assert.equal(highway.override, null, 'the highway is told to clear, not left holding the override'); +}); + +test('a replaced player context clears the override that referred to the old one', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 0, end_time: 10, + }); + + // Arrangement switch: player-identity republishes the context, which + // changes its key, so the captured override no longer resolves. + window.feedBack.playerContexts.updateActive('player-1', { arrangement_id: 'rhythm' }); + + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.deepEqual(snapshot.payload.active_overrides, []); + assert.equal(highway.override, null); +}); + +test('a departing player context clears the override on song replacement', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 0, end_time: 10, + }); + + window.feedBack.playerContexts.leave('player-1', 'plugin.splitscreen'); + + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + assert.deepEqual(snapshot.payload.active_overrides, []); + assert.equal(highway.override, null); +}); + +test('requests against a Host without this domain degrade to a no-op outcome', async () => { + const window = load(); + // No practice-difficulty module evaluated — the shape an older Host has. + const bare = createWindow(); + const context = vm.createContext(bare); + vm.runInContext(CAPABILITIES, context, { filename: 'capabilities.js' }); + vm.runInContext(PLAYER_IDENTITY, context, { filename: 'player-identity.js' }); + + const result = await bare.feedBack.capabilities.dispatch({ + capability: 'practice-difficulty', command: 'activate', source: 'plugin.section_practice', + args: { player_context: { player_id: 'player-1' }, difficulty_pct: 40, start_time: 0, end_time: 10 }, + }); + assert.notEqual(result.outcome, 'handled'); + assert.equal(typeof result.outcome, 'string'); + assert.equal(window.feedBack.practiceDifficultyDomain.version, 1); +}); + +test('override snapshots and diagnostics never carry raw song/profile/player identity', async () => { + const window = load(); + const highway = fakeHighway(); + const context = makeContext(window, {}, highway); + await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 0, end_time: 10, + label: 'private label', + }); + + const secrets = ['song.feedpak', 'hash-alex', 'player-1', 'profile_id']; + const snapshot = await dispatch(window, 'inspect', 'plugin.spectator'); + const serializedSnapshot = JSON.stringify(snapshot.payload); + for (const secret of secrets) { + assert.equal(serializedSnapshot.includes(secret), false, `snapshot leaked ${secret}`); + } + assert.match(snapshot.payload.active_overrides[0].context_ref, /^context-[a-z0-9]+$/); + // The label is a bounded, user-facing practice description (the same + // shape playback.js exports for target titles) so it IS part of the + // public snapshot — but it must never reach diagnostics. + assert.equal(snapshot.payload.active_overrides[0].label, 'private label'); + + const diagnostics = window.feedBack.diagnostics.snapshotContributions()['practice-difficulty']; + assert.deepEqual({ ...diagnostics }, { + schema: 'feedBack.practice_difficulty.diagnostics.v1', + available: true, + active_overrides: 1, + participants: 0, + last_outcome: 'handled', + }); + const serializedDiagnostics = JSON.stringify(diagnostics); + for (const secret of [...secrets, 'private label']) { + assert.equal(serializedDiagnostics.includes(secret), false, `diagnostics leaked ${secret}`); + } +}); + +test('registrant labels are redacted of control characters and bounded', async () => { + const window = load(); + const result = await dispatch(window, 'register-participant', 'plugin.section_practice', { + label: `line\nbreak\t${'x'.repeat(200)}`, + }); + const participant = result.payload.participants[0]; + assert.equal(participant.participant_ref, 'plugin.section_practice'); + assert.equal(participant.label.includes('\n'), false); + assert.equal(participant.label.length <= 80, true); +}); + +test('labels are stripped of filesystem paths, URLs and credentials', async () => { + // Each pass is exercised on its own: the path pattern is deliberately + // greedy (it matches playback.js's target-title redaction exactly), so + // a path followed by a URL collapses to '[path]' in one go. + const window = load(); + const context = makeContext(window, {}, fakeHighway()); + const cases = [ + ['C:\\Users\\alex\\chart.gp5', ['[path]'], ['alex', 'chart.gp5']], + ['/home/alex/song.sloppak verse', ['[path]'], ['alex', 'song.sloppak']], + ['https://example.com/a?b=c bridge', ['[url]'], ['example.com']], + ['token=abc123 keep this', ['token=[redacted]'], ['abc123']], + ]; + for (const [label, expected, forbidden] of cases) { + const result = await dispatch(window, 'activate', 'plugin.section_practice', { + player_context: context, difficulty_pct: 40, start_time: 0, end_time: 10, label, + }); + for (const needle of expected) { + assert.equal(result.payload.override.label.includes(needle), true, + `${JSON.stringify(label)} should keep ${needle}`); + } + for (const needle of forbidden) { + assert.equal(result.payload.override.label.includes(needle), false, + `${JSON.stringify(label)} leaked ${needle}`); + } + } +}); \ No newline at end of file