feat(career): 50/150 star thresholds + star collection overview

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 <noreply@anthropic.com>
This commit is contained in:
byrongamatos
2026-07-12 21:14:00 +02:00
co-authored by Claude Fable 5
parent 99b6d3c384
commit 702a9c6daa
7 changed files with 125 additions and 16 deletions
+30
View File
@@ -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; }
+26 -9
View File
@@ -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:
+7
View File
@@ -11,4 +11,11 @@
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
</div>
<div id="career-venues" class="career-venues"></div>
<div class="mt-8">
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
<div id="career-star-summary" class="text-xs text-gray-400"></div>
</div>
<div id="career-star-list" class="career-star-list"></div>
</div>
</div>
+38
View File
@@ -108,6 +108,43 @@
</div>`;
}
function starGlyphs(n) {
let out = '';
for (let i = 0; i < 3; i++) {
out += `<span class="${i < n ? 'on' : 'off'}">★</span>`;
}
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 = '<div class="text-xs text-gray-500">Play songs to start collecting stars — 60% accuracy earns the first one.</div>';
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 `<div class="career-star-row">
<span class="stars">${starGlyphs(r.stars)}</span>
<span class="song">${esc(r.title)}${r.artist ? ` <span class="artist">— ${esc(r.artist)}</span>` : ''}</span>
<span class="hint${close}">best ${(r.best_accuracy * 100).toFixed(0)}% · ${hint}</span>
</div>`;
}).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) {
+7 -3
View File
@@ -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
}
]
+4 -3
View File
@@ -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()
+13 -1
View File
@@ -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