Skip to content

feat(capabilities): add practice-difficulty override domain (#136) - #138

Merged
cchacs merged 3 commits into
mainfrom
pullfrog/136-practice-difficulty-capability
Oct 5, 2026
Merged

cchacs merged 3 commits into
mainfrom
pullfrog/136-practice-difficulty-capability

Conversation

@pullfrog

@pullfrog pullfrog Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Closes #136. Draft — do not merge; the dependent Section Practice / Difficulty Ladder wiring lands separately, and the API names plus conflict policy still need maintainer sign-off (see Note for maintainers).

What this adds

A core-owned practice-difficulty capability domain: one time-scoped difficulty override per player context. A practice plugin can now play a stretch of a chart at a different difficulty than the rest of the song (a slowed verse, an isolated tricky bar) without a second difficulty slider existing.

await api.dispatch({ capability: 'practice-difficulty', command: 'activate',
  source: 'my_practice',
  payload: { player_context, difficulty_pct: 40, start_time: 12.5, end_time: 24 } });

How it differs from player-difficulty.v1

Deliberately complementary, not a duplicate. player-difficulty.v1 owns the song-wide mastery slider; this domain owns a transient half-open [start_time, end_time) window and never rewrites the slider.

getMastery() keeps reporting the slider unchanged — so Difficulty Ladder's adaptive controller and the slider UI stay consistent and cannot mistake an override for a slider move. The override only affects phrase rendering: _rebuildMasteryFilter() resolves each phrase's level from _effectiveMasteryAt(p.start_time) rather than hwState._mastery. getEffectiveMastery(time) and bundle.difficultyOverride expose the value actually in force.

Contract

Domain / owner practice-difficulty / core.practice-difficulty (provider-coordinator, safe)
Commands inspect, register-participant, unregister-participant, activate, clear
Operation practice.difficulty
Events participant-registered, participant-unregistered, override-activated, override-cleared, override-rejected
Payloads snake_case (player_context, difficulty_pct, start_time, end_time, phrase_indices, label)
  • Routing — resolves through playerContexts.getHighway(), the same private path player-difficulty.v1 uses, so a splitscreen panel only ever affects its own highway. A stale snapshot resolves to nothing and returns no-target with no state recorded.
  • Attribution — registrant identity comes from the dispatch caller, never the payload; a caller cannot claim another's slot.
  • Conflicts — incumbent-wins, never stacked; the same registrant updates in place. Because a highway holds a single override slot, a request whose context resolves to a highway another live record owns is refused rather than silently overwriting that window while both records claim to be in force.
  • Validation — difficulty_pct must be finite and clamps to 0–100 (clamped: true reports it); the window must satisfy end_time > start_time. Vocal (role: 'karaoke') contexts are denied, matching the sibling player-difficulty.v1 gate.
  • Lifecycle — cleared on clear, unregister-participant, runtime participant disappearance, song replacement, and context replacement/departure. Reconciliation also re-installs a record whose highway was rebuilt mid-song, so a chart rebuild cannot leave a record reported as active while the chart renders at the slider.
  • Practice-mode exit — Section-practice difficulty override 1/4 (core): versioned Host capability and per-highway effective difficulty #136 also lists "practice-mode exit", and this PR deliberately implements that condition at the producer boundary, not in core: leaving practice mode is the producer's own clear (or unregister-participant) call from its teardown path. 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, so honouring that toggle here would clear overrides belonging to other registrants. Documented in docs/capability-domains.md and as a consumer rule in docs/capability-recipes.md.
  • Privacy — diagnostics are count-only (active_overrides, participants, last_outcome). The public surface exposes a hashed context-… ref, never raw song/player/profile identity; registrant labels are bounded and stripped of paths, URLs and credentials. Documented explicitly in docs/capability-domains.md: the redaction boundary is the diagnostics surface — dispatch deliberately broadcasts its full result (labels included) on the shared capability:event bus, the same way playback echoes target titles.

Also in this PR

  • CORE_DOMAIN_REVIEW entry so the Capability Inspector labels this an active core contract rather than "plugin-defined".
  • bundle.difficultyOverride hands out a reused view object, keeping the documented allocation-free render-bundle contract (a per-frame spread would also have changed field identity every frame).
  • Docs: domain inventory, safety-matrix row, roadmap slice, a consumer recipe, and the getEffectiveMastery() / getDifficultyOverride() getters in AGENTS.md + CLAUDE.md.

Validation

  • pytest — 2939 passed / 4 skipped (full suite).
  • node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js' 'plugins/*/tests/*.test.js' — 1435 passed, including 22 new domain tests and 10 new highway-seam tests.
  • npm run lint — 0 errors (pre-existing max-lines warnings only).
  • Self-reviewed with reviewfrog before commit; its medium findings (shared-highway clobbering, one-way reconciliation, stale-context clear, missing Inspector entry, unreachable participant-disappearance claim, per-frame allocation) are all addressed above.

Note for maintainers

Pullfrog  | View workflow run | via Pullfrog | Using big-pickle (free) | 𝕏

@pullfrog
pullfrog Bot requested a review from cchacs October 2, 2026 21:11
@pullfrog

pullfrog Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Author

Diagnosis. The only failing job was core-commit-labeling (.github/workflows/fork-audit.yml) — this fork's commit-labeling policy, not a code or test failure. The commit 058c77f touched core paths (AGENTS.md, CLAUDE.md, static/capabilities.js, static/capabilities/practice-difficulty.js, static/highway.js, static/v3/index.html) but its subject was feat(capabilities): …, which does not match the workflow's ^(core|hook|sync|fix)(\([^)]*\))?: regex (docs/fork-maintenance.md Rule 2). Every other job in the suite was green, so the failure was introduced by this PR and needs no code change.

Fix. Relabelled the commit in place — 058c77f → e71e30c — as core(capabilities): add practice-difficulty override domain (#136), matching the existing convention for the sibling core work in this area (e5339c0 core(highway): map the mastery slider through phrase tier numbers). A short paragraph in the body records why the subject deviates from Conventional Commits on this fork. A follow-up commit could not have satisfied the check, because it iterates every commit in base..head — hence the amend plus force-push of the feature branch.

Verification. The commit tree is byte-identical to the original (7a0aa0fa… before and after), as is the author, so the PR diff is unchanged; git diff --merge-base d1980ff HEAD still lists the same 14 files. The workflow's check step was reproduced verbatim over d1980ff..e71e30c — a 1-commit range, fully local, so the local verdict is faithful — and it exits 0. Pushed with force: true; the remote tip is now e71e30c.

Task list (5/5 completed)

Pullfrog  | View workflow run | via Pullfrog | Using big-pickle (free) | 𝕏

@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 3 critical

Alerts:
⚠ 3 issues (≤ 0 issues of at least minor severity)

Results:
3 new issues

Category Results
Security 3 critical

View in Codacy

🟢 Metrics 214 complexity · 35 duplication

Metric Results
Complexity 214
Duplication 35

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Adds a core-owned `practice-difficulty` domain so a practice plugin can
play part of a chart at a different difficulty than the rest of the song,
without a second difficulty slider existing.

It is deliberately complementary to `player-difficulty.v1`, which drives
the song-wide mastery slider. `getMastery()` keeps reporting that slider
unchanged; the new override only affects phrase rendering, by resolving
each phrase's level from `_effectiveMasteryAt(p.start_time)` instead of
`hwState._mastery`. `getEffectiveMastery(time)` and `bundle.difficultyOverride`
expose the value actually in force.

Why this shape:

- One override per player context, routed through
  `playerContexts.getHighway()` so a splitscreen panel only ever affects
  its own highway, and a stale snapshot resolves to no highway at all.
- Registrant identity comes from the dispatch caller, never the payload,
  so a caller cannot claim another's slot.
- Conflicts are incumbent-wins and never stack. Because a highway holds a
  single override slot, a request whose context resolves to a highway
  another live record owns is refused too, rather than silently overwriting
  that window while both records claim to be in force.
- Lifecycle reconciliation releases records whose context stopped
  resolving and re-installs records whose highway was rebuilt mid-song, so
  a chart rebuild cannot leave a record reported as active while the chart
  renders at the slider.
- Diagnostics are count-only; the redacted context ref never exposes raw
  song/player/profile identity, and registrant labels are bounded and
  stripped of paths, URLs and credentials.

The subject is labelled `core(capabilities):` rather than
`feat(capabilities):` to satisfy this fork's commit-labeling policy —
the change edits core paths (`static/**`, `AGENTS.md`, `CLAUDE.md`), and
docs/fork-maintenance.md Rule 2 requires a `core:`/`hook:`/`sync:`/`fix:`
prefix on any such commit.
@pullfrog
pullfrog Bot force-pushed the pullfrog/136-practice-difficulty-capability branch from 058c77f to e71e30c Compare October 2, 2026 21:20

@cchacs cchacs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review for #136 (practice-difficulty capability). I found no code defect, but the API names and the conflict policy this PR implements have not been approved by a human, so please treat them as open.

Decisions for @itsjustroxi (tagged on #136)

  • Names: domain practice-difficulty (owner core.practice-difficulty, no .v1), commands inspect / register-participant / unregister-participant / activate / clear, events participant-registered / participant-unregistered / override-activated / override-cleared / override-rejected, snake_case payloads.
  • Conflict attribution: incumbent wins, never stacked. The same registrant updates in place; a second registrant is denied with held_by.
  • The naming proposal was posted on #136 by Pullfrog at 20:28 UTC. I see no human reply on the issue before this PR opened, so the PR's "posted for approval before any code was written" is true, but the approval was never given. The PR is marked draft / do not merge, which is the right state until someone signs off.

Checked

  • node --test tests/js/*.test.js → 1199 pass / 0 fail. The three directly relevant files (practice_difficulty_domain, highway_difficulty_override, highway_phrase_tiers) → 38/38.
  • Not run: pytest, npm run lint, and the plugin-level JS suites the description counts in its 1435 total. I did not audit the whole 543-line domain module, only the activate path and the lifecycle wiring.
  • Matches #136: one override per player context; clamp to 0–100 with clamped; half-open [start_time, end_time) with end_time <= start_time denied; registrant identity taken from the dispatch caller; runtime participant disappearance handled via capabilities.subscribe('unregistered', …); clears on song/context replacement; getMastery() untouched.

Findings (non-blocking)

  1. Practice-mode exit is not a clearing condition. #136 and the naming proposal both list "practice-mode exit". The code and the PR's lifecycle list cover clear, unregister-participant, song replacement and context replacement/departure only, and I found no practice-mode hook. That may be right if exit is the plugin's own clear / unregister call, but the PR doesn't say it deliberately drops this criterion. Please state it explicitly.
  2. Acceptance item not done: the recipe is not linked from difficulty-ladder#22. The description says this is outside the token's scope. I'd hold the cross-repo post until the names are approved, so #22 doesn't get names that later change.
  3. Incumbent-wins has no takeover path. A second practice plugin must wait for the incumbent to clear or disappear. That is deterministic, as required, but it is the behavior @itsjustroxi should confirm.

Not approving or merging.


Generated by Claude Code

@pullfrog

pullfrog Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Author

Addressed the review on #138 (a comment-only review with no inline threads; it found no code defect).

Finding 1 — practice-mode exit (acted on). The criterion is now stated explicitly rather than silently dropped: it is implemented at the producer boundary, not as a Host subscription, because 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 honouring it here would clear overrides belonging to other registrants. Documented in docs/capability-domains.md and as a consumer rule (plus the snippet comment) in docs/capability-recipes.md, and called out in the PR body's contract table.

Findings 2 and 3 — no code change, now called out for the sign-off. The difficulty-ladder#22 recipe link stays deliberately deferred until the names are approved (that repo is outside this token's scope anyway), and incumbent-wins having no takeover path is recorded in the PR body as one of the decisions requested on #136. The PR body was rewritten to make both explicit; it remains draft / do not merge.

Commit 18b4501 on pullfrog/136-practice-difficulty-capability — docs only, plus the missing trailing newline on the domain module. Verified: node --test tests/js/*.test.js → 1199 pass / 0 fail (matching the reviewer's run) and pytest tests/test_plugin_manifest_contract.py → 15 pass (the only suite that reads the recipe doc).

Task list (1/7 completed)

Pullfrog  | View workflow run | via Pullfrog | Using big-pickle (free) | 𝕏

@pullfrog

pullfrog Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Author

Fixed and force-pushed. The failure was a labeling policy, not a code problem.

Diagnosis — core-commit-labeling (from .github/workflows/fork-audit.yml) flagged commit 18b4501:

docs(capabilities): state practice-mode exit as the producer's clear touches core path(s) but its subject isn't prefixed core:/hook:/sync:/fix:

That commit edits static/capabilities/practice-difficulty.js (the trailing-newline fix) alongside docs/capability-domains.md and docs/capability-recipes.md. The step's exclusion list is per file, so a docs-flavoured commit that also touches static/** still counts as core, and the fork's regex admits only the four prefixes — docs(...) is rejected (docs/fork-maintenance.md Rule 2).

Fix — relabelled the commit to core(capabilities): state practice-mode exit as the producer's clear, keeping the Conventional-Commit scope and appending a paragraph explaining the relabel (naming Rule 2 and noting that the docs(...) title is retained for the eventual upstream got-feedback/feedBack PR). Rewrote the offending commit with commit --amend (tree, parent and author preserved) and recreated the tip merge via git commit-tree on the original tree — an amend can't be used there, and git merge hit a CHANGELOG.md conflict that the original merge had already resolved.

Verification

  • git diff --stat 366c890 a328cbf is empty — tip tree byte-identical, so the PR diff, test and lint results are unchanged.
  • git rev-list ce883f7..HEAD = 3 commits, all the PR's own (the range does not cross the shallow-graft boundary, so the local verdict is faithful).
  • The workflow's step, run verbatim over base.sha=ce883f7..new_head, exits 0 with no flagged commits.
  • Remote PR head is now a328cbf.

One cosmetic consequence of the rewrite: the tip merge's author/committer now reads as the sandbox bot identity instead of the human author (the message, its Signed-off-by trailer, both parents and the tree are unchanged) — unavoidable when re-parenting a merge, and not something the check inspects.

Task list (7/7 completed)
  • Checkout PR feat(capabilities): add practice-difficulty override domain (#136) #138 branch
  • Fetch and analyze check suite logs for check_suite 101148223121
  • Verify failure was introduced by this PR (not pre-existing)
  • Reproduce the failure locally with the exact CI command
  • Implement fix
  • Verify fix with exact CI command + review diff
  • Commit and push to PR branch, report progress

Pullfrog  | View workflow run | via Pullfrog | Using big-pickle (free) | 𝕏

@cchacs
cchacs marked this pull request as ready for review October 5, 2026 16:52
pullfrog Bot added 2 commits October 5, 2026 16:54
#136 lists "clears on ... practice-mode exit" as a lifecycle condition, but the
domain never implemented a Host-side hook for it and the PR did not say so. It
cannot: 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 honouring it here
would clear overrides belonging to other registrants.

Document the condition where it actually lives - the producer's own clear /
unregister-participant call from its teardown path - in the domain inventory and
as a consumer rule in the recipe.

Also add the missing trailing newline to the domain module.

Relabelled from "docs(capabilities):" for docs/fork-maintenance.md Rule 2: this
commit also edits static/capabilities/practice-difficulty.js (the trailing
newline above), and the exclusion list is per file, so it counts as a core commit
however docs-flavoured the subject reads. The org's Conventional-Commit title is
retained for the eventual got-feedback/feedBack PR.
Signed-off-by: Carolina <79524656+carochacs@users.noreply.github.com>
@pullfrog
pullfrog Bot force-pushed the pullfrog/136-practice-difficulty-capability branch from 366c890 to a328cbf Compare October 5, 2026 16:56
@get-flashbacks get-flashbacks deleted a comment from coderabbitai Bot Oct 5, 2026
@get-flashbacks get-flashbacks deleted a comment from coderabbitai Bot Oct 5, 2026
@kilo-code-bot

kilo-code-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (14 files)
  • AGENTS.md
  • CHANGELOG.md
  • CLAUDE.md
  • docs/capability-domains.md
  • docs/capability-recipes.md
  • docs/capability-roadmap.md
  • docs/capability-safety-matrix.md
  • static/capabilities.js
  • static/capabilities/practice-difficulty.js
  • static/highway.js
  • static/v3/index.html
  • tests/js/highway_difficulty_override.test.js
  • tests/js/highway_phrase_tiers.test.js
  • tests/js/practice_difficulty_domain.test.js

Reviewed by free · Input: 110.5K · Output: 27.9K · Cached: 900.6K

@cchacs
cchacs merged commit 1cc7a6c into main Oct 5, 2026
13 of 14 checks passed
@cchacs
cchacs deleted the pullfrog/136-practice-difficulty-capability branch October 5, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Section-practice difficulty override 1/4 (core): versioned Host capability and per-highway effective difficulty

1 participant