feat(achievements): opt-in, privacy controls & data-min gate (epic PR2) (#591)

Sharing earned Feats on the (forthcoming) public wall is strictly opt-in,
default OFF, with a binding data-minimization contract.

- Onboarding (static/v3/profile.js): a new opt-in step (now a 5-step wizard)
  after song-directory / before paths — publishes only display name + earned
  Feats, never songs/skills/scores; off by default.
- Settings (plugins/achievements/settings.html, System tab via
  settings.category): the same toggle + a "Remove me from the wall" button
  (POST remove-me — wipes local synced state offline + enqueues removal).
- Core (server.py): achievements_enabled (bool, default false) in
  _default_settings + /api/settings validation + _RESETTABLE_SETTINGS_KEYS;
  mirrored to localStorage in app.js loadSettings().
- Data-minimization gate: engine.build_wall_payload is the single explicit-dict
  serializer; key-set is EXACTLY {display_name, player_hash, achievement_id,
  unlocked_at}, achievement_id always a Feat id. Enqueue is gated on
  opted-in AND profile identity (reused player_hash); competency never
  enqueues (integration law).

Verified natively: settings round-trip + validation + remove-me; opted-in
activity enqueues exactly one 4-field Feat payload; Playwright confirms the
5-step wizard + opt-in card (default unchecked), zero console errors.
29 plugin tests + new settings tests pass.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-24 17:00:30 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 05dd3d227a
commit 287c23a532
9 changed files with 318 additions and 25 deletions
+4
View File
@@ -3275,6 +3275,10 @@ async function loadSettings() {
try { localStorage.setItem('countdownBeforeSong', countdownOn ? '1' : '0'); } catch (_) { /* private mode */ }
const countdownEl = document.getElementById('setting-countdown-before-song');
if (countdownEl) countdownEl.checked = countdownOn;
// Achievements epic: mirror the opt-in flag to localStorage so the
// onboarding card + the bundled achievements plugin can read the current
// state app-wide (the plugin's own settings panel still owns the toggle).
try { localStorage.setItem('achievementsEnabled', data.achievements_enabled === true ? '1' : '0'); } catch (_) { /* private mode */ }
const missEl = document.getElementById('setting-miss-penalty');
if (missEl) missEl.value = typeof data.miss_penalty === 'string' ? data.miss_penalty : 'none';
const failEl = document.getElementById('setting-fail-behavior');
+48 -16
View File
@@ -332,7 +332,7 @@
const stepDots = editing ? '' :
'<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
[1, 2, 3, 4].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
[1, 2, 3, 4, 5].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
'</div>';
const overlay = document.createElement('div');
@@ -365,13 +365,23 @@
'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary focus:ring-1 focus:ring-fb-primary">' +
'<button type="button" id="v3-ob-songdir-browse" class="hidden px-3 py-2 rounded-md text-sm bg-gray-800/50 border border-gray-700 text-fb-text hover:border-fb-primary whitespace-nowrap">Browse…</button>' +
'</div></div>' +
// Step 3 — instrument paths (first-run only; tiles filled on entry).
// Step 3 — Achievements wall opt-in (first-run only; default OFF).
'<div id="v3-ob-step3" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Feats of Power</label>' +
'<p class="text-sm text-fb-textDim mb-3">As you practise youll earn rare <span class="text-fb-text">Feats of Power</span> — silly, bombastic activity trophies. Want to show them off on the public <span class="text-fb-text">Feats wall</span>?</p>' +
'<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
'<input type="checkbox" id="v3-ob-optin" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
'<span class="text-sm text-fb-text">Share my Feats on the wall' +
'<span class="block text-xs text-fb-textDim mt-1">Publishes only your display name and the Feats you earn — never songs, skills, or scores. You can change this any time in Settings, and remove yourself with one click.</span></span>' +
'</label>' +
'<p class="text-xs text-fb-textDim mt-2">Leave it unticked to keep everything private. This is off by default.</p></div>' +
// Step 4 — instrument paths (first-run only; tiles filled on entry).
'<div id="v3-ob-step4" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
'<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
'<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
// Step 4 — calibration offer (first-run only).
'<div id="v3-ob-step4" class="hidden">' +
// Step 5 — calibration offer (first-run only).
'<div id="v3-ob-step5" class="hidden">' +
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
'<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and youll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
@@ -417,6 +427,9 @@
// for now" is available for users who'll set it later.
submit.disabled = !songDir.trim();
} else if (step === 3) {
// Achievements opt-in — either choice is valid; always enabled.
submit.disabled = false;
} else if (step === 4) {
// ≥1 path required — unless none could be offered (offline /
// empty content), where blocking would strand onboarding.
submit.disabled = pathsAvailable && selectedPaths.length < 1;
@@ -428,7 +441,7 @@
function setStep(n) {
step = n;
errEl.classList.add('hidden');
for (let i = 1; i <= 4; i++) {
for (let i = 1; i <= 5; i++) {
overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
}
overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
@@ -439,13 +452,14 @@
if (subtitle) {
subtitle.textContent = n === 1 ? 'Set up your player profile'
: n === 2 ? 'Point us at your songs'
: n === 3 ? 'Choose your instrument paths'
: n === 3 ? 'Feats of Power (optional)'
: n === 4 ? 'Choose your instrument paths'
: 'One last thing — calibrate your setup';
}
submit.textContent = n === 4 ? 'Play it now' : 'Next';
submit.textContent = n === 5 ? 'Play it now' : 'Next';
// Skip is offered on the song-directory step (configure later) and
// the calibration challenge.
skipBtn.classList.toggle('hidden', !(n === 2 || n === 4));
skipBtn.classList.toggle('hidden', !(n === 2 || n === 5));
refreshSubmit();
}
@@ -630,23 +644,42 @@
}
if (step === 2) {
// Save the song directory + kick a library scan, then continue
// to instrument paths. "Skip for now" leaves it unconfigured.
// to the achievements opt-in. "Skip for now" leaves it unconfigured.
submit.disabled = true;
try {
await saveSongDir();
setStep(3);
loadPathTiles();
} catch (e) { showErr(e.message || 'Could not set the song directory.'); refreshSubmit(); }
return;
}
if (step === 3) {
// Persist the wall opt-in choice (default OFF) then continue to
// instrument paths. Best-effort — a failed write must not block
// onboarding; the user can still set it later in Settings.
submit.disabled = true;
try {
const optEl = overlay.querySelector('#v3-ob-optin');
const optedIn = !!(optEl && optEl.checked);
try {
await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ achievements_enabled: optedIn }),
});
try { localStorage.setItem('achievementsEnabled', optedIn ? '1' : '0'); } catch (_) { /* noop */ }
} catch (e) { /* best-effort — settable later */ }
setStep(4);
loadPathTiles();
} finally { refreshSubmit(); }
return;
}
if (step === 4) {
// Create the profile (onboarded=1) BEFORE the calibration choice
// so closing the overlay at the challenge can never lose the profile.
submit.disabled = true;
try {
_profile = await postProfile();
if (selectedPaths.length) {
// A failed path save must NOT advance — step 3's skip
// A failed path save must NOT advance — step 4's skip
// requires ≥1 selected path (spec invariant) and would
// otherwise leave a pathless rank-1 profile.
const res = await fetch('/api/progression/paths', {
@@ -662,11 +695,11 @@
// New step: input-device selection + calibration, between
// path selection and the note-detect calibration challenge.
await runInputSetup(selectedPaths);
setStep(4);
setStep(5);
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
return;
}
// Step 4 — "Play it now": leave calibration pending (it completes
// Step 5 — "Play it now": leave calibration pending (it completes
// through the normal scored-stats path) and launch the diagnostic.
const target = diagnosticFilename;
await finish({ launchingSong: !!target });
@@ -675,13 +708,12 @@
skipBtn.addEventListener('click', async () => {
// Step 2 — skip the song directory (the user can set it later in
// Settings). Proceed straight to instrument paths.
// Settings). Proceed to the achievements opt-in.
if (step === 2) {
setStep(3);
loadPathTiles();
return;
}
// Step 4 — skip: Mastery Rank 1 immediately, calibration stays
// Step 5 — skip: Mastery Rank 1 immediately, calibration stays
// replayable from the Progress screen.
skipBtn.disabled = true;
try {