/* * Career plugin β€” venue progression UI + crowd-manifest push. * * Reads /api/plugins/career/state (stars from song_stats, per-venue * unlock/install/download status), renders the career screen, and pushes the * active venue's pack manifest into the crowd video layer * (window.v3VenueCrowd, shipped with the venue crowd PR) whenever it changes. * Everything degrades: no crowd layer β†’ screen still works; no packs β†’ the * venue scene keeps its static plate. */ (function () { 'use strict'; const API = '/api/plugins/career'; const VENUE_OVERRIDE_KEY = 'feedBack-career-venue'; const NO_VENUE = '__none__'; const PREV_VIZ_KEY = 'feedBack-career-prev-viz'; const POLL_MS = 2000; // Passports (the badge-journey layer; see routes.py β€” badges are computed // server-side, this file only renders and relays). const PP_SEEN_KEY = 'feedBack-career-badges-seen'; const PP_INST_KEY = 'feedBack-career-instrument'; const PP_TAB_KEY = 'feedBack-career-tab'; const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' }; const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', 'πŸ₯', '🎺', '🎻', '🎀', 'πŸͺ•']; let _state = null; let _pollTimer = 0; let _appliedManifestVenue = null; let _manifestReqGen = 0; // invalidates in-flight manifest fetches let _prevUnlockedIds = null; let _pp = null; // last /passports view let _ppRelayTimer = 0; let _ppBook = null; // {inst, gkey} of the open spread let _ppReturnFocus = null; // element to refocus when the book closes let _ppCeremonyQueue = []; // badges awaiting their ceremony overlay let _ppCeremonyActive = false; let _ppBootstrapped = false; let _ppNotified = {}; // badges chimed this session (slam still pending) function $(id) { return document.getElementById(id); } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } async function fetchState() { const res = await fetch(API + '/state'); if (!res.ok) throw new Error('career state ' + res.status); return res.json(); } function lastOf(arr) { return arr.length ? arr[arr.length - 1] : null; } // Active pack = localStorage override when unlocked+installed, else the // highest unlocked+installed tier; none β†’ clear the crowd manifest. async function pushCrowdManifest(state) { const crowd = window.v3VenueCrowd; if (!crowd || typeof crowd.setManifest !== 'function') return; // Any newer invocation (delete, venue switch, fresher state) must win // over a manifest fetch still in flight from this one. const gen = ++_manifestReqGen; const unlocked = state.venues.filter((v) => v.unlocked); let venue = null; let override = null; try { override = localStorage.getItem(VENUE_OVERRIDE_KEY); } catch (_) { /* ok */ } if (override !== NO_VENUE) { venue = unlocked.find((v) => v.id === override && v.installed) || null; if (!venue) venue = lastOf(unlocked.filter((v) => v.installed)); } if (!venue) { if (_appliedManifestVenue !== null) { _appliedManifestVenue = null; crowd.setManifest(null); } return; } if (venue.id === _appliedManifestVenue) return; try { const res = await fetch(`${API}/venues/${venue.id}/manifest.json`); if (gen !== _manifestReqGen || !res.ok) return; const manifest = await res.json(); if (gen !== _manifestReqGen) return; manifest.base = `${API}/venues/${venue.id}/`; _appliedManifestVenue = venue.id; crowd.setManifest(manifest); } catch (_) { /* pack half-installed; next refresh retries */ } } function venueCardHTML(v, state) { const locked = !v.unlocked; const dl = v.download || { status: 'idle' }; const pct = dl.bytes_total > 0 ? Math.round((dl.bytes_done / dl.bytes_total) * 100) : 0; let action = ''; if (locked) { action = `
Unlocks at ${v.star_threshold} β˜… β€” ${Math.max(0, v.star_threshold - state.stars_total)} to go
`; } else if (dl.status === 'running') { action = `
Downloading… ${pct}%
`; } else if (v.installed) { const active = localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id; const main = active ? `` : ``; const remove = v.bundled ? '' : ``; action = `
${main} ${remove}
`; } else if (v.has_pack) { const err = dl.status === 'error' ? `
${esc(dl.error || 'Download failed')} β€” try again
` : ''; action = `${err}`; } else { action = '
Venue pack coming soon β€” plays with the standard stage for now
'; } // Mirror pushCrowdManifest(): an override only counts while the pack // is installed β€” after a removal the badge must not claim a venue the // crowd layer can't use. const isActive = !locked && v.installed && localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id; return `
${esc(v.name)}${isActive ? ' ● playing here' : ''}
${v.star_threshold} β˜…
${esc(v.description)}
${action}
`; } function starGlyphs(n) { let out = ''; for (let i = 0; i < 3; i++) { out += `β˜…`; } return out; } function renderStars(state) { const list = $('career-star-list'); const summary = $('career-star-summary'); if (!list || !summary) return; const detail = state.star_detail || []; const tiers = [0, 0, 0, 0]; for (const r of detail) tiers[r.stars]++; summary.textContent = `${tiers[3]}Γ— 3β˜… Β· ${tiers[2]}Γ— 2β˜… Β· ${tiers[1]}Γ— 1β˜… Β· ${tiers[0]} unstarred`; if (!detail.length) { list.innerHTML = '
Play songs to start collecting stars β€” 60% accuracy earns the first one.
'; return; } list.innerHTML = detail.map((r) => { let hint = 'maxed'; let close = ''; if (r.next_star_at != null) { const gap = Math.max(0, r.next_star_at - r.best_accuracy) * 100; hint = `${gap.toFixed(0)}% to next β˜…`; if (gap <= 5) close = ' close'; } return `
${starGlyphs(r.stars)} ${esc(r.title)}${r.artist ? ` β€” ${esc(r.artist)}` : ''} best ${(r.best_accuracy * 100).toFixed(0)}% Β· ${hint}
`; }).join(''); } function render(state) { const host = $('career-venues'); if (!host) return; $('career-stars-summary').textContent = `β˜… ${state.stars_total} total`; const next = state.venues.find((v) => !v.unlocked); const bar = $('career-progress-bar'); const label = $('career-progress-label'); if (next) { const prevThreshold = state.venues .filter((v) => v.unlocked) .reduce((m, v) => Math.max(m, v.star_threshold), 0); const span = Math.max(1, next.star_threshold - prevThreshold); const into = Math.max(0, state.stars_total - prevThreshold); bar.style.width = Math.min(100, Math.round((into / span) * 100)) + '%'; label.textContent = `${state.stars_total} / ${next.star_threshold} β˜… to unlock ${next.name}`; } else { bar.style.width = '100%'; label.textContent = 'All venues unlocked β€” enjoy the arena.'; } host.innerHTML = state.venues.map((v) => venueCardHTML(v, state)).join(''); renderStars(state); } function schedulePoll(state) { clearTimeout(_pollTimer); if (state.venues.some((v) => (v.download || {}).status === 'running')) { _pollTimer = setTimeout(refresh, POLL_MS); } } function announceUnlocks(state) { const unlocked = state.venues.filter((v) => v.unlocked).map((v) => v.id); if (_prevUnlockedIds) { for (const v of state.venues) { if (v.unlocked && !_prevUnlockedIds.includes(v.id)) { const sm = window.feedBack; if (sm && typeof sm.emit === 'function') { sm.emit('career:venue-unlocked', { id: v.id, name: v.name }); } if (window.fbNotify && typeof window.fbNotify.show === 'function') { window.fbNotify.show({ big: true, icon: '🎀', accent: '#06B6D4', title: 'New venue unlocked!', message: `${v.name} β€” your crowd just got bigger.`, }); } } } } _prevUnlockedIds = unlocked; } async function refresh() { let state; try { state = await fetchState(); } catch (_) { return; // server restarting; next trigger retries } _state = state; announceUnlocks(state); render(state); schedulePoll(state); pushCrowdManifest(state); refreshPassports(); // independent fetch; failures don't touch venues } // ── Passports ───────────────────────────────────────────────────────── function lsGet(k) { try { return localStorage.getItem(k); } catch (_) { return null; } } function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* ok */ } } function ppLabel(inst) { return PP_LABELS[inst] || (inst.charAt(0).toUpperCase() + inst.slice(1)); } function ppKey(genre) { return String(genre || '').trim().replace(/\s+/g, ' ').toLowerCase(); } function ppHash(seed) { let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0; return h; } // Deterministic per-key jitter (sin-hash): stamps and stubs land slightly // askew, the same way on every visit. function ppJitter(seed, range) { return (Math.abs(Math.sin(ppHash(seed))) * 2 - 1) * range; } function sfx(name) { try { const a = new Audio(`${API}/assets/sfx/${name}.mp3`); a.volume = 0.45; a.play().catch(() => { /* autoplay policy β€” silent is fine */ }); } catch (_) { /* no Audio β€” fine */ } } function showCareerTab(tab) { lsSet(PP_TAB_KEY, tab); const venues = $('career-tab-venues'); const pp = $('career-tab-passports'); if (!venues || !pp) return; venues.classList.toggle('hidden', tab !== 'venues'); pp.classList.toggle('hidden', tab !== 'passports'); document.querySelectorAll('#plugin-career .career-tab').forEach((b) => { const active = b.dataset.careerTab === tab; b.classList.toggle('active', active); b.setAttribute('aria-selected', active ? 'true' : 'false'); }); } function activeInstrument() { const list = (_pp && _pp.config && _pp.config.instruments) || []; const saved = lsGet(PP_INST_KEY); if (saved && list.includes(saved)) return saved; const committed = list.find((i) => ((_pp.instruments || {})[i] || {}).committed_at); return committed || list[0] || 'guitar'; } function seenBadges() { try { const seen = JSON.parse(lsGet(PP_SEEN_KEY) || '{}'); // Guard non-object JSON (a stray "null" or array) β€” a broken // stored value must not throw on every passport refresh. return seen && typeof seen === 'object' && !Array.isArray(seen) ? seen : {}; } catch (_) { return {}; } } function badgeId(inst, gkey) { return inst + '/' + gkey; } function markBadgeSeen(inst, gkey) { const seen = seenBadges(); seen[badgeId(inst, gkey)] = 1; lsSet(PP_SEEN_KEY, JSON.stringify(seen)); } // New badge β†’ chime + notification + the venue ceremony, once per // session; the stamp SLAM plays when the passport is next opened (and // only then is the badge marked seen, so a pending slam survives a // reload). function detectNewBadges(view) { const seen = seenBadges(); for (const inst of Object.keys(view.instruments || {})) { for (const p of (view.instruments[inst].passports || [])) { const id = badgeId(inst, p.genre_key); if (p.badge !== 'earned' || seen[id] || _ppNotified[id]) continue; _ppNotified[id] = true; sfx('chime'); if (window.fbNotify && typeof window.fbNotify.show === 'function') { window.fbNotify.show({ big: true, icon: 'πŸ›‚', accent: '#b45309', title: 'Badge earned!', message: `${p.genre} β€” Bronze, ready to stamp into your ${ppLabel(inst)} passport.`, }); } badgeCeremony(inst, p); } } } function reducedMotion() { try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; } } // The badge moment: the crowd erupts first (if a venue pack is live β€” // badges land post-stats:recorded while the player is still on screen), // then a body-level overlay. It CANNOT live in #pp-overlay: #plugin-career // is display:none during playback. function badgeCeremony(inst, p) { // Reduced motion: the chime + fbNotify already delivered the news β€” // no overlay, and no app-initiated crowd eruption either. if (reducedMotion()) return; const crowd = window.v3VenueCrowd; if (crowd && typeof crowd.celebrate === 'function') { try { crowd.celebrate(); } catch (_) { /* crowd layer optional */ } } if (!document.body || typeof document.createElement !== 'function') return; // Several badges can land in one refresh (first load, drill-snapshot // bootstrap): queue the ceremonies and play them back to back. _ppCeremonyQueue.push({ inst, p }); if (!_ppCeremonyActive) setTimeout(drainCeremonies, 300); } function drainCeremonies() { if (_ppCeremonyActive) return; const queued = _ppCeremonyQueue.shift(); if (!queued) return; _ppCeremonyActive = true; showCeremonyOverlay(queued.inst, queued.p, () => { _ppCeremonyActive = false; setTimeout(drainCeremonies, 250); }); } function showCeremonyOverlay(inst, p, done) { const el = document.createElement('div'); el.id = 'pp-ceremony'; el.className = 'pp-ceremony-overlay'; el.innerHTML = `
${esc(p.genre.toUpperCase())} BRONZE
Badge earned
${esc(p.genre)} β€” ${esc(ppLabel(inst))} passport
`; let timer = 0; let closed = false; const dismiss = () => { if (closed) return; closed = true; clearTimeout(timer); el.classList.add('pp-ceremony-out'); setTimeout(() => { el.remove(); done(); }, 350); }; el.addEventListener('click', dismiss); document.body.appendChild(el); timer = setTimeout(dismiss, 4200); confettiBurst(el.querySelector('.pp-confetti')); } function confettiBurst(canvas) { if (!canvas || typeof canvas.getContext !== 'function') return; const ctx = canvas.getContext('2d'); if (!ctx) return; canvas.width = canvas.clientWidth; canvas.height = canvas.clientHeight; const colors = ['#d9a253', '#b45309', '#facc15', '#06b6d4', '#e5e7eb']; const parts = Array.from({ length: 42 }, () => ({ x: canvas.width / 2 + (Math.random() - 0.5) * 90, y: canvas.height * 0.42, vx: (Math.random() - 0.5) * 9, vy: -(4 + Math.random() * 7), rot: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 0.3, w: 5 + Math.random() * 5, h: 3 + Math.random() * 4, c: colors[(Math.random() * colors.length) | 0], })); let frames = 0; (function tick() { if (!canvas.isConnected || frames++ > 240) return; ctx.clearRect(0, 0, canvas.width, canvas.height); for (const q of parts) { q.x += q.vx; q.y += q.vy; q.vy += 0.18; q.rot += q.vr; ctx.save(); ctx.translate(q.x, q.y); ctx.rotate(q.rot); ctx.fillStyle = q.c; ctx.fillRect(-q.w / 2, -q.h / 2, q.w, q.h); ctx.restore(); } requestAnimationFrame(tick); }()); } // Relay the Virtuoso drill snapshot (localStorage doc, not the thin bus // payload) to the server intake, debounced across event bursts. function relayDrillState() { clearTimeout(_ppRelayTimer); _ppRelayTimer = setTimeout(() => { let snap = null; try { snap = JSON.parse(lsGet('virtuoso.progress') || 'null'); } catch (_) { /* corrupt */ } if (!snap || typeof snap !== 'object' || !snap.byNode || typeof snap.byNode !== 'object') return; fetch(`${API}/drill-state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: snap.mode, xp: snap.xp, byNode: snap.byNode }), }).then(() => refreshPassports()).catch(() => { /* next event retries */ }); }, 1500); } async function refreshPassports() { let view; try { const res = await fetch(`${API}/passports`); if (!res.ok) return; view = await res.json(); } catch (_) { return; } _pp = view; detectNewBadges(view); renderPassports(); if (!_ppBootstrapped) { _ppBootstrapped = true; // Sync the local drill snapshot once per session β€” drill progress // made before the career plugin existed (or a relay POST that // failed) must not deny a gated badge until the next virtuoso // event happens to fire. Tiny payload, single-user app. relayDrillState(); } } // Honest hours odometer (Stage 5 post-cap). Below a minute of history // there is nothing meaningful to show. function fmtHours(seconds) { const s = Number(seconds) || 0; if (s < 60) return ''; if (s < 3600) return `${Math.round(s / 60)} min`; return `${(s / 3600).toFixed(1).replace(/\.0$/, '')} h`; } function ppCoverHTML(inst, p) { const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2); const stamp = p.badge === 'earned' ? `BRONZE` : ''; const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`; const hours = fmtHours(p.seconds_total); return ``; } function renderShelf(inst, data) { const shelf = $('pp-shelf'); if (!shelf) return; if (!data.committed_at) { shelf.innerHTML = `
${esc(ppLabel(inst).toUpperCase())} passport
Pick up the ${esc(ppLabel(inst).toLowerCase())}.
Press your seal to commit β€” then choose a genre below and go deep.
`; return; } const books = (data.passports || []).map((p) => ppCoverHTML(inst, p)).join(''); shelf.innerHTML = books || '
Your shelf is ready β€” open your first genre passport below.
'; } function renderRack(inst, data) { const rack = $('pp-rack'); if (!rack || !_pp) return; const openedKeys = new Set((data.passports || []).map((p) => p.genre_key)); const genres = (_pp.genres || []).filter((g) => !openedKeys.has(g.genre_key)); if (!genres.length) { rack.innerHTML = '
No further genres in your library yet β€” new songs bring new brochures.
'; return; } rack.innerHTML = genres.map((g) => { const art = PP_BROCHURE_ART[Math.abs(ppHash(g.genre_key)) % PP_BROCHURE_ART.length]; return ``; }).join(''); } function renderPassports() { const host = $('pp-instruments'); if (!host || !_pp) return; const inst = activeInstrument(); const data = (_pp.instruments || {})[inst] || { passports: [] }; host.innerHTML = ((_pp.config || {}).instruments || []).map((i) => { const d = (_pp.instruments || {})[i] || {}; const earned = (d.passports || []).filter((p) => p.badge === 'earned').length; const committed = !!d.committed_at; return ``; }).join(''); renderShelf(inst, data); renderRack(inst, data); } function ppStubHTML(s) { const date = (s.last_played_at || '').slice(0, 10); return `
${'β˜…'.repeat(s.stars)} ${esc(s.title)} ${s.artist ? `${esc(s.artist)}` : ''} ${date ? `${esc(date)} Β· ` : ''}best ${(s.best_accuracy * 100).toFixed(0)}%
`; } function ppBookHTML(inst, p, pendingSlam) { const req = p.requirement || {}; const need = Math.max(0, (req.songs || 0) - p.qualifying_count); const starGl = 'β˜…'.repeat(req.min_stars || 0); const reqNodes = (p.drills || {}).required || []; const clearedNodes = new Set((p.drills || {}).cleared || []); const labels = ((_pp && _pp.config) || {}).drill_labels || {}; const pendingDrills = reqNodes.filter((n) => !clearedNodes.has(n)); // The invite names what actually blocks the stamp: songs first, then // the genre drill once the song bar is met. let invite; if (need > 0) { invite = need === 1 ? `One more ${starGl} song mints this stamp.` : `${need} more ${starGl} songs mint this stamp.`; } else { const names = pendingDrills.map((n) => labels[n] || n).join(', '); invite = `Clear ${names || 'the genre drill'} in Virtuoso to mint this stamp.`; } let badgeArea = ''; if (p.badge === 'shown_not_judged') { badgeArea = `
Shown, not judged β€” your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.
`; } else if (p.badge === 'earned') { badgeArea = `
${esc(p.genre.toUpperCase())} BRONZE
Gold rung coming β€” improvise it, verified.
`; } else { badgeArea = `
${esc(p.genre.toUpperCase())} BRONZE
${esc(invite)}
`; } const hours = fmtHours(p.seconds_total); const odometer = hours ? `
${hours} in ${esc(p.genre)}
` : ''; let drills = ''; if (reqNodes.length) { drills = `
${reqNodes.map((n) => `
${clearedNodes.has(n) ? 'βœ“' : 'β—‹'} ${esc(labels[n] || n)}
`).join('')}
`; } // Graded instruments collect stubs at the badge bar; shown-not-judged // instruments have no bar β€” every played genre song is repertoire. const stubs = p.badge === 'shown_not_judged' ? (p.songs || []) : (p.songs || []).filter((s) => s.qualifies); const emptyLine = p.badge === 'shown_not_judged' ? `Play ${esc(p.genre)} songs to fill this page.` : `Play ${esc(p.genre)} songs at ${starGl} to collect ticket stubs.`; const stubsHTML = stubs.length ? stubs.map(ppStubHTML).join('') : `
${emptyLine}
`; return ``; } function openBook(inst, gkey) { if (!_pp) return; const p = (((_pp.instruments || {})[inst] || {}).passports || []) .find((x) => x.genre_key === gkey); const overlay = $('pp-overlay'); if (!p || !overlay) return; _ppBook = { inst, gkey }; _ppReturnFocus = document.activeElement; const pending = p.badge === 'earned' && !seenBadges()[badgeId(inst, gkey)]; overlay.innerHTML = ppBookHTML(inst, p, pending); overlay.classList.remove('hidden'); const close = overlay.querySelector('.pp-book-close'); if (close) close.focus(); sfx('page'); // Double rAF so the cover's closed state paints before the transition. requestAnimationFrame(() => requestAnimationFrame(() => { const book = overlay.querySelector('.pp-book'); if (book) book.classList.add('open'); })); if (pending) { setTimeout(() => { if (!_ppBook || _ppBook.gkey !== gkey || _ppBook.inst !== inst) return; const stamp = overlay.querySelector('.pp-stamp-page'); const book = overlay.querySelector('.pp-book'); if (!stamp) return; stamp.classList.remove('pp-stamp-hidden'); stamp.classList.add('pp-slam'); if (book) book.classList.add('pp-shake'); sfx('stamp'); markBadgeSeen(inst, gkey); renderPassports(); // the shelf cover gains its mini-stamp }, 950); } } function closeBook() { _ppBook = null; const overlay = $('pp-overlay'); if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; } if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' && document.contains(_ppReturnFocus)) { _ppReturnFocus.focus(); } _ppReturnFocus = null; } function commitInstrument(inst, after) { fetch(`${API}/passports/commit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }), }).then(() => refreshPassports()) .then(() => { if (after) after(); }) .catch(() => { /* server restarting; user retries */ }); } // Stage 0 β€” the wax seal. Purely theatrical: the overlay plays the press, // the POST commits, the shelf re-renders committed. function sealCeremony(inst, after) { const overlay = $('pp-overlay'); if (!overlay) { commitInstrument(inst, after); return; } overlay.innerHTML = `
${esc(ppLabel(inst).toUpperCase())} passport ${esc(ppLabel(inst).charAt(0))}
`; overlay.classList.remove('hidden'); setTimeout(() => sfx('seal'), 450); setTimeout(() => { overlay.classList.add('hidden'); overlay.innerHTML = ''; commitInstrument(inst, after); }, 1500); } function openGenre(inst, genre) { fetch(`${API}/passports/open`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst, genre }), }).then((res) => { if (!res.ok) throw new Error('open ' + res.status); }) .then(() => refreshPassports()) .then(() => openBook(inst, ppKey(genre))) .catch(() => { /* validation/restart; rack stays */ }); } function onClick(e) { const tabBtn = e.target.closest('[data-career-tab]'); const instBtn = e.target.closest('[data-pp-inst]'); const commitBtn = e.target.closest('[data-pp-commit]'); const coverBtn = e.target.closest('[data-pp-open]'); const brochureBtn = e.target.closest('[data-pp-genre]'); if (tabBtn) { showCareerTab(tabBtn.dataset.careerTab); return; } if (instBtn) { lsSet(PP_INST_KEY, instBtn.dataset.ppInst); renderPassports(); return; } if (commitBtn) { sealCeremony(commitBtn.dataset.ppCommit); return; } if (coverBtn) { openBook(activeInstrument(), coverBtn.dataset.ppOpen); return; } if (brochureBtn) { const inst = activeInstrument(); const genre = brochureBtn.dataset.ppGenre; const committed = _pp && ((_pp.instruments || {})[inst] || {}).committed_at; // Opening your first passport on an instrument IS the commitment β€” // the seal ceremony runs first, then the passport opens. if (committed) openGenre(inst, genre); else sealCeremony(inst, () => openGenre(inst, genre)); return; } if (e.target.closest('[data-pp-close]') || (e.target.dataset && e.target.dataset.ppCloseBg)) { closeBook(); return; } const dlBtn = e.target.closest('[data-career-download]'); const delBtn = e.target.closest('[data-career-delete]'); const playBtn = e.target.closest('[data-career-play]'); if (dlBtn) { fetch(`${API}/packs/${dlBtn.dataset.careerDownload}/download`, { method: 'POST' }) .then(refresh); } else if (delBtn) { // Do NOT null _appliedManifestVenue here: pushCrowdManifest() // clears/replaces the crowd manifest precisely by seeing that the // applied venue is no longer among the installed ones. fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' }) .then(refresh); } else if (playBtn) { try { localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay); // Selecting a venue makes the Venue visualization the default; // remember what the user had so Leave venue can restore it. const cur = localStorage.getItem('vizSelection'); if (cur && cur !== 'venue') localStorage.setItem(PREV_VIZ_KEY, cur); localStorage.setItem('vizSelection', 'venue'); if (typeof window.setViz === 'function') window.setViz('venue'); } catch (_) { /* ok */ } _appliedManifestVenue = null; // force manifest re-push refresh(); } else if (e.target.closest('[data-career-unselect]')) { try { localStorage.setItem(VENUE_OVERRIDE_KEY, NO_VENUE); const prev = localStorage.getItem(PREV_VIZ_KEY); if (prev) { localStorage.setItem('vizSelection', prev); if (typeof window.setViz === 'function') window.setViz(prev); } } catch (_) { /* ok */ } // keep _appliedManifestVenue: pushCrowdManifest clears the crowd // manifest precisely by seeing it is still set with no venue left refresh(); } } function boot() { const screen = document.getElementById('plugin-career'); if (screen) screen.addEventListener('click', onClick); const sm = window.feedBack; if (sm && typeof sm.on === 'function') { // New song stats can add stars β†’ thresholds may cross mid-session. sm.on('stats:recorded', () => refresh()); // Virtuoso's progress emits are the drill-state relay trigger; the // payload is a thin delta, so the relay reads the full localStorage // snapshot instead (see relayDrillState). sm.on('virtuoso:progress', relayDrillState); } showCareerTab(lsGet(PP_TAB_KEY) === 'passports' ? 'passports' : 'venues'); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _ppBook) closeBook(); }); refresh(); } // Test seam (bare-vm harness, see plugins/career/tests/): pure helpers + // the badge-diff logic; nothing here touches the DOM. window.__careerPassportTest = { ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen, fmtHours, }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } }());