/* * 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'; // Unpacking a setlist is real work (zips, possibly on a slow/network drive), // so this is generous — but it is a CEILING, not a wait. Past it we start the // gig and let the first play extract lazily, as it always did. const PREPARE_TIMEOUT_MS = 60000; 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) let _ppGigProposal = null; // the booking poster's proposal, while open let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, idx} mid-set 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 {}; } } // Bronze keeps the legacy un-suffixed id, so badges seen before the Gold // tier existed stay seen; gold is a distinct moment with its own id. function badgeId(inst, gkey, tier) { return inst + '/' + gkey + (tier === 'gold' ? '@gold' : ''); } function markBadgeSeen(inst, gkey, tier) { const seen = seenBadges(); seen[badgeId(inst, gkey, tier)] = 1; // A gold slam covers the bronze moment too — never queue both. if (tier === 'gold') 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 || [])) { if (p.badge !== 'earned' && p.badge !== 'gold') continue; const gold = p.badge === 'gold'; const id = badgeId(inst, p.genre_key, p.badge); if (seen[id] || _ppNotified[id]) continue; _ppNotified[id] = true; sfx('chime'); if (window.fbNotify && typeof window.fbNotify.show === 'function') { window.fbNotify.show({ big: true, icon: gold ? '🏅' : '🛂', accent: gold ? '#d9a253' : '#b45309', title: gold ? 'Gold — a verified improv!' : 'Badge earned!', message: gold ? `${p.genre} — your ${ppLabel(inst)} badge turns gold.` : `${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())} ${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}
${p.badge === 'gold' ? 'Gold — a verified improv' : '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, ...(snap.goldImprov && typeof snap.goldImprov === 'object' && !Array.isArray(snap.goldImprov) ? { goldImprov: snap.goldImprov } : {}), }), }).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(); renderProfileWall(); renderDashCard(); 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 earned = p.badge === 'earned' || p.badge === 'gold'; const stamp = earned ? `${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}` : ''; const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`; const hours = fmtHours(p.seconds_total); // Earned covers are trading cards: rotation moves into a CSS var so // the pointer-tracked tilt transform can compose with it. const style = earned ? `--pp-cover-rot:${rot}deg` : `transform:rotate(${rot}deg)`; return ``; } // Practice invitations: which stamps are closest, and what would bring // them home. Invitations only — no meters, no obligations. // Floor, never round: 74.9% must not display as the already-met "75%". function pct(frac) { return Math.floor((Number(frac) || 0) * 100); } function ppNeed(p) { return Math.max(0, ((p.requirement || {}).songs || 0) - (p.qualifying_count || 0)); } // The one blocker phrase — shared by the Closest-stamps strip and the // passport book's invite line so they can never contradict each other. function ppAskHTML(p, withHint) { const req = p.requirement || {}; const need = ppNeed(p); const starGl = '★'.repeat(req.min_stars || 0); if (need > 0) { const near = withHint ? (p.nearest || [])[0] : null; const hint = near ? ` · nearest: ${esc(near.title)} at ${pct(near.best_accuracy)}%` : ''; return `${need === 1 ? `one more ${starGl} song` : `${need} more ${starGl} songs`}${hint}`; } const labels = ((_pp && _pp.config) || {}).drill_labels || {}; const drills = p.drills || {}; const pending = (drills.required || []).filter((n) => !(drills.cleared || []).includes(n)); return `clear ${pending.map((n) => esc(labels[n] || n)).join(', ') || 'the genre drill'} in Virtuoso`; } function closestLineHTML(p) { return ``; } function renderClosest(inst, data) { const host = $('pp-closest'); if (!host) return; const candidates = (data.passports || []) .filter((p) => p.badge === 'in_progress') .sort((a, b) => ppNeed(a) - ppNeed(b)) .slice(0, 3); if (!candidates.length) { host.innerHTML = ''; return; } host.innerHTML = `
Closest stamps
${candidates.map(closestLineHTML).join('')}
`; } 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' || p.badge === 'gold').length; const committed = !!d.committed_at; return ``; }).join(''); renderClosest(inst, data); 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)}%
`; } // Emerging-stamp ink: how much of the ghost stamp has "carved in". // Song progress toward the bar only — the invite line stays the words. function ppFillFraction(p) { if (!p || p.badge !== 'in_progress') return 0; const need = Number((p.requirement || {}).songs) || 0; if (need <= 0) return 0; return Math.max(0, Math.min(1, (p.qualifying_count || 0) / need)); } function ppBookHTML(inst, p, pendingSlam) { const req = p.requirement || {}; 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 || {}; // The invite names what actually blocks the stamp — same shared // phrase as the Closest-stamps strip, so they can't contradict. const invite = `${ppAskHTML(p, false)} mints 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' || p.badge === 'gold') { const gold = p.badge === 'gold'; badgeArea = `
${esc(p.genre.toUpperCase())} ${gold ? 'GOLD' : 'BRONZE'}
${gold ? '
A verified improv — the comb heard it live.
' : '
Gold rung: improvise over this style in a Virtuoso jam — verified, not self-reported.
'}
`; } else { const fill = (ppFillFraction(p) * 100).toFixed(0); badgeArea = `
${esc(p.genre.toUpperCase())} BRONZE
${invite.charAt(0).toUpperCase()}${invite.slice(1)}
`; } 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}
`; // Bring-these-up: nearest-to-the-bar songs (graded, unearned only — // an earned page is memorabilia, not homework). let nearest = ''; if (p.badge === 'in_progress' && (p.nearest || []).length) { nearest = `
Bring these up
${p.nearest.map((s) => `
${esc(s.title)} — best ${pct(s.best_accuracy)}%, ${starGl} at ${pct(s.bar_at)}%
`).join('')}
`; } let gigLog = ''; if ((p.gigs || []).length) { gigLog = `
Gigs played
${p.gigs.slice(0, 6).map((g) => `
${esc((g.at || '').slice(0, 10))} · ${esc(_venueName(g.venue_id))}${g.encore ? ' · encore' : ''}
`).join('')}
`; } 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' || p.badge === 'gold') && !seenBadges()[badgeId(inst, gkey, p.badge)]; 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'); stamp.classList.add('pp-tilt'); // freshly slammed = trading card too if (book) book.classList.add('pp-shake'); sfx('stamp'); markBadgeSeen(inst, gkey, p.badge); renderPassports(); // the shelf cover gains its mini-stamp }, 950); } } function closeBook() { _ppBook = null; _ppGigProposal = null; // a dismissed poster is a dismissed booking 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); } // ── Trading-card tilt (earned artifacts only) ───────────────────────── let _tiltRaf = 0; let _tiltEl = null; function tiltAllowed() { try { return window.matchMedia('(hover: hover)').matches && !window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; } } function resetTilt(el) { if (!el) return; el.style.removeProperty('--pp-tilt-x'); el.style.removeProperty('--pp-tilt-y'); el.style.removeProperty('--pp-glint-x'); } function onTiltMove(e) { if (!tiltAllowed()) return; const card = e.target && e.target.closest ? e.target.closest('.pp-tilt') : null; if (_tiltEl && _tiltEl !== card) { resetTilt(_tiltEl); _tiltEl = null; } if (!card) return; _tiltEl = card; if (_tiltRaf) return; const x = e.clientX; const y = e.clientY; _tiltRaf = requestAnimationFrame(() => { _tiltRaf = 0; const r = card.getBoundingClientRect(); if (!r.width || !r.height) return; const px = (x - r.left) / r.width; const py = (y - r.top) / r.height; card.style.setProperty('--pp-tilt-x', `${((0.5 - py) * 10).toFixed(2)}deg`); card.style.setProperty('--pp-tilt-y', `${((px - 0.5) * 12).toFixed(2)}deg`); card.style.setProperty('--pp-glint-x', `${(px * 100).toFixed(1)}%`); }); } function onTiltLeave() { // Cancel any queued frame: it closes over the departed card and would // re-apply tilt vars after the pointer has left. if (_tiltRaf) { cancelAnimationFrame(_tiltRaf); _tiltRaf = 0; } if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; } } // ── Shareable passport card (canvas → PNG, save or clipboard) ───────── // Keep in sync with the .pp-leather-* gradients in assets/career.css — // canvas can't consume a CSS class, so the pairs live twice on purpose. const PP_LEATHER_HEX = { guitar: ['#5c2321', '#401412'], bass: ['#1f3252', '#131f36'], keys: ['#1e4034', '#122a21'], drums: ['#3f3f46', '#26262b'], }; function drawPassportCard(inst, p) { const W = 480; const H = 640; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); const [c1, c2] = PP_LEATHER_HEX[inst] || PP_LEATHER_HEX.guitar; const bg = ctx.createLinearGradient(0, 0, W, H); bg.addColorStop(0, c1); bg.addColorStop(1, c2); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); // Emboss frame ctx.strokeStyle = 'rgba(240,226,195,0.35)'; ctx.lineWidth = 3; ctx.strokeRect(18, 18, W - 36, H - 36); // Genre title ctx.fillStyle = 'rgba(240,226,195,0.95)'; ctx.textAlign = 'center'; ctx.font = '700 34px Georgia, serif'; ctx.fillText(p.genre.toUpperCase(), W / 2, 92, W - 80); ctx.font = '400 15px Georgia, serif'; ctx.fillStyle = 'rgba(240,226,195,0.55)'; ctx.fillText(`${ppLabel(inst).toUpperCase()} PASSPORT`, W / 2, 122); // Stamp ring const gold = p.badge === 'gold'; const ink = gold ? '#d9a253' : '#b06a2a'; const cy = 330; ctx.strokeStyle = ink; ctx.lineWidth = 6; ctx.beginPath(); ctx.arc(W / 2, cy, 118, 0, Math.PI * 2); ctx.stroke(); ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(W / 2, cy, 106, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = ink; ctx.font = '800 26px Georgia, serif'; ctx.fillText(p.genre.toUpperCase(), W / 2, cy - 6, 190); ctx.font = '600 16px Georgia, serif'; ctx.fillText(gold ? 'G O L D' : 'B R O N Z E', W / 2, cy + 28); // Facts const stubCount = (p.songs || []).filter((sng) => sng.qualifies).length; const hours = fmtHours(p.seconds_total); ctx.fillStyle = 'rgba(240,226,195,0.75)'; ctx.font = '400 17px Georgia, serif'; ctx.fillText(`${stubCount} ticket stub${stubCount === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}`, W / 2, 512); ctx.fillStyle = 'rgba(240,226,195,0.4)'; ctx.font = '400 13px Georgia, serif'; ctx.fillText('fee[dB]ack · career passport', W / 2, H - 44); return canvas; } // One export path for every canvas artifact: copy (with download // fallback + notice) or save, failures audible. function exportCanvasPng(canvas, filename, mode, noun) { canvas.toBlob(async (blob) => { const fail = (why) => { if (window.fbNotify) window.fbNotify.show({ icon: '⚠️', title: `${noun} export failed`, message: why }); }; if (!blob) { fail('The canvas produced no image.'); return; } try { const io = await import('/static/js/blob-io.js'); if (mode === 'copy') { const ok = await io.copyImageBlob(blob); if (ok) { if (window.fbNotify) window.fbNotify.show({ icon: '📋', title: `${noun} copied`, message: 'Paste it anywhere.' }); return; } if (window.fbNotify) window.fbNotify.show({ icon: '💾', title: 'Clipboard unavailable', message: `Saved the ${noun.toLowerCase()} instead.` }); } io.downloadBlob(blob, filename); } catch (e) { fail('Export helper unavailable.'); } }, 'image/png'); } function exportPassportCard(mode) { if (!_ppBook || !_pp) return; const { inst, gkey } = _ppBook; const p = (((_pp.instruments || {})[inst] || {}).passports || []) .find((x) => x.genre_key === gkey); if (!p) return; const canvas = drawPassportCard(inst, p); exportCanvasPng(canvas, `passport-${inst}-${gkey.replace(/[^a-z0-9-]+/g, '-')}.png`, mode, 'Card'); } // ── Career surfaces outside the plugin screen ───────────────────────── // Profile passport wall + the home-page career card. Both inject into // core-owned mounts announced by v3:profile-rendered / // v3:dashboard-rendered (the achievements seam). Absent-not-empty: with // no committed instrument they render nothing and the dashboard keeps // its built-in fallback stat. function careerTotals() { if (!_pp) return null; let badges = 0; let seconds = 0; let gigs = 0; const walls = []; for (const inst of (_pp.config || {}).instruments || []) { const d = (_pp.instruments || {})[inst]; // A commitment with no opened passport isn't a wall yet — the // external surfaces (profile, home card) stay ABSENT until a // passport exists (absent-not-empty). if (!d || !d.committed_at || !(d.passports || []).length) continue; const earned = (d.passports || []).filter((p) => p.badge === 'earned' || p.badge === 'gold'); badges += earned.length; seconds += (d.passports || []).reduce((t, p) => t + (p.seconds_total || 0), 0); gigs += d.gig_count || 0; walls.push({ inst, earned, opened: d.passports.length }); } if (!walls.length) return null; return { badges, seconds, gigs, walls }; } function closestAskHTML() { if (!_pp) return ''; let best = null; for (const inst of (_pp.config || {}).instruments || []) { for (const p of (((_pp.instruments || {})[inst] || {}).passports || [])) { if (p.badge !== 'in_progress') continue; const need = Math.max(0, ((p.requirement || {}).songs || 0) - p.qualifying_count); if (!best || need < best.need) best = { p, need }; } } if (!best) return ''; const starGl = '★'.repeat((best.p.requirement || {}).min_stars || 0); if (best.need > 0) { return `${esc(best.p.genre)} — ${best.need === 1 ? `one more ${starGl} song` : `${best.need} more ${starGl} songs`}`; } return `${esc(best.p.genre)} — one drill away`; } function renderProfileWall() { const mount = document.getElementById('v3-profile-passports-mount'); if (!mount) return; const totals = careerTotals(); if (!totals) { mount.innerHTML = ''; return; } const shelves = totals.walls.map(({ inst, earned, opened }) => { const covers = earned.map((p) => ``).join(''); const line = earned.length ? covers : `${opened} passport${opened === 1 ? '' : 's'} open — first stamp pending`; return `
${esc(ppLabel(inst))}${line}
`; }).join(''); const hours = fmtHours(totals.seconds); mount.innerHTML = `
Passport wall ${totals.badges} badge${totals.badges === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}
${shelves}
`; if (!mount.dataset.ppWired) { mount.dataset.ppWired = '1'; mount.addEventListener('click', onWallClick); } } function onWallClick(e) { const open = e.target.closest('[data-pp-wall-inst]'); if (open) { // Two attributes, not a '/'-joined pair: a genre key may itself // contain '/' ("drum/bass") and must round-trip intact. const inst = open.dataset.ppWallInst; const gkey = open.dataset.ppWallGkey; lsSet(PP_INST_KEY, inst); if (window.showScreen) window.showScreen('plugin-career'); showCareerTab('passports'); renderPassports(); openBook(inst, gkey); return; } if (e.target.closest('[data-pp-wall-career]')) { if (window.showScreen) window.showScreen('plugin-career'); showCareerTab('passports'); } } function renderDashCard() { const slot = document.getElementById('v3-dash-career-slot'); if (!slot) return; const totals = careerTotals(); if (!totals) return; // keep core's fallback stat card const hours = fmtHours(totals.seconds); const ask = closestAskHTML(); slot.innerHTML = ``; if (!slot.dataset.ppWired) { slot.dataset.ppWired = '1'; slot.addEventListener('click', onWallClick); } } // ── Gigs: booking poster → set runner → summary ────────────────────── function _venueName(venueId) { const v = (_state && _state.venues || []).find((x) => x.id === venueId); return v ? v.name : (venueId || 'the stage'); } function gigPosterHTML(prop) { const bill = prop.songs.map((s, i) => `
${i + 1}. ${esc(s.title)}${s.artist ? ` ${esc(s.artist)}` : ''}
`).join(''); return ``; } async function bookGig(gkey) { if (!_pp) return; const inst = activeInstrument(); const p = (((_pp.instruments || {})[inst] || {}).passports || []) .find((x) => x.genre_key === gkey); if (!p) return; try { const res = await fetch(`${API}/gigs/propose`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst, genre: p.genre }), }); if (!res.ok) return; _ppGigProposal = await res.json(); } catch (_) { return; } const overlay = $('pp-overlay'); if (!overlay) return; _ppBook = null; // the poster replaces the book in the overlay overlay.innerHTML = gigPosterHTML(_ppGigProposal); overlay.classList.remove('hidden'); sfx('page'); } // Unpack the whole set before the first note. // // A feedpak is a zip, and the first play of one pays for its extraction. In // a set that cost landed BETWEEN songs: the player finished a number and // then sat there waiting for the next one to unpack, mid-gig. The setlist is // known up front, so warm it all while the poster is still on screen. // // Best-effort by design: a library that won't pre-extract must not stop the // gig from starting — the play itself surfaces the error the same way it // does outside a gig. Slow is better than blocked. async function prepareGigSongs(prop, btn) { const label = btn && btn.textContent; if (btn) { btn.disabled = true; btn.textContent = 'Preparing set…'; } // A bare `await fetch(...)` only rejects on a network ERROR — a server // that accepts the connection and then never answers hangs forever, and // the gig would never start. That would make this optimisation the very // thing it promises never to be: the reason you cannot play. Give up // waiting and let the first play extract lazily, exactly as before. const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), PREPARE_TIMEOUT_MS); try { await fetch(`${API}/gigs/prepare`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ songs: prop.songs.map((s) => s.filename) }), signal: ctrl.signal, }); } catch (_) { // abort, offline, non-2xx — all the same: start the gig anyway. } finally { clearTimeout(timer); if (btn) { btn.disabled = false; if (label) btn.textContent = label; } } } async function startGig(btn) { const prop = _ppGigProposal; const q = window.feedBack && window.feedBack.playQueue; if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return; // Extract the setlist BEFORE the stage is borrowed and the queue starts, // so a failure here leaves nothing half-applied to unwind. await prepareGigSongs(prop, btn); // The poster's Play could have been cancelled while we were unpacking. if (_ppGigProposal !== prop) return; // The gig BORROWS the stage: stash whatever venue/viz the user had so // the set ending gives it back (unlike "Play here", which is an // explicit persistent choice on the venue card). let restore = null; if (prop.venue_id) { // Capture the restore snapshot BEFORE any write: if a later write // (or setViz) throws, the stage must still be returnable. try { restore = { venue: localStorage.getItem(VENUE_OVERRIDE_KEY), viz: localStorage.getItem('vizSelection'), }; } catch (_) { restore = null; } try { localStorage.setItem(VENUE_OVERRIDE_KEY, prop.venue_id); localStorage.setItem('vizSelection', 'venue'); if (typeof window.setViz === 'function') window.setViz('venue'); } catch (_) { /* viz optional — restore stays intact */ } } // Push the gig's venue pack to the crowd layer NOW. // // crowd.setManifest(venue) is reached only through pushCrowdManifest, // and pushCrowdManifest is called only from refresh() — the career // tab's own reload. A gig navigates AWAY from the career tab to the // player, so refresh() never runs during it, and setting the override // above does nothing on its own. The result the testers saw: the venue // visualization turns on (3D highway) but its crowd/stage pack never // loads, so the song plays over the bare highway backdrop ("standard // particles"), or over whatever venue a previous refresh() happened to // leave applied. We just changed the override to this gig's venue, so // re-push for it. _state is the career state the booking screen already // fetched; guard for the rare null. _appliedManifestVenue = null; if (_state) pushCrowdManifest(_state); _ppGigRun = { songs: prop.songs, venue_id: prop.venue_id, genre: prop.genre, genre_key: prop.genre_key, instrument: prop.instrument, idx: 0, restore, }; closeBook(); _ppGigProposal = null; // RAW filenames: the queue itself encodes for playSong — pre-encoding // double-encodes and breaks loading + the stats/gig filename join. if (!q.start(prop.songs.map((s) => s.filename), { source: 'gig' })) { _ppGigRun = null; return; } renderGigStrip(); } function restoreGigStage(run) { const r = run && run.restore; if (!r) return; try { if (r.venue == null) localStorage.removeItem(VENUE_OVERRIDE_KEY); else localStorage.setItem(VENUE_OVERRIDE_KEY, r.venue); if (r.viz && r.viz !== 'venue') { localStorage.setItem('vizSelection', r.viz); if (typeof window.setViz === 'function') window.setViz(r.viz); } } catch (_) { /* best effort */ } _appliedManifestVenue = null; } function renderGigStrip() { if (!_ppGigRun || !document.body || typeof document.createElement !== 'function') return; let strip = document.getElementById('pp-gig-strip'); if (!strip) { strip = document.createElement('div'); strip.id = 'pp-gig-strip'; strip.className = 'pp-gig-strip'; document.body.appendChild(strip); } const run = _ppGigRun; const next = run.songs[run.idx + 1]; strip.innerHTML = `GIG · ${esc(run.genre)} at ${esc(_venueName(run.venue_id))} · set ${Math.min(run.idx + 1, run.songs.length)}/${run.songs.length}${next ? ` — next: ${esc(next.title)}` : ' — closer!'}`; } function removeGigStrip() { const strip = document.getElementById('pp-gig-strip'); if (strip) strip.remove(); } function abandonGig() { // No fail state: an abandoned set logs nothing and says nothing. const run = _ppGigRun; _ppGigRun = null; removeGigStrip(); restoreGigStage(run); } function completeGig() { const run = _ppGigRun; _ppGigRun = null; removeGigStrip(); restoreGigStage(run); // The final song's own stats POST races this moment (both ride // song:ended): wait for its stats:recorded — or a short timeout, since // an UNSCORED play never emits one — so /gigs reads the set's real // accuracies, not last week's. const lastFile = run.songs[run.songs.length - 1].filename; const sm = window.feedBack; let done = false; const proceed = () => { if (done) return; done = true; if (sm && typeof sm.off === 'function') { try { sm.off('stats:recorded', onRec); } catch (_) { /* ok */ } } postGig(run); }; const onRec = (e) => { const d = (e && e.detail) || {}; if (d.filename === lastFile) proceed(); }; if (sm && typeof sm.on === 'function') sm.on('stats:recorded', onRec); setTimeout(proceed, 3500); } async function postGig(run) { let gig = null; try { const res = await fetch(`${API}/gigs`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: run.instrument, genre: run.genre, venue_id: run.venue_id, songs: run.songs.map((s) => s.filename), }), }); if (res.ok) gig = (await res.json()).gig; } catch (_) { /* summary still shows, unlogged */ } showGigSummary(run, gig); refreshPassports(); } function showGigSummary(run, gig) { if (!document.body || typeof document.createElement !== 'function') return; const entries = (gig && gig.songs) || run.songs.map((s) => ({ filename: s.filename, title: s.title, accuracy: null })); const encore = !!(gig && gig.encore); if (encore && !reducedMotion()) { const crowd = window.v3VenueCrowd; if (crowd && typeof crowd.celebrate === 'function') { try { crowd.celebrate(); } catch (_) { /* optional */ } } } const el = document.createElement('div'); el.id = 'pp-gig-summary'; el.className = 'pp-ceremony-overlay'; el.innerHTML = `
${esc(_venueName(run.venue_id))}
${esc(run.genre.toUpperCase())} NIGHT
${encore ? 'ENCORE! ' : ''}the set, as played
${entries.map((s, i) => `
${i + 1}. ${esc(s.title)}${s.accuracy != null ? ` ${Math.floor(s.accuracy * 100)}%` : ''}
`).join('')}
`; el.addEventListener('click', (e) => { if (e.target === el || e.target.closest('[data-pp-gig-done]')) { el.remove(); } else if (e.target.closest('[data-pp-poster]')) { exportGigPoster(e.target.closest('[data-pp-poster]').dataset.ppPoster, { ...run, encore, entries }); } }); document.body.appendChild(el); if (encore && !reducedMotion()) confettiBurst(el.querySelector('.pp-confetti')); } function drawGigPoster(data) { const W = 480; const H = 640; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); const bg = ctx.createLinearGradient(0, 0, 0, H); bg.addColorStop(0, '#141019'); bg.addColorStop(1, '#241318'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); ctx.strokeStyle = 'rgba(217,162,83,0.5)'; ctx.lineWidth = 3; ctx.strokeRect(16, 16, W - 32, H - 32); ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(240,226,195,0.65)'; ctx.font = '400 18px Georgia, serif'; ctx.fillText(_venueName(data.venue_id), W / 2, 76, W - 80); ctx.font = '400 12px Georgia, serif'; ctx.fillText('P R E S E N T S', W / 2, 102); ctx.fillStyle = '#d9a253'; ctx.font = '800 40px Georgia, serif'; ctx.fillText(`${data.genre.toUpperCase()}`, W / 2, 160, W - 60); ctx.font = '800 26px Georgia, serif'; ctx.fillText('NIGHT', W / 2, 194); if (data.encore) { ctx.fillStyle = '#f3d179'; ctx.font = '700 16px Georgia, serif'; ctx.fillText('— E N C O R E —', W / 2, 226); } ctx.fillStyle = 'rgba(240,226,195,0.85)'; ctx.font = '400 18px Georgia, serif'; const entries = data.entries || data.songs || []; entries.slice(0, 6).forEach((sng, i) => { const acc = sng.accuracy != null ? ` · ${Math.floor(sng.accuracy * 100)}%` : ''; ctx.fillText(`${sng.title}${acc}`, W / 2, 290 + i * 44, W - 80); }); ctx.fillStyle = 'rgba(240,226,195,0.4)'; ctx.font = '400 13px Georgia, serif'; ctx.fillText(`${ppLabel(data.instrument || 'guitar')} · fee[dB]ack career`, W / 2, H - 42); return canvas; } function exportGigPoster(mode, data) { exportCanvasPng(drawGigPoster(data), `gig-${(data.genre_key || 'set').replace(/[^a-z0-9-]+/g, '-')}.png`, mode, 'Poster'); } // Queue lifecycle: advance the strip per song; complete or abandon. function onGigSongLoading() { if (!_ppGigRun) return; renderGigStrip(); } function onGigSongEnded() { if (!_ppGigRun) return; const q = window.feedBack && window.feedBack.playQueue; if (!q || typeof q.remaining !== 'function') return; // Only OUR live queue counts: remaining()===0 is also true for a // cleared/foreign queue (a manual play silently clears the gig queue, // and that unrelated song's end must not log a gig). if (q.source && q.source() !== 'gig') { abandonGig(); return; } if (!q.remaining()) { if (q.active && q.active()) completeGig(); else abandonGig(); return; } _ppGigRun.idx = Math.min(_ppGigRun.idx + 1, _ppGigRun.songs.length - 1); renderGigStrip(); } function onGigSongStop() { // A deliberate quit mid-set (Escape clears the queue) abandons the // gig — but the LAST song's teardown also fires song:stop after // song:ended, so only abandon while songs genuinely remain. if (!_ppGigRun) return; const q = window.feedBack && window.feedBack.playQueue; const active = q && typeof q.active === 'function' ? q.active() : false; if (!active) abandonGig(); } 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 gigBtn = e.target.closest('[data-pp-gig]'); if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; } if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; } if (e.target.closest('[data-pp-gig-reroll]')) { if (_ppGigProposal) bookGig(_ppGigProposal.genre_key); return; } const posterBtn = e.target.closest('[data-pp-poster]'); if (posterBtn && _ppGigProposal) { exportGigPoster(posterBtn.dataset.ppPoster, _ppGigProposal); return; } const cardBtn = e.target.closest('[data-pp-card]'); if (cardBtn) { exportPassportCard(cardBtn.dataset.ppCard); 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); screen.addEventListener('pointermove', onTiltMove); screen.addEventListener('pointerleave', onTiltLeave); } 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()); // Gig runner lifecycle (no-ops when no gig is live). sm.on('song:loading', onGigSongLoading); sm.on('song:ended', onGigSongEnded); sm.on('song:stop', onGigSongStop); // 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') return; const overlay = $('pp-overlay'); if (_ppBook || (overlay && !overlay.classList.contains('hidden'))) closeBook(); }); // Core re-renders profile/dashboard shells (innerHTML wipe) and // announces the fresh mount points — same seam achievements uses. document.addEventListener('v3:profile-rendered', renderProfileWall); document.addEventListener('v3:dashboard-rendered', renderDashCard); 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, ppFillFraction, careerTotals, closestAskHTML, onGigSongEnded, onGigSongStop, setGigRun(r) { _ppGigRun = r; }, getGigRun() { return _ppGigRun; }, setView(v) { _pp = v; }, }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } }());