/*
* 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)
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
? `Leave venue `
: `Play here `;
const remove = v.bundled
? ''
: `Remove pack `;
action = `
${main}
${remove}
`;
} else if (v.has_pack) {
const err = dl.status === 'error'
? `${esc(dl.error || 'Download failed')} β try again
` : '';
action = `${err}Download venue pack `;
} 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 `
${esc(p.genre.toUpperCase())}
${esc(ppLabel(inst))} passport
${stamp}
${stubs}${hours ? ` Β· ${hours}` : ''}
`;
}
// 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 `
${esc(p.genre)}
${ppAskHTML(p, true)}
`;
}
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.
Press the seal
`;
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 `
${art}
${esc(g.genre)}
${g.songs_in_library === 1 ? '1 song' : `${g.songs_in_library} songs`} in your library
`;
}).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 `
${esc(ppLabel(i))}${earned ? ` β‘${earned} ` : ''}${committed ? '' : ' + '}
`;
}).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
? 'GOLD
A verified improv β the comb heard it live.
'
: 'Gold rung: improvise over this style in a Virtuoso jam β verified, not self-reported.
'}
Save card
Copy card
`;
} 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 `
${esc(p.genre)} β ${esc(ppLabel(inst))}
${badgeArea}${odometer}${drills}
Book a gig
Ticket stubs
${stubsHTML}${nearest}${gigLog}
${esc(p.genre.toUpperCase())}
${esc(ppLabel(inst))} passport
β
`;
}
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) =>
`
${esc(p.genre.toUpperCase())}
${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}
`).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}
Open career β
`;
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 = `
Career
${'β‘'.repeat(Math.min(totals.badges, 5))}${totals.badges} badge${totals.badges === 1 ? '' : 's'}
${hours ? `${hours} played` : 'the stage is set'}${totals.gigs ? ` Β· ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}
${ask ? `closest: ${ask} ` : ''}
`;
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 `
${esc(prop.venue_name || 'The stage')}
presents
${esc(prop.genre.toUpperCase())} NIGHT
${esc(ppLabel(prop.instrument))} Β· tonight
${bill}
Play the gig
Re-roll
Save
Copy
β
`;
}
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');
}
function startGig() {
const prop = _ppGigProposal;
const q = window.feedBack && window.feedBack.playQueue;
if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') 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 */ }
}
_appliedManifestVenue = null;
_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('')}
Save poster
Copy poster
Done
`;
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(); 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();
}
}());