From 702a9c6daaebd58a54a7bd7bad5a322b9c2ded92 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 21:14:00 +0200 Subject: [PATCH] feat(career): 50/150 star thresholds + star collection overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byron's progression tuning: club at 50★, arena at 150★. /state now returns star_detail rows (title/artist joined from the library, stars, best accuracy, next-star threshold) sorted closest-to-next-star first, and the career screen renders a collection panel: tier summary plus a per-song list with a 'N% to next star' practice hint. Co-Authored-By: Claude Fable 5 --- plugins/career/assets/career.css | 30 +++++++++++++++++++++++ plugins/career/routes.py | 35 +++++++++++++++++++------- plugins/career/screen.html | 7 ++++++ plugins/career/screen.js | 38 +++++++++++++++++++++++++++++ plugins/career/venues.json | 10 +++++--- tests/plugins/career/conftest.py | 7 +++--- tests/plugins/career/test_routes.py | 14 ++++++++++- 7 files changed, 125 insertions(+), 16 deletions(-) diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css index 83cd1b0..5fac8c2 100644 --- a/plugins/career/assets/career.css +++ b/plugins/career/assets/career.css @@ -34,3 +34,33 @@ background-color: #06b6d4; transition: width 0.3s ease; } + +.career-star-list { + display: grid; + gap: 0.375rem; +} +.career-star-row { + display: flex; + align-items: baseline; + gap: 0.75rem; + padding: 0.375rem 0.625rem; + border-radius: 0.5rem; + background-color: rgba(31, 41, 55, 0.4); + font-size: 0.8rem; +} +.career-star-row .stars { + color: #facc15; + letter-spacing: 0.1em; + min-width: 3.2em; +} +.career-star-row .stars .off { color: rgba(250, 204, 21, 0.25); } +.career-star-row .song { + color: #e5e7eb; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.career-star-row .song .artist { color: #9ca3af; } +.career-star-row .hint { color: #6b7280; white-space: nowrap; } +.career-star-row .hint.close { color: #22d3ee; } diff --git a/plugins/career/routes.py b/plugins/career/routes.py index 3705194..b9d7310 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -65,23 +65,39 @@ def _installed(venue_id): def _stars(): - """(total, per-song dict). Accuracy in song_stats is a 0..1 fraction.""" + """(total, per-song dict, detail rows). Accuracy is a 0..1 fraction.""" db = _state["meta_db"] if db is None: - return 0, {} + return 0, {}, [] thresholds = _state["content"]["star_accuracy_thresholds"] # Existing-song filter: a scan hides (not deletes) stats of songs removed # from the library, so orphaned rows must not keep counting toward stars. rows = db.conn.execute( - "SELECT filename, MAX(best_accuracy) FROM song_stats " - "WHERE filename IN (SELECT filename FROM songs) GROUP BY filename" + "SELECT s.filename, MAX(s.best_accuracy), " + " COALESCE(MAX(sg.title), ''), COALESCE(MAX(sg.artist), '') " + "FROM song_stats s JOIN songs sg ON sg.filename = s.filename " + "GROUP BY s.filename" ).fetchall() per_song = {} - for filename, acc in rows: - stars = sum(1 for t in thresholds if (acc or 0.0) >= t) + detail = [] + for filename, acc, title, artist in rows: + acc = acc or 0.0 + stars = sum(1 for t in thresholds if acc >= t) if stars: per_song[filename] = stars - return sum(per_song.values()), per_song + next_at = next((t for t in thresholds if acc < t), None) + detail.append({ + "filename": filename, + "title": title or filename, + "artist": artist, + "stars": stars, + "best_accuracy": round(acc, 4), + "next_star_at": next_at, + }) + # closest-to-next-star first (a practice worklist), maxed songs last + detail.sort(key=lambda r: (r["next_star_at"] is None, + (r["next_star_at"] or 1.0) - r["best_accuracy"])) + return sum(per_song.values()), per_song, detail def _validate_pack_dir(pack_dir: Path): @@ -164,7 +180,7 @@ def setup(app, context): @app.get(f"/api/plugins/{PLUGIN_ID}/state") def get_state(): - stars_total, per_song = _stars() + stars_total, per_song, star_detail = _stars() venues = [] for v in _state["content"]["venues"]: with _lock: @@ -182,6 +198,7 @@ def setup(app, context): return { "stars_total": stars_total, "stars_per_song": per_song, + "star_detail": star_detail, "star_accuracy_thresholds": _state["content"]["star_accuracy_thresholds"], "venues": venues, } @@ -194,7 +211,7 @@ def setup(app, context): pack = venue.get("pack") if not pack: raise HTTPException(404, "No pack published for this venue yet.") - stars_total, _ = _stars() + stars_total, _, _ = _stars() if stars_total < venue["star_threshold"]: raise HTTPException(403, "Venue not unlocked yet.") with _lock: diff --git a/plugins/career/screen.html b/plugins/career/screen.html index 9305a8c..38c58ca 100644 --- a/plugins/career/screen.html +++ b/plugins/career/screen.html @@ -11,4 +11,11 @@
+
+
+

Your star collection

+
+
+
+
diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 44a67e7..58e0c03 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -108,6 +108,43 @@ `; } + 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; @@ -128,6 +165,7 @@ label.textContent = 'All venues unlocked — enjoy the arena.'; } host.innerHTML = state.venues.map((v) => venueCardHTML(v, state)).join(''); + renderStars(state); } function schedulePoll(state) { diff --git a/plugins/career/venues.json b/plugins/career/venues.json index d4684ba..c749a65 100644 --- a/plugins/career/venues.json +++ b/plugins/career/venues.json @@ -1,5 +1,9 @@ { - "star_accuracy_thresholds": [0.6, 0.75, 0.85], + "star_accuracy_thresholds": [ + 0.6, + 0.75, + 0.85 + ], "venues": [ { "id": "bar", @@ -12,14 +16,14 @@ "id": "club", "name": "Velvet Room", "description": "A proper club stage. People actually came to hear you.", - "star_threshold": 15, + "star_threshold": 50, "pack": null }, { "id": "arena", "name": "Feedback Arena", "description": "Ten thousand seats. Try not to think about it.", - "star_threshold": 40, + "star_threshold": 150, "pack": null } ] diff --git a/tests/plugins/career/conftest.py b/tests/plugins/career/conftest.py index f738dd3..eb6ad63 100644 --- a/tests/plugins/career/conftest.py +++ b/tests/plugins/career/conftest.py @@ -23,15 +23,16 @@ class FakeMetaDb: filename TEXT, arrangement TEXT, best_accuracy REAL )""" ) - self.conn.execute("CREATE TABLE songs (filename TEXT)") + self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)") def add(self, filename, arrangement, best_accuracy, in_library=True): self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)", (filename, arrangement, best_accuracy)) if in_library: self.conn.execute( - "INSERT INTO songs SELECT ? WHERE NOT EXISTS " - "(SELECT 1 FROM songs WHERE filename = ?)", (filename, filename)) + "INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS " + "(SELECT 1 FROM songs WHERE filename = ?)", + (filename, filename.replace(".feedpak", "").title(), "Test Artist", filename)) self.conn.commit() diff --git a/tests/plugins/career/test_routes.py b/tests/plugins/career/test_routes.py index 3ec59bb..581d553 100644 --- a/tests/plugins/career/test_routes.py +++ b/tests/plugins/career/test_routes.py @@ -32,7 +32,7 @@ def test_stars_from_best_accuracy_across_arrangements(client, meta_db): def test_unlock_flags_follow_thresholds(client, meta_db): - # 6 stars: bar (0) unlocked, club (15) and arena (40) locked. + # 6 stars: bar (0) unlocked, club (50) and arena (150) locked. for i in range(2): meta_db.add(f"s{i}.feedpak", "guitar", 0.9) # 3 stars each state = client.get("/api/plugins/career/state").json() @@ -52,6 +52,18 @@ def test_orphaned_stats_do_not_count(client, meta_db): assert "gone.feedpak" not in state["stars_per_song"] +def test_star_detail_rows_sorted_by_next_star_gap(client, meta_db): + meta_db.add("far.feedpak", "guitar", 0.61) # 1★, 14% from next + meta_db.add("close.feedpak", "guitar", 0.84) # 2★, 1% from next + meta_db.add("maxed.feedpak", "guitar", 0.99) # 3★, maxed + detail = client.get("/api/plugins/career/state").json()["star_detail"] + assert [r["filename"] for r in detail] == \ + ["close.feedpak", "far.feedpak", "maxed.feedpak"] + close = detail[0] + assert close["stars"] == 2 and close["next_star_at"] == 0.85 + assert detail[2]["next_star_at"] is None + + def test_no_stats_still_serves_state(client): state = client.get("/api/plugins/career/state").json() assert state["stars_total"] == 0