Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions plugins/career/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 56 additions & 2 deletions plugins/career/screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
Expand Down Expand Up @@ -121,7 +161,10 @@
} 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>`;
// 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}<button data-career-download="${esc(v.id)}" class="career-btn career-btn-primary">${label}</button>`;
} else {
action = '<div class="text-xs text-gray-500">Venue pack coming soon — plays with the standard stage for now</div>';
}
Expand Down Expand Up @@ -1309,6 +1352,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();
}
Expand Down Expand Up @@ -1454,7 +1498,7 @@
showCareerTab(tabBtn.dataset.careerTab);
return;
}
if (instBtn) {

Check warning on line 1501 in plugins/career/screen.js

View workflow job for this annotation

GitHub Actions / ci / lint

File has too many lines (1644). Maximum allowed is 1500
lsSet(PP_INST_KEY, instBtn.dataset.ppInst);
renderPassports();
return;
Expand Down Expand Up @@ -1568,14 +1612,24 @@
// 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();
}

// 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,
fmtHours, ppFillFraction, careerTotals, closestAskHTML, formatBytes,
onGigSongEnded, onGigSongStop,
setGigRun(r) { _ppGigRun = r; },
getGigRun() { return _ppGigRun; },
Expand Down
38 changes: 38 additions & 0 deletions plugins/career/tests/passports.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
40 changes: 40 additions & 0 deletions tests/plugins/career/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
Loading