From a58227252eb64f9b6145b6d59150d55116492789 Mon Sep 17 00:00:00 2001 From: Matthew Harris Glover Date: Wed, 22 Jul 2026 18:27:34 -0400 Subject: [PATCH 1/2] feat(career): first-launch milestone + download-size disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the opt-in content model into the career UI: - Download-size disclosure: the "Download venue pack" button now states the pack size (e.g. "Download venue pack (~335 MB)"), read live from the manifest (`pack.bytes`, exposed as `pack_bytes` in /state) so the number can't drift from the real asset. Absent/zero size → no suffix. - Career achievements: career is the first source plugin to use the achievements cross-plugin API. Registers career_started + first_venue_gig and unlocks them on first visit / first logged gig (competency only, idempotent). No new event bus; uses the documented pending-queue contract. - No progress loss on upgrade: verified `unlocked` is computed from stars (meta.db), independent of pack presence — a previously-unlocked venue whose media is no longer bundled shows unlocked + downloadable, never locked. Tests: upgrade/no-progress-loss (arena unlocked with media unbundled → downloadable → restored); formatBytes MB/GB; achievement wiring drains the pending queue and fires career_started once. All green. Stacked on the venue-packs PR (feedBack#1023). Part of #122. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Matthew Harris Glover --- plugins/career/routes.py | 3 ++ plugins/career/screen.js | 47 ++++++++++++++++++++++++-- plugins/career/tests/passports.test.js | 38 +++++++++++++++++++++ tests/plugins/career/test_routes.py | 40 ++++++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) 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..9657fdb9 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -29,6 +29,16 @@ const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' }; const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕']; + // First-visit welcome + career achievements (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 +57,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 +150,10 @@ } else if (v.has_pack) { const err = dl.status === 'error' ? `
${esc(dl.error || 'Download failed')} — try again
` : ''; - action = `${err}`; + // Consent disclosure: this button starts a download — say its size. + const size = formatBytes(v.pack_bytes); + const label = size ? `Download venue pack (${size})` : 'Download venue pack'; + action = `${err}`; } else { action = '
Venue pack coming soon — plays with the standard stage for now
'; } @@ -1309,6 +1341,7 @@ }); if (res.ok) gig = (await res.json()).gig; } catch (_) { /* summary still shows, unlogged */ } + if (gig) grantAchievement('first_venue_gig'); // idempotent; only 1st counts showGigSummary(run, gig); refreshPassports(); } @@ -1568,6 +1601,16 @@ // announces the fresh mount points — same seam achievements uses. document.addEventListener('v3:profile-rendered', renderProfileWall); document.addEventListener('v3:dashboard-rendered', renderDashCard); + // Career achievements are contributed on every mount (register is + // idempotent) so they render greyed before they're earned. + withAchievements((api) => api.registerAll(CAREER_ACHIEVEMENTS)); + // First visit IS "starting career" → the career_started milestone. The + // achievements plugin surfaces its own unlock toast, so career doesn't + // fire one here (would double up). WELCOME_KEY just avoids re-calling. + if (!lsGet(WELCOME_KEY)) { + lsSet(WELCOME_KEY, '1'); + grantAchievement('career_started'); + } refresh(); } @@ -1575,7 +1618,7 @@ // the badge-diff logic; nothing here touches the DOM. window.__careerPassportTest = { ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen, - fmtHours, ppFillFraction, careerTotals, closestAskHTML, + fmtHours, ppFillFraction, careerTotals, closestAskHTML, formatBytes, onGigSongEnded, onGigSongStop, setGigRun(r) { _ppGigRun = r; }, getGigRun() { return _ppGigRun; }, diff --git a/plugins/career/tests/passports.test.js b/plugins/career/tests/passports.test.js index a106d5c8..ba839616 100644 --- a/plugins/career/tests/passports.test.js +++ b/plugins/career/tests/passports.test.js @@ -234,6 +234,44 @@ test('a gold slam marks the bronze moment seen too — never both ceremonies', ( assert.equal(w2.notifications.length, 0); }); +test('formatBytes renders the download-size disclosure (MB/GB, empty when unknown)', () => { + const f = load().__careerPassportTest.formatBytes; + assert.equal(f(351284599), '~335 MB'); // arena + assert.equal(f(359899852), '~343 MB'); // club + assert.equal(f(2 * 1024 * 1024 * 1024), '~2.0 GB'); + assert.equal(f(0), ''); // nothing to download + assert.equal(f(null), ''); + assert.equal(f(undefined), ''); +}); + +test('boot registers career achievements and unlocks career_started on first visit', () => { + // No achievements API in the bare-vm harness, so career's contributions + // land in the __feedBackAchievementsPending queue (the documented drain + // contract). Draining against a fake API must registerAll + unlock. + const w = load(); // fresh window → WELCOME_KEY unset → first visit + const pending = w.__feedBackAchievementsPending || []; + assert.ok(pending.length >= 2, 'register + career_started unlock should be queued'); + + const registered = []; + const unlocked = []; + const api = { + register: () => {}, + registerAll: (defs) => defs.forEach((d) => registered.push(d.id)), + unlock: (id) => unlocked.push(id), + }; + pending.forEach((fn) => fn(api)); + assert.deepEqual(registered, ['career_started', 'first_venue_gig']); + assert.deepEqual(unlocked, ['career_started']); + + // Second boot (welcomed flag now set) must NOT re-unlock career_started. + const store = { 'feedBack-career-welcomed': '1' }; + const w2 = load(store); + const pending2 = w2.__feedBackAchievementsPending || []; + const unlocked2 = []; + pending2.forEach((fn) => fn({ register() {}, registerAll() {}, unlock: (id) => unlocked2.push(id) })); + assert.deepEqual(unlocked2, []); +}); + test('careerTotals counts gold badges on the wall', () => { const t = load().__careerPassportTest; t.setView({ diff --git a/tests/plugins/career/test_routes.py b/tests/plugins/career/test_routes.py index dcb844cc..3d3f7da3 100644 --- a/tests/plugins/career/test_routes.py +++ b/tests/plugins/career/test_routes.py @@ -226,6 +226,46 @@ def test_content_packs_rejects_files_the_downloader_would_refuse(tmp_path): raise AssertionError("build_pack accepted a .DS_Store the downloader rejects") +def test_unlocked_venue_survives_media_being_unbundled(client, meta_db, monkeypatch, tmp_path): + # Upgrade / no-progress-loss: a player who already unlocked arena upgrades + # into the slim build where arena media is no longer bundled. The venue must + # stay UNLOCKED and become DOWNLOADABLE — never locked or broken — and a + # download must restore it. (Star state lives in meta.db, untouched by the + # bundle change; `unlocked` is computed from stars, not pack presence.) + empty = tmp_path / "no-bundled-media" + empty.mkdir() + # Simulate the slim build: nothing bundled under venue-packs/. + monkeypatch.setattr(career_routes, "_bundled_venue_dir", lambda vid: empty / vid) + # Earn 150 stars (50 songs x 3 stars) → arena (threshold 150) unlocked. + for i in range(50): + meta_db.add(f"song{i}.feedpak", "guitar", 0.99) + + arena = {v["id"]: v for v in client.get("/api/plugins/career/state").json()["venues"]}["arena"] + assert arena["unlocked"] is True # progress preserved + assert arena["bundled"] is False # media stripped from the build + assert arena["installed"] is False # nothing to play yet + assert arena["has_pack"] is True # but it's downloadable + assert arena["pack_bytes"] and arena["pack_bytes"] > 0 # size known for the disclosure + + # And a real download restores it (mirror the file:// worker path). + src = tmp_path / "src" + src.mkdir() + for s in career_routes.REQUIRED_LOOPS: + (src / f"{s}.mp4").write_bytes(b"v-" + s.encode()) + (src / "manifest.json").write_text(json.dumps( + {"venue": "arena", "version": 1, + "loops": {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS}})) + zip_path = tmp_path / "arena.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for p in src.iterdir(): + zf.write(p, p.name) + sha = hashlib.sha256(zip_path.read_bytes()).hexdigest() + progress = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None} + career_routes._download_pack("arena", {"url": zip_path.as_uri(), "sha256": sha}, progress) + assert progress["status"] == "done", progress["error"] + assert career_routes._installed("arena") is True + + def test_double_download_409s(client, monkeypatch): bar = career_routes._venue("bar") monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64, "bytes": 123}) From cba2aad3ca7d7040a4e34f999ed70eb34ec82fb2 Mon Sep 17 00:00:00 2001 From: Matthew Harris Glover Date: Wed, 22 Jul 2026 18:27:34 -0400 Subject: [PATCH 2/2] =?UTF-8?q?docs(career):=20state=20the=20achievement?= =?UTF-8?q?=20design=20intent=20=E2=80=94=20reward=20real=20musician=20lea?= =?UTF-8?q?rning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Signed-off-by: Matthew Harris Glover --- plugins/career/screen.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 9657fdb9..572441b1 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -29,8 +29,19 @@ const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' }; const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕']; - // First-visit welcome + career achievements (contributed to the achievements - // plugin via its cross-plugin API — career is a source, competency only). + // 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',