From 7ffa6e2c51fb6ee28a7f4f4db1892abf728552d4 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Mon, 13 Jul 2026 11:56:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(career):=20passport=20UI=20=E2=80=94=20the?= =?UTF-8?q?=20book,=20the=20stamp,=20the=20rack=20(#936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Passports tab beside Venues renders the badge journey physically: - Per-instrument passport book: embossed CSS-leather cover, 3D page-turn spread (badge page left, ticket stubs right), Escape/backdrop close. - Wax-seal commitment ceremony (Stage 0) — pressing the seal commits the instrument; opening a first passport runs the ceremony implicitly. - Rubber-stamp badge slam: earned badges chime + notify immediately, the slam (with ink bleed, page shake, deterministic sin-hash jitter) plays when the passport is next opened, then the badge is marked seen. - Ticket-stub repertoire: qualifying songs as collected stubs. - Brochure rack: unopened genres as "Explore next" invitations — no greyed slots, no completion meters (the anti-list as layout). - Drill relay: on virtuoso:progress bus events the career screen posts the full virtuoso.progress localStorage snapshot to the drill-state intake (debounced; one-time bootstrap when the server has none). - Four synthesized sfx (stamp/seal/page/chime, 13 KB total) as plugin assets; prefers-reduced-motion disables the theatrics. Pure logic (ppKey, ppJitter, badge diff/seen) is covered by a bare-vm node --test suite via a window.__careerPassportTest seam. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 12 + plugins/career/assets/career.css | 344 +++++++++++++++++++++ plugins/career/assets/sfx/chime.mp3 | Bin 0 -> 5065 bytes plugins/career/assets/sfx/page.mp3 | Bin 0 -> 3636 bytes plugins/career/assets/sfx/seal.mp3 | Bin 0 -> 2724 bytes plugins/career/assets/sfx/stamp.mp3 | Bin 0 -> 2179 bytes plugins/career/screen.html | 45 ++- plugins/career/screen.js | 404 +++++++++++++++++++++++++ plugins/career/tests/passports.test.js | 83 +++++ 9 files changed, 876 insertions(+), 12 deletions(-) create mode 100644 plugins/career/assets/sfx/chime.mp3 create mode 100644 plugins/career/assets/sfx/page.mp3 create mode 100644 plugins/career/assets/sfx/seal.mp3 create mode 100644 plugins/career/assets/sfx/stamp.mp3 create mode 100644 plugins/career/tests/passports.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c30282..494a943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. ### Removed - **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css index 5fac8c2..b21d9f2 100644 --- a/plugins/career/assets/career.css +++ b/plugins/career/assets/career.css @@ -64,3 +64,347 @@ .career-star-row .song .artist { color: #9ca3af; } .career-star-row .hint { color: #6b7280; white-space: nowrap; } .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; } +} diff --git a/plugins/career/assets/sfx/chime.mp3 b/plugins/career/assets/sfx/chime.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..24fd9f238b421b9341e3cf130925449df948095c GIT binary patch literal 5065 zcmbuC2UOF^_Qxk75E2w3NK=$ZlO`Q2NFaowaSd&yO6ZFyf+(T_`O!4MN&;2{H1w`C z6%|ZC5Jhx@!eZkY3oW1`iVF$w`zN})f8Wvf&Ut6&oJ{67znS~F-+S-mOq}h_Q9#@@ z+}+$rqHkFMB*+1=p=-^I%#BPrFlarI_bOwWQ^5n_9ygU}Gw6wIky1K5e z?(*f!H*Rn^oX*b9?(S|bx4*yt>C>k?9&c!9h|iy#oSdGX76=3|EGCP53=;WhYASyC zt7)OGd>twj?BQJ5`DF|X>pB3CQZo*74}di66?gryq0R{)u^9NVj8*QY-%X%Jh6V3$ zv@Ss*rA*>&l0nklC|a`Y?7@!()~m84Y)WuE^Es1jum_UmN}kFF1ON~e(`3SLJQNy{ zmh9kxpxYhO@W_?E8R?mwd*{xaH2N4u3er)KWFTcE=*~+zRGU@&I9uZv$-munR~_Mp zf=qqG`P#0y>qfar`3#led+pu2a?~v-=A-Iz#mic|?|WBOw(9*}e&K2O{w<+rV7EB< z<2dbh#Ma8n@bDl(AihFh!>W>khy!LTCV}2rg%QO_gVNqrRW)xv?g?oNZ-Mg5RW&sX zWqMMyPKB=y^BGBL<9yhiPqb;)nu>AjiT;x;gglC{;T3nnYqp8kouxvl zY}l5h_#h4a!Cif_(UDsTzkh(i-W(Se!E-NZqzV%dhQzCLfXT7fvVNNH1j`!`ggJXZ z=LAiEvtitN+h1I^9-&2^l?@Xb_wC-SE?jUuV*)IJiCnaDhD>DkXWWIMbdT4@F(noU zZ|I$?zW2_Vh{0{{JN?2JCP<8p2n6zy9h>Sk$|=AD5R?f(ePsBsfl`cG@jZ>B=hvRt zrdswq>yHDz5Wa&TR|H>mQf4f$-5J&|iaa3%7XcFbjt)hWfjDZepU>rbl@m}1b~y| zSXu-N@f{z41C`-3(OQmwn8i7ol^@P@fz z!O5glwS9pgnV@91OJ#q@yk~glC>jqb%Shw8oD&!{7pk%o*tnD> zB75>)2V?me|03YYDTI=p90g+q!NW>w)e>&Q(?P`k#CPr*lQu;jw+_9n@@dw-Tlq)L z>+3x@a~sD;AL!9GpZ2=-RGPCN4zge&wPOKbSbjmXmB6mzoS}_n5x^bB*iu@6&QM?N zz`BNpxg+Bfi&+M`fW;Wma6NcP)zeBrcFop?C&6A%7A~@XUw3L=82?1|(V$nX^b&-@ zH9t+8SVeX(CV7hq+M|=ra(0~IG}~XSY)(g*%nH19Y8MJ2jI$>lqG@^v?JVUz18d0E7V>fAY4Rv(jM%=){Q?@>SKJs~MGZM=@wer%*C5fgs z8~iFZOusuyqCeV+w79tz6HtFkS07V|xp{A;^s~<|KAbJO;M26`ew0b2w9AubV6^3e z)8UezsP3}bItZPJ{s@U9?6UP6XN0iOq(cBO%>zO6bO=9ouD0cKMt+qqgpNiCl$@g9 zPERkGtJAJ-Q*J;hL*sYd1ceVz4{0kdzZZi)+u&O^sQ*LsdfX$C zJxBMx;GM?==@{F`0GJI~x$b=Wv-6qFtz0jnb`7C6k(#R?a++n$*_|1iU}?40y<8w* z>uX-z1(LP29V;RZtlU5>e^r(!E|FP)wy%vCX?lwnD%P|ftK6+0wYFHf!tojT3F`Q> zT;Jf-#mGYm=U->iUic3a6%SO_;q$Q39{||kbBLo~+os|(__M-^wWnXmSu-A4NVdnF zd_IXsrHqW#dBKkN=Sc=;JkTwh(qt(Sis8`D)~)%eRr*N1Z@u+U&dUSZUydW_B3Fk@ z=*ZwZaE8}X^-f;?Jt`r!q|@)o^KiJjddg}KIgSd#rK4X0U^#8jW3`u%MvuYTZp!pY zy>cbAIMATK(Xi#PDw1i?I9?lsx!MnBZ*fYG0_oD^eWg&Tk-|eskJfACb)&=x#^578 z^uwU}gy*ShsP!a;Jq3UMAehj?M^0U2-IbeYO|{Htt~iqT6jpyo`bA^~6ChG+b9zYr zkA3dAt;%`Vx7Tk|j#Rw*mdw{(oAO~t=O=~Gi|}Ygq4IpA4em5?Yf=iqOpY?&Lx`3N zgN`HHiAqi!sqzY;-#eMy{sB3H8{+xF!bc$$F$YCyQP&cCFavJaBm_)o*YMip#%EJ4 z`?NA($ap2g2_UjOKc9M9fgrK^(q%r&oH{q&QHZ-v4p+&T#2w8sZfrV8yL9PeNki^Q zunh5R@vq$R^FXlQJA?DnvAX;<=eWu^8HSc#X-pP-Ab@UDX&w{x)%bP>|IlgY;GUIkup;i)$4b+R->snk0mqFCO@7NE+K zYobg)sq)xrh6||ycyzkWGZ^j#brIt%$+XaqnNfJoO zdA zuQeVHX$zxo*|B#fU!Foo$w=f9qASBNo)Oafq@g%vy+i6XVx`ki=V+2kO%ayY%+#AL zFW8;4c~DX1*GcAb+RGsRUmR1sZeR$e9$!c87QxoWxV&Ncbdhu=76qx4PU#)#n~2mw zXecpYDFs3jA=_To*ugx<)ph5KH;W~oFPf|9ZC%PTTa*XG@X+6`e=#XmIu9HcC2b$# zYw<2I_*Pa;zWW34=6Y4)hUG9h-Yp`cIVE*&8De#?L=8Iuz5KI|BtpnuaMDO^M=;VzaG_PNP~O7 z^z7>=VDJ)ugXtDE&{jX`+zG3Bp44Vgl<-O}W&L=>8Obp{gHw=M1>t9487{lYtCRehGIUXLEHF z^uOl-B9G|9Yc0k{(QE#5+(eqd2?`Su{pALh>QZJ%DNL^9T&3KMtO_V(vY5YuWw#ml zgm@i=s~>)wmfQ9n=N-637U`)k#(PNbqDVG*{5U^fVh56=Af}jP?NqWHF*;%Jo6X2# z5(9EJS?@#w*aW#-C{tVb^B$J`6kCFYkwGGzypD00aL))U>ha2@YJ`=qE2tnFA- z>8cs!ir{?GtEI^MKULoxn<}yCK}Z-qfb_6*{zbr{inaj|u$pIDlDtf!hkT{?0s=%=yCH#Mw^xgOtQLd2YLsQrzo4n!QXm{Z~YzhJ)O{&?c69wH#_ z;90%mxl2dHOX<4*cL_RMx0kyJ$me@in8+W!Mz8Qq1XIB-970T5bU?3`1Tqv7&!48+daH~6S_yj9(R6| zvj=&Pr@v|4qj!&q-^}f?z6bL+yPkJq^xlb4MP;}1U!*~+dy*H-th?_1TQeCIv|Yin zEwq!Rm})Uo=p6u9IdEVi&qqW`-NJe*Td{S|z=7DiJ%9<%lnRy$xUbmD`b~R17noD? z4yVRdEW)KEvGvp?1G{FTHrfSB>%PAE*eCuxI-xmMfNs_7%|t1st1O{jC%hYaeTwzq zc6Q@o8ME&eO4NIq%wEgcs%Za4#W%vD4_&S^hu-&XFX!}UH@u!1;7oK3w>2LM!0U$i z>b_+EwdLp{ZUN2YGn4yJRb-RtP0$w+%z8U|Q~a=4q)a?()GO%#cC@h9bMIWigXk)D zm>V>~z(GT9rA~I`z|+_D)@};@-*Mi*wl(+z4p$yHq>&YZkeB!%b0b3uVlVKB&+jfL zN4%v#9@AD*_CeSYN5YX=8PiLe8D>$^N7o%xroFxoynWcdH0}82aZK^haDH`L&GFO5 ziLLG9mo7K9^{$L|n|(Fime%ZI~Z;t1&DsGSJWV z({ao9-3<|weu2-A9)V%MV&|cy{tFDL2sr2M&iUbwp>4sXs|(o+TKNy!b8(X3Cz6AJ zvzEj+d#_-+!A+zBiZymjm*-O01@r%j8(B=TK zfZG8`nbA4u`4cVoZcbJ4bY_3;$JGM(;!|05rWU3o zOMNLlNq4f_UDvT`*(U698p5)UBDYx8^aA#pxOVXeMR&}ux(Wy5kU$1}slwEiWXjKQ zc6|uy8B?|2@+Q@(I6Z&*E#nlNl|P1f&Y4Z`XUWa+Md3QJ61y}aGQXtU`;*$QQ0{Ae zHm&cd>uzo75hE9FuQiM`q^WGoQ+vAuo%*RU<()CwvBz6S3u*~p_}A0v(VnbblfDHE z>Wy&3&fepQufXIPXp*sZ6cVT&yG{$B5Ubx^F8lG=!lYw3pIGN&Z<(KzjKxBc_#Zd> zmoIL3fgT|>P`pSR+n0wNJn-k#B|@z{K4oOUu=4t#sHAurQMsirCiim*lVL^M2KM2v zT$v$t#YKre*(4+@KTwfR-zS~wsTfq>Bv8`&V zpWp1k!!>$8afwh;K5~iRjX$QJxb{$Mbz|GOU*h)i#+qVduSH;jJB?@Vvd7Qlt;Wkb z=N>+XAk|Mc)Tb}6UPhrxV>ERt7`ASf@44ieTd%V-O~rixUN%pV7fYp>IaIX2^dv~K zgK&~lf{wUa#X;^wt4fO24hi#AL3KVgRxb{fFzPmre13LzeKC)%{5oSdpAEn)?PFv+ z=q%T?O7PX6_chd?h{*?lIeaG8HB3MSd`S`t{0|Lq2+vk>nvll>n4lgWOdk7yI*uoCAg zRm5f0x@VRLllOB(%nS@tP#$&;@9#uO{ERBL`6IHXwi)f(rhcZ!5|qyvjvxFIExi!E zx|q`!;Z@)iu4|B5oe>uODl1{1>!fZYwc>l{Cz-3>_2uolr$H)WvEodI2kkrN%+p)p z?b7GFf;ksA+jyM9_aQovrJ3eJ*6o-yOrHJwBaayZ*9PN!q{9A84-@A)QRmL~Vfp^} zDh@7-j13?W95OFTb-gJ^tnTL;Slk+I%)I0sc#nt6n!^l;*E7OmRfSAMP@?oCqBvMu z8Fb)a7Uy(l86X%neoZNuP?8nGpQG?cdCl9ovyr4xyO%+uWjV~4>fkPK>3BevjK78y zgdk*6;*N>1!1Ty%%g)lW4`4n#=y=*_s1m&I8S6mJtBgU(i>Ax6@2KrIc+-sAzDr<- zVRC9iVddEvfzhquricrZMi^l*UvoS<$%X9>XD?g`>LNu)lG!EYSJGw>a;Z z4X@%xeYJCqBk!yC4WbC3+i}vx6N)%&O4_dH%s1+-GcjtF|&wG=)l@` z>2fGb1_>u=%bJJs~-bZZ2l-la%6i z!{Vn)6+e*6&PJg=_i9n`m4F7DZyY;OIDZo_xpt z{Q~0wKQzR6j~>!e&n)+KglCj(tJswTN1L&{sYcmux=p`|BR)3frmPadlNv$jQr)j~ZgWcr8}@0d#tvY~pu@}-U+LvnS9q2oh~7N-c` z@G+h*w>0UXE**+KpHA6VDK7tR{ z$yy65%#@j`k{b^%yO&E2^bbmpR|@A+j4rraQ%{gX&V#L zna+Z6p_uDi81G)bRbWUj-!Mze;xc4 zLw{q=?mlH=xC!n-H0+fY4)*!YR2JOdu=h_+-L3d#fJ;%$cbR};Ie;&&gH!zks7NH%gt)?DxO6Z;h;$Q8)scml&Nzhmm`#|i_uT6e#+9XA;y-EcIsMN0 ze&>7Kd%HU)9S0=Yv-|etQdeI9ZhpxthceQV)00yb3fjngxmVTXnkh5fU!^-tjU?13 z5I|snJxIk!B}mn@jvzH4wIRJl>kQI|NEW04TEj?RAvuudXx&7*jpRl`Wa^A%)EQG# z(d4dTc^8?Xz;N{`iGv=yS1rQ;Jg5JGJ`&>nMM-W|7iGYno)`pp#-Il^a7`mb$?WVr z34mj*t*w-Sn=X-TaRP+9BePr}g^336!_;9Mf?7ja9Vdq{i{gAsR5ZEE53|UcUNG;E zJ0H*@N{(>5dG-V+mgBdrfn)1FE`0yDm=$NR^T~+(9eZ+7N>ios(uyM`()9Qu{PYxZ z2f6lBc>MKBU1GfHu|o5LHQ?x-y0iG=l)2%PAUw= zZsO`z*iO`5s@?2;;3zHpsTJC1ZV2)9LzAbXbwj^jB&|7ua%Eor#2fbn?q2m9{spr+ z!(W^sK)S0r>D89le7fLkV%}iLU;dTG1Ub9&TXPG_4iwb8AYq$tW@#;P^2;I?=V#1j z3D-_$5z_3YrT&(%Af~bIUr1yXOffz3h_6~XHloKnI^zaOp?s6W$DBdF#^U^h*-U{o zlL}d-lB4YpHH$oqhYC}0ovCirE%lpi9i<$Z|6r$o_I7*6m3QA^aTYL}NL(|XO2`w9 zz5StGJWu1HLK3+c5U(4X4yo8tPhb~tr6xPqe zn+z#rd_18|^tMpE1mYV9y9x%_9Ly#Ut{eS8s!40osrpR8p66{HHIhPZl0{}x&)H+u zY>v^U2&@~mkdIIowZr6bU$27*nL}=vES3Bi1S19kb+xv2|&t&T6O#X#`%-L%)*jsJ;*PgsJfukgQth|VjYU-TP_Kh Ti@J@a07`=T|K!DfPnZ4!6~Pxi literal 0 HcmV?d00001 diff --git a/plugins/career/assets/sfx/stamp.mp3 b/plugins/career/assets/sfx/stamp.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..757ba71d0830413bdd948b33c086cb7048aa6d00 GIT binary patch literal 2179 zcmeZtF=k-^0i}@OU{@f`$H2hslUSB!W~67VXJ}vmmV^-he>)sN;zF1anR)3z7C#X4 zFfee0Gvq=+GZgeg!5kP^0R>y3;2;c~gMwR7@Eis{L&1M20ND(5V=~ZEt!pPw8{Qp1x<_ACj`0>ww&CbYZP&chD zt*kUHd-bdvkA0^F&%COd_HfGPoZ0I;dIdh6Q|9ivzCcr3p=$D12bJZ;7CKEK$<_gS zfyX>;U4^GMpJdls85QIA@y3$6_|U8+n^lxO3%ZItBe{64M0V$`FRE8dDQY?_vb}pU zK8kvCT@w7HsX(RYyZH}}s+i^M|wwVumqf0y53wnaj4W|BW!`1&c)RePSz?N$GO zd~ae!!ED_}NguXG*0^aIc$UQ+ymNg^%%iV+-|v6mu%-6pi(eR<$?lwuwbMF0GF@n{+zaa7NlRW1dMW9;^-*Ot`M_^0758$cm6+ zTd=`|mp#=nAVidPhxTb^`;Qvh5hlE0+;V@U*kad6vCaS5U|xU9X{pdQzy62QibXXZ zu6FZr^mPTLCqo5|q|}l`1!K)sSO8P~KX9C#;V4LZptpeWKtfyE^Ty}jVk8@+RxouP zIwW<_6w{Rgmz5U=05M&-cS142IPGxTLJjl|+MG$FS45F;17 zG*y>cxiLA%L3#gQgFU>uOimsgy=vqb3ABOp{{x6ag9X03Rs~csKHS~FxZ;oGiiPT- zm_`CKxZZ;8Vh~+t^--B=y949phsS$ft-`bi z&FeV19Je6`8VIpPF?qJOr8cnkGX`zel|?b~|8IdKFBlIkk<2|~*q~s^lv~+mzza?i zaF-EgaLj;M#hk$?G<|O3HU=hHE+!G)c zvCAZ;d!jxxmOoE4Gdtmm90Ig%(5b}YOdGZ=<411n1 z9?}uaJ-+8ah8E+RAIE1v{DUXB8Z3l48yp-OK&J9`cQ_^9oyvKT)642k)0E9OxF=g5 z6qKK)B7|Yj!^VRvxU-KganMk1Tw5`vgP=WloPbLTlnx#>9(=-^d}OJDh8p9WieoH1 zxD-<$4zlMV&KJ^R$PiH y5C_?Fr}4lI&Zy=d1_Oyk%|-JV*eNg%msXHHFBw=4=sV9;1mywy!wn3y2sZ#yXJ@Ve literal 0 HcmV?d00001 diff --git a/plugins/career/screen.html b/plugins/career/screen.html index 38c58ca..756f369 100644 --- a/plugins/career/screen.html +++ b/plugins/career/screen.html @@ -3,19 +3,40 @@

Career

-

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.

-
-
-
-
-
+
+ +
-
-
-
-

Your star collection

-
+ +
+

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.

+
+
+
+
+
+
+
+
+
+

Your star collection

+
+
+
+
+
+ +
+ diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 1d081cb..ff6641f 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -17,11 +17,24 @@ const PREV_VIZ_KEY = 'feedBack-career-prev-viz'; 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 _pollTimer = 0; let _appliedManifestVenue = null; let _manifestReqGen = 0; // invalidates in-flight manifest fetches let _prevUnlockedIds = null; + let _pp = null; // last /passports view + let _ppRelayTimer = 0; + let _ppBook = null; // {inst, gkey} of the open spread + let _ppBootstrapped = false; + let _ppNotified = {}; // badges chimed this session (slam still pending) function $(id) { return document.getElementById(id); } @@ -219,9 +232,386 @@ render(state); schedulePoll(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) => { + b.classList.toggle('active', b.dataset.careerTab === tab); + }); + } + + 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 { return JSON.parse(lsGet(PP_SEEN_KEY) || '{}'); } 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' + ? `BRONZE` + : ''; + const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`; + return ``; + } + + function renderShelf(inst, data) { + const shelf = $('pp-shelf'); + if (!shelf) return; + if (!data.committed_at) { + shelf.innerHTML = `
+
+ ${esc(ppLabel(inst).toUpperCase())} + passport +
+
+
Pick up the ${esc(ppLabel(inst).toLowerCase())}.
+
Press your seal to commit — then choose a genre below and go deep.
+ +
+
`; + return; + } + const books = (data.passports || []).map((p) => ppCoverHTML(inst, p)).join(''); + shelf.innerHTML = books || + '
Your shelf is ready — open your first genre passport below.
'; + } + + 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 = '
No further genres in your library yet — new songs bring new brochures.
'; + return; + } + rack.innerHTML = genres.map((g) => { + const art = PP_BROCHURE_ART[Math.abs(ppHash(g.genre_key)) % PP_BROCHURE_ART.length]; + return ``; + }).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 ``; + }).join(''); + renderShelf(inst, data); + renderRack(inst, data); + } + + function ppStubHTML(s) { + const date = (s.last_played_at || '').slice(0, 10); + return `
+ ${'★'.repeat(s.stars)} + ${esc(s.title)} + ${s.artist ? `${esc(s.artist)}` : ''} + ${date ? `${esc(date)} · ` : ''}best ${(s.best_accuracy * 100).toFixed(0)}% +
`; + } + + 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 = `
Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.
`; + } else if (p.badge === 'earned') { + badgeArea = `
+ ${esc(p.genre.toUpperCase())} + BRONZE +
+
Gold rung coming — improvise it, verified.
`; + } else { + badgeArea = `
+ ${esc(p.genre.toUpperCase())} + BRONZE +
+
${need === 1 ? `One more ${starGl} song mints this stamp.` : `${need} more ${starGl} songs mint this stamp.`}
`; + } + let drills = ''; + const reqNodes = (p.drills || {}).required || []; + if (reqNodes.length) { + const cleared = new Set((p.drills || {}).cleared || []); + drills = `
${reqNodes.map((n) => + `
${cleared.has(n) ? '✓' : '○'} ${esc(n)}
`).join('')}
`; + } + // 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('') + : `
${emptyLine}
`; + return `
+
+
+
${esc(p.genre)} — ${esc(ppLabel(inst))}
+ ${badgeArea}${drills} +
+
+
Ticket stubs
+
${stubsHTML}
+
+
+ ${esc(p.genre.toUpperCase())} + ${esc(ppLabel(inst))} passport +
+ +
+
`; + } + + 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 }; + const pending = p.badge === 'earned' && !seenBadges()[badgeId(inst, gkey)]; + overlay.innerHTML = ppBookHTML(inst, p, pending); + overlay.classList.remove('hidden'); + 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 = ''; } + } + + 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 = `
+
+ ${esc(ppLabel(inst).toUpperCase())} + passport + ${esc(ppLabel(inst).charAt(0))} +
+
`; + 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) { + 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 delBtn = e.target.closest('[data-career-delete]'); const playBtn = e.target.closest('[data-career-play]'); @@ -268,10 +658,24 @@ if (sm && typeof sm.on === 'function') { // New song stats can add stars → thresholds may cross mid-session. 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(); } + // 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') { document.addEventListener('DOMContentLoaded', boot); } else { diff --git a/plugins/career/tests/passports.test.js b/plugins/career/tests/passports.test.js new file mode 100644 index 0000000..7ed4cfe --- /dev/null +++ b/plugins/career/tests/passports.test.js @@ -0,0 +1,83 @@ +// 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() { + const store = {}; + 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}'); +});