mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-26 23:01:22 +00:00
* feat(career): passport visuals pack — tilt, emerging ink, gold foil Career v2, WS4. All CSS + a rAF-throttled pointer handler, no deps: - Trading-card tilt on EARNED artifacts only (shelf covers + the badge page stamp): pointer-tracked perspective rotateX/Y with a glint sweep following the pointer. The cover's jitter rotation moves into a CSS var so the tilt transform composes with it; the blanket cover-hover translate is scoped :not(.pp-tilt) so it can't fight the tilt. Hover-capable pointers only; off under prefers-reduced-motion. - Emerging-stamp ink: the ghost stamp fills by qualifying/required via a conic-gradient — the stamp visibly carves in, no numbers added. - Gold foil preview: the "coming" note gains a dashed foil chip with a periodic shimmer — honest, never earnable-looking. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): tilt rAF race + freshly-slammed stamp becomes a card CodeRabbit on #944: a queued tilt frame closed over the departed card and re-applied vars after pointerleave; and the just-slammed stamp never regained pp-tilt until the next open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
898 lines
41 KiB
JavaScript
898 lines
41 KiB
JavaScript
/*
|
||
* 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 = `<div class="text-xs text-gray-500">Unlocks at ${v.star_threshold} ★ — ${Math.max(0, v.star_threshold - state.stars_total)} to go</div>`;
|
||
} else if (dl.status === 'running') {
|
||
action = `<div class="career-bar-track mb-1" style="height:0.375rem"><div class="career-bar-fill" style="width:${pct}%"></div></div>
|
||
<div class="text-xs text-gray-400">Downloading… ${pct}%</div>`;
|
||
} else if (v.installed) {
|
||
const active = localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
|
||
const main = active
|
||
? `<button data-career-unselect="1" class="career-btn career-btn-ghost">Leave venue</button>`
|
||
: `<button data-career-play="${esc(v.id)}" class="career-btn career-btn-primary">Play here</button>`;
|
||
const remove = v.bundled
|
||
? ''
|
||
: `<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>`;
|
||
action = `<div class="flex items-center gap-2">
|
||
${main}
|
||
${remove}
|
||
</div>`;
|
||
} else if (v.has_pack) {
|
||
const err = dl.status === 'error'
|
||
? `<div class="text-xs text-amber-400 mb-1">${esc(dl.error || 'Download failed')} — try again</div>` : '';
|
||
action = `${err}<button data-career-download="${esc(v.id)}" class="career-btn career-btn-primary">Download venue pack</button>`;
|
||
} else {
|
||
action = '<div class="text-xs text-gray-500">Venue pack coming soon — plays with the standard stage for now</div>';
|
||
}
|
||
// 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 `<div class="rounded-xl border ${locked ? 'border-gray-800 opacity-60' : 'border-gray-700'} bg-dark-700/40 p-4 flex flex-col gap-2">
|
||
<div class="flex items-center justify-between">
|
||
<div class="font-semibold text-white">${esc(v.name)}${isActive ? ' <span class="text-cyan-400 text-xs">● playing here</span>' : ''}</div>
|
||
<div class="text-xs text-gray-400">${v.star_threshold} ★</div>
|
||
</div>
|
||
<div class="text-xs text-gray-400 flex-1">${esc(v.description)}</div>
|
||
${action}
|
||
</div>`;
|
||
}
|
||
|
||
function starGlyphs(n) {
|
||
let out = '';
|
||
for (let i = 0; i < 3; i++) {
|
||
out += `<span class="${i < n ? 'on' : 'off'}">★</span>`;
|
||
}
|
||
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 = '<div class="text-xs text-gray-500">Play songs to start collecting stars — 60% accuracy earns the first one.</div>';
|
||
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 `<div class="career-star-row">
|
||
<span class="stars">${starGlyphs(r.stars)}</span>
|
||
<span class="song">${esc(r.title)}${r.artist ? ` <span class="artist">— ${esc(r.artist)}</span>` : ''}</span>
|
||
<span class="hint${close}">best ${(r.best_accuracy * 100).toFixed(0)}% · ${hint}</span>
|
||
</div>`;
|
||
}).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 = `
|
||
<canvas class="pp-confetti"></canvas>
|
||
<div class="pp-ceremony-card">
|
||
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||
<span class="pp-stamp-tier">BRONZE</span>
|
||
</div>
|
||
<div class="pp-ceremony-title">Badge earned</div>
|
||
<div class="pp-ceremony-sub">${esc(p.genre)} — ${esc(ppLabel(inst))} passport</div>
|
||
</div>`;
|
||
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 earned = p.badge === 'earned';
|
||
const stamp = earned
|
||
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
|
||
: '';
|
||
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 `<button class="pp-cover${earned ? ' pp-tilt' : ''} pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="${style}">
|
||
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
||
${stamp}
|
||
<span class="pp-cover-sub">${stubs}${hours ? ` · ${hours}` : ''}</span>
|
||
</button>`;
|
||
}
|
||
|
||
function renderShelf(inst, data) {
|
||
const shelf = $('pp-shelf');
|
||
if (!shelf) return;
|
||
if (!data.committed_at) {
|
||
shelf.innerHTML = `<div class="pp-commit-card">
|
||
<div class="pp-commit-cover pp-leather-${esc(inst)}">
|
||
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
|
||
<span class="pp-cover-inst">passport</span>
|
||
</div>
|
||
<div>
|
||
<div class="text-sm text-gray-200 font-medium mb-1">Pick up the ${esc(ppLabel(inst).toLowerCase())}.</div>
|
||
<div class="text-xs text-gray-400 mb-2">Press your seal to commit — then choose a genre below and go deep.</div>
|
||
<button class="career-btn career-btn-primary" data-pp-commit="${esc(inst)}">Press the seal</button>
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
const books = (data.passports || []).map((p) => ppCoverHTML(inst, p)).join('');
|
||
shelf.innerHTML = books ||
|
||
'<div class="text-xs text-gray-500">Your shelf is ready — open your first genre passport below.</div>';
|
||
}
|
||
|
||
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 = '<div class="text-xs text-gray-500">No further genres in your library yet — new songs bring new brochures.</div>';
|
||
return;
|
||
}
|
||
rack.innerHTML = genres.map((g) => {
|
||
const art = PP_BROCHURE_ART[Math.abs(ppHash(g.genre_key)) % PP_BROCHURE_ART.length];
|
||
return `<button class="pp-brochure" data-pp-genre="${esc(g.genre)}">
|
||
<span class="pp-brochure-art" aria-hidden="true">${art}</span>
|
||
<span class="pp-brochure-name">${esc(g.genre)}</span>
|
||
<span class="pp-brochure-sub">${g.songs_in_library === 1 ? '1 song' : `${g.songs_in_library} songs`} in your library</span>
|
||
</button>`;
|
||
}).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 `<button class="pp-inst${i === inst ? ' active' : ''}${committed ? '' : ' uncommitted'}" data-pp-inst="${esc(i)}">
|
||
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
|
||
</button>`;
|
||
}).join('');
|
||
renderShelf(inst, data);
|
||
renderRack(inst, data);
|
||
}
|
||
|
||
function ppStubHTML(s) {
|
||
const date = (s.last_played_at || '').slice(0, 10);
|
||
return `<div class="pp-stub" style="transform:rotate(${ppJitter(s.filename, 1.2).toFixed(2)}deg)">
|
||
<span class="pp-stub-stars">${'★'.repeat(s.stars)}</span>
|
||
<span class="pp-stub-title">${esc(s.title)}</span>
|
||
${s.artist ? `<span class="pp-stub-artist">${esc(s.artist)}</span>` : ''}
|
||
<span class="pp-stub-meta">${date ? `${esc(date)} · ` : ''}best ${(s.best_accuracy * 100).toFixed(0)}%</span>
|
||
</div>`;
|
||
}
|
||
|
||
// 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 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 = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
||
} else if (p.badge === 'earned') {
|
||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||
<span class="pp-stamp-tier">BRONZE</span>
|
||
</div>
|
||
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
|
||
} else {
|
||
const fill = (ppFillFraction(p) * 100).toFixed(0);
|
||
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
|
||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||
<span class="pp-stamp-tier">BRONZE</span>
|
||
</div>
|
||
<div class="pp-invite">${esc(invite)}</div>`;
|
||
}
|
||
const hours = fmtHours(p.seconds_total);
|
||
const odometer = hours
|
||
? `<div class="pp-hours">${hours} in ${esc(p.genre)}</div>` : '';
|
||
let drills = '';
|
||
if (reqNodes.length) {
|
||
drills = `<div class="pp-drills">${reqNodes.map((n) =>
|
||
`<div class="pp-drill${clearedNodes.has(n) ? ' cleared' : ''}">${clearedNodes.has(n) ? '✓' : '○'} ${esc(labels[n] || n)}</div>`).join('')}</div>`;
|
||
}
|
||
// 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('')
|
||
: `<div class="pp-stub-empty">${emptyLine}</div>`;
|
||
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="${esc(p.genre)} ${esc(ppLabel(inst))} passport">
|
||
<div class="pp-book">
|
||
<div class="pp-page pp-page-left">
|
||
<div class="pp-page-head">${esc(p.genre)} — ${esc(ppLabel(inst))}</div>
|
||
${badgeArea}${odometer}${drills}
|
||
</div>
|
||
<div class="pp-page pp-page-right">
|
||
<div class="pp-page-head">Ticket stubs</div>
|
||
<div class="pp-stubs">${stubsHTML}</div>
|
||
</div>
|
||
<div class="pp-book-cover pp-leather-${esc(inst)}">
|
||
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
||
</div>
|
||
<button class="pp-book-close" data-pp-close="1" aria-label="Close">✕</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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');
|
||
stamp.classList.add('pp-tilt'); // freshly slammed = trading card too
|
||
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 = `<div class="pp-book-wrap">
|
||
<div class="pp-commit-cover pp-ceremony pp-leather-${esc(inst)}">
|
||
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
|
||
<span class="pp-cover-inst">passport</span>
|
||
<span class="pp-wax"><span>${esc(ppLabel(inst).charAt(0))}</span></span>
|
||
</div>
|
||
</div>`;
|
||
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; }
|
||
}
|
||
|
||
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);
|
||
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());
|
||
// 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, ppFillFraction,
|
||
};
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', boot);
|
||
} else {
|
||
boot();
|
||
}
|
||
}());
|