Compare commits

...
Author SHA1 Message Date
byrongamatosandClaude Fable 5 b6bf8a2825 docs: register career screen.js in the size register (planned, not exempt)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:49:51 +02:00
byrongamatosandClaude Fable 5 f0298abda1 fix(career): stage restore survives a setViz throw; size-register row
CodeRabbit on #956: a setViz failure nulled the restore snapshot AFTER
the overrides were written, permanently borrowing the stage — snapshot
now captured before any write, write failures keep it intact. Also
registers career screen.js in docs/size-exemptions.md 'Planned, not
exempt' (1,516 lines; max-lines WARNS non-blocking — the split plan
needs Byron's sign-off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:47:20 +02:00
byrongamatosandClaude Fable 5 82edbb7266 feat(career): gigs frontend — poster, runner strip, summary, encore
Career v3, WS3 (frontend half), rebuilt cleanly on merged main (v3-a/b/c
in) after git interleaved the structurally-similar canvas functions:

- Book a gig from any opened passport: /gigs/propose renders as a GIG
  POSTER (venue presents GENRE NIGHT, numbered bill) with re-roll and
  Save/Copy poster (natively-drawn canvas via blob-io, audible failure
  paths, slash-safe filenames).
- Play the gig: venue override + Venue viz handoff, then
  playQueue.start(..., {source:'gig'}) — the queue's auto-advance runs
  the set; zero new playback machinery.
- Floating gig strip (body-level, pointer-events none, z 35 per the
  chrome invariant) tracks set position and names what's next.
- Completion = song:ended with an empty queue → POST /gigs → summary
  poster overlay with per-song accuracies; encore fires the crowd
  celebrate + confetti (reduced-motion: neither). song:stop with a dead
  queue = abandoned (no log); end-of-song teardown (queue still active)
  must NOT abandon.
- Gigs played render as dated rows in the passport book.

vm tests: runner advance/abandon semantics via the queue-state seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:40:59 +02:00
7c897e9f2b feat(career): gigs backend — propose a setlist, log the completed set (#954)
* feat(career): gigs backend — propose a setlist, log the completed set

Career v3, WS3 (backend half). A gig is career's verb:

- POST /gigs/propose {instrument, genre, size}: setlist from the
  passport's own stubs — qualifying songs (per the genre's badge bar,
  family-aware) shuffled for a free re-roll, topped with the
  highest-accuracy near-bar songs as stakes, and filled from UNPLAYED
  genre songs when the passport is young (the first gig is how stubs
  start). Names the highest venue the current stars can book.
- POST /gigs: logs a COMPLETED set only (abandoned sets never log — no
  fail state). Per-song accuracy = MAX(last_accuracy) from song_stats,
  freshly written by the set's own plays; encore = avg ≥ the data-driven
  bar (passports.json gig.encore_accuracy, 0.75). Appends to the career
  state file (same atomic _save_json pattern).
- Passports view: per-passport gigs (newest first, capped 20) and
  per-instrument gig_count — the profile wall's gig line lights up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): gig backfill offsets by qualifying taken, not picks length

CodeRabbit on #954: after the stakes loop appends near-bar songs,
qualifying[len(picks):] overshoots and skips eligible qualifying songs
— a stocked passport could still get a short set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:26:23 +02:00
6272af8d33 feat(career): profile passport wall, home career card, shareable PNG card (#955)
* feat(career): profile passport wall, home career card, shareable PNG card

Career v3, WS2. The identity artifact leaves the plugin tab:

- Profile: #v3-profile-passports-mount (core, one div) filled by career
  on v3:profile-rendered — per-instrument shelves of earned covers,
  hours, gig count, open-career link. Absent-not-empty.
- Home: the plugin-count stat tile becomes #v3-dash-career-slot with the
  old stat as fallback content; career replaces it with a trading-card
  tile (leather + foil shine, badge count, hours, closest-stamp ask) on
  the existing v3:dashboard-rendered event.
- Shareable card: static/js/blob-io.js (downloadBlob lifts the idiom
  duplicated verbatim in settings-io/diagnostics-export — both
  refactored; copyImageBlob wraps ClipboardItem, returns false to signal
  the download fallback). Earned passports get Save/Copy card: a
  natively-drawn 480×640 canvas (leather, stamp ring, stubs+hours line);
  copy falls back to download with a notice when the clipboard refuses.
- Mount-point convention documented in docs/plugin-v3-ui.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): external surfaces stay absent until a passport exists

CodeRabbit on #955: a bare commitment produced a zero-passport wall and
replaced the dashboard fallback. Docs also now say mounts may hold
fallback content and plugins REPLACE, never append.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:25:39 +02:00
831117fb96 feat(career): practice invitations — closest stamps + bring-these-up (#953)
* feat(career): practice invitations — closest stamps + bring-these-up

Career v3, WS1. The passport now points at the practice that pays:

- Stubs carry next_star_at (the same primitive _stars() uses) and each
  passport exposes `nearest`: the top 3 non-qualifying songs by distance
  to their next star, in the worklist order.
- "Closest stamps" strip above the shelf: the in-progress graded
  passports nearest to minting, each row naming the ask — N more songs
  (with the nearest title + best %) or the blocking Virtuoso drill.
  Rows open the passport.
- "Bring these up" list on the stubs page of in-progress passports;
  earned pages stay memorabilia (no homework on a won badge).
- Invitation-voiced throughout: no meters, no completion pressure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(test): comment says qualifying-bar ranking, matching the assertion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 01:10:55 +02:00
6cc0312661 feat(career): genre families — sub-genres inherit the family drill (#951)
The enrichment fallback made the passport rack real (hundreds of MB
sub-genres) but only the five exact umbrella keys carried Virtuoso
drills. Genres now resolve to a family by keyword substring (MB's
vocabulary is open — 'metalcore' must hit metal without an alias),
first-match-wins in list order ('blues rock' → blues), and inherit the
family's requirement from the same genres map. Exact entries still win;
per-instrument scoping unchanged; unmatched genres stay songs-only.

Data: families for metal (incl. djent/grindcore/thrash/doom), blues,
jazz (bebop/swing/bossa), funk (disco), rock (punk/grunge/shoegaze).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:05:05 +02:00
16 changed files with 1414 additions and 47 deletions
+31
View File
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Gigs (the career verb, frontend)** — book a gig from any opened passport:
a gig poster proposes the setlist (re-roll for a different bill; save or
copy the poster as a PNG), "Play the gig" hands the set to the play queue
with the venue on stage, a floating strip tracks the set, and finishing it
logs dated entries with per-song accuracies in the passport book — with an
encore celebration (crowd eruption + confetti) when the whole set clears
the bar, and a summary poster to share. Quitting mid-set simply abandons
it: no log, no fail state.
- **Career on the Profile and Home pages** — the Profile gains a passport
wall (earned-badge covers per instrument, hours, gig count; absent until a
passport exists), injected through the same mount-point + rendered-event
seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md).
The home page's plugin-count stat tile becomes a career trading card
(badges, hours, the closest stamp ask, foil shine) with the old stat as the
built-in fallback when career has no state. Earned passports gain **Save
card / Copy card** — a natively-drawn PNG passport card, downloadable or
copied straight to the clipboard for pasting outside the app (shared
`blob-io` helpers replace the download idiom previously duplicated in
settings-io and diagnostics-export).
- **Gigs (backend)** — career mode gains its verb: `POST
/api/plugins/career/gigs/propose` builds a playable setlist for an
instrument+genre (your qualifying songs plus a couple of stakes songs near
the bar; a young passport fills from unplayed genre songs — the first gig
is how stubs start; re-roll by calling again), naming the room your stars
can book. `POST /gigs` logs a **completed** set — per-song accuracies read
from the set's own freshly-recorded stats, an encore flag at the
data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never
log (no fail state: the gig you finished is the gig you played). Passports
carry their gig log; instruments their gig count.
### Changed
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
top-level manifest key this repo invented (#583) that the feedpak spec never had.
+20
View File
@@ -189,3 +189,23 @@ out of the capability graph.
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
rail 30, popovers 40).
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
## Injecting into core shells (profile, dashboard)
Core screens that accept plugin sections render **mount points** — usually
empty, sometimes holding core's own **fallback content** (the Dashboard's
career slot ships the plugin-count stat) — and announce each (re)build with a
DOM event, because their `innerHTML` swap wipes anything previously injected.
A plugin listens for the event and **replaces the mount's content** (never
append — a fallback may be present) by id — the same seam every time:
| Shell | Event | Mounts |
| --- | --- | --- |
| Profile | `v3:profile-rendered` | `#v3-profile-passports-mount` (career wall), `#v3-profile-feats-slot`, `#v3-profile-achievements-mount` |
| Dashboard | `v3:dashboard-rendered` | `#v3-dash-career-slot` (career card; core's plugin-count stat is the fallback content a plugin may replace) |
| Settings | `v3:settings-rendered` | per-plugin `settings.html` panels |
Rules: inject on every event (the mount is fresh), keep the section
**absent-not-empty** (no state → leave the mount alone / empty), and guard
re-wired listeners with a `dataset` flag when your own refresh path can run
against an unwiped mount.
+3 -1
View File
@@ -61,6 +61,8 @@ extractions and twenty-two `routers/` modules, plus lib/library_registry.py for
and is a monolith in its own right, to be split per-table once the router train
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) · `plugins/career/screen.js`
(1,516 — career v3 gigs pushed it over; split plan: carve the gig block into a
`scriptType: module` file when career work next touches it) — and every monolith with a PR
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
by policy — the norm governs source files.
+220 -1
View File
@@ -99,7 +99,8 @@
.pp-inst-plus { color: #6b7280; }
/* Leather covers — per-instrument hue, embossed with layered shadows and a
subtle grain gradient (no image assets). */
subtle grain gradient (no image assets). Keep the hex pairs in sync with
PP_LEATHER_HEX in screen.js (the canvas card draws the same leather). */
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
@@ -559,3 +560,221 @@
/* The hover glint is motion theatrics too — not just the JS tilt. */
.pp-tilt::after { display: none; }
}
/* Practice invitations — closest stamps + bring-these-up */
.pp-closest {
border: 1px solid rgba(75, 85, 99, 0.45);
border-radius: 0.6rem;
background: linear-gradient(165deg, rgba(45, 55, 72, 0.4), rgba(31, 41, 55, 0.4));
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.pp-closest-head {
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #9ca3af;
}
.pp-closest-row {
display: flex;
align-items: baseline;
gap: 0.75rem;
text-align: left;
font-size: 0.8rem;
padding: 0.15rem 0.25rem;
border-radius: 0.35rem;
}
.pp-closest-row:hover { background: rgba(55, 65, 81, 0.5); }
.pp-closest-genre { color: #e5e7eb; font-weight: 600; white-space: nowrap; }
.pp-closest-ask { color: #9ca3af; font-size: 0.72rem; }
.pp-closest-ask em { color: #cbd5e1; font-style: italic; }
.pp-nearest { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
.pp-nearest-head {
font-size: 0.58rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #8a7a5e;
margin-bottom: 0.25rem;
}
.pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
.pp-nearest-row em { color: #3f3428; }
/* ── Career surfaces outside the plugin: profile wall + home card ───────── */
.pp-wall { display: flex; flex-direction: column; gap: 0.6rem; }
.pp-wall-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-weight: 600;
color: #e5e7eb;
font-size: 0.9rem;
}
.pp-wall-meta { color: #9ca3af; font-size: 0.7rem; font-weight: 400; }
.pp-wall-shelf {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid rgba(75, 85, 99, 0.25);
}
.pp-wall-inst {
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6b7280;
min-width: 3.6rem;
}
.pp-wall-cover {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
width: 4.2rem;
height: 5.6rem;
border-radius: 0.3rem 0.45rem 0.45rem 0.3rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07),
inset 0.25rem 0 0.4rem -0.25rem rgba(0, 0, 0, 0.8),
0 3px 8px rgba(0, 0, 0, 0.4);
padding: 0.3rem;
transition: transform 0.15s ease;
}
.pp-wall-cover:hover { transform: translateY(-3px); }
.pp-wall-cover span {
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.1em;
color: rgba(240, 226, 195, 0.9);
overflow-wrap: anywhere;
text-align: center;
}
.pp-wall-cover em {
font-size: 0.42rem;
letter-spacing: 0.22em;
font-style: normal;
color: #d9a253;
}
.pp-wall-none { font-size: 0.7rem; color: #6b7280; font-style: italic; }
.pp-wall-link {
align-self: flex-end;
font-size: 0.72rem;
color: #22d3ee;
padding: 0.15rem 0.3rem;
}
.pp-wall-link:hover { text-decoration: underline; }
/* The home-page career card — a trading card among stat tiles. */
.pp-dash-card {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.2rem;
text-align: left;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid rgba(217, 162, 83, 0.35);
background:
linear-gradient(135deg, rgba(92, 35, 33, 0.85), rgba(30, 27, 34, 0.92)),
linear-gradient(160deg, #2b1414, #17111c);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.pp-dash-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); }
.pp-dash-shine {
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 42%, rgba(255, 223, 128, 0.18) 50%, transparent 58%);
transform: translateX(-130%);
pointer-events: none;
}
.pp-dash-card:hover .pp-dash-shine { animation: pp-foil 1.4s ease-out; }
.pp-dash-head {
font-size: 0.58rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: #d9a253;
}
.pp-dash-badges { color: #f3ead2; font-size: 1.05rem; }
.pp-dash-badges b { font-weight: 700; margin: 0 0.25rem 0 0.35rem; }
.pp-dash-meta { color: #b5a488; font-size: 0.72rem; }
.pp-dash-ask { color: #8d9aa8; font-size: 0.66rem; }
.pp-dash-ask em { color: #cbd5e1; }
.pp-card-actions { display: flex; gap: 0.5rem; margin-top: 0.9rem; }
@media (prefers-reduced-motion: reduce) {
.pp-dash-card:hover .pp-dash-shine { animation: none; }
.pp-wall-cover, .pp-dash-card { transition: none; }
}
/* ── Gigs: poster, runner strip, summary, log ───────────────────────────── */
.pp-poster {
position: relative;
width: min(92vw, 420px);
padding: 2rem 1.6rem 1.4rem;
border-radius: 0.5rem;
background: linear-gradient(180deg, #141019, #241318);
border: 2px solid rgba(217, 162, 83, 0.45);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
text-align: center;
}
.pp-poster-venue { color: rgba(240, 226, 195, 0.7); font-size: 0.95rem; letter-spacing: 0.08em; }
.pp-poster-presents { color: rgba(240, 226, 195, 0.4); font-size: 0.58rem; letter-spacing: 0.4em; text-transform: uppercase; }
.pp-poster-title {
color: #d9a253;
font-size: 1.7rem;
font-weight: 800;
letter-spacing: 0.1em;
line-height: 1.15;
overflow-wrap: anywhere;
}
.pp-poster-inst { color: rgba(240, 226, 195, 0.5); font-size: 0.68rem; letter-spacing: 0.2em; text-transform: uppercase; }
.pp-poster-bill { margin: 0.9rem 0 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; width: 100%; }
.pp-poster-line { color: rgba(240, 226, 195, 0.85); font-size: 0.85rem; }
.pp-poster-line span { color: rgba(217, 162, 83, 0.7); margin-right: 0.35rem; }
.pp-poster-line em { color: rgba(240, 226, 195, 0.5); font-style: italic; font-size: 0.72rem; }
.pp-poster-line b { color: #f3d179; margin-left: 0.3rem; }
.pp-poster-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; margin-top: 0.6rem; }
.pp-poster-summary { cursor: default; }
.pp-gig-strip {
position: fixed;
top: 0.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 35; /* above the rail (30), under popovers (40) — the chrome invariant */
background: rgba(10, 8, 14, 0.85);
border: 1px solid rgba(217, 162, 83, 0.4);
border-radius: 999px;
color: rgba(240, 226, 195, 0.85);
font-size: 0.72rem;
padding: 0.3rem 0.9rem;
pointer-events: none;
backdrop-filter: blur(2px);
}
.pp-gig-strip b { color: #d9a253; letter-spacing: 0.2em; }
.pp-gig-strip em { color: #f3ead2; font-style: italic; }
.pp-giglog { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
.pp-giglog-head {
font-size: 0.58rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #8a7a5e;
margin-bottom: 0.25rem;
}
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
+13
View File
@@ -3,6 +3,19 @@
"songs": 5,
"min_stars": 2
},
"gig": {
"min_songs": 3,
"max_songs": 5,
"stakes_songs": 2,
"encore_accuracy": 0.75
},
"families": [
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
{ "key": "blues", "match": ["blues"] },
{ "key": "jazz", "match": ["jazz", "bebop", "swing", "bossa"] },
{ "key": "funk", "match": ["funk", "disco"] },
{ "key": "rock", "match": ["rock", "punk", "grunge", "shoegaze"] }
],
"genres": {
"blues": { "virtuoso_nodes": { "guitar": ["blues_shuffle"] } },
"rock": { "virtuoso_nodes": { "guitar": ["rock_power_backbeat"] } },
+228 -5
View File
@@ -26,11 +26,14 @@ Endpoints (all under /api/plugins/career/):
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
POST /passports/open open a genre passport for an instrument
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
POST /gigs/propose build a playable setlist for a genre gig
POST /gigs log a COMPLETED gig (abandoned sets never log)
"""
import hashlib
import json
import logging
import random
import re
import shutil
import tempfile
@@ -114,10 +117,9 @@ def _stars():
detail = []
for filename, acc, title, artist in rows:
acc = acc or 0.0
stars = sum(1 for t in thresholds if acc >= t)
stars, next_at = _star_progress(acc, thresholds)
if stars:
per_song[filename] = stars
next_at = next((t for t in thresholds if acc < t), None)
detail.append({
"filename": filename,
"title": title or filename,
@@ -249,10 +251,18 @@ def _played_by_instrument_genre():
for stub in stubs.values():
acc = stub["best_accuracy"]
stub["best_accuracy"] = round(acc, 4)
stub["stars"] = sum(1 for t in thresholds if acc >= t)
stub["stars"], stub["next_star_at"] = _star_progress(acc, thresholds)
return out, seconds
def _star_progress(acc, thresholds):
"""(stars, next_star_at) — the one place the ascending-thresholds
assumption lives; _stars() and the passport stubs both use it."""
stars = sum(1 for t in thresholds if acc >= t)
next_at = next((t for t in thresholds if acc < t), None)
return stars, next_at
def _library_genres():
"""Distinct effective genres across the live library (the brochure rack)."""
db = _state["meta_db"]
@@ -276,12 +286,33 @@ def _library_genres():
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
def _genre_family(gkey):
"""First family whose keyword appears in the genre key (substring — MB's
vocabulary is open: 'metalcore' must hit the 'metal' family without an
exact alias). List order decides ambiguity: families are checked top to
bottom, so 'blues rock' lands on whichever of blues/rock is listed first."""
for fam in _state["passports_content"].get("families") or []:
if not isinstance(fam, dict):
continue
for kw in fam.get("match") or []:
if isinstance(kw, str) and kw and kw in gkey:
return fam.get("key")
return None
def _badge_requirement(gkey, instrument="guitar"):
cfg = _state["passports_content"]
req = dict(cfg.get("badge_requirement") or {})
req.setdefault("songs", 5)
req.setdefault("min_stars", 2)
override = (cfg.get("genres") or {}).get(gkey)
# Exact per-genre override wins; otherwise the genre inherits its FAMILY's
# requirement — so 'death metal' / 'metalcore' passports carry the metal
# drill without curating every MB sub-genre by hand.
genres_cfg = cfg.get("genres") or {}
override = genres_cfg.get(gkey)
if not isinstance(override, dict):
family = _genre_family(gkey)
override = genres_cfg.get(family) if family else None
if isinstance(override, dict):
req.update(override)
# virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its
@@ -355,6 +386,7 @@ def _passports_view():
cfg = _state["passports_content"]
graded = set(cfg.get("graded_instruments") or [])
st = _career_state()
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
played, played_seconds = _played_by_instrument_genre()
received_at, by_node = _drill_by_node()
instruments = {}
@@ -385,6 +417,17 @@ def _passports_view():
badge = "earned"
else:
badge = "in_progress"
# Practice invitation: the non-qualifying songs closest to the
# QUALIFYING bar (the badge ask), nearest first — invitation
# data, the UI voices it without meters.
thresholds = _state["content"]["star_accuracy_thresholds"]
bar = (thresholds[req["min_stars"] - 1]
if 0 < req["min_stars"] <= len(thresholds) else None)
nearest = [] if bar is None else sorted(
(s for s in songs if not s["qualifies"]),
key=lambda s: bar - s["best_accuracy"])[:3]
for s in nearest:
s["bar_at"] = bar
passports.append({
"genre_key": gkey,
"genre": meta.get("genre") or gkey,
@@ -393,13 +436,18 @@ def _passports_view():
"graded": is_graded,
"songs": songs,
"qualifying_count": qualifying,
"nearest": nearest,
# Honest hours odometer (Stage 5 post-cap): a true fact that
# only grows — never a target, never a meter.
"seconds_total": round(played_seconds.get((inst, gkey), 0.0), 1),
"drills": {"required": required, "cleared": cleared},
"badge": badge,
})
instruments[inst] = {"committed_at": committed_at, "passports": passports}
inst_gigs = [g for g in all_gigs if g.get("instrument") == inst]
for p in passports:
p["gigs"] = [g for g in inst_gigs if g.get("genre_key") == p["genre_key"]][-20:][::-1]
instruments[inst] = {"committed_at": committed_at, "passports": passports,
"gig_count": len(inst_gigs)}
return {
"config": {
"badge_requirement": cfg.get("badge_requirement") or {},
@@ -414,6 +462,60 @@ def _passports_view():
}
def _gig_config():
cfg = _state["passports_content"].get("gig")
cfg = cfg if isinstance(cfg, dict) else {}
def _num(key, default, cast):
# Tuning data, not code: junk falls back instead of 500ing both gig
# endpoints, and a legitimate 0 (stakes_songs: 0) is respected.
val = cfg.get(key)
if isinstance(val, bool) or not isinstance(val, (int, float)):
return default
return cast(val)
return {
"min_songs": max(1, _num("min_songs", 3, int)),
"max_songs": max(1, _num("max_songs", 5, int)),
"stakes_songs": max(0, _num("stakes_songs", 2, int)),
"encore_accuracy": _num("encore_accuracy", 0.75, float),
}
def _current_venue():
"""Highest unlocked venue (the room you can book today)."""
stars_total, _, _ = _stars()
best = None
for v in _state["content"]["venues"]:
if stars_total >= v["star_threshold"]:
if best is None or v["star_threshold"] >= best["star_threshold"]:
best = v
return best
def _unplayed_genre_songs(gkey, exclude, limit):
"""Library songs of a genre with no stats yet — a young passport's gig
still gets a full set (playing them is how stubs start).
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
songs, single-user); push the match into SQL if propose ever feels slow."""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
).fetchall()
out = []
for filename, title, artist, genre in rows:
if _genre_key(genre) != gkey or filename in exclude:
continue
out.append({"filename": filename, "title": title or filename,
"artist": artist or ""})
if len(out) >= limit:
break
return out
def _validate_pack_dir(pack_dir: Path):
"""Raise ValueError unless pack_dir holds a complete venue pack."""
manifest_path = pack_dir / "manifest.json"
@@ -595,6 +697,127 @@ def setup(app, context):
"snapshot": snapshot})
return {"ok": True}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
def propose_gig(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
cfg = _gig_config()
try:
size = int((body or {}).get("size") or 4)
except (TypeError, ValueError):
raise HTTPException(400, "size must be a number.")
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
played, _seconds = _played_by_instrument_genre()
stubs = list(played.get((inst, gkey), {}).values())
req = _badge_requirement(gkey, inst)
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
# The set: mostly songs you own, plus a couple of stakes songs near
# the bar; a young passport fills from unplayed genre songs so the
# first gig is how stubs start. random per call = free re-roll.
random.shuffle(qualifying)
rest.sort(key=lambda s: -s["best_accuracy"])
qtaken = max(1, size - cfg["stakes_songs"])
picks = qualifying[:qtaken]
for s in rest:
if len(picks) >= size:
break
picks.append(s)
# Surplus qualifying songs backfill a short set — a mature passport
# with no near-bar songs left must still fill the bill. Offset by how
# many QUALIFYING songs were taken, not len(picks): rest's stakes
# additions would otherwise skip eligible qualifying songs entirely.
for s in qualifying[qtaken:]:
if len(picks) >= size:
break
picks.append(s)
if len(picks) < size:
exclude = {s["filename"] for s in picks}
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
if not picks:
raise HTTPException(404, "No songs of this genre in the library.")
venue = _current_venue()
return {
"instrument": inst,
"genre": genre,
"genre_key": gkey,
"venue_id": venue["id"] if venue else None,
"venue_name": venue["name"] if venue else "",
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
"artist": s.get("artist") or ""} for s in picks[:size]],
}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
def log_gig(body: dict = Body(...)):
# Called by the runner ONLY when the set completed — an abandoned set
# never logs (no fail state; the gig you finished is the gig you
# played). Accuracies come from song_stats, freshly written by the
# set's own plays.
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
venue_id = str((body or {}).get("venue_id") or "")
songs = (body or {}).get("songs")
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
if venue_id and (not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None):
raise HTTPException(400, "Unknown venue.")
if (not isinstance(songs, list) or not songs or len(songs) > 8
or not all(isinstance(f, str) and f.strip() for f in songs)):
raise HTTPException(400, "songs must be 1-8 filenames.")
db = _state["meta_db"]
entries = []
accuracies = []
for filename in songs:
title = filename
accuracy = None
if db is not None:
# The NEWEST row is the set's own just-recorded play — a
# MAX(last_accuracy) across arrangements would happily log a
# stale higher score from another instrument's old session.
row = db.conn.execute(
"SELECT last_accuracy FROM song_stats WHERE filename = ? "
"ORDER BY last_played_at DESC LIMIT 1",
(filename,)).fetchone()
if row and row[0] is not None:
accuracy = round(float(row[0]), 4)
accuracies.append(accuracy)
trow = db.conn.execute(
"SELECT title FROM songs WHERE filename = ?", (filename,)).fetchone()
if trow and trow[0]:
title = trow[0]
entries.append({"filename": filename, "title": title, "accuracy": accuracy})
# Encore needs the WHOLE set scored at the bar — one scored song must
# not earn an encore for a set that was 4/5 unheard.
encore = (len(accuracies) == len(songs) and
sum(accuracies) / len(accuracies) >= _gig_config()["encore_accuracy"])
gig = {
"at": _now_iso(),
"venue_id": venue_id or None,
"instrument": inst,
"genre": genre,
"genre_key": gkey,
"songs": entries,
"encore": encore,
}
with _lock:
st = _career_state()
if not isinstance(st.get("gigs"), list):
st["gigs"] = []
st["gigs"].append(gig)
# ponytail: hard cap — nothing reads past the last 20 per
# passport; the state file must not grow (and export) forever.
st["gigs"] = st["gigs"][-500:]
_save_json(_state_file(), st)
return {"ok": True, "gig": gig}
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
def start_download(venue_id: str):
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
+1
View File
@@ -29,6 +29,7 @@
<div id="career-tab-passports" class="hidden" role="tabpanel" aria-labelledby="career-tab-btn-passports">
<p class="text-sm text-gray-400 mb-4">Commit to an instrument, pick a genre, and stamp your way to its badge — five ★★ songs mint a Bronze. Your passport wall is who you are as a musician.</p>
<div id="pp-instruments" class="pp-instruments"></div>
<div id="pp-closest" class="mt-4"></div>
<div id="pp-shelf-wrap" class="mt-5">
<div id="pp-shelf" class="pp-shelf"></div>
</div>
+635 -17
View File
@@ -38,6 +38,8 @@
let _ppCeremonyActive = false;
let _ppBootstrapped = false;
let _ppNotified = {}; // badges chimed this session (slam still pending)
let _ppGigProposal = null; // the booking poster's proposal, while open
let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, idx} mid-set
function $(id) { return document.getElementById(id); }
@@ -458,6 +460,8 @@
_pp = view;
detectNewBadges(view);
renderPassports();
renderProfileWall();
renderDashCard();
if (!_ppBootstrapped) {
_ppBootstrapped = true;
// Sync the local drill snapshot once per session — drill progress
@@ -497,6 +501,56 @@
</button>`;
}
// Practice invitations: which stamps are closest, and what would bring
// them home. Invitations only — no meters, no obligations.
// Floor, never round: 74.9% must not display as the already-met "75%".
function pct(frac) { return Math.floor((Number(frac) || 0) * 100); }
function ppNeed(p) {
return Math.max(0, ((p.requirement || {}).songs || 0) - (p.qualifying_count || 0));
}
// The one blocker phrase — shared by the Closest-stamps strip and the
// passport book's invite line so they can never contradict each other.
function ppAskHTML(p, withHint) {
const req = p.requirement || {};
const need = ppNeed(p);
const starGl = '★'.repeat(req.min_stars || 0);
if (need > 0) {
const near = withHint ? (p.nearest || [])[0] : null;
const hint = near
? ` · nearest: <em>${esc(near.title)}</em> at ${pct(near.best_accuracy)}%`
: '';
return `${need === 1 ? `one more ${starGl} song` : `${need} more ${starGl} songs`}${hint}`;
}
const labels = ((_pp && _pp.config) || {}).drill_labels || {};
const drills = p.drills || {};
const pending = (drills.required || []).filter((n) => !(drills.cleared || []).includes(n));
return `clear ${pending.map((n) => esc(labels[n] || n)).join(', ') || 'the genre drill'} in Virtuoso`;
}
function closestLineHTML(p) {
return `<button class="pp-closest-row" data-pp-open="${esc(p.genre_key)}">
<span class="pp-closest-genre">${esc(p.genre)}</span>
<span class="pp-closest-ask">${ppAskHTML(p, true)}</span>
</button>`;
}
function renderClosest(inst, data) {
const host = $('pp-closest');
if (!host) return;
const candidates = (data.passports || [])
.filter((p) => p.badge === 'in_progress')
.sort((a, b) => ppNeed(a) - ppNeed(b))
.slice(0, 3);
if (!candidates.length) { host.innerHTML = ''; return; }
host.innerHTML = `<div class="pp-closest">
<div class="pp-closest-head">Closest stamps</div>
${candidates.map(closestLineHTML).join('')}
</div>`;
}
function renderShelf(inst, data) {
const shelf = $('pp-shelf');
if (!shelf) return;
@@ -551,6 +605,7 @@
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
</button>`;
}).join('');
renderClosest(inst, data);
renderShelf(inst, data);
renderRack(inst, data);
}
@@ -576,22 +631,13 @@
function ppBookHTML(inst, p, pendingSlam) {
const req = p.requirement || {};
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
const starGl = '★'.repeat(req.min_stars || 0);
const reqNodes = (p.drills || {}).required || [];
const clearedNodes = new Set((p.drills || {}).cleared || []);
const labels = ((_pp && _pp.config) || {}).drill_labels || {};
const pendingDrills = reqNodes.filter((n) => !clearedNodes.has(n));
// The invite names what actually blocks the stamp: songs first, then
// the genre drill once the song bar is met.
let invite;
if (need > 0) {
invite = need === 1 ? `One more ${starGl} song mints this stamp.`
: `${need} more ${starGl} songs mint this stamp.`;
} else {
const names = pendingDrills.map((n) => labels[n] || n).join(', ');
invite = `Clear ${names || 'the genre drill'} in Virtuoso to mint this stamp.`;
}
// The invite names what actually blocks the stamp — same shared
// phrase as the Closest-stamps strip, so they can't contradict.
const invite = `${ppAskHTML(p, false)} mints this stamp.`;
let badgeArea = '';
if (p.badge === 'shown_not_judged') {
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
@@ -601,14 +647,18 @@
<span class="pp-stamp-tier">BRONZE</span>
</div>
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
<div class="pp-gold-note">Gold rung coming improvise it, verified.</div>`;
<div class="pp-gold-note">Gold rung coming improvise it, verified.</div>
<div class="pp-card-actions">
<button class="career-btn career-btn-ghost" data-pp-card="save">Save card</button>
<button class="career-btn career-btn-ghost" data-pp-card="copy">Copy card</button>
</div>`;
} else {
const fill = (ppFillFraction(p) * 100).toFixed(0);
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
<span class="pp-stamp-tier">BRONZE</span>
</div>
<div class="pp-invite">${esc(invite)}</div>`;
<div class="pp-invite">${invite.charAt(0).toUpperCase()}${invite.slice(1)}</div>`;
}
const hours = fmtHours(p.seconds_total);
const odometer = hours
@@ -628,15 +678,34 @@
: `Play ${esc(p.genre)} songs at ${starGl} to collect ticket stubs.`;
const stubsHTML = stubs.length ? stubs.map(ppStubHTML).join('')
: `<div class="pp-stub-empty">${emptyLine}</div>`;
// Bring-these-up: nearest-to-the-bar songs (graded, unearned only —
// an earned page is memorabilia, not homework).
let nearest = '';
if (p.badge === 'in_progress' && (p.nearest || []).length) {
nearest = `<div class="pp-nearest">
<div class="pp-nearest-head">Bring these up</div>
${p.nearest.map((s) =>
`<div class="pp-nearest-row"><em>${esc(s.title)}</em> — best ${pct(s.best_accuracy)}%, ${starGl} at ${pct(s.bar_at)}%</div>`).join('')}
</div>`;
}
let gigLog = '';
if ((p.gigs || []).length) {
gigLog = `<div class="pp-giglog">
<div class="pp-giglog-head">Gigs played</div>
${p.gigs.slice(0, 6).map((g) =>
`<div class="pp-giglog-row">${esc((g.at || '').slice(0, 10))} · ${esc(_venueName(g.venue_id))}${g.encore ? ' · <b>encore</b>' : ''}</div>`).join('')}
</div>`;
}
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="${esc(p.genre)} ${esc(ppLabel(inst))} passport">
<div class="pp-book">
<div class="pp-page pp-page-left">
<div class="pp-page-head">${esc(p.genre)} ${esc(ppLabel(inst))}</div>
${badgeArea}${odometer}${drills}
<button class="career-btn career-btn-primary pp-gig-book" data-pp-gig="${esc(p.genre_key)}">Book a gig</button>
</div>
<div class="pp-page pp-page-right">
<div class="pp-page-head">Ticket stubs</div>
<div class="pp-stubs">${stubsHTML}</div>
<div class="pp-stubs">${stubsHTML}${nearest}${gigLog}</div>
</div>
<div class="pp-book-cover pp-leather-${esc(inst)}">
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
@@ -685,6 +754,7 @@
function closeBook() {
_ppBook = null;
_ppGigProposal = null; // a dismissed poster is a dismissed booking
const overlay = $('pp-overlay');
if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; }
if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' &&
@@ -771,6 +841,523 @@
if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
}
// ── Shareable passport card (canvas → PNG, save or clipboard) ─────────
// Keep in sync with the .pp-leather-* gradients in assets/career.css —
// canvas can't consume a CSS class, so the pairs live twice on purpose.
const PP_LEATHER_HEX = {
guitar: ['#5c2321', '#401412'],
bass: ['#1f3252', '#131f36'],
keys: ['#1e4034', '#122a21'],
drums: ['#3f3f46', '#26262b'],
};
function drawPassportCard(inst, p) {
const W = 480;
const H = 640;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
const [c1, c2] = PP_LEATHER_HEX[inst] || PP_LEATHER_HEX.guitar;
const bg = ctx.createLinearGradient(0, 0, W, H);
bg.addColorStop(0, c1);
bg.addColorStop(1, c2);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// Emboss frame
ctx.strokeStyle = 'rgba(240,226,195,0.35)';
ctx.lineWidth = 3;
ctx.strokeRect(18, 18, W - 36, H - 36);
// Genre title
ctx.fillStyle = 'rgba(240,226,195,0.95)';
ctx.textAlign = 'center';
ctx.font = '700 34px Georgia, serif';
ctx.fillText(p.genre.toUpperCase(), W / 2, 92, W - 80);
ctx.font = '400 15px Georgia, serif';
ctx.fillStyle = 'rgba(240,226,195,0.55)';
ctx.fillText(`${ppLabel(inst).toUpperCase()} PASSPORT`, W / 2, 122);
// Stamp ring
const gold = p.badge === 'gold';
const ink = gold ? '#d9a253' : '#b06a2a';
const cy = 330;
ctx.strokeStyle = ink;
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(W / 2, cy, 118, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(W / 2, cy, 106, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = ink;
ctx.font = '800 26px Georgia, serif';
ctx.fillText(p.genre.toUpperCase(), W / 2, cy - 6, 190);
ctx.font = '600 16px Georgia, serif';
ctx.fillText(gold ? 'G O L D' : 'B R O N Z E', W / 2, cy + 28);
// Facts
const stubCount = (p.songs || []).filter((sng) => sng.qualifies).length;
const hours = fmtHours(p.seconds_total);
ctx.fillStyle = 'rgba(240,226,195,0.75)';
ctx.font = '400 17px Georgia, serif';
ctx.fillText(`${stubCount} ticket stub${stubCount === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}`, W / 2, 512);
ctx.fillStyle = 'rgba(240,226,195,0.4)';
ctx.font = '400 13px Georgia, serif';
ctx.fillText('fee[dB]ack · career passport', W / 2, H - 44);
return canvas;
}
// One export path for every canvas artifact: copy (with download
// fallback + notice) or save, failures audible.
function exportCanvasPng(canvas, filename, mode, noun) {
canvas.toBlob(async (blob) => {
const fail = (why) => {
if (window.fbNotify) window.fbNotify.show({ icon: '⚠️', title: `${noun} export failed`, message: why });
};
if (!blob) { fail('The canvas produced no image.'); return; }
try {
const io = await import('/static/js/blob-io.js');
if (mode === 'copy') {
const ok = await io.copyImageBlob(blob);
if (ok) {
if (window.fbNotify) window.fbNotify.show({ icon: '📋', title: `${noun} copied`, message: 'Paste it anywhere.' });
return;
}
if (window.fbNotify) window.fbNotify.show({ icon: '💾', title: 'Clipboard unavailable', message: `Saved the ${noun.toLowerCase()} instead.` });
}
io.downloadBlob(blob, filename);
} catch (e) { fail('Export helper unavailable.'); }
}, 'image/png');
}
function exportPassportCard(mode) {
if (!_ppBook || !_pp) return;
const { inst, gkey } = _ppBook;
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
.find((x) => x.genre_key === gkey);
if (!p) return;
const canvas = drawPassportCard(inst, p);
exportCanvasPng(canvas, `passport-${inst}-${gkey.replace(/[^a-z0-9-]+/g, '-')}.png`, mode, 'Card');
}
// ── Career surfaces outside the plugin screen ─────────────────────────
// Profile passport wall + the home-page career card. Both inject into
// core-owned mounts announced by v3:profile-rendered /
// v3:dashboard-rendered (the achievements seam). Absent-not-empty: with
// no committed instrument they render nothing and the dashboard keeps
// its built-in fallback stat.
function careerTotals() {
if (!_pp) return null;
let badges = 0;
let seconds = 0;
let gigs = 0;
const walls = [];
for (const inst of (_pp.config || {}).instruments || []) {
const d = (_pp.instruments || {})[inst];
// A commitment with no opened passport isn't a wall yet — the
// external surfaces (profile, home card) stay ABSENT until a
// passport exists (absent-not-empty).
if (!d || !d.committed_at || !(d.passports || []).length) continue;
const earned = (d.passports || []).filter((p) => p.badge === 'earned' || p.badge === 'gold');
badges += earned.length;
seconds += (d.passports || []).reduce((t, p) => t + (p.seconds_total || 0), 0);
gigs += d.gig_count || 0;
walls.push({ inst, earned, opened: d.passports.length });
}
if (!walls.length) return null;
return { badges, seconds, gigs, walls };
}
function closestAskHTML() {
if (!_pp) return '';
let best = null;
for (const inst of (_pp.config || {}).instruments || []) {
for (const p of (((_pp.instruments || {})[inst] || {}).passports || [])) {
if (p.badge !== 'in_progress') continue;
const need = Math.max(0, ((p.requirement || {}).songs || 0) - p.qualifying_count);
if (!best || need < best.need) best = { p, need };
}
}
if (!best) return '';
const starGl = '★'.repeat((best.p.requirement || {}).min_stars || 0);
if (best.need > 0) {
return `${esc(best.p.genre)}${best.need === 1 ? `one more ${starGl} song` : `${best.need} more ${starGl} songs`}`;
}
return `${esc(best.p.genre)} — one drill away`;
}
function renderProfileWall() {
const mount = document.getElementById('v3-profile-passports-mount');
if (!mount) return;
const totals = careerTotals();
if (!totals) { mount.innerHTML = ''; return; }
const shelves = totals.walls.map(({ inst, earned, opened }) => {
const covers = earned.map((p) =>
`<button class="pp-wall-cover pp-leather-${esc(inst)}" data-pp-wall-inst="${esc(inst)}" data-pp-wall-gkey="${esc(p.genre_key)}" title="${esc(p.genre)}">
<span>${esc(p.genre.toUpperCase())}</span>
<em>${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</em>
</button>`).join('');
const line = earned.length
? covers
: `<span class="pp-wall-none">${opened} passport${opened === 1 ? '' : 's'} open — first stamp pending</span>`;
return `<div class="pp-wall-shelf"><span class="pp-wall-inst">${esc(ppLabel(inst))}</span>${line}</div>`;
}).join('');
const hours = fmtHours(totals.seconds);
mount.innerHTML = `<div class="bg-fb-card/80 backdrop-blur rounded-lg p-4 border border-fb-border/50 pp-wall">
<div class="pp-wall-head">
<span>Passport wall</span>
<span class="pp-wall-meta">${totals.badges} badge${totals.badges === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
</div>
${shelves}
<button class="pp-wall-link" data-pp-wall-career="1">Open career </button>
</div>`;
if (!mount.dataset.ppWired) {
mount.dataset.ppWired = '1';
mount.addEventListener('click', onWallClick);
}
}
function onWallClick(e) {
const open = e.target.closest('[data-pp-wall-inst]');
if (open) {
// Two attributes, not a '/'-joined pair: a genre key may itself
// contain '/' ("drum/bass") and must round-trip intact.
const inst = open.dataset.ppWallInst;
const gkey = open.dataset.ppWallGkey;
lsSet(PP_INST_KEY, inst);
if (window.showScreen) window.showScreen('plugin-career');
showCareerTab('passports');
renderPassports();
openBook(inst, gkey);
return;
}
if (e.target.closest('[data-pp-wall-career]')) {
if (window.showScreen) window.showScreen('plugin-career');
showCareerTab('passports');
}
}
function renderDashCard() {
const slot = document.getElementById('v3-dash-career-slot');
if (!slot) return;
const totals = careerTotals();
if (!totals) return; // keep core's fallback stat card
const hours = fmtHours(totals.seconds);
const ask = closestAskHTML();
slot.innerHTML = `<button class="pp-dash-card" data-pp-wall-career="1">
<span class="pp-dash-shine" aria-hidden="true"></span>
<span class="pp-dash-head">Career</span>
<span class="pp-dash-badges">${'⚡'.repeat(Math.min(totals.badges, 5))}<b>${totals.badges}</b> badge${totals.badges === 1 ? '' : 's'}</span>
<span class="pp-dash-meta">${hours ? `${hours} played` : 'the stage is set'}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
${ask ? `<span class="pp-dash-ask">closest: ${ask}</span>` : ''}
</button>`;
if (!slot.dataset.ppWired) {
slot.dataset.ppWired = '1';
slot.addEventListener('click', onWallClick);
}
}
// ── Gigs: booking poster → set runner → summary ──────────────────────
function _venueName(venueId) {
const v = (_state && _state.venues || []).find((x) => x.id === venueId);
return v ? v.name : (venueId || 'the stage');
}
function gigPosterHTML(prop) {
const bill = prop.songs.map((s, i) =>
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.artist ? ` <em>${esc(s.artist)}</em>` : ''}</div>`).join('');
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="Gig poster">
<div class="pp-poster">
<div class="pp-poster-venue">${esc(prop.venue_name || 'The stage')}</div>
<div class="pp-poster-presents">presents</div>
<div class="pp-poster-title">${esc(prop.genre.toUpperCase())} NIGHT</div>
<div class="pp-poster-inst">${esc(ppLabel(prop.instrument))} · tonight</div>
<div class="pp-poster-bill">${bill}</div>
<div class="pp-poster-actions">
<button class="career-btn career-btn-primary" data-pp-gig-play="1">Play the gig</button>
<button class="career-btn career-btn-ghost" data-pp-gig-reroll="1">Re-roll</button>
<button class="career-btn career-btn-ghost" data-pp-poster="save">Save</button>
<button class="career-btn career-btn-ghost" data-pp-poster="copy">Copy</button>
</div>
<button class="pp-book-close" data-pp-close="1" aria-label="Close"></button>
</div>
</div>`;
}
async function bookGig(gkey) {
if (!_pp) return;
const inst = activeInstrument();
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
.find((x) => x.genre_key === gkey);
if (!p) return;
try {
const res = await fetch(`${API}/gigs/propose`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instrument: inst, genre: p.genre }),
});
if (!res.ok) return;
_ppGigProposal = await res.json();
} catch (_) { return; }
const overlay = $('pp-overlay');
if (!overlay) return;
_ppBook = null; // the poster replaces the book in the overlay
overlay.innerHTML = gigPosterHTML(_ppGigProposal);
overlay.classList.remove('hidden');
sfx('page');
}
function startGig() {
const prop = _ppGigProposal;
const q = window.feedBack && window.feedBack.playQueue;
if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return;
// The gig BORROWS the stage: stash whatever venue/viz the user had so
// the set ending gives it back (unlike "Play here", which is an
// explicit persistent choice on the venue card).
let restore = null;
if (prop.venue_id) {
// Capture the restore snapshot BEFORE any write: if a later write
// (or setViz) throws, the stage must still be returnable.
try {
restore = {
venue: localStorage.getItem(VENUE_OVERRIDE_KEY),
viz: localStorage.getItem('vizSelection'),
};
} catch (_) { restore = null; }
try {
localStorage.setItem(VENUE_OVERRIDE_KEY, prop.venue_id);
localStorage.setItem('vizSelection', 'venue');
if (typeof window.setViz === 'function') window.setViz('venue');
} catch (_) { /* viz optional — restore stays intact */ }
}
_appliedManifestVenue = null;
_ppGigRun = {
songs: prop.songs,
venue_id: prop.venue_id,
genre: prop.genre,
genre_key: prop.genre_key,
instrument: prop.instrument,
idx: 0,
restore,
};
closeBook();
_ppGigProposal = null;
// RAW filenames: the queue itself encodes for playSong — pre-encoding
// double-encodes and breaks loading + the stats/gig filename join.
if (!q.start(prop.songs.map((s) => s.filename), { source: 'gig' })) {
_ppGigRun = null;
return;
}
renderGigStrip();
}
function restoreGigStage(run) {
const r = run && run.restore;
if (!r) return;
try {
if (r.venue == null) localStorage.removeItem(VENUE_OVERRIDE_KEY);
else localStorage.setItem(VENUE_OVERRIDE_KEY, r.venue);
if (r.viz && r.viz !== 'venue') {
localStorage.setItem('vizSelection', r.viz);
if (typeof window.setViz === 'function') window.setViz(r.viz);
}
} catch (_) { /* best effort */ }
_appliedManifestVenue = null;
}
function renderGigStrip() {
if (!_ppGigRun || !document.body || typeof document.createElement !== 'function') return;
let strip = document.getElementById('pp-gig-strip');
if (!strip) {
strip = document.createElement('div');
strip.id = 'pp-gig-strip';
strip.className = 'pp-gig-strip';
document.body.appendChild(strip);
}
const run = _ppGigRun;
const next = run.songs[run.idx + 1];
strip.innerHTML = `<b>GIG</b> · ${esc(run.genre)} at ${esc(_venueName(run.venue_id))} · set ${Math.min(run.idx + 1, run.songs.length)}/${run.songs.length}${next ? ` — next: <em>${esc(next.title)}</em>` : ' — closer!'}`;
}
function removeGigStrip() {
const strip = document.getElementById('pp-gig-strip');
if (strip) strip.remove();
}
function abandonGig() {
// No fail state: an abandoned set logs nothing and says nothing.
const run = _ppGigRun;
_ppGigRun = null;
removeGigStrip();
restoreGigStage(run);
}
function completeGig() {
const run = _ppGigRun;
_ppGigRun = null;
removeGigStrip();
restoreGigStage(run);
// The final song's own stats POST races this moment (both ride
// song:ended): wait for its stats:recorded — or a short timeout, since
// an UNSCORED play never emits one — so /gigs reads the set's real
// accuracies, not last week's.
const lastFile = run.songs[run.songs.length - 1].filename;
const sm = window.feedBack;
let done = false;
const proceed = () => {
if (done) return;
done = true;
if (sm && typeof sm.off === 'function') { try { sm.off('stats:recorded', onRec); } catch (_) { /* ok */ } }
postGig(run);
};
const onRec = (e) => {
const d = (e && e.detail) || {};
if (d.filename === lastFile) proceed();
};
if (sm && typeof sm.on === 'function') sm.on('stats:recorded', onRec);
setTimeout(proceed, 3500);
}
async function postGig(run) {
let gig = null;
try {
const res = await fetch(`${API}/gigs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instrument: run.instrument,
genre: run.genre,
venue_id: run.venue_id,
songs: run.songs.map((s) => s.filename),
}),
});
if (res.ok) gig = (await res.json()).gig;
} catch (_) { /* summary still shows, unlogged */ }
showGigSummary(run, gig);
refreshPassports();
}
function showGigSummary(run, gig) {
if (!document.body || typeof document.createElement !== 'function') return;
const entries = (gig && gig.songs) || run.songs.map((s) => ({ filename: s.filename, title: s.title, accuracy: null }));
const encore = !!(gig && gig.encore);
if (encore && !reducedMotion()) {
const crowd = window.v3VenueCrowd;
if (crowd && typeof crowd.celebrate === 'function') {
try { crowd.celebrate(); } catch (_) { /* optional */ }
}
}
const el = document.createElement('div');
el.id = 'pp-gig-summary';
el.className = 'pp-ceremony-overlay';
el.innerHTML = `<canvas class="pp-confetti"></canvas>
<div class="pp-poster pp-poster-summary">
<div class="pp-poster-venue">${esc(_venueName(run.venue_id))}</div>
<div class="pp-poster-title">${esc(run.genre.toUpperCase())} NIGHT</div>
<div class="pp-poster-inst">${encore ? 'ENCORE! ' : ''}the set, as played</div>
<div class="pp-poster-bill">${entries.map((s, i) =>
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.accuracy != null ? ` <b>${Math.floor(s.accuracy * 100)}%</b>` : ''}</div>`).join('')}</div>
<div class="pp-poster-actions">
<button class="career-btn career-btn-ghost" data-pp-poster="save">Save poster</button>
<button class="career-btn career-btn-ghost" data-pp-poster="copy">Copy poster</button>
<button class="career-btn career-btn-primary" data-pp-gig-done="1">Done</button>
</div>
</div>`;
el.addEventListener('click', (e) => {
if (e.target === el || e.target.closest('[data-pp-gig-done]')) {
el.remove();
} else if (e.target.closest('[data-pp-poster]')) {
exportGigPoster(e.target.closest('[data-pp-poster]').dataset.ppPoster,
{ ...run, encore, entries });
}
});
document.body.appendChild(el);
if (encore && !reducedMotion()) confettiBurst(el.querySelector('.pp-confetti'));
}
function drawGigPoster(data) {
const W = 480;
const H = 640;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
const bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, '#141019');
bg.addColorStop(1, '#241318');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = 'rgba(217,162,83,0.5)';
ctx.lineWidth = 3;
ctx.strokeRect(16, 16, W - 32, H - 32);
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(240,226,195,0.65)';
ctx.font = '400 18px Georgia, serif';
ctx.fillText(_venueName(data.venue_id), W / 2, 76, W - 80);
ctx.font = '400 12px Georgia, serif';
ctx.fillText('P R E S E N T S', W / 2, 102);
ctx.fillStyle = '#d9a253';
ctx.font = '800 40px Georgia, serif';
ctx.fillText(`${data.genre.toUpperCase()}`, W / 2, 160, W - 60);
ctx.font = '800 26px Georgia, serif';
ctx.fillText('NIGHT', W / 2, 194);
if (data.encore) {
ctx.fillStyle = '#f3d179';
ctx.font = '700 16px Georgia, serif';
ctx.fillText('— E N C O R E —', W / 2, 226);
}
ctx.fillStyle = 'rgba(240,226,195,0.85)';
ctx.font = '400 18px Georgia, serif';
const entries = data.entries || data.songs || [];
entries.slice(0, 6).forEach((sng, i) => {
const acc = sng.accuracy != null ? ` · ${Math.floor(sng.accuracy * 100)}%` : '';
ctx.fillText(`${sng.title}${acc}`, W / 2, 290 + i * 44, W - 80);
});
ctx.fillStyle = 'rgba(240,226,195,0.4)';
ctx.font = '400 13px Georgia, serif';
ctx.fillText(`${ppLabel(data.instrument || 'guitar')} · fee[dB]ack career`, W / 2, H - 42);
return canvas;
}
function exportGigPoster(mode, data) {
exportCanvasPng(drawGigPoster(data),
`gig-${(data.genre_key || 'set').replace(/[^a-z0-9-]+/g, '-')}.png`, mode, 'Poster');
}
// Queue lifecycle: advance the strip per song; complete or abandon.
function onGigSongLoading() {
if (!_ppGigRun) return;
renderGigStrip();
}
function onGigSongEnded() {
if (!_ppGigRun) return;
const q = window.feedBack && window.feedBack.playQueue;
if (!q || typeof q.remaining !== 'function') return;
// Only OUR live queue counts: remaining()===0 is also true for a
// cleared/foreign queue (a manual play silently clears the gig queue,
// and that unrelated song's end must not log a gig).
if (q.source && q.source() !== 'gig') { abandonGig(); return; }
if (!q.remaining()) {
if (q.active && q.active()) completeGig();
else abandonGig();
return;
}
_ppGigRun.idx = Math.min(_ppGigRun.idx + 1, _ppGigRun.songs.length - 1);
renderGigStrip();
}
function onGigSongStop() {
// A deliberate quit mid-set (Escape clears the queue) abandons the
// gig — but the LAST song's teardown also fires song:stop after
// song:ended, so only abandon while songs genuinely remain.
if (!_ppGigRun) return;
const q = window.feedBack && window.feedBack.playQueue;
const active = q && typeof q.active === 'function' ? q.active() : false;
if (!active) abandonGig();
}
function openGenre(inst, genre) {
fetch(`${API}/passports/open`, {
method: 'POST',
@@ -820,6 +1407,23 @@
closeBook();
return;
}
const gigBtn = e.target.closest('[data-pp-gig]');
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
if (e.target.closest('[data-pp-gig-play]')) { startGig(); return; }
if (e.target.closest('[data-pp-gig-reroll]')) {
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
return;
}
const posterBtn = e.target.closest('[data-pp-poster]');
if (posterBtn && _ppGigProposal) {
exportGigPoster(posterBtn.dataset.ppPoster, _ppGigProposal);
return;
}
const cardBtn = e.target.closest('[data-pp-card]');
if (cardBtn) {
exportPassportCard(cardBtn.dataset.ppCard);
return;
}
const dlBtn = e.target.closest('[data-career-download]');
const delBtn = e.target.closest('[data-career-delete]');
const playBtn = e.target.closest('[data-career-play]');
@@ -870,6 +1474,10 @@
if (sm && typeof sm.on === 'function') {
// New song stats can add stars → thresholds may cross mid-session.
sm.on('stats:recorded', () => refresh());
// Gig runner lifecycle (no-ops when no gig is live).
sm.on('song:loading', onGigSongLoading);
sm.on('song:ended', onGigSongEnded);
sm.on('song:stop', onGigSongStop);
// Virtuoso's progress emits are the drill-state relay trigger; the
// payload is a thin delta, so the relay reads the full localStorage
// snapshot instead (see relayDrillState).
@@ -877,8 +1485,14 @@
}
showCareerTab(lsGet(PP_TAB_KEY) === 'passports' ? 'passports' : 'venues');
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && _ppBook) closeBook();
if (e.key !== 'Escape') return;
const overlay = $('pp-overlay');
if (_ppBook || (overlay && !overlay.classList.contains('hidden'))) closeBook();
});
// Core re-renders profile/dashboard shells (innerHTML wipe) and
// announces the fresh mount points — same seam achievements uses.
document.addEventListener('v3:profile-rendered', renderProfileWall);
document.addEventListener('v3:dashboard-rendered', renderDashCard);
refresh();
}
@@ -886,7 +1500,11 @@
// the badge-diff logic; nothing here touches the DOM.
window.__careerPassportTest = {
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
fmtHours, ppFillFraction,
fmtHours, ppFillFraction, careerTotals, closestAskHTML,
onGigSongEnded, onGigSongStop,
setGigRun(r) { _ppGigRun = r; },
getGigRun() { return _ppGigRun; },
setView(v) { _pp = v; },
};
if (document.readyState === 'loading') {
+48
View File
@@ -150,3 +150,51 @@ test('ppFillFraction: song progress toward the bar, in-progress only', () => {
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
assert.equal(ppFillFraction(null), 0);
});
test('careerTotals / wall + dash card stay absent without commitment', () => {
const w = load();
const t = w.__careerPassportTest;
// No _pp at all → null; committed-less view → null (absent-not-empty).
assert.equal(t.careerTotals(), null);
t.setView({ config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: null, passports: [] } } });
assert.equal(t.careerTotals(), null);
// Committed but zero passports opened: still absent (no zero-wall).
t.setView({ config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: 'x', passports: [] } } });
assert.equal(t.careerTotals(), null);
// Committed with an earned badge + hours → totals aggregate.
t.setView({ config: { instruments: ['guitar', 'bass'] },
instruments: {
guitar: { committed_at: 'x', passports: [
{ badge: 'earned', seconds_total: 3600, genre: 'Blues', genre_key: 'blues' },
{ badge: 'in_progress', seconds_total: 120, genre: 'Funk', genre_key: 'funk',
qualifying_count: 4, requirement: { songs: 5, min_stars: 2 } }] },
bass: { committed_at: null, passports: [] },
} });
const totals = t.careerTotals();
assert.equal(totals.badges, 1);
assert.equal(totals.seconds, 3720);
assert.equal(totals.walls.length, 1);
});
test('gig runner lifecycle: advance on ended, abandon on dead-queue stop', () => {
const w = load();
const t = w.__careerPassportTest;
let remaining = 1;
w.feedBack = { playQueue: { remaining: () => remaining, active: () => remaining > 0 } };
t.setGigRun({
songs: [{ filename: 'a', title: 'A' }, { filename: 'b', title: 'B' }],
venue_id: null, genre: 'Soul', genre_key: 'soul', instrument: 'guitar', idx: 0,
});
// First song ends, one remains → the strip advances, no completion.
t.onGigSongEnded();
assert.equal(t.getGigRun().idx, 1);
// Stop while the queue is still active (end-of-song teardown) → run survives.
t.onGigSongStop();
assert.notEqual(t.getGigRun(), null);
// User quits: queue cleared → stop with a dead queue abandons (no log).
remaining = 0;
t.onGigSongStop();
assert.equal(t.getGigRun(), null);
});
+28
View File
@@ -0,0 +1,28 @@
// Blob export helpers — the download idiom that used to be duplicated in
// settings-io.js and diagnostics-export.js, plus image-to-clipboard for
// shareable cards/posters. A LEAF module: imports nothing. Classic-script
// plugins reach it via dynamic import('/static/js/blob-io.js').
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Copy an image blob to the system clipboard. Returns true on success, false
// when the Clipboard API is unavailable or refuses (insecure context, no user
// gesture, permission denied) — callers fall back to downloadBlob and say so.
export async function copyImageBlob(blob) {
try {
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return false;
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
return true;
} catch (_) {
return false;
}
}
+3 -8
View File
@@ -21,6 +21,8 @@
// redact toggles.
// 3. Stream the returned zip to disk.
import { downloadBlob } from './blob-io.js';
function _diagIncludeFromUI() {
const v = (id) => document.getElementById(id)?.checked !== false;
return {
@@ -265,14 +267,7 @@ export async function exportDiagnostics() {
}
try {
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloadBlob(blob, filename);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed during download: ${e.message}`;
+4 -9
View File
@@ -1,6 +1,6 @@
// Settings backup — the export / import bundle.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
// Carved verbatim out of static/app.js (R3a). Imports only the blob-io leaf.
//
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
// re-exposing them on window. The import is two-phase (server first, atomic; then
@@ -29,6 +29,8 @@
// phase 2; the localStorage side is best-effort merge after server
// success. Failures are reported, never silenced.
import { downloadBlob } from './blob-io.js';
export async function exportSettings() {
const status = document.getElementById('backup-status');
status.textContent = 'Exporting...';
@@ -66,14 +68,7 @@ export async function exportSettings() {
if (match) filename = match[1];
}
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloadBlob(blob, filename);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed: ${e.message}`;
+6 -1
View File
@@ -208,12 +208,17 @@
'</div></div></div>' +
continueCard +
'</div>' +
// Stats row
// Stats row. The third slot belongs to the career plugin (it
// replaces the slot's content on v3:dashboard-rendered); the
// plugin-count stat is the built-in fallback when career is
// absent or has no state yet.
'<div class="grid md:grid-cols-3 gap-6 mt-6">' +
audioRoutingCard() +
statCard(String(songCount), 'songs', 'text-fb-gold') +
'<div id="v3-dash-career-slot" class="grid">' +
statCard(String(pluginCount), 'active', 'text-fb-good') +
'</div>' +
'</div>' +
recentSection +
'</div>';
+4
View File
@@ -191,6 +191,10 @@
'<div class="space-y-6">' +
headerCard +
bestsCard +
// Passport wall — rendered by the career plugin on
// v3:profile-rendered (absent-not-empty: nothing shows until a
// passport exists).
'<div id="v3-profile-passports-mount"></div>' +
// Feats of Power trophy shelf — rendered by the achievements plugin
// (earned Feats only; hidden-until-earned, so empty when none).
'<div id="v3-profile-feats-slot"></div>' +
+7 -5
View File
@@ -26,7 +26,7 @@ class FakeMetaDb:
self.conn.execute(
"""CREATE TABLE song_stats (
filename TEXT, arrangement TEXT, best_accuracy REAL,
last_played_at TEXT,
last_accuracy REAL, last_played_at TEXT,
seconds_total REAL NOT NULL DEFAULT 0
)"""
)
@@ -38,10 +38,12 @@ class FakeMetaDb:
)
def add(self, filename, arrangement, best_accuracy, in_library=True,
genre="", arrangements=None, last_played_at=None, seconds_total=0):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)",
(filename, arrangement, best_accuracy, last_played_at,
seconds_total))
genre="", arrangements=None, last_played_at=None, seconds_total=0,
last_accuracy=None):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?, ?)",
(filename, arrangement, best_accuracy,
last_accuracy if last_accuracy is not None else best_accuracy,
last_played_at, seconds_total))
if in_library:
self.conn.execute(
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
+163
View File
@@ -208,3 +208,166 @@ def test_drill_state_merge_is_gained_only(client, meta_db):
p = _passport(client)
assert p["drills"]["cleared"] == ["blues_shuffle"]
assert p["badge"] == "earned"
def test_genre_families_inherit_drills(client, meta_db):
# 'death metal' has no exact entry — it inherits the metal family's drill.
for i in range(5):
meta_db.add(f"dm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=LEAD)
_open(client, "guitar", "Death Metal")
p = _passport(client, "guitar", "death metal")
assert p["drills"]["required"] == ["melodic_metal_gallop"]
assert p["badge"] == "in_progress"
# 'metalcore' (single word) matches by substring, no alias needed.
_open(client, "guitar", "Metalcore")
assert _passport(client, "guitar", "metalcore")["drills"]["required"] == \
["melodic_metal_gallop"]
# 'blues rock' resolves by family LIST ORDER: blues comes before rock.
_open(client, "guitar", "Blues Rock")
assert _passport(client, "guitar", "blues rock")["drills"]["required"] == \
["blues_shuffle"]
# A genre outside every family stays songs-only.
_open(client, "guitar", "Reggae")
assert _passport(client, "guitar", "reggae")["drills"]["required"] == []
# Exact per-genre entries still beat the family (the shipped 'metal' entry
# IS the exact entry for genre key 'metal').
_open(client, "guitar", "Metal")
assert _passport(client, "guitar", "metal")["drills"]["required"] == \
["melodic_metal_gallop"]
def test_family_drills_stay_per_instrument(client, meta_db):
# Family inheritance must not leak guitar drills onto other instruments.
keys_arr = [{"type": "lead", "name": "Keys"}]
for i in range(5):
meta_db.add(f"kdm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=keys_arr)
_open(client, "keys", "Death Metal")
p = _passport(client, "keys", "death metal")
assert p["drills"]["required"] == []
assert p["badge"] == "earned"
def test_nearest_invitations_order_and_exclusions(client, meta_db):
# Non-qualifying songs sorted by distance to the QUALIFYING bar;
# qualifying songs never appear; capped at 3.
meta_db.add("q.feedpak", 0, 0.80, genre="Soul", arrangements=LEAD) # qualifies
meta_db.add("close.feedpak", 0, 0.74, genre="Soul", arrangements=LEAD) # 1% to 2★
meta_db.add("mid.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD) # 5% to 2★
meta_db.add("far.feedpak", 0, 0.30, genre="Soul", arrangements=LEAD) # 30% to 1★
meta_db.add("far2.feedpak", 0, 0.25, genre="Soul", arrangements=LEAD)
_open(client, "guitar", "Soul")
p = _passport(client, "guitar", "soul")
names = [s["filename"] for s in p["nearest"]]
assert names == ["close.feedpak", "mid.feedpak", "far.feedpak"]
assert all(s["next_star_at"] is not None for s in p["nearest"])
assert "q.feedpak" not in names
def test_nearest_targets_the_qualifying_bar_not_next_star(client, meta_db):
# A 0★ song 1% from its NEXT star is farther from the ★★ badge bar than
# a 1★ song 5% from it — nearest must rank by the badge bar.
meta_db.add("one_star.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD) # 5% to bar
meta_db.add("zero_star.feedpak", 0, 0.59, genre="Soul", arrangements=LEAD) # 1% to next ★, 16% to bar
_open(client, "guitar", "Soul")
p = _passport(client, "guitar", "soul")
assert [s["filename"] for s in p["nearest"]] == ["one_star.feedpak", "zero_star.feedpak"]
assert all(s["bar_at"] == 0.75 for s in p["nearest"])
# ── Gigs ──────────────────────────────────────────────────────────────────────
def test_gig_propose_mixes_owned_and_stakes(client, meta_db):
for i in range(4):
meta_db.add(f"own{i}.feedpak", 0, 0.85, genre="Soul", arrangements=LEAD)
meta_db.add("stake.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD)
meta_db.add_song_only("fresh.feedpak", genre="Soul")
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Soul", "size": 4})
assert res.status_code == 200
gig = res.json()
files = [s["filename"] for s in gig["songs"]]
assert len(files) == 4
assert "stake.feedpak" in files # a near-bar song gives the set stakes
assert gig["venue_id"] == "bar" # 9 stars < 50: the dive bar
# A young passport (nothing played) still gets a playable set from the
# library's unplayed genre songs.
res2 = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Ska"})
assert res2.status_code == 404 # no ska in the library at all
meta_db.add_song_only("ska1.feedpak", genre="Ska")
res3 = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Ska"})
assert [s["filename"] for s in res3.json()["songs"]] == ["ska1.feedpak"]
def test_gig_log_computes_encore_and_surfaces_in_passports(client, meta_db):
for i in range(2):
meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
last_accuracy=0.9)
_open(client, "guitar", "Soul")
res = client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "venue_id": "bar",
"songs": ["s0.feedpak", "s1.feedpak"]})
assert res.status_code == 200
gig = res.json()["gig"]
assert gig["encore"] is True # avg 0.9 ≥ 0.75
assert gig["songs"][0]["accuracy"] == 0.9
view = client.get("/api/plugins/career/passports").json()
assert view["instruments"]["guitar"]["gig_count"] == 1
p = _passport(client, "guitar", "soul")
assert len(p["gigs"]) == 1 and p["gigs"][0]["encore"] is True
def test_gig_log_validation_and_no_fail_state(client):
# Unknown venue / bad songs shapes are rejected; nothing is ever logged
# as a failed gig — the endpoint only appends completed sets.
assert client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "venue_id": "nope",
"songs": ["x"]}).status_code == 400
assert client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "songs": []}).status_code == 400
assert client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul",
"songs": ["f"] * 9}).status_code == 400
def test_gig_accuracy_reads_newest_row_and_encore_needs_full_set(client, meta_db):
# Newest row wins: a stale higher accuracy on another arrangement must
# not inflate the gig log.
meta_db.add("dual.feedpak", 1, 0.95, genre="Soul", arrangements=BASS,
last_accuracy=0.95, last_played_at="2026-06-01T00:00:00")
meta_db.add("dual.feedpak", 0, 0.60, genre="Soul", arrangements=LEAD,
last_accuracy=0.60, last_played_at="2026-07-14T00:00:00")
res = client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "songs": ["dual.feedpak"]})
assert res.json()["gig"]["songs"][0]["accuracy"] == 0.6
# A set with an unscored song never earns the encore off one good song.
meta_db.add("scored.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
last_accuracy=0.9, last_played_at="2026-07-14T00:01:00")
res2 = client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul",
"songs": ["scored.feedpak", "ghost.feedpak"]})
assert res2.json()["gig"]["encore"] is False
def test_gig_propose_backfills_from_surplus_qualifying(client, meta_db):
# Mature passport: plenty of qualifying songs, nothing near the bar,
# nothing unplayed — the set still fills to size.
for i in range(8):
meta_db.add(f"own{i}.feedpak", 0, 0.9, genre="Ska", arrangements=LEAD)
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Ska", "size": 5})
assert len(res.json()["songs"]) == 5
def test_gig_propose_backfill_offset_survives_stakes(client, meta_db):
# 4 qualifying + 1 near-bar stake, size 5: the stake must not shift the
# qualifying backfill window past eligible songs.
for i in range(4):
meta_db.add(f"q{i}.feedpak", 0, 0.9, genre="Reggae", arrangements=LEAD)
meta_db.add("near.feedpak", 0, 0.7, genre="Reggae", arrangements=LEAD)
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Reggae", "size": 5})
files = [s["filename"] for s in res.json()["songs"]]
assert len(files) == 5 and len(set(files)) == 5
assert "near.feedpak" in files