mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
Merge branch 'main' into chore/feedpak-spec-gate
Signed-off-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
@@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
||||||
|
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
||||||
|
passport walls: genre badges computed on read from `song_stats` × the library's
|
||||||
|
effective genre — Bronze = N genre songs at K★, data-driven in
|
||||||
|
`plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song
|
||||||
|
"ticket stubs", the library genre list, and drill status), `POST /passports/commit`
|
||||||
|
(instrument commitment), `POST /passports/open` (open a genre
|
||||||
|
passport), and `POST /drill-state` (intake for the relayed Virtuoso
|
||||||
|
`virtuoso.progress` snapshot, so drill requirements can gate badges
|
||||||
|
server-side). Badges are never stored; the only persisted state (commitments,
|
||||||
|
opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the
|
||||||
|
settings export/import bundle via `settings.server_files`. Instruments are
|
||||||
|
attributed via the existing progression arrangement→instrument mapping;
|
||||||
|
non-graded instruments (bass, drums) render shown-not-judged — repertoire
|
||||||
|
without a pass bar, never a false badge denial.
|
||||||
|
- **Career passports (UI)** — the Career screen gains a Passports tab beside
|
||||||
|
Venues: a physical per-instrument passport book (embossed leather cover, 3D
|
||||||
|
page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge
|
||||||
|
slam with ink bleed and deterministic per-genre jitter, qualifying songs as
|
||||||
|
collected ticket stubs, and unopened genres as an "Explore next"
|
||||||
|
travel-brochure rack (invitations, never greyed-out slots or completion
|
||||||
|
meters). Badge earns chime + notify immediately; the stamp slam plays when
|
||||||
|
the passport is next opened. Four small synthesized sound effects ship as
|
||||||
|
plugin assets. The career screen also relays the Virtuoso `virtuoso.progress`
|
||||||
|
localStorage snapshot to the drill-state intake on `virtuoso:progress` bus
|
||||||
|
events (debounced, plus a one-time bootstrap), closing the
|
||||||
|
fires-into-a-void seam without touching the virtuoso plugin.
|
||||||
- **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as
|
- **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as
|
||||||
an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing
|
an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing
|
||||||
stopped core from reading a manifest key the spec never defined, which is exactly what happened with
|
stopped core from reading a manifest key the spec never defined, which is exactly what happened with
|
||||||
@@ -53,6 +80,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Career passports review polish** — the passport tabs and book overlay carry
|
||||||
|
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
|
||||||
|
`role="dialog"` + `aria-modal` with focus moved to the close button on open
|
||||||
|
and restored on close), and a corrupt stored seen-badges value (e.g. a stray
|
||||||
|
`"null"`) can no longer throw on every passport refresh.
|
||||||
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
|
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
|
||||||
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
|
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
|
||||||
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
|
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
|
||||||
|
|||||||
@@ -64,3 +64,348 @@
|
|||||||
.career-star-row .song .artist { color: #9ca3af; }
|
.career-star-row .song .artist { color: #9ca3af; }
|
||||||
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
|
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
|
||||||
.career-star-row .hint.close { color: #22d3ee; }
|
.career-star-row .hint.close { color: #22d3ee; }
|
||||||
|
|
||||||
|
/* ── Passports (badge journey) ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.career-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(55, 65, 81, 0.6);
|
||||||
|
}
|
||||||
|
.career-tab {
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.career-tab:hover { color: #e5e7eb; }
|
||||||
|
.career-tab.active { color: #fff; border-bottom-color: #06b6d4; }
|
||||||
|
|
||||||
|
.pp-instruments { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
|
.pp-inst {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
background-color: rgba(31, 41, 55, 0.7);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.pp-inst:hover { background-color: rgba(55, 65, 81, 0.9); }
|
||||||
|
.pp-inst.active { border-color: #06b6d4; color: #fff; }
|
||||||
|
.pp-inst.uncommitted { color: #6b7280; border-style: dashed; border-color: rgba(107, 114, 128, 0.5); }
|
||||||
|
.pp-inst-badges { color: #fbbf24; font-size: 0.7rem; }
|
||||||
|
.pp-inst-plus { color: #6b7280; }
|
||||||
|
|
||||||
|
/* Leather covers — per-instrument hue, embossed with layered shadows and a
|
||||||
|
subtle grain gradient (no image assets). */
|
||||||
|
.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); }
|
||||||
|
.pp-leather-drums { background: linear-gradient(160deg, #3f3f46, #26262b); }
|
||||||
|
|
||||||
|
.pp-shelf { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; }
|
||||||
|
.pp-cover, .pp-commit-cover {
|
||||||
|
position: relative;
|
||||||
|
width: 9.5rem;
|
||||||
|
height: 13rem;
|
||||||
|
border-radius: 0.5rem 0.75rem 0.75rem 0.5rem;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||||
|
inset 0.5rem 0 0.75rem -0.5rem rgba(0, 0, 0, 0.8),
|
||||||
|
0 6px 16px rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
|
||||||
|
.pp-cover:hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); }
|
||||||
|
.pp-cover-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: rgba(240, 226, 195, 0.92);
|
||||||
|
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7), 0 -1px 0 rgba(255, 255, 255, 0.12);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.pp-cover-inst {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(240, 226, 195, 0.55);
|
||||||
|
}
|
||||||
|
.pp-cover-sub {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: rgba(240, 226, 195, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-commit-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid rgba(55, 65, 81, 0.6);
|
||||||
|
background-color: rgba(31, 41, 55, 0.35);
|
||||||
|
}
|
||||||
|
.pp-commit-card .pp-commit-cover { width: 7rem; height: 9.5rem; flex: none; }
|
||||||
|
|
||||||
|
.pp-rack { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); }
|
||||||
|
.pp-brochure {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
background: linear-gradient(165deg, rgba(45, 55, 72, 0.55), rgba(31, 41, 55, 0.55));
|
||||||
|
border: 1px solid rgba(75, 85, 99, 0.5);
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.pp-brochure:hover { transform: translateY(-2px); border-color: #06b6d4; }
|
||||||
|
.pp-brochure-art { font-size: 1.4rem; }
|
||||||
|
.pp-brochure-name { color: #e5e7eb; font-size: 0.85rem; font-weight: 600; }
|
||||||
|
.pp-brochure-sub { color: #6b7280; font-size: 0.65rem; }
|
||||||
|
|
||||||
|
/* The open book */
|
||||||
|
.pp-overlay { position: fixed; inset: 0; z-index: 60; }
|
||||||
|
.pp-book-wrap {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(3, 7, 18, 0.72);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.pp-book {
|
||||||
|
position: relative;
|
||||||
|
width: min(92vw, 720px);
|
||||||
|
height: min(72vh, 470px);
|
||||||
|
perspective: 1800px;
|
||||||
|
}
|
||||||
|
.pp-page {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 50%;
|
||||||
|
background:
|
||||||
|
linear-gradient(105deg, rgba(0, 0, 0, 0.08), transparent 12%),
|
||||||
|
#efe6d0;
|
||||||
|
color: #3f3428;
|
||||||
|
padding: 1.1rem 1.2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.pp-page-left { left: 0; border-radius: 0.6rem 0 0 0.6rem; opacity: 0; transition: opacity 0.35s ease 0.3s; align-items: center; }
|
||||||
|
.pp-page-right { right: 0; border-radius: 0 0.6rem 0.6rem 0; box-shadow: inset 0.4rem 0 0.6rem -0.4rem rgba(0, 0, 0, 0.35); }
|
||||||
|
.pp-book.open .pp-page-left { opacity: 1; }
|
||||||
|
.pp-book-cover {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 50%;
|
||||||
|
border-radius: 0 0.6rem 0.6rem 0;
|
||||||
|
transform-origin: left center;
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transition: transform 0.8s cubic-bezier(0.4, 0.1, 0.2, 1);
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.pp-book.open .pp-book-cover { transform: rotateY(-180deg); }
|
||||||
|
.pp-book-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.75rem;
|
||||||
|
right: -0.75rem;
|
||||||
|
z-index: 8;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(17, 24, 39, 0.95);
|
||||||
|
color: #d1d5db;
|
||||||
|
border: 1px solid rgba(107, 114, 128, 0.5);
|
||||||
|
}
|
||||||
|
.pp-book-close:hover { color: #fff; border-color: #06b6d4; }
|
||||||
|
.pp-page-head {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8a7a5e;
|
||||||
|
border-bottom: 1px solid rgba(138, 122, 94, 0.35);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The rubber stamp */
|
||||||
|
.pp-stamp {
|
||||||
|
--pp-rot: 0deg;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
width: 9rem;
|
||||||
|
height: 9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid #9a5b16;
|
||||||
|
box-shadow: inset 0 0 0 3px #efe6d0, inset 0 0 0 4px #9a5b16;
|
||||||
|
color: #9a5b16;
|
||||||
|
transform: rotate(var(--pp-rot));
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
.pp-stamp-genre { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.16em; overflow-wrap: anywhere; }
|
||||||
|
.pp-stamp-tier { font-size: 0.58rem; letter-spacing: 0.3em; }
|
||||||
|
.pp-stamp-ghost {
|
||||||
|
border-style: dashed;
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: #b3a68b;
|
||||||
|
color: #b3a68b;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.pp-stamp-hidden { opacity: 0; }
|
||||||
|
.pp-stamp-mini {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
border-width: 2px;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: #d9a253;
|
||||||
|
border-color: #d9a253;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
display: inline-block;
|
||||||
|
transform: rotate(var(--pp-rot));
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.pp-stamp-page::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -10%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: radial-gradient(closest-side, rgba(154, 91, 22, 0.25), transparent 72%);
|
||||||
|
filter: blur(5px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.pp-slam { animation: pp-slam 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) forwards; }
|
||||||
|
.pp-slam::after { animation: pp-ink 0.45s ease-out 0.12s forwards; }
|
||||||
|
@keyframes pp-slam {
|
||||||
|
0% { transform: rotate(calc(var(--pp-rot) - 15deg)) scale(2.5); opacity: 0; }
|
||||||
|
55% { transform: rotate(var(--pp-rot)) scale(0.92); opacity: 1; }
|
||||||
|
75% { transform: rotate(var(--pp-rot)) scale(1.05); }
|
||||||
|
100% { transform: rotate(var(--pp-rot)) scale(1); opacity: 0.92; }
|
||||||
|
}
|
||||||
|
@keyframes pp-ink {
|
||||||
|
from { opacity: 0; transform: scale(0.6); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
.pp-shake { animation: pp-shake 0.4s ease-out 0.28s; }
|
||||||
|
@keyframes pp-shake {
|
||||||
|
0%, 100% { transform: translate(0, 0) rotate(0); }
|
||||||
|
25% { transform: translate(2px, 1px) rotate(0.3deg); }
|
||||||
|
50% { transform: translate(-2px, 2px) rotate(-0.25deg); }
|
||||||
|
75% { transform: translate(1px, -1px) rotate(0.15deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-invite, .pp-snj, .pp-gold-note { font-size: 0.75rem; text-align: center; }
|
||||||
|
.pp-invite { color: #6d5d40; }
|
||||||
|
.pp-snj { color: #6d5d40; margin-top: 2rem; font-style: italic; max-width: 15rem; }
|
||||||
|
.pp-gold-note { color: #a8946d; font-size: 0.62rem; margin-top: 0.5rem; }
|
||||||
|
.pp-drills { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.7rem; color: #6d5d40; }
|
||||||
|
.pp-drill.cleared { color: #4d7c0f; }
|
||||||
|
|
||||||
|
/* Ticket stubs */
|
||||||
|
.pp-stubs { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; padding-right: 0.25rem; }
|
||||||
|
.pp-stub {
|
||||||
|
background: #f7f1e3;
|
||||||
|
border: 1px solid #d8cbaa;
|
||||||
|
border-left: 2px dashed #b6a98c;
|
||||||
|
border-radius: 0.25rem 0.4rem 0.4rem 0.25rem;
|
||||||
|
padding: 0.4rem 0.6rem 0.4rem 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
column-gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
box-shadow: 0 1px 2px rgba(63, 52, 40, 0.15);
|
||||||
|
}
|
||||||
|
.pp-stub-stars { color: #b8860b; font-size: 0.7rem; letter-spacing: 0.08em; grid-row: span 2; }
|
||||||
|
.pp-stub-title { font-size: 0.78rem; font-weight: 600; color: #3f3428; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pp-stub-artist { grid-column: 2; font-size: 0.65rem; color: #6d5d40; }
|
||||||
|
.pp-stub-meta { grid-column: 2; font-size: 0.6rem; color: #8a7a5e; }
|
||||||
|
.pp-stub-empty { font-size: 0.72rem; color: #8a7a5e; font-style: italic; padding: 0.75rem 0.25rem; }
|
||||||
|
|
||||||
|
/* Wax-seal commitment ceremony */
|
||||||
|
.pp-ceremony { width: 11rem; height: 15rem; }
|
||||||
|
.pp-wax {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1.4rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.4rem;
|
||||||
|
height: 3.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 32% 30%, #d24545 0%, #a41f1f 42%, #7c1414 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 4px rgba(124, 20, 20, 0.9),
|
||||||
|
inset 0 2px 4px rgba(255, 255, 255, 0.25),
|
||||||
|
0 3px 8px rgba(0, 0, 0, 0.55);
|
||||||
|
color: rgba(255, 235, 235, 0.9);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
animation: pp-seal-drop 0.9s cubic-bezier(0.25, 0.9, 0.3, 1.15) 0.35s backwards;
|
||||||
|
}
|
||||||
|
@keyframes pp-seal-drop {
|
||||||
|
0% { transform: translateY(-120px) scale(2.1); opacity: 0; }
|
||||||
|
60% { transform: translateY(0) scale(0.9); opacity: 1; }
|
||||||
|
80% { transform: translateY(0) scale(1.05); }
|
||||||
|
100% { transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small screens: the spread stacks; the flip cover would straddle both
|
||||||
|
pages, so the book simply opens. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pp-book { height: min(80vh, 620px); }
|
||||||
|
.pp-page { position: static; width: 100%; height: 50%; border-radius: 0; }
|
||||||
|
.pp-page-left { border-radius: 0.6rem 0.6rem 0 0; opacity: 1; }
|
||||||
|
.pp-page-right { border-radius: 0 0 0.6rem 0.6rem; }
|
||||||
|
.pp-book-cover { display: none; }
|
||||||
|
.pp-book { display: flex; flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pp-book-cover, .pp-page-left, .pp-cover { transition: none; }
|
||||||
|
.pp-slam, .pp-slam::after, .pp-shake, .pp-wax { animation: none; }
|
||||||
|
.pp-slam, .pp-stamp-page::after { opacity: 1; }
|
||||||
|
.pp-stamp-hidden { opacity: 0.92; }
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"badge_requirement": {
|
||||||
|
"songs": 5,
|
||||||
|
"min_stars": 2
|
||||||
|
},
|
||||||
|
"genres": {},
|
||||||
|
"graded_instruments": [
|
||||||
|
"guitar",
|
||||||
|
"keys"
|
||||||
|
],
|
||||||
|
"instruments": [
|
||||||
|
"guitar",
|
||||||
|
"bass",
|
||||||
|
"keys",
|
||||||
|
"drums"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
{
|
{
|
||||||
"id": "career",
|
"id": "career",
|
||||||
"name": "Career",
|
"name": "Career",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Career mode — gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.",
|
"description": "Career mode — gig your way from a local bar to the arena, and build a passport wall of genre badges per instrument. Earn stars per song; the crowd reacts to how you play.",
|
||||||
"screen": "screen.html",
|
"screen": "screen.html",
|
||||||
"script": "screen.js",
|
"script": "screen.js",
|
||||||
"styles": "assets/career.css",
|
"styles": "assets/career.css",
|
||||||
|
"settings": {
|
||||||
|
"html": "settings.html",
|
||||||
|
"server_files": [
|
||||||
|
"career/"
|
||||||
|
]
|
||||||
|
},
|
||||||
"routes": "routes.py"
|
"routes": "routes.py"
|
||||||
}
|
}
|
||||||
|
|||||||
+309
-1
@@ -10,11 +10,22 @@ the plugin under ``venue-packs/<id>/`` or downloaded on demand into
|
|||||||
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
|
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
|
||||||
bundled packs so release assets can replace a built-in starter venue.
|
bundled packs so release assets can replace a built-in starter venue.
|
||||||
|
|
||||||
|
Passports (badge journey per instrument × genre — the identity layer on top
|
||||||
|
of the same stars): badges are COMPUTED on read from ``song_stats`` × the
|
||||||
|
library's effective genre, never stored. The only persisted career state is
|
||||||
|
what cannot be derived — instrument commitment, opened passports, and the
|
||||||
|
relayed virtuoso drill snapshot — as JSON under ``CONFIG_DIR/career/``
|
||||||
|
(exported via ``settings.server_files``).
|
||||||
|
|
||||||
Endpoints (all under /api/plugins/career/):
|
Endpoints (all under /api/plugins/career/):
|
||||||
GET /state stars + per-venue unlock/install/download status
|
GET /state stars + per-venue unlock/install/download status
|
||||||
POST /packs/{venue_id}/download start background pack download (409 if running)
|
POST /packs/{venue_id}/download start background pack download (409 if running)
|
||||||
DELETE /packs/{venue_id} remove an installed pack
|
DELETE /packs/{venue_id} remove an installed pack
|
||||||
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers)
|
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers)
|
||||||
|
GET /passports passport walls: badges, stubs, genres, drill status
|
||||||
|
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)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -26,11 +37,14 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import Body, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from progression import instrument_for_arrangement
|
||||||
|
|
||||||
PLUGIN_ID = "career"
|
PLUGIN_ID = "career"
|
||||||
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
|
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
|
||||||
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
||||||
@@ -118,6 +132,235 @@ def _stars():
|
|||||||
return sum(per_song.values()), per_song, detail
|
return sum(per_song.values()), per_song, detail
|
||||||
|
|
||||||
|
|
||||||
|
# ── Passports ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
GENRE_MAX_LEN = 64
|
||||||
|
DRILL_SNAPSHOT_MAX_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso():
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _genre_display(genre):
|
||||||
|
return " ".join(str(genre or "").strip().split())
|
||||||
|
|
||||||
|
|
||||||
|
def _genre_key(genre):
|
||||||
|
return _genre_display(genre).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _state_file() -> Path:
|
||||||
|
return _state["state_dir"] / "passports-state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _drill_file() -> Path:
|
||||||
|
return _state["state_dir"] / "drill-state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path, default):
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _save_json(path: Path, obj):
|
||||||
|
tmp = path.with_name(path.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(obj, indent=2), encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _career_state():
|
||||||
|
st = _load_json(_state_file(), {})
|
||||||
|
if not isinstance(st, dict):
|
||||||
|
st = {}
|
||||||
|
if not isinstance(st.get("instruments"), dict):
|
||||||
|
st["instruments"] = {}
|
||||||
|
if not isinstance(st.get("passports"), dict):
|
||||||
|
st["passports"] = {}
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def _genre_expr(db):
|
||||||
|
# Reuse the host's override-aware effective-genre SQL (Fix-metadata popup
|
||||||
|
# overrides); plain `genre` on stand-ins that don't implement it.
|
||||||
|
fn = getattr(db, "_effective_genre_expr", None)
|
||||||
|
return fn() if callable(fn) else "genre"
|
||||||
|
|
||||||
|
|
||||||
|
def _instrument_of(arrangements, arrangement):
|
||||||
|
"""Progression's arrangement→instrument mapping, via the song_stats
|
||||||
|
arrangement index into the song's arrangements JSON."""
|
||||||
|
entry = None
|
||||||
|
try:
|
||||||
|
idx = int(arrangement)
|
||||||
|
if isinstance(arrangements, list) and 0 <= idx < len(arrangements):
|
||||||
|
entry = arrangements[idx]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
entry = None
|
||||||
|
return instrument_for_arrangement(entry)
|
||||||
|
|
||||||
|
|
||||||
|
def _played_by_instrument_genre():
|
||||||
|
"""(instrument, genre_key) → {filename: stub dict}. Best accuracy per
|
||||||
|
(instrument, song); the JOIN keeps the same dead-song filter as _stars()."""
|
||||||
|
db = _state["meta_db"]
|
||||||
|
if db is None:
|
||||||
|
return {}
|
||||||
|
thresholds = _state["content"]["star_accuracy_thresholds"]
|
||||||
|
rows = db.conn.execute(
|
||||||
|
"SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, "
|
||||||
|
" songs.title, songs.artist, songs.arrangements, "
|
||||||
|
f" {_genre_expr(db)} "
|
||||||
|
"FROM song_stats s JOIN songs ON songs.filename = s.filename"
|
||||||
|
).fetchall()
|
||||||
|
arrs_cache = {}
|
||||||
|
out = {}
|
||||||
|
for filename, arrangement, acc, played_at, title, artist, arrs_json, genre in rows:
|
||||||
|
gkey = _genre_key(genre)
|
||||||
|
if not gkey:
|
||||||
|
continue
|
||||||
|
if filename not in arrs_cache:
|
||||||
|
try:
|
||||||
|
arrs_cache[filename] = json.loads(arrs_json) if arrs_json else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
arrs_cache[filename] = None
|
||||||
|
instrument = _instrument_of(arrs_cache[filename], arrangement)
|
||||||
|
acc = acc or 0.0
|
||||||
|
stub = out.setdefault((instrument, gkey), {}).get(filename)
|
||||||
|
if stub is None:
|
||||||
|
out[(instrument, gkey)][filename] = {
|
||||||
|
"filename": filename,
|
||||||
|
"title": title or filename,
|
||||||
|
"artist": artist or "",
|
||||||
|
"best_accuracy": acc,
|
||||||
|
"last_played_at": played_at,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
stub["best_accuracy"] = max(stub["best_accuracy"], acc)
|
||||||
|
stub["last_played_at"] = max(stub["last_played_at"] or "", played_at or "") or None
|
||||||
|
for stubs in out.values():
|
||||||
|
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)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _library_genres():
|
||||||
|
"""Distinct effective genres across the live library (the brochure rack)."""
|
||||||
|
db = _state["meta_db"]
|
||||||
|
if db is None:
|
||||||
|
return []
|
||||||
|
rows = db.conn.execute(
|
||||||
|
f"SELECT {_genre_expr(db)} AS g, COUNT(*) FROM songs GROUP BY g").fetchall()
|
||||||
|
by_key = {}
|
||||||
|
for genre, count in rows:
|
||||||
|
display = _genre_display(genre)
|
||||||
|
key = display.lower()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
cur = by_key.get(key)
|
||||||
|
if cur: # case-variant duplicates collapse onto the first-seen casing
|
||||||
|
cur["songs_in_library"] += count
|
||||||
|
else:
|
||||||
|
by_key[key] = {"genre_key": key, "genre": display,
|
||||||
|
"songs_in_library": count}
|
||||||
|
return sorted(by_key.values(),
|
||||||
|
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _badge_requirement(gkey):
|
||||||
|
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)
|
||||||
|
if isinstance(override, dict):
|
||||||
|
req.update(override)
|
||||||
|
req["virtuoso_nodes"] = [n for n in (req.get("virtuoso_nodes") or [])
|
||||||
|
if isinstance(n, str)]
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
def _drill_by_node():
|
||||||
|
doc = _load_json(_drill_file(), {})
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return None, {}
|
||||||
|
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
|
||||||
|
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
|
||||||
|
return doc.get("received_at"), by_node
|
||||||
|
|
||||||
|
|
||||||
|
def _node_cleared(by_node, node_id):
|
||||||
|
"""A drill counts as cleared on real completion evidence: mastered, or any
|
||||||
|
depth rung flipped true (virtuoso's gained-only false→true artifacts)."""
|
||||||
|
entry = by_node.get(node_id)
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return False
|
||||||
|
depth = entry.get("depth") if isinstance(entry.get("depth"), dict) else {}
|
||||||
|
return bool(entry.get("masteredAt")) or any(bool(v) for v in depth.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _passports_view():
|
||||||
|
cfg = _state["passports_content"]
|
||||||
|
graded = set(cfg.get("graded_instruments") or [])
|
||||||
|
st = _career_state()
|
||||||
|
played = _played_by_instrument_genre()
|
||||||
|
received_at, by_node = _drill_by_node()
|
||||||
|
instruments = {}
|
||||||
|
for inst in cfg.get("instruments") or []:
|
||||||
|
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
|
||||||
|
opened = st["passports"].get(inst)
|
||||||
|
opened = opened if isinstance(opened, dict) else {}
|
||||||
|
passports = []
|
||||||
|
for gkey, meta in sorted(opened.items(),
|
||||||
|
key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])):
|
||||||
|
meta = meta if isinstance(meta, dict) else {}
|
||||||
|
req = _badge_requirement(gkey)
|
||||||
|
songs = list(played.get((inst, gkey), {}).values())
|
||||||
|
for s in songs:
|
||||||
|
s["qualifies"] = s["stars"] >= req["min_stars"]
|
||||||
|
songs.sort(key=lambda s: (not s["qualifies"], -s["stars"],
|
||||||
|
s["title"].lower()))
|
||||||
|
qualifying = sum(1 for s in songs if s["qualifies"])
|
||||||
|
required = req["virtuoso_nodes"]
|
||||||
|
cleared = [n for n in required if _node_cleared(by_node, n)]
|
||||||
|
is_graded = inst in graded
|
||||||
|
if not is_graded:
|
||||||
|
# Where the engine can't fairly grade the instrument's job
|
||||||
|
# (bass pocket, feel) the passport shows repertoire, never a
|
||||||
|
# false badge denial — the doc's shown-not-judged rule.
|
||||||
|
badge = "shown_not_judged"
|
||||||
|
elif qualifying >= req["songs"] and len(cleared) == len(required):
|
||||||
|
badge = "earned"
|
||||||
|
else:
|
||||||
|
badge = "in_progress"
|
||||||
|
passports.append({
|
||||||
|
"genre_key": gkey,
|
||||||
|
"genre": meta.get("genre") or gkey,
|
||||||
|
"opened_at": meta.get("opened_at"),
|
||||||
|
"requirement": req,
|
||||||
|
"graded": is_graded,
|
||||||
|
"songs": songs,
|
||||||
|
"qualifying_count": qualifying,
|
||||||
|
"drills": {"required": required, "cleared": cleared},
|
||||||
|
"badge": badge,
|
||||||
|
})
|
||||||
|
instruments[inst] = {"committed_at": committed_at, "passports": passports}
|
||||||
|
return {
|
||||||
|
"config": {
|
||||||
|
"badge_requirement": cfg.get("badge_requirement") or {},
|
||||||
|
"graded_instruments": sorted(graded),
|
||||||
|
"instruments": list(cfg.get("instruments") or []),
|
||||||
|
},
|
||||||
|
"instruments": instruments,
|
||||||
|
"genres": _library_genres(),
|
||||||
|
"drill_state": {"received_at": received_at},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _validate_pack_dir(pack_dir: Path):
|
def _validate_pack_dir(pack_dir: Path):
|
||||||
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
||||||
manifest_path = pack_dir / "manifest.json"
|
manifest_path = pack_dir / "manifest.json"
|
||||||
@@ -197,6 +440,13 @@ def setup(app, context):
|
|||||||
_state["venues_dir"] = (
|
_state["venues_dir"] = (
|
||||||
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
|
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
|
||||||
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
|
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
|
_state["passports_content"] = json.loads(
|
||||||
|
(plugin_dir / "passports.json").read_text(encoding="utf-8"))
|
||||||
|
# Persisted career state (commitment / opened passports / drill snapshot)
|
||||||
|
# lives under CONFIG_DIR/career/ — declared in settings.server_files so it
|
||||||
|
# rides the settings export/import bundle. Packs stay out (they're media).
|
||||||
|
_state["state_dir"] = Path(context["config_dir"]) / PLUGIN_ID
|
||||||
|
_state["state_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
_state["meta_db"] = context.get("meta_db")
|
_state["meta_db"] = context.get("meta_db")
|
||||||
_state["log"] = context.get("log") or _state["log"]
|
_state["log"] = context.get("log") or _state["log"]
|
||||||
for v in _state["content"]["venues"]:
|
for v in _state["content"]["venues"]:
|
||||||
@@ -229,6 +479,64 @@ def setup(app, context):
|
|||||||
"venues": venues,
|
"venues": venues,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.get(f"/api/plugins/{PLUGIN_ID}/passports")
|
||||||
|
def get_passports():
|
||||||
|
with _lock:
|
||||||
|
return _passports_view()
|
||||||
|
|
||||||
|
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/commit")
|
||||||
|
def commit_instrument(body: dict = Body(...)):
|
||||||
|
inst = str((body or {}).get("instrument") or "")
|
||||||
|
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||||
|
raise HTTPException(400, "Unknown instrument.")
|
||||||
|
with _lock:
|
||||||
|
st = _career_state()
|
||||||
|
entry = st["instruments"].setdefault(inst, {})
|
||||||
|
# Idempotent: the wax seal is pressed once; re-commits keep the
|
||||||
|
# original date (only-gained-never-lost).
|
||||||
|
if not entry.get("committed_at"):
|
||||||
|
entry["committed_at"] = _now_iso()
|
||||||
|
_save_json(_state_file(), st)
|
||||||
|
return {"ok": True, "instrument": inst,
|
||||||
|
"committed_at": entry["committed_at"]}
|
||||||
|
|
||||||
|
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/open")
|
||||||
|
def open_passport(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.")
|
||||||
|
with _lock:
|
||||||
|
st = _career_state()
|
||||||
|
# Opening a passport implies the instrument commitment (permissive
|
||||||
|
# server, ceremony ordering is the UI's job).
|
||||||
|
st["instruments"].setdefault(inst, {}).setdefault(
|
||||||
|
"committed_at", _now_iso())
|
||||||
|
genres = st["passports"].setdefault(inst, {})
|
||||||
|
if gkey not in genres:
|
||||||
|
genres[gkey] = {"genre": genre, "opened_at": _now_iso()}
|
||||||
|
_save_json(_state_file(), st)
|
||||||
|
return {"ok": True, "instrument": inst, "passport": genres[gkey]}
|
||||||
|
|
||||||
|
@app.post(f"/api/plugins/{PLUGIN_ID}/drill-state")
|
||||||
|
def post_drill_state(body: dict = Body(...)):
|
||||||
|
# The relayed virtuoso.progress snapshot (career's screen.js listens to
|
||||||
|
# the virtuoso:progress bus event and forwards the localStorage doc).
|
||||||
|
# Only the fields the badge check reads are kept.
|
||||||
|
if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict):
|
||||||
|
raise HTTPException(400, "Expected a progress snapshot with byNode.")
|
||||||
|
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
|
||||||
|
"byNode": body["byNode"]}
|
||||||
|
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||||
|
raise HTTPException(413, "Snapshot too large.")
|
||||||
|
with _lock:
|
||||||
|
_save_json(_drill_file(), {"received_at": _now_iso(),
|
||||||
|
"snapshot": snapshot})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
||||||
def start_download(venue_id: str):
|
def start_download(venue_id: str):
|
||||||
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
||||||
|
|||||||
+33
-12
@@ -3,19 +3,40 @@
|
|||||||
<h1 class="text-2xl font-bold text-white">Career</h1>
|
<h1 class="text-2xl font-bold text-white">Career</h1>
|
||||||
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
|
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
|
<div class="career-tabs" role="tablist">
|
||||||
<div id="career-progress-wrap" class="mb-6">
|
<button class="career-tab" data-career-tab="venues" role="tab" id="career-tab-btn-venues" aria-controls="career-tab-venues">Venues</button>
|
||||||
<div class="career-bar-track">
|
<button class="career-tab" data-career-tab="passports" role="tab" id="career-tab-btn-passports" aria-controls="career-tab-passports">Passports</button>
|
||||||
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
|
|
||||||
</div>
|
|
||||||
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="career-venues" class="career-venues"></div>
|
|
||||||
<div class="mt-8">
|
<div id="career-tab-venues" role="tabpanel" aria-labelledby="career-tab-btn-venues">
|
||||||
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
|
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
|
||||||
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
|
<div id="career-progress-wrap" class="mb-6">
|
||||||
<div id="career-star-summary" class="text-xs text-gray-400"></div>
|
<div class="career-bar-track">
|
||||||
|
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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-shelf-wrap" class="mt-5">
|
||||||
|
<div id="pp-shelf" class="pp-shelf"></div>
|
||||||
|
</div>
|
||||||
|
<div id="pp-rack-wrap" class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-1">Explore next</h2>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">More genres, whenever you want them — your wall is complete as it is.</p>
|
||||||
|
<div id="pp-rack" class="pp-rack"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="career-star-list" class="career-star-list"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="pp-overlay" class="pp-overlay hidden"></div>
|
||||||
|
|||||||
@@ -17,11 +17,25 @@
|
|||||||
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
|
|
||||||
|
// Passports (the badge-journey layer; see routes.py — badges are computed
|
||||||
|
// server-side, this file only renders and relays).
|
||||||
|
const PP_SEEN_KEY = 'feedBack-career-badges-seen';
|
||||||
|
const PP_INST_KEY = 'feedBack-career-instrument';
|
||||||
|
const PP_TAB_KEY = 'feedBack-career-tab';
|
||||||
|
const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' };
|
||||||
|
const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕'];
|
||||||
|
|
||||||
let _state = null;
|
let _state = null;
|
||||||
let _pollTimer = 0;
|
let _pollTimer = 0;
|
||||||
let _appliedManifestVenue = null;
|
let _appliedManifestVenue = null;
|
||||||
let _manifestReqGen = 0; // invalidates in-flight manifest fetches
|
let _manifestReqGen = 0; // invalidates in-flight manifest fetches
|
||||||
let _prevUnlockedIds = null;
|
let _prevUnlockedIds = null;
|
||||||
|
let _pp = null; // last /passports view
|
||||||
|
let _ppRelayTimer = 0;
|
||||||
|
let _ppBook = null; // {inst, gkey} of the open spread
|
||||||
|
let _ppReturnFocus = null; // element to refocus when the book closes
|
||||||
|
let _ppBootstrapped = false;
|
||||||
|
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
@@ -219,9 +233,401 @@
|
|||||||
render(state);
|
render(state);
|
||||||
schedulePoll(state);
|
schedulePoll(state);
|
||||||
pushCrowdManifest(state);
|
pushCrowdManifest(state);
|
||||||
|
refreshPassports(); // independent fetch; failures don't touch venues
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passports ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function lsGet(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
|
||||||
|
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* ok */ } }
|
||||||
|
|
||||||
|
function ppLabel(inst) {
|
||||||
|
return PP_LABELS[inst] || (inst.charAt(0).toUpperCase() + inst.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ppKey(genre) {
|
||||||
|
return String(genre || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ppHash(seed) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic per-key jitter (sin-hash): stamps and stubs land slightly
|
||||||
|
// askew, the same way on every visit.
|
||||||
|
function ppJitter(seed, range) {
|
||||||
|
return (Math.abs(Math.sin(ppHash(seed))) * 2 - 1) * range;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sfx(name) {
|
||||||
|
try {
|
||||||
|
const a = new Audio(`${API}/assets/sfx/${name}.mp3`);
|
||||||
|
a.volume = 0.45;
|
||||||
|
a.play().catch(() => { /* autoplay policy — silent is fine */ });
|
||||||
|
} catch (_) { /* no Audio — fine */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCareerTab(tab) {
|
||||||
|
lsSet(PP_TAB_KEY, tab);
|
||||||
|
const venues = $('career-tab-venues');
|
||||||
|
const pp = $('career-tab-passports');
|
||||||
|
if (!venues || !pp) return;
|
||||||
|
venues.classList.toggle('hidden', tab !== 'venues');
|
||||||
|
pp.classList.toggle('hidden', tab !== 'passports');
|
||||||
|
document.querySelectorAll('#plugin-career .career-tab').forEach((b) => {
|
||||||
|
const active = b.dataset.careerTab === tab;
|
||||||
|
b.classList.toggle('active', active);
|
||||||
|
b.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeInstrument() {
|
||||||
|
const list = (_pp && _pp.config && _pp.config.instruments) || [];
|
||||||
|
const saved = lsGet(PP_INST_KEY);
|
||||||
|
if (saved && list.includes(saved)) return saved;
|
||||||
|
const committed = list.find((i) => ((_pp.instruments || {})[i] || {}).committed_at);
|
||||||
|
return committed || list[0] || 'guitar';
|
||||||
|
}
|
||||||
|
|
||||||
|
function seenBadges() {
|
||||||
|
try {
|
||||||
|
const seen = JSON.parse(lsGet(PP_SEEN_KEY) || '{}');
|
||||||
|
// Guard non-object JSON (a stray "null" or array) — a broken
|
||||||
|
// stored value must not throw on every passport refresh.
|
||||||
|
return seen && typeof seen === 'object' && !Array.isArray(seen) ? seen : {};
|
||||||
|
} catch (_) { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeId(inst, gkey) { return inst + '/' + gkey; }
|
||||||
|
|
||||||
|
function markBadgeSeen(inst, gkey) {
|
||||||
|
const seen = seenBadges();
|
||||||
|
seen[badgeId(inst, gkey)] = 1;
|
||||||
|
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
// New badge → chime + notification once per session; the stamp SLAM plays
|
||||||
|
// when the passport is next opened (and only then is the badge marked
|
||||||
|
// seen, so a pending slam survives a reload).
|
||||||
|
function detectNewBadges(view) {
|
||||||
|
const seen = seenBadges();
|
||||||
|
for (const inst of Object.keys(view.instruments || {})) {
|
||||||
|
for (const p of (view.instruments[inst].passports || [])) {
|
||||||
|
const id = badgeId(inst, p.genre_key);
|
||||||
|
if (p.badge !== 'earned' || seen[id] || _ppNotified[id]) continue;
|
||||||
|
_ppNotified[id] = true;
|
||||||
|
sfx('chime');
|
||||||
|
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||||
|
window.fbNotify.show({
|
||||||
|
big: true, icon: '🛂', accent: '#b45309',
|
||||||
|
title: 'Badge earned!',
|
||||||
|
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay the Virtuoso drill snapshot (localStorage doc, not the thin bus
|
||||||
|
// payload) to the server intake, debounced across event bursts.
|
||||||
|
function relayDrillState() {
|
||||||
|
clearTimeout(_ppRelayTimer);
|
||||||
|
_ppRelayTimer = setTimeout(() => {
|
||||||
|
let snap = null;
|
||||||
|
try { snap = JSON.parse(lsGet('virtuoso.progress') || 'null'); } catch (_) { /* corrupt */ }
|
||||||
|
if (!snap || typeof snap !== 'object' || !snap.byNode || typeof snap.byNode !== 'object') return;
|
||||||
|
fetch(`${API}/drill-state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode: snap.mode, xp: snap.xp, byNode: snap.byNode }),
|
||||||
|
}).then(() => refreshPassports()).catch(() => { /* next event retries */ });
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPassports() {
|
||||||
|
let view;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/passports`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
view = await res.json();
|
||||||
|
} catch (_) { return; }
|
||||||
|
_pp = view;
|
||||||
|
detectNewBadges(view);
|
||||||
|
renderPassports();
|
||||||
|
if (!_ppBootstrapped) {
|
||||||
|
_ppBootstrapped = true;
|
||||||
|
// First run on this browser: seed the server with the local drill
|
||||||
|
// snapshot if it has never received one.
|
||||||
|
if (!(view.drill_state || {}).received_at) relayDrillState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ppCoverHTML(inst, p) {
|
||||||
|
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
||||||
|
const stamp = p.badge === 'earned'
|
||||||
|
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
|
||||||
|
: '';
|
||||||
|
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
|
||||||
|
return `<button class="pp-cover pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="transform:rotate(${rot}deg)">
|
||||||
|
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||||||
|
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
||||||
|
${stamp}
|
||||||
|
<span class="pp-cover-sub">${stubs}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShelf(inst, data) {
|
||||||
|
const shelf = $('pp-shelf');
|
||||||
|
if (!shelf) return;
|
||||||
|
if (!data.committed_at) {
|
||||||
|
shelf.innerHTML = `<div class="pp-commit-card">
|
||||||
|
<div class="pp-commit-cover pp-leather-${esc(inst)}">
|
||||||
|
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
|
||||||
|
<span class="pp-cover-inst">passport</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-200 font-medium mb-1">Pick up the ${esc(ppLabel(inst).toLowerCase())}.</div>
|
||||||
|
<div class="text-xs text-gray-400 mb-2">Press your seal to commit — then choose a genre below and go deep.</div>
|
||||||
|
<button class="career-btn career-btn-primary" data-pp-commit="${esc(inst)}">Press the seal</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const books = (data.passports || []).map((p) => ppCoverHTML(inst, p)).join('');
|
||||||
|
shelf.innerHTML = books ||
|
||||||
|
'<div class="text-xs text-gray-500">Your shelf is ready — open your first genre passport below.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRack(inst, data) {
|
||||||
|
const rack = $('pp-rack');
|
||||||
|
if (!rack || !_pp) return;
|
||||||
|
const openedKeys = new Set((data.passports || []).map((p) => p.genre_key));
|
||||||
|
const genres = (_pp.genres || []).filter((g) => !openedKeys.has(g.genre_key));
|
||||||
|
if (!genres.length) {
|
||||||
|
rack.innerHTML = '<div class="text-xs text-gray-500">No further genres in your library yet — new songs bring new brochures.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rack.innerHTML = genres.map((g) => {
|
||||||
|
const art = PP_BROCHURE_ART[Math.abs(ppHash(g.genre_key)) % PP_BROCHURE_ART.length];
|
||||||
|
return `<button class="pp-brochure" data-pp-genre="${esc(g.genre)}">
|
||||||
|
<span class="pp-brochure-art" aria-hidden="true">${art}</span>
|
||||||
|
<span class="pp-brochure-name">${esc(g.genre)}</span>
|
||||||
|
<span class="pp-brochure-sub">${g.songs_in_library === 1 ? '1 song' : `${g.songs_in_library} songs`} in your library</span>
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPassports() {
|
||||||
|
const host = $('pp-instruments');
|
||||||
|
if (!host || !_pp) return;
|
||||||
|
const inst = activeInstrument();
|
||||||
|
const data = (_pp.instruments || {})[inst] || { passports: [] };
|
||||||
|
host.innerHTML = ((_pp.config || {}).instruments || []).map((i) => {
|
||||||
|
const d = (_pp.instruments || {})[i] || {};
|
||||||
|
const earned = (d.passports || []).filter((p) => p.badge === 'earned').length;
|
||||||
|
const committed = !!d.committed_at;
|
||||||
|
return `<button class="pp-inst${i === inst ? ' active' : ''}${committed ? '' : ' uncommitted'}" data-pp-inst="${esc(i)}">
|
||||||
|
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
renderShelf(inst, data);
|
||||||
|
renderRack(inst, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ppStubHTML(s) {
|
||||||
|
const date = (s.last_played_at || '').slice(0, 10);
|
||||||
|
return `<div class="pp-stub" style="transform:rotate(${ppJitter(s.filename, 1.2).toFixed(2)}deg)">
|
||||||
|
<span class="pp-stub-stars">${'★'.repeat(s.stars)}</span>
|
||||||
|
<span class="pp-stub-title">${esc(s.title)}</span>
|
||||||
|
${s.artist ? `<span class="pp-stub-artist">${esc(s.artist)}</span>` : ''}
|
||||||
|
<span class="pp-stub-meta">${date ? `${esc(date)} · ` : ''}best ${(s.best_accuracy * 100).toFixed(0)}%</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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>`;
|
||||||
|
} else if (p.badge === 'earned') {
|
||||||
|
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||||
|
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
|
<span class="pp-stamp-tier">BRONZE</span>
|
||||||
|
</div>
|
||||||
|
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
|
||||||
|
} else {
|
||||||
|
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||||
|
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
|
<span class="pp-stamp-tier">BRONZE</span>
|
||||||
|
</div>
|
||||||
|
<div class="pp-invite">${need === 1 ? `One more ${starGl} song mints this stamp.` : `${need} more ${starGl} songs mint this stamp.`}</div>`;
|
||||||
|
}
|
||||||
|
let drills = '';
|
||||||
|
const reqNodes = (p.drills || {}).required || [];
|
||||||
|
if (reqNodes.length) {
|
||||||
|
const cleared = new Set((p.drills || {}).cleared || []);
|
||||||
|
drills = `<div class="pp-drills">${reqNodes.map((n) =>
|
||||||
|
`<div class="pp-drill${cleared.has(n) ? ' cleared' : ''}">${cleared.has(n) ? '✓' : '○'} ${esc(n)}</div>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
// Graded instruments collect stubs at the badge bar; shown-not-judged
|
||||||
|
// instruments have no bar — every played genre song is repertoire.
|
||||||
|
const stubs = p.badge === 'shown_not_judged'
|
||||||
|
? (p.songs || [])
|
||||||
|
: (p.songs || []).filter((s) => s.qualifies);
|
||||||
|
const emptyLine = p.badge === 'shown_not_judged'
|
||||||
|
? `Play ${esc(p.genre)} songs to fill this page.`
|
||||||
|
: `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>`;
|
||||||
|
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}${drills}
|
||||||
|
</div>
|
||||||
|
<div class="pp-page pp-page-right">
|
||||||
|
<div class="pp-page-head">Ticket stubs</div>
|
||||||
|
<div class="pp-stubs">${stubsHTML}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pp-book-cover pp-leather-${esc(inst)}">
|
||||||
|
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||||||
|
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
||||||
|
</div>
|
||||||
|
<button class="pp-book-close" data-pp-close="1" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBook(inst, gkey) {
|
||||||
|
if (!_pp) return;
|
||||||
|
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
|
||||||
|
.find((x) => x.genre_key === gkey);
|
||||||
|
const overlay = $('pp-overlay');
|
||||||
|
if (!p || !overlay) return;
|
||||||
|
_ppBook = { inst, gkey };
|
||||||
|
_ppReturnFocus = document.activeElement;
|
||||||
|
const pending = p.badge === 'earned' && !seenBadges()[badgeId(inst, gkey)];
|
||||||
|
overlay.innerHTML = ppBookHTML(inst, p, pending);
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
const close = overlay.querySelector('.pp-book-close');
|
||||||
|
if (close) close.focus();
|
||||||
|
sfx('page');
|
||||||
|
// Double rAF so the cover's closed state paints before the transition.
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
const book = overlay.querySelector('.pp-book');
|
||||||
|
if (book) book.classList.add('open');
|
||||||
|
}));
|
||||||
|
if (pending) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!_ppBook || _ppBook.gkey !== gkey || _ppBook.inst !== inst) return;
|
||||||
|
const stamp = overlay.querySelector('.pp-stamp-page');
|
||||||
|
const book = overlay.querySelector('.pp-book');
|
||||||
|
if (!stamp) return;
|
||||||
|
stamp.classList.remove('pp-stamp-hidden');
|
||||||
|
stamp.classList.add('pp-slam');
|
||||||
|
if (book) book.classList.add('pp-shake');
|
||||||
|
sfx('stamp');
|
||||||
|
markBadgeSeen(inst, gkey);
|
||||||
|
renderPassports(); // the shelf cover gains its mini-stamp
|
||||||
|
}, 950);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBook() {
|
||||||
|
_ppBook = null;
|
||||||
|
const overlay = $('pp-overlay');
|
||||||
|
if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; }
|
||||||
|
if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' &&
|
||||||
|
document.contains(_ppReturnFocus)) {
|
||||||
|
_ppReturnFocus.focus();
|
||||||
|
}
|
||||||
|
_ppReturnFocus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitInstrument(inst, after) {
|
||||||
|
fetch(`${API}/passports/commit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ instrument: inst }),
|
||||||
|
}).then(() => refreshPassports())
|
||||||
|
.then(() => { if (after) after(); })
|
||||||
|
.catch(() => { /* server restarting; user retries */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 0 — the wax seal. Purely theatrical: the overlay plays the press,
|
||||||
|
// the POST commits, the shelf re-renders committed.
|
||||||
|
function sealCeremony(inst, after) {
|
||||||
|
const overlay = $('pp-overlay');
|
||||||
|
if (!overlay) { commitInstrument(inst, after); return; }
|
||||||
|
overlay.innerHTML = `<div class="pp-book-wrap">
|
||||||
|
<div class="pp-commit-cover pp-ceremony pp-leather-${esc(inst)}">
|
||||||
|
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
|
||||||
|
<span class="pp-cover-inst">passport</span>
|
||||||
|
<span class="pp-wax"><span>${esc(ppLabel(inst).charAt(0))}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
setTimeout(() => sfx('seal'), 450);
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
overlay.innerHTML = '';
|
||||||
|
commitInstrument(inst, after);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGenre(inst, genre) {
|
||||||
|
fetch(`${API}/passports/open`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ instrument: inst, genre }),
|
||||||
|
}).then((res) => { if (!res.ok) throw new Error('open ' + res.status); })
|
||||||
|
.then(() => refreshPassports())
|
||||||
|
.then(() => openBook(inst, ppKey(genre)))
|
||||||
|
.catch(() => { /* validation/restart; rack stays */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClick(e) {
|
function onClick(e) {
|
||||||
|
const tabBtn = e.target.closest('[data-career-tab]');
|
||||||
|
const instBtn = e.target.closest('[data-pp-inst]');
|
||||||
|
const commitBtn = e.target.closest('[data-pp-commit]');
|
||||||
|
const coverBtn = e.target.closest('[data-pp-open]');
|
||||||
|
const brochureBtn = e.target.closest('[data-pp-genre]');
|
||||||
|
if (tabBtn) {
|
||||||
|
showCareerTab(tabBtn.dataset.careerTab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (instBtn) {
|
||||||
|
lsSet(PP_INST_KEY, instBtn.dataset.ppInst);
|
||||||
|
renderPassports();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (commitBtn) {
|
||||||
|
sealCeremony(commitBtn.dataset.ppCommit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coverBtn) {
|
||||||
|
openBook(activeInstrument(), coverBtn.dataset.ppOpen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (brochureBtn) {
|
||||||
|
const inst = activeInstrument();
|
||||||
|
const genre = brochureBtn.dataset.ppGenre;
|
||||||
|
const committed = _pp && ((_pp.instruments || {})[inst] || {}).committed_at;
|
||||||
|
// Opening your first passport on an instrument IS the commitment —
|
||||||
|
// the seal ceremony runs first, then the passport opens.
|
||||||
|
if (committed) openGenre(inst, genre);
|
||||||
|
else sealCeremony(inst, () => openGenre(inst, genre));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target.closest('[data-pp-close]') ||
|
||||||
|
(e.target.dataset && e.target.dataset.ppCloseBg)) {
|
||||||
|
closeBook();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dlBtn = e.target.closest('[data-career-download]');
|
const dlBtn = e.target.closest('[data-career-download]');
|
||||||
const delBtn = e.target.closest('[data-career-delete]');
|
const delBtn = e.target.closest('[data-career-delete]');
|
||||||
const playBtn = e.target.closest('[data-career-play]');
|
const playBtn = e.target.closest('[data-career-play]');
|
||||||
@@ -268,10 +674,24 @@
|
|||||||
if (sm && typeof sm.on === 'function') {
|
if (sm && typeof sm.on === 'function') {
|
||||||
// New song stats can add stars → thresholds may cross mid-session.
|
// New song stats can add stars → thresholds may cross mid-session.
|
||||||
sm.on('stats:recorded', () => refresh());
|
sm.on('stats:recorded', () => refresh());
|
||||||
|
// 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).
|
||||||
|
sm.on('virtuoso:progress', relayDrillState);
|
||||||
}
|
}
|
||||||
|
showCareerTab(lsGet(PP_TAB_KEY) === 'passports' ? 'passports' : 'venues');
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && _ppBook) closeBook();
|
||||||
|
});
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test seam (bare-vm harness, see plugins/career/tests/): pure helpers +
|
||||||
|
// the badge-diff logic; nothing here touches the DOM.
|
||||||
|
window.__careerPassportTest = {
|
||||||
|
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||||
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', boot);
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!-- Career plugin — data panel. Exists so the passport/drill state declared in
|
||||||
|
settings.server_files has a visible home in Settings; nothing to configure. -->
|
||||||
|
<div class="text-sm text-gray-300 space-y-2">
|
||||||
|
<p><strong>Career</strong> computes stars and genre badges from your play
|
||||||
|
stats — they are never stored, so there is nothing to back up or reset.</p>
|
||||||
|
<p class="text-gray-400">What <em>is</em> saved server-side: your instrument
|
||||||
|
commitments, opened genre passports, and the practice-drill snapshot the
|
||||||
|
Virtuoso plugin reports. These ride along in
|
||||||
|
<em>Settings → Export</em> automatically.</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Passport UI pure-logic tests: load screen.js in a bare vm window and
|
||||||
|
// exercise the __careerPassportTest seam (no DOM beyond stubs, no network).
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
function load(seed) {
|
||||||
|
const store = Object.assign({}, seed);
|
||||||
|
const window = {
|
||||||
|
console,
|
||||||
|
localStorage: {
|
||||||
|
getItem: (k) => (k in store ? store[k] : null),
|
||||||
|
setItem: (k, v) => { store[k] = String(v); },
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
readyState: 'complete',
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
notifications: [],
|
||||||
|
};
|
||||||
|
window.window = window;
|
||||||
|
window.globalThis = window;
|
||||||
|
window.fbNotify = { show: (n) => window.notifications.push(n) };
|
||||||
|
const context = vm.createContext(window);
|
||||||
|
// `document` and `localStorage` resolve as bare names inside the IIFE.
|
||||||
|
context.document = window.document;
|
||||||
|
context.localStorage = window.localStorage;
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||||
|
vm.runInContext(src, context, { filename: 'career/screen.js' });
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('module loads (and boots) in a bare vm window', () => {
|
||||||
|
const w = load();
|
||||||
|
assert.equal(typeof w.__careerPassportTest.ppKey, 'function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ppKey normalizes case and whitespace', () => {
|
||||||
|
const { ppKey } = load().__careerPassportTest;
|
||||||
|
assert.equal(ppKey(' Blues Rock '), 'blues rock');
|
||||||
|
assert.equal(ppKey('FUNK'), 'funk');
|
||||||
|
assert.equal(ppKey(''), '');
|
||||||
|
assert.equal(ppKey(null), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ppJitter is deterministic and bounded', () => {
|
||||||
|
const { ppJitter } = load().__careerPassportTest;
|
||||||
|
assert.equal(ppJitter('blues', 8), ppJitter('blues', 8));
|
||||||
|
for (const seed of ['blues', 'funk', 'jazz', 'metal']) {
|
||||||
|
const j = ppJitter(seed, 8);
|
||||||
|
assert.ok(j >= -8 && j <= 8, `${seed} → ${j}`);
|
||||||
|
}
|
||||||
|
assert.notEqual(ppJitter('blues', 8), ppJitter('funk', 8));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectNewBadges notifies once per badge, never after it is seen', () => {
|
||||||
|
const w = load();
|
||||||
|
const t = w.__careerPassportTest;
|
||||||
|
const view = {
|
||||||
|
instruments: {
|
||||||
|
guitar: {
|
||||||
|
passports: [
|
||||||
|
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' },
|
||||||
|
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
t.detectNewBadges(view);
|
||||||
|
assert.equal(w.notifications.length, 1);
|
||||||
|
assert.match(w.notifications[0].message, /Blues/);
|
||||||
|
// Same view again in the same session: no duplicate notification.
|
||||||
|
t.detectNewBadges(view);
|
||||||
|
assert.equal(w.notifications.length, 1);
|
||||||
|
// Seen (slam played) → a fresh session stays quiet too.
|
||||||
|
t.markBadgeSeen('guitar', 'blues');
|
||||||
|
// JSON-compare: vm objects carry a foreign Object prototype.
|
||||||
|
assert.equal(JSON.stringify(t.seenBadges()), '{"guitar/blues":1}');
|
||||||
|
|
||||||
|
// Fresh session (new vm, empty notify cache) with the badge already seen:
|
||||||
|
// detection must stay silent.
|
||||||
|
const w2 = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
|
||||||
|
w2.__careerPassportTest.detectNewBadges(view);
|
||||||
|
assert.equal(w2.notifications.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seenBadges tolerates corrupt stored values', () => {
|
||||||
|
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
|
||||||
|
const w = load({ 'feedBack-career-badges-seen': bad });
|
||||||
|
const t = w.__careerPassportTest;
|
||||||
|
assert.equal(JSON.stringify(t.seenBadges()), '{}', `stored ${bad}`);
|
||||||
|
// And detection still works on top of the recovered empty state.
|
||||||
|
t.detectNewBadges({ instruments: { guitar: { passports: [
|
||||||
|
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } });
|
||||||
|
assert.equal(w.notifications.length, 1, `stored ${bad}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -14,25 +15,45 @@ import routes as career_routes
|
|||||||
|
|
||||||
|
|
||||||
class FakeMetaDb:
|
class FakeMetaDb:
|
||||||
"""song_stats-only stand-in for MetadataDB (the plugin reads nothing else)."""
|
"""song_stats/songs stand-in for MetadataDB (the plugin reads nothing else).
|
||||||
|
|
||||||
|
The real song_stats.arrangement is an INTEGER index into the song's
|
||||||
|
arrangements JSON; the legacy star tests pass strings ("guitar"), which
|
||||||
|
the passport code treats as index-less → instrument defaults to guitar."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""CREATE TABLE song_stats (
|
"""CREATE TABLE song_stats (
|
||||||
filename TEXT, arrangement TEXT, best_accuracy REAL
|
filename TEXT, arrangement TEXT, best_accuracy REAL,
|
||||||
|
last_played_at TEXT
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"""CREATE TABLE songs (
|
||||||
|
filename TEXT, title TEXT, artist TEXT,
|
||||||
|
genre TEXT DEFAULT '', arrangements TEXT
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)")
|
|
||||||
|
|
||||||
def add(self, filename, arrangement, best_accuracy, in_library=True):
|
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
||||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)",
|
genre="", arrangements=None, last_played_at=None):
|
||||||
(filename, arrangement, best_accuracy))
|
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?)",
|
||||||
|
(filename, arrangement, best_accuracy, last_played_at))
|
||||||
if in_library:
|
if in_library:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS "
|
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
|
||||||
"(SELECT 1 FROM songs WHERE filename = ?)",
|
"(SELECT 1 FROM songs WHERE filename = ?)",
|
||||||
(filename, filename.replace(".feedpak", "").title(), "Test Artist", filename))
|
(filename, filename.replace(".feedpak", "").title(), "Test Artist",
|
||||||
|
genre,
|
||||||
|
json.dumps(arrangements) if arrangements is not None else None,
|
||||||
|
filename))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def add_song_only(self, filename, genre=""):
|
||||||
|
"""A library song with no plays — feeds the genre (brochure) list."""
|
||||||
|
self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(filename, filename, "Test Artist", genre, None))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""HTTP-level tests for the passport layer: badges, stubs, genres, drill intake.
|
||||||
|
|
||||||
|
Badges are computed on read (never stored): N genre songs at min_stars — with
|
||||||
|
stars ≥2 meaning best_accuracy ≥ 0.75 under the default 0.6/0.75/0.85
|
||||||
|
thresholds — plus any configured virtuoso drills.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import routes as career_routes
|
||||||
|
|
||||||
|
LEAD = [{"type": "lead", "name": "Lead"}]
|
||||||
|
BASS = [{"type": "bass", "name": "Bass"}]
|
||||||
|
|
||||||
|
|
||||||
|
def _open(client, instrument="guitar", genre="Blues"):
|
||||||
|
res = client.post("/api/plugins/career/passports/open",
|
||||||
|
json={"instrument": instrument, "genre": genre})
|
||||||
|
assert res.status_code == 200
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _passport(client, instrument="guitar", genre_key="blues"):
|
||||||
|
view = client.get("/api/plugins/career/passports").json()
|
||||||
|
for p in view["instruments"][instrument]["passports"]:
|
||||||
|
if p["genre_key"] == genre_key:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_badge_earned_at_five_genre_songs_two_stars(client, meta_db):
|
||||||
|
for i in range(5):
|
||||||
|
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
|
||||||
|
_open(client)
|
||||||
|
p = _passport(client)
|
||||||
|
assert p["badge"] == "earned"
|
||||||
|
assert p["qualifying_count"] == 5
|
||||||
|
assert all(s["qualifies"] and s["stars"] == 2 for s in p["songs"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_badge_in_progress_below_the_bar(client, meta_db):
|
||||||
|
for i in range(4):
|
||||||
|
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
|
||||||
|
meta_db.add("weak.feedpak", 0, 0.65, genre="Blues", arrangements=LEAD) # 1★
|
||||||
|
_open(client)
|
||||||
|
p = _passport(client)
|
||||||
|
assert p["badge"] == "in_progress"
|
||||||
|
assert p["qualifying_count"] == 4
|
||||||
|
# Qualifying stubs sort ahead of the near-misses.
|
||||||
|
assert [s["qualifies"] for s in p["songs"]] == [True] * 4 + [False]
|
||||||
|
|
||||||
|
|
||||||
|
def test_instruments_split_and_bass_is_shown_not_judged(client, meta_db):
|
||||||
|
# Same 5 songs but played on the BASS arrangement: no guitar badge credit.
|
||||||
|
for i in range(5):
|
||||||
|
meta_db.add(f"blues{i}.feedpak", 0, 0.9, genre="Blues", arrangements=BASS)
|
||||||
|
_open(client, "guitar")
|
||||||
|
_open(client, "bass")
|
||||||
|
guitar = _passport(client, "guitar")
|
||||||
|
bass = _passport(client, "bass")
|
||||||
|
assert guitar["qualifying_count"] == 0 and guitar["badge"] == "in_progress"
|
||||||
|
assert bass["qualifying_count"] == 5
|
||||||
|
# Bass isn't a graded instrument: repertoire shows, no pass/fail bar.
|
||||||
|
assert bass["badge"] == "shown_not_judged" and bass["graded"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_accuracy_per_instrument_across_arrangements(client, meta_db):
|
||||||
|
both = [{"type": "lead", "name": "Lead"}, {"type": "lead", "name": "Alt. Lead"}]
|
||||||
|
meta_db.add("song.feedpak", 0, 0.7, genre="Blues", arrangements=both)
|
||||||
|
meta_db.add("song.feedpak", 1, 0.9, genre="Blues", arrangements=both)
|
||||||
|
_open(client)
|
||||||
|
p = _passport(client)
|
||||||
|
assert len(p["songs"]) == 1
|
||||||
|
assert p["songs"][0]["best_accuracy"] == 0.9
|
||||||
|
assert p["songs"][0]["stars"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_orphaned_songs_do_not_feed_stubs(client, meta_db):
|
||||||
|
meta_db.add("gone.feedpak", 0, 0.9, genre="Blues", arrangements=LEAD,
|
||||||
|
in_library=False)
|
||||||
|
_open(client)
|
||||||
|
assert _passport(client)["songs"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_genre_rack_collapses_case_and_skips_blank(client, meta_db):
|
||||||
|
meta_db.add_song_only("a.feedpak", genre="Blues")
|
||||||
|
meta_db.add_song_only("b.feedpak", genre="blues")
|
||||||
|
meta_db.add_song_only("c.feedpak", genre="Funk")
|
||||||
|
meta_db.add_song_only("d.feedpak", genre="")
|
||||||
|
genres = client.get("/api/plugins/career/passports").json()["genres"]
|
||||||
|
assert genres == [
|
||||||
|
{"genre_key": "blues", "genre": "Blues", "songs_in_library": 2},
|
||||||
|
{"genre_key": "funk", "genre": "Funk", "songs_in_library": 1},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_is_idempotent_and_open_implies_commit(client):
|
||||||
|
first = client.post("/api/plugins/career/passports/commit",
|
||||||
|
json={"instrument": "guitar"}).json()
|
||||||
|
again = client.post("/api/plugins/career/passports/commit",
|
||||||
|
json={"instrument": "guitar"}).json()
|
||||||
|
assert first["committed_at"] == again["committed_at"]
|
||||||
|
_open(client, "bass", "Funk")
|
||||||
|
view = client.get("/api/plugins/career/passports").json()
|
||||||
|
assert view["instruments"]["bass"]["committed_at"]
|
||||||
|
# Re-opening the same passport keeps the original opened_at.
|
||||||
|
opened = view["instruments"]["bass"]["passports"][0]["opened_at"]
|
||||||
|
_open(client, "bass", " funk ") # normalizes to the same key
|
||||||
|
view = client.get("/api/plugins/career/passports").json()
|
||||||
|
assert [p["opened_at"] for p in view["instruments"]["bass"]["passports"]] == [opened]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_and_commit_validation(client):
|
||||||
|
assert client.post("/api/plugins/career/passports/commit",
|
||||||
|
json={"instrument": "theremin"}).status_code == 400
|
||||||
|
assert client.post("/api/plugins/career/passports/open",
|
||||||
|
json={"instrument": "guitar", "genre": " "}).status_code == 400
|
||||||
|
assert client.post("/api/plugins/career/passports/open",
|
||||||
|
json={"instrument": "guitar", "genre": "x" * 65}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_drill_requirement_gates_badge_until_snapshot_clears_it(client, meta_db):
|
||||||
|
for i in range(5):
|
||||||
|
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
|
||||||
|
career_routes._state["passports_content"]["genres"]["blues"] = {
|
||||||
|
"virtuoso_nodes": ["node.shuffle"]}
|
||||||
|
_open(client)
|
||||||
|
p = _passport(client)
|
||||||
|
assert p["badge"] == "in_progress"
|
||||||
|
assert p["drills"] == {"required": ["node.shuffle"], "cleared": []}
|
||||||
|
|
||||||
|
res = client.post("/api/plugins/career/drill-state", json={
|
||||||
|
"mode": "casual", "xp": 120,
|
||||||
|
"byNode": {"node.shuffle": {"masteredAt": 1720000000,
|
||||||
|
"depth": {"travel": None}}}})
|
||||||
|
assert res.status_code == 200
|
||||||
|
p = _passport(client)
|
||||||
|
assert p["drills"]["cleared"] == ["node.shuffle"]
|
||||||
|
assert p["badge"] == "earned"
|
||||||
|
|
||||||
|
|
||||||
|
def test_drill_state_validation(client):
|
||||||
|
assert client.post("/api/plugins/career/drill-state",
|
||||||
|
json={"mode": "casual"}).status_code == 400
|
||||||
|
huge = {"byNode": {"pad": "x" * (300 * 1024)}}
|
||||||
|
assert client.post("/api/plugins/career/drill-state",
|
||||||
|
json=huge).status_code == 413
|
||||||
Reference in New Issue
Block a user