diff --git a/plugins/career/routes.py b/plugins/career/routes.py
index f53a5104..45d20565 100644
--- a/plugins/career/routes.py
+++ b/plugins/career/routes.py
@@ -672,6 +672,9 @@ def get_state():
"installed": _installed(v["id"]),
"bundled": _bundled(v["id"]),
"has_pack": _bundled(v["id"]) or _pack_published(v.get("pack")),
+ # Size of the downloadable pack (for the "Download (~343 MB)"
+ # disclosure); None when there's nothing to download.
+ "pack_bytes": (v.get("pack") or {}).get("bytes") or None,
"download": dl,
})
return {
diff --git a/plugins/career/screen.js b/plugins/career/screen.js
index 4e47db1d..572441b1 100644
--- a/plugins/career/screen.js
+++ b/plugins/career/screen.js
@@ -29,6 +29,27 @@
const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' };
const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕'];
+ // Career achievements — DESIGN INTENT (for other contributors):
+ // These reward *real musician learning*, not game busywork. The whole point
+ // of the slim/opt-in content model is that the player grows into it, and the
+ // achievements mark the milestones of that growth on the real-world path:
+ // - starting a career (committing to the practice loop),
+ // - later (rig_builder, PR 3) deciding to learn simulated amps/effects and
+ // dialing in a tone,
+ // - and graduating to running a *real* rig alongside the game.
+ // If you add achievements here or in a sibling plugin, keep to that spirit:
+ // celebrate a skill or decision a working musician would recognize, so
+ // playing the game rewards learning the craft. Contributed to the
+ // achievements plugin via its cross-plugin API (career is a source,
+ // competency only).
+ const WELCOME_KEY = 'feedBack-career-welcomed';
+ const CAREER_ACHIEVEMENTS = [
+ { id: 'career_started', title: 'Hit the Road',
+ description: 'Start your career.', category: 'career', sourceId: 'career' },
+ { id: 'first_venue_gig', title: 'First Gig',
+ description: 'Play your first venue gig.', category: 'career', sourceId: 'career' },
+ ];
+
let _state = null;
let _pollTimer = 0;
let _appliedManifestVenue = null;
@@ -47,6 +68,25 @@
function $(id) { return document.getElementById(id); }
+ // Human-readable pack size for the download-consent disclosure. '' when
+ // unknown/absent so callers can omit the "(~X MB)" suffix entirely.
+ function formatBytes(n) {
+ if (!n || n <= 0) return '';
+ const mb = n / (1024 * 1024);
+ return mb >= 1024 ? `~${(mb / 1024).toFixed(1)} GB` : `~${Math.round(mb)} MB`;
+ }
+
+ // Run fn against the achievements API now if it's loaded, else queue it for
+ // the achievements plugin to drain when it comes up (documented contract in
+ // plugins/achievements/screen.js — the __feedBackAchievementsPending queue).
+ function withAchievements(fn) {
+ const api = window.feedBack && window.feedBack.achievements;
+ if (api) { try { fn(api); } catch (_) { /* optional */ } return; }
+ (window.__feedBackAchievementsPending =
+ window.__feedBackAchievementsPending || []).push(fn);
+ }
+ function grantAchievement(id) { withAchievements((api) => api.unlock(id)); }
+
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g,
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
@@ -121,7 +161,10 @@
} else if (v.has_pack) {
const err = dl.status === 'error'
? `