mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 08:29:28 +00:00
Bundled plugin: per-song stars from best_accuracy (60/75/85% → 1/2/3★), cumulative stars unlock bar → club → arena (data-driven venues.json). Venue packs (UE-rendered crowd loops) download on demand to CONFIG_DIR/plugin_uploads/career/ on a background thread with sha256 + zip-slip validation, served via FileResponse. Career screen (promoted sidebar entry) shows progress and pushes the active venue's manifest into the crowd video layer (v3VenueCrowd, PR1) — degrades cleanly when either side is absent. Pack URLs land in venues.json in PR3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
201 lines
8.6 KiB
JavaScript
201 lines
8.6 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 POLL_MS = 2000;
|
|
|
|
let _state = null;
|
|
let _pollTimer = 0;
|
|
let _appliedManifestVenue = null;
|
|
let _prevUnlockedIds = null;
|
|
|
|
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;
|
|
const unlocked = state.venues.filter((v) => v.unlocked);
|
|
let venue = null;
|
|
try {
|
|
const override = localStorage.getItem(VENUE_OVERRIDE_KEY);
|
|
venue = unlocked.find((v) => v.id === override && v.installed) || null;
|
|
} catch (_) { /* ok */ }
|
|
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 (!res.ok) return;
|
|
const manifest = await res.json();
|
|
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) {
|
|
action = `<div class="flex items-center gap-2">
|
|
<button data-career-play="${esc(v.id)}" class="career-btn career-btn-primary">Play here</button>
|
|
<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>
|
|
</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>';
|
|
}
|
|
const isActive = !locked && 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 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('');
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function onClick(e) {
|
|
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) {
|
|
fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' })
|
|
.then(() => { _appliedManifestVenue = null; refresh(); });
|
|
} else if (playBtn) {
|
|
try { localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay); } catch (_) { /* ok */ }
|
|
_appliedManifestVenue = null; // force manifest re-push
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
function boot() {
|
|
const screen = document.getElementById('plugin-career');
|
|
if (screen) screen.addEventListener('click', onClick);
|
|
const sm = window.feedBack;
|
|
if (sm && typeof sm.on === 'function') {
|
|
// New song stats can add stars → thresholds may cross mid-session.
|
|
sm.on('stats:recorded', () => refresh());
|
|
}
|
|
refresh();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', boot);
|
|
} else {
|
|
boot();
|
|
}
|
|
}());
|