Compare commits

...
Author SHA1 Message Date
byrongamatosandClaude Fable 5 c27220ada3 fix(career): tilt rAF race + freshly-slammed stamp becomes a card
CodeRabbit on #944: a queued tilt frame closed over the departed card
and re-applied vars after pointerleave; and the just-slammed stamp
never regained pp-tilt until the next open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:18:01 +02:00
byrongamatosandClaude Fable 5 955c8a12d0 feat(career): passport visuals pack — tilt, emerging ink, gold foil
Career v2, WS4. All CSS + a rAF-throttled pointer handler, no deps:

- Trading-card tilt on EARNED artifacts only (shelf covers + the badge
  page stamp): pointer-tracked perspective rotateX/Y with a glint sweep
  following the pointer. The cover's jitter rotation moves into a CSS
  var so the tilt transform composes with it; the blanket cover-hover
  translate is scoped :not(.pp-tilt) so it can't fight the tilt.
  Hover-capable pointers only; off under prefers-reduced-motion.
- Emerging-stamp ink: the ghost stamp fills by qualifying/required via
  a conic-gradient — the stamp visibly carves in, no numbers added.
- Gold foil preview: the "coming" note gains a dashed foil chip with a
  periodic shimmer — honest, never earnable-looking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:17:03 +02:00
4027c31a61 feat(career): curated genre drills — per-instrument, achievably cleared (#943)
Career v2, WS3. Bronze in blues/rock/metal/funk/jazz now also asks for
the genre's signature Virtuoso drill, data-driven in passports.json:
blues_shuffle, rock_power_backbeat, melodic_metal_gallop,
sixteenth_pocket, vl_shells — with career-side display labels the
passport page renders instead of raw node ids.

- virtuoso_nodes becomes {instrument: [node_ids]} so a keys passport
  never demands a guitar drill; a flat list keeps meaning guitar
  (virtuoso's content is guitar-first).
- _node_cleared also accepts keysCleared (a top-tier clean pass in one
  key — virtuoso's FIRST gained-only artifact). The depth rungs
  additionally require a maxed speed tier, too high a bar for Bronze.
- Genres without a curated entry stay songs-only.

Note: pre-release behavior change — v1 passports aren't in any shipped
build, so no earned badge can demote in the wild.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:14:39 +02:00
0fc6a4beed feat(career): hours-per-genre odometer — honest wall-clock play time (#942)
Career v2, WS2. Nothing measured play time before (the achievements
plugin's final-position shortcut double-counts loops and mis-reads
seeks). Now:

- stats-recorder.js accrues WALL-CLOCK seconds across song:play/resume ↔
  pause/stop/ended spans (single spans clamp at 2h against suspend
  inflation) and piggybacks them as `seconds` on the POSTs it already
  sends; failed POSTs restore the accumulator; a session reset flushes
  first so time can't re-attribute to the next song/arrangement.
- POST /api/stats accepts optional `seconds` (finite, 0 < s ≤ 6h) on the
  scored and position branches, plus a new seconds-only branch for
  unscored plays that ran to the natural end — banks time WITHOUT
  touching the resume position (song:ended must not overwrite Continue)
  and still counts as playing today for the streak.
- song_stats gains additive idempotent `seconds_total`; record_session/
  touch_position accrue, new add_play_seconds() for the seconds-only
  path; the legacy-encoding stats merge sums seconds across duplicates.
- Passports surface it: "14.2 h in Blues" under the badge stamp and on
  the shelf cover sub-line — a true fact that only grows, never a
  target or a meter (Stage 5 post-cap, per the career design).

Tests: seconds accrual/validation/seconds-only branch (stats API),
per-instrument-and-genre summing (career), fmtHours formatting (vm).
Full suites: pytest 2480, JS 1165.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:12:23 +02:00
d26347981c feat(career): badge ceremony — the crowd erupts, the stamp drops (#941)
Earning a genre badge now stages the full moment (career v2, WS1):

- venue-crowd.js gains a public celebrate(): machine.force('ecstatic')
  commits instantly (bypassing STABLE_MS/DWELL_MS; the stamped
  lastSwitchAt makes the dwell window HOLD the forced state before the
  real perf machine reasserts) + a cheer stinger via the same
  _lastStingerAt=-Infinity bypass the end-of-song reaction uses. If a
  stinger/intro owns the idle layer, the ecstatic loop is queued via
  _pendingLoop exactly like onPerformanceState. No-op without a
  manifest/active venue.
- career detectNewBadges() calls badgeCeremony(): crowd first, then a
  body-appended full-screen overlay 300ms later (it cannot live in
  #pp-overlay — #plugin-career is display:none during playback): dimmed
  backdrop, the bronze stamp slamming in with a shine sweep, a 42-piece
  canvas confetti burst, click-or-4s dismiss.
- prefers-reduced-motion: chime + fbNotify only, no overlay.

Tests: machine.force commit/dwell-hold/bogus-state, celebrate export +
no-manifest no-op, celebrate-called-once-per-badge, crowd-absent and
crowd-throwing degradation.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:07:28 +02:00
14 changed files with 871 additions and 60 deletions
+36
View File
@@ -8,6 +8,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
a no-op without a venue pack) and a full-screen overlay drops the bronze
stamp with a shine sweep and a confetti burst over whatever screen is active
(badges land right after `stats:recorded`, while the player is still up).
Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing
chime + notification only. The stamp still slams into the passport book on
next open, unchanged.
- **Hours-per-genre odometer (career passports)** — the app now measures real
play time: the stats recorder accrues **wall-clock** seconds across
play/resume ↔ pause/stop/end spans (wall time, not song position — position
deltas double-count A-B loops and mis-read seeks; single spans clamp at 2h
against suspend/sleep inflation) and piggybacks them as `seconds` on the
`POST /api/stats` calls it already makes. New additive
`song_stats.seconds_total` column; a seconds-only POST banks time for
unscored plays that run to the song's natural end without touching the
resume position (and still counts as playing today for the streak).
Passports surface it honestly: "14.2 h in Blues" under the badge and on the
shelf cover — a true fact that only grows, never a target or a meter.
- **Career passport drills, curated** — Bronze in blues/rock/metal/funk/jazz
now also asks for that genre's signature Virtuoso drill (Blues Shuffle,
Power Chords & Backbeat, Gallop Picking, 16th Pocket, Shell Voicings — one
per genre, data-driven in `passports.json` with display labels). Drill
lists are per-instrument (`virtuoso_nodes: {instrument: [nodes]}`; a flat
list still means guitar), so a keys passport never demands a guitar drill.
A drill counts as cleared on the first real completion artifact — a
top-tier clean pass in one key (`keysCleared`), any depth rung, or
mastery — rather than only the maxed-speed depth flips. Genres without a
curated drill stay songs-only.
- **Career passport visuals pack** — earned covers and badge stamps become
trading cards (pointer-tracked tilt + light glint, hover-capable devices
only); the ghost stamp visibly "carves in" as qualifying songs land (a
conic ink fill, no numbers added); the Gold rung preview is a small foil
chip with a shimmer sweep, still honestly labeled coming. All theatrics
disabled under `prefers-reduced-motion`.
- **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
+46 -11
View File
@@ -614,6 +614,14 @@ class MetadataDB:
)
""")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)")
# Cumulative wall-clock play time (career "hours in genre" odometer).
# Fed by the same POST /api/stats the recorder already sends; additive
# + idempotent like every other song_stats change.
try:
self.conn.execute(
"ALTER TABLE song_stats ADD COLUMN seconds_total REAL NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Playlists + the reserved "Saved for Later" system playlist. Additive.
self.conn.execute("""
CREATE TABLE IF NOT EXISTS playlists (
@@ -901,6 +909,9 @@ class MetadataDB:
"best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0),
"last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"],
"last_position": newer["last_position"],
# Play time is additive: both encodings' hours belong to
# the one canonical song.
"seconds_total": (cur.get("seconds_total") or 0.0) + (r.get("seconds_total") or 0.0),
"last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"],
}
# Atomic swap: clear and reinsert the canonicalized set in one txn.
@@ -1693,7 +1704,8 @@ class MetadataDB:
# ── Per-song practice stats ───────────────────────────────────────────---
_STATS_COLS = (
"filename", "arrangement", "plays", "best_score", "best_accuracy",
"last_score", "last_accuracy", "last_position", "last_played_at", "updated_at",
"last_score", "last_accuracy", "last_position", "seconds_total",
"last_played_at", "updated_at",
)
def _stats_row(self, filename: str, arrangement: int) -> dict | None:
@@ -2060,8 +2072,9 @@ class MetadataDB:
self.conn.commit()
def record_session(self, filename: str, arrangement: int, *, score: int,
accuracy: float, last_position=None) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new."""
accuracy: float, last_position=None, seconds: float = 0) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new.
`seconds` (wall-clock play time from the recorder) accrues."""
from song_score import merge_stats
with self._lock:
existing = self._stats_row(filename, int(arrangement))
@@ -2071,8 +2084,9 @@ class MetadataDB:
self.conn.execute(
"""INSERT INTO song_stats
(filename, arrangement, plays, best_score, best_accuracy,
last_score, last_accuracy, last_position, last_played_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
last_score, last_accuracy, last_position, seconds_total,
last_played_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
plays = excluded.plays,
@@ -2081,32 +2095,53 @@ class MetadataDB:
last_score = excluded.last_score,
last_accuracy = excluded.last_accuracy,
last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), merged["plays"], merged["best_score"],
merged["best_accuracy"], merged["last_score"], merged["last_accuracy"],
merged["last_position"]),
merged["last_position"], float(seconds or 0)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
def touch_position(self, filename: str, arrangement: int, last_position: float) -> dict:
def touch_position(self, filename: str, arrangement: int, last_position: float,
seconds: float = 0) -> dict:
"""Persist just the resume position (no plays/score change), so
Continue-Playing works for non-scored plays. Also stamps
last_played_at both /api/stats/recent and /api/session/continue
filter/order on it, so a position-only touch must set it or the song
never surfaces as 'recent' / 'continue playing'."""
never surfaces as 'recent' / 'continue playing'. `seconds` accrues
wall-clock play time (career hours odometer)."""
with self._lock:
self.conn.execute(
"""INSERT INTO song_stats (filename, arrangement, last_position,
last_played_at, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
seconds_total, last_played_at, updated_at)
VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), float(last_position)),
(filename, int(arrangement), float(last_position), float(seconds or 0)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
def add_play_seconds(self, filename: str, arrangement: int, seconds: float) -> dict:
"""Accrue wall-clock play time only (no plays/score/position change) —
the recorder's seconds-only flush for unscored plays that ran to the
song's natural end (no resume position to touch there: `song:ended`
must not overwrite Continue with the end-of-song offset)."""
with self._lock:
self.conn.execute(
"""INSERT INTO song_stats (filename, arrangement, seconds_total, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
seconds_total = song_stats.seconds_total + excluded.seconds_total,
updated_at = excluded.updated_at""",
(filename, int(arrangement), float(seconds)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
+34 -2
View File
@@ -76,6 +76,22 @@ def api_record_stats(data: dict):
last_pos = data.get("lastPlayPosition", data.get("last_position"))
if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
# Optional wall-clock play time (career hours odometer). Bounded per POST:
# the recorder flushes on pause/stop/end, so a single delta beyond 6h is a
# clock artifact (suspend/sleep), not practice.
seconds = data.get("seconds")
if seconds is not None:
if isinstance(seconds, bool):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
try:
seconds = float(seconds)
if not math.isfinite(seconds):
raise ValueError("non-finite")
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
if not (0 < seconds <= 6 * 3600):
return JSONResponse({"error": "seconds must be between 0 and 21600"}, status_code=400)
seconds = seconds or 0.0
# A scored session needs BOTH score and accuracy. Exactly one provided is
# ambiguous — don't silently fall through to the position-only branch.
@@ -115,7 +131,8 @@ def api_record_stats(data: dict):
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
row = appstate.meta_db.record_session(filename, arrangement, score=score,
accuracy=accuracy, last_position=last_pos)
accuracy=accuracy, last_position=last_pos,
seconds=seconds)
# Unified XP + streak side-effects — never let these drop the stat write.
progress = None
try:
@@ -152,6 +169,21 @@ def api_record_stats(data: dict):
log.warning("stats side-effects (progression) failed", exc_info=True)
return {"stats": row, "progress": progress, "progression": progression_summary}
# Seconds-only accrual: an unscored play that ran to the song's natural
# end has play time to bank but no resume position to touch (song:ended
# must not overwrite Continue with the end-of-song offset). Still counts
# as playing today for the streak below.
if last_pos is None and seconds:
row = appstate.meta_db.add_play_seconds(filename, arrangement, seconds)
progress = None
try:
from datetime import date
appstate.meta_db.record_active_day(date.today().isoformat())
progress = appstate.meta_db.get_progress()
except Exception:
log.warning("stats side-effects (streak) failed", exc_info=True)
return {"stats": row, "progress": progress}
# Position-only touch.
if last_pos is None:
return JSONResponse(
@@ -162,7 +194,7 @@ def api_record_stats(data: dict):
pos = float(last_pos)
if not math.isfinite(pos):
raise ValueError("non-finite")
row = appstate.meta_db.touch_position(filename, arrangement, pos)
row = appstate.meta_db.touch_position(filename, arrangement, pos, seconds=seconds)
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
# A resume session still counts as playing today: advance the streak (no XP —
+151 -1
View File
@@ -124,7 +124,7 @@
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:not(.pp-tilt):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;
@@ -409,3 +409,153 @@
.pp-slam, .pp-stamp-page::after { opacity: 1; }
.pp-stamp-hidden { opacity: 0.92; }
}
/* Badge ceremony (body-level overlay — shows over the player) */
.pp-ceremony-overlay {
position: fixed;
inset: 0;
z-index: 220;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 7, 18, 0.55);
backdrop-filter: blur(1.5px);
animation: pp-ceremony-in 0.3s ease-out;
cursor: pointer;
}
.pp-ceremony-out { opacity: 0; transition: opacity 0.3s ease-out; }
.pp-confetti { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.pp-ceremony-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
}
.pp-ceremony-stamp {
position: relative;
overflow: hidden;
background: rgba(239, 230, 208, 0.97);
transform: rotate(var(--pp-rot)) scale(1.25);
animation: pp-slam 0.55s cubic-bezier(0.2, 0.8, 0.3, 1) 0.15s backwards;
margin-top: 0;
}
.pp-ceremony-stamp::before {
content: '';
position: absolute;
inset: -40%;
background: linear-gradient(115deg, transparent 42%, rgba(255, 255, 255, 0.55) 50%, transparent 58%);
transform: translateX(-120%);
animation: pp-shine 1.1s ease-out 0.75s forwards;
pointer-events: none;
}
@keyframes pp-shine {
to { transform: translateX(120%); }
}
@keyframes pp-ceremony-in {
from { opacity: 0; }
to { opacity: 1; }
}
.pp-ceremony-title {
margin-top: 1rem;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #f0e2c3;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
}
.pp-ceremony-sub { font-size: 0.8rem; color: #d1d5db; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); }
/* Hours odometer (Stage 5 post-cap — a true fact, never a meter) */
.pp-hours {
font-size: 0.72rem;
letter-spacing: 0.08em;
color: #8a7a5e;
margin-top: 0.75rem;
font-variant-numeric: tabular-nums;
}
/* ── Visuals pack: trading-card tilt, emerging ink, gold foil ──────────── */
/* Trading-card tilt (earned artifacts; JS feeds --pp-tilt-* on hover-capable
pointers only). */
.pp-tilt {
position: relative;
overflow: hidden;
will-change: transform;
}
.pp-cover.pp-tilt {
transform: perspective(700px)
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
rotate(var(--pp-cover-rot, 0deg));
transition: transform 0.12s ease, box-shadow 0.15s ease;
}
.pp-cover.pp-tilt:hover { box-shadow: 0 12px 26px rgba(0, 0, 0, 0.6); }
.pp-stamp-page.pp-tilt {
overflow: visible;
transform: perspective(600px)
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
rotate(var(--pp-rot));
transition: transform 0.12s ease;
}
.pp-tilt::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(105deg,
transparent calc(var(--pp-glint-x, 50%) - 14%),
rgba(255, 255, 255, 0.16) var(--pp-glint-x, 50%),
transparent calc(var(--pp-glint-x, 50%) + 14%));
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.pp-tilt:hover::after { opacity: 1; }
/* Emerging-stamp ink: the ghost fills as qualifying songs land. */
.pp-stamp-ghost::before {
content: '';
position: absolute;
inset: 7%;
border-radius: 999px;
background: conic-gradient(rgba(154, 91, 22, 0.16) var(--pp-fill, 0%), transparent 0);
pointer-events: none;
}
/* Gold foil preview — honest "coming", never earnable-looking. */
.pp-gold-foil {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 0.9rem;
padding: 0.28rem 0.85rem;
border-radius: 999px;
border: 2px dashed #c8b273;
color: #a8946d;
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.32em;
}
.pp-gold-foil::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 40%, rgba(255, 223, 128, 0.35) 50%, transparent 60%);
transform: translateX(-120%);
animation: pp-foil 3.4s ease-in-out infinite;
}
@keyframes pp-foil {
0%, 55% { transform: translateX(-120%); }
100% { transform: translateX(120%); }
}
@media (prefers-reduced-motion: reduce) {
.pp-gold-foil::after { animation: none; }
.pp-cover.pp-tilt, .pp-stamp-page.pp-tilt { transition: none; }
/* The hover glint is motion theatrics too — not just the JS tilt. */
.pp-tilt::after { display: none; }
}
+14 -1
View File
@@ -3,7 +3,20 @@
"songs": 5,
"min_stars": 2
},
"genres": {},
"genres": {
"blues": { "virtuoso_nodes": { "guitar": ["blues_shuffle"] } },
"rock": { "virtuoso_nodes": { "guitar": ["rock_power_backbeat"] } },
"metal": { "virtuoso_nodes": { "guitar": ["melodic_metal_gallop"] } },
"funk": { "virtuoso_nodes": { "guitar": ["sixteenth_pocket"] } },
"jazz": { "virtuoso_nodes": { "guitar": ["vl_shells"] } }
},
"drill_labels": {
"blues_shuffle": "Blues Shuffle",
"rock_power_backbeat": "Power Chords & Backbeat",
"melodic_metal_gallop": "Gallop Picking",
"sixteenth_pocket": "16th Pocket",
"vl_shells": "Shell Voicings"
},
"graded_instruments": [
"guitar",
"keys"
+77 -19
View File
@@ -203,21 +203,24 @@ def _instrument_of(arrangements, arrangement):
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()."""
"""((instrument, genre_key) → {filename: stub dict},
(instrument, genre_key) total played seconds).
Best accuracy per (instrument, song); seconds sum across every
arrangement row; the JOIN keeps the same dead-song filter as _stars()."""
db = _state["meta_db"]
if db is None:
return {}
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, "
" s.seconds_total, 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:
seconds = {}
for filename, arrangement, acc, played_at, secs, title, artist, arrs_json, genre in rows:
gkey = _genre_key(genre)
if not gkey:
continue
@@ -227,10 +230,12 @@ def _played_by_instrument_genre():
except (TypeError, ValueError):
arrs_cache[filename] = None
instrument = _instrument_of(arrs_cache[filename], arrangement)
key = (instrument, gkey)
seconds[key] = seconds.get(key, 0.0) + (secs or 0.0)
acc = acc or 0.0
stub = out.setdefault((instrument, gkey), {}).get(filename)
stub = out.setdefault(key, {}).get(filename)
if stub is None:
out[(instrument, gkey)][filename] = {
out[key][filename] = {
"filename": filename,
"title": title or filename,
"artist": artist or "",
@@ -245,7 +250,7 @@ def _played_by_instrument_genre():
acc = stub["best_accuracy"]
stub["best_accuracy"] = round(acc, 4)
stub["stars"] = sum(1 for t in thresholds if acc >= t)
return out
return out, seconds
def _library_genres():
@@ -271,7 +276,7 @@ def _library_genres():
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
def _badge_requirement(gkey):
def _badge_requirement(gkey, instrument="guitar"):
cfg = _state["passports_content"]
req = dict(cfg.get("badge_requirement") or {})
req.setdefault("songs", 5)
@@ -279,8 +284,15 @@ def _badge_requirement(gkey):
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)]
# virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its
# own instrument's drills. A flat list keeps meaning guitar (back-compat;
# virtuoso's drill content is guitar-first).
nodes = req.get("virtuoso_nodes") or []
if isinstance(nodes, dict):
nodes = nodes.get(instrument) or []
elif instrument != "guitar":
nodes = []
req["virtuoso_nodes"] = [n for n in nodes if isinstance(n, str)]
return req
@@ -293,21 +305,57 @@ def _drill_by_node():
return doc.get("received_at"), by_node
def _merge_drill_nodes(old, new):
"""Gained-only merge of virtuoso byNode snapshots: a completion artifact
once relayed never un-earns via a stale snapshot (multi-browser races,
settings import, the once-per-session boot relay). Incoming wins the
descriptive fields; masteredAt / depth flips / keysCleared only grow."""
out = dict(old)
for node_id, incoming in new.items():
if not isinstance(incoming, dict):
continue
cur = out.get(node_id)
if not isinstance(cur, dict):
out[node_id] = incoming
continue
merged = dict(cur)
merged.update(incoming)
merged["masteredAt"] = cur.get("masteredAt") or incoming.get("masteredAt")
d_old = cur.get("depth") if isinstance(cur.get("depth"), dict) else {}
d_new = incoming.get("depth") if isinstance(incoming.get("depth"), dict) else {}
depth = dict(d_new)
for axis, val in d_old.items():
if val and not depth.get(axis):
depth[axis] = val
if depth:
merged["depth"] = depth
keys_old = cur.get("keysCleared") if isinstance(cur.get("keysCleared"), list) else []
keys_new = incoming.get("keysCleared") if isinstance(incoming.get("keysCleared"), list) else []
merged["keysCleared"] = keys_old + [k for k in keys_new if k not in keys_old]
out[node_id] = merged
return out
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)."""
"""A drill counts as cleared on real completion evidence: mastered, any
depth rung flipped true, or a key cleared (a top-tier clean pass in one
key virtuoso's first gained-only artifact, and an achievable Bronze
bar; the depth rungs additionally require a maxed speed tier)."""
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())
keys = entry.get("keysCleared")
return (bool(entry.get("masteredAt"))
or any(bool(v) for v in depth.values())
or bool(isinstance(keys, list) and keys))
def _passports_view():
cfg = _state["passports_content"]
graded = set(cfg.get("graded_instruments") or [])
st = _career_state()
played = _played_by_instrument_genre()
played, played_seconds = _played_by_instrument_genre()
received_at, by_node = _drill_by_node()
instruments = {}
for inst in cfg.get("instruments") or []:
@@ -318,7 +366,7 @@ def _passports_view():
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)
req = _badge_requirement(gkey, inst)
songs = list(played.get((inst, gkey), {}).values())
for s in songs:
s["qualifies"] = s["stars"] >= req["min_stars"]
@@ -345,6 +393,9 @@ def _passports_view():
"graded": is_graded,
"songs": songs,
"qualifying_count": qualifying,
# Honest hours odometer (Stage 5 post-cap): a true fact that
# only grows — never a target, never a meter.
"seconds_total": round(played_seconds.get((inst, gkey), 0.0), 1),
"drills": {"required": required, "cleared": cleared},
"badge": badge,
})
@@ -354,6 +405,8 @@ def _passports_view():
"badge_requirement": cfg.get("badge_requirement") or {},
"graded_instruments": sorted(graded),
"instruments": list(cfg.get("instruments") or []),
# Career-side display names for virtuoso drill node ids.
"drill_labels": dict(cfg.get("drill_labels") or {}),
},
"instruments": instruments,
"genres": _library_genres(),
@@ -528,11 +581,16 @@ def setup(app, context):
# 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:
# Bound the INCOMING snapshot before the merge — the gained-only merge
# drops junk entries, which must not become a size-guard bypass.
if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
with _lock:
_, existing = _drill_by_node()
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
"byNode": _merge_drill_nodes(existing, body["byNode"])}
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
_save_json(_drill_file(), {"received_at": _now_iso(),
"snapshot": snapshot})
return {"ok": True}
+214 -17
View File
@@ -34,6 +34,8 @@
let _ppRelayTimer = 0;
let _ppBook = null; // {inst, gkey} of the open spread
let _ppReturnFocus = null; // element to refocus when the book closes
let _ppCeremonyQueue = []; // badges awaiting their ceremony overlay
let _ppCeremonyActive = false;
let _ppBootstrapped = false;
let _ppNotified = {}; // badges chimed this session (slam still pending)
@@ -308,9 +310,10 @@
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).
// New badge → chime + notification + the venue ceremony, 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 || {})) {
@@ -326,10 +329,109 @@
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
});
}
badgeCeremony(inst, p);
}
}
}
function reducedMotion() {
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; }
}
// The badge moment: the crowd erupts first (if a venue pack is live —
// badges land post-stats:recorded while the player is still on screen),
// then a body-level overlay. It CANNOT live in #pp-overlay: #plugin-career
// is display:none during playback.
function badgeCeremony(inst, p) {
// Reduced motion: the chime + fbNotify already delivered the news —
// no overlay, and no app-initiated crowd eruption either.
if (reducedMotion()) return;
const crowd = window.v3VenueCrowd;
if (crowd && typeof crowd.celebrate === 'function') {
try { crowd.celebrate(); } catch (_) { /* crowd layer optional */ }
}
if (!document.body || typeof document.createElement !== 'function') return;
// Several badges can land in one refresh (first load, drill-snapshot
// bootstrap): queue the ceremonies and play them back to back.
_ppCeremonyQueue.push({ inst, p });
if (!_ppCeremonyActive) setTimeout(drainCeremonies, 300);
}
function drainCeremonies() {
if (_ppCeremonyActive) return;
const queued = _ppCeremonyQueue.shift();
if (!queued) return;
_ppCeremonyActive = true;
showCeremonyOverlay(queued.inst, queued.p, () => {
_ppCeremonyActive = false;
setTimeout(drainCeremonies, 250);
});
}
function showCeremonyOverlay(inst, p, done) {
const el = document.createElement('div');
el.id = 'pp-ceremony';
el.className = 'pp-ceremony-overlay';
el.innerHTML = `
<canvas class="pp-confetti"></canvas>
<div class="pp-ceremony-card">
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp" 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-ceremony-title">Badge earned</div>
<div class="pp-ceremony-sub">${esc(p.genre)} ${esc(ppLabel(inst))} passport</div>
</div>`;
let timer = 0;
let closed = false;
const dismiss = () => {
if (closed) return;
closed = true;
clearTimeout(timer);
el.classList.add('pp-ceremony-out');
setTimeout(() => { el.remove(); done(); }, 350);
};
el.addEventListener('click', dismiss);
document.body.appendChild(el);
timer = setTimeout(dismiss, 4200);
confettiBurst(el.querySelector('.pp-confetti'));
}
function confettiBurst(canvas) {
if (!canvas || typeof canvas.getContext !== 'function') return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
const colors = ['#d9a253', '#b45309', '#facc15', '#06b6d4', '#e5e7eb'];
const parts = Array.from({ length: 42 }, () => ({
x: canvas.width / 2 + (Math.random() - 0.5) * 90,
y: canvas.height * 0.42,
vx: (Math.random() - 0.5) * 9,
vy: -(4 + Math.random() * 7),
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.3,
w: 5 + Math.random() * 5,
h: 3 + Math.random() * 4,
c: colors[(Math.random() * colors.length) | 0],
}));
let frames = 0;
(function tick() {
if (!canvas.isConnected || frames++ > 240) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const q of parts) {
q.x += q.vx; q.y += q.vy; q.vy += 0.18; q.rot += q.vr;
ctx.save();
ctx.translate(q.x, q.y);
ctx.rotate(q.rot);
ctx.fillStyle = q.c;
ctx.fillRect(-q.w / 2, -q.h / 2, q.w, q.h);
ctx.restore();
}
requestAnimationFrame(tick);
}());
}
// Relay the Virtuoso drill snapshot (localStorage doc, not the thin bus
// payload) to the server intake, debounced across event bursts.
function relayDrillState() {
@@ -358,23 +460,40 @@
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();
// Sync the local drill snapshot once per session — drill progress
// made before the career plugin existed (or a relay POST that
// failed) must not deny a gated badge until the next virtuoso
// event happens to fire. Tiny payload, single-user app.
relayDrillState();
}
}
// Honest hours odometer (Stage 5 post-cap). Below a minute of history
// there is nothing meaningful to show.
function fmtHours(seconds) {
const s = Number(seconds) || 0;
if (s < 60) return '';
if (s < 3600) return `${Math.round(s / 60)} min`;
return `${(s / 3600).toFixed(1).replace(/\.0$/, '')} h`;
}
function ppCoverHTML(inst, p) {
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
const stamp = p.badge === 'earned'
const earned = p.badge === 'earned';
const stamp = 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)">
const hours = fmtHours(p.seconds_total);
// Earned covers are trading cards: rotation moves into a CSS var so
// the pointer-tracked tilt transform can compose with it.
const style = earned
? `--pp-cover-rot:${rot}deg` : `transform:rotate(${rot}deg)`;
return `<button class="pp-cover${earned ? ' pp-tilt' : ''} pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="${style}">
<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>
<span class="pp-cover-sub">${stubs}${hours ? ` · ${hours}` : ''}</span>
</button>`;
}
@@ -446,32 +565,58 @@
</div>`;
}
// Emerging-stamp ink: how much of the ghost stamp has "carved in".
// Song progress toward the bar only — the invite line stays the words.
function ppFillFraction(p) {
if (!p || p.badge !== 'in_progress') return 0;
const need = Number((p.requirement || {}).songs) || 0;
if (need <= 0) return 0;
return Math.max(0, Math.min(1, (p.qualifying_count || 0) / need));
}
function ppBookHTML(inst, p, pendingSlam) {
const req = p.requirement || {};
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
const starGl = '★'.repeat(req.min_stars || 0);
const reqNodes = (p.drills || {}).required || [];
const clearedNodes = new Set((p.drills || {}).cleared || []);
const labels = ((_pp && _pp.config) || {}).drill_labels || {};
const pendingDrills = reqNodes.filter((n) => !clearedNodes.has(n));
// The invite names what actually blocks the stamp: songs first, then
// the genre drill once the song bar is met.
let invite;
if (need > 0) {
invite = need === 1 ? `One more ${starGl} song mints this stamp.`
: `${need} more ${starGl} songs mint this stamp.`;
} else {
const names = pendingDrills.map((n) => labels[n] || n).join(', ');
invite = `Clear ${names || 'the genre drill'} in Virtuoso to mint this stamp.`;
}
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">
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}" 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-foil" aria-hidden="true">GOLD</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">
const fill = (ppFillFraction(p) * 100).toFixed(0);
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
<span class="pp-stamp-tier">BRONZE</span>
</div>
<div class="pp-invite">${need === 1 ? `One more ${starGl} song mints this stamp.` : `${need} more ${starGl} songs mint this stamp.`}</div>`;
<div class="pp-invite">${esc(invite)}</div>`;
}
const hours = fmtHours(p.seconds_total);
const odometer = hours
? `<div class="pp-hours">${hours} in ${esc(p.genre)}</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>`;
`<div class="pp-drill${clearedNodes.has(n) ? ' cleared' : ''}">${clearedNodes.has(n) ? '✓' : '○'} ${esc(labels[n] || 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.
@@ -487,7 +632,7 @@
<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}
${badgeArea}${odometer}${drills}
</div>
<div class="pp-page pp-page-right">
<div class="pp-page-head">Ticket stubs</div>
@@ -529,6 +674,7 @@
if (!stamp) return;
stamp.classList.remove('pp-stamp-hidden');
stamp.classList.add('pp-slam');
stamp.classList.add('pp-tilt'); // freshly slammed = trading card too
if (book) book.classList.add('pp-shake');
sfx('stamp');
markBadgeSeen(inst, gkey);
@@ -579,6 +725,52 @@
}, 1500);
}
// ── Trading-card tilt (earned artifacts only) ─────────────────────────
let _tiltRaf = 0;
let _tiltEl = null;
function tiltAllowed() {
try {
return window.matchMedia('(hover: hover)').matches &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
} catch (_) { return false; }
}
function resetTilt(el) {
if (!el) return;
el.style.removeProperty('--pp-tilt-x');
el.style.removeProperty('--pp-tilt-y');
el.style.removeProperty('--pp-glint-x');
}
function onTiltMove(e) {
if (!tiltAllowed()) return;
const card = e.target && e.target.closest ? e.target.closest('.pp-tilt') : null;
if (_tiltEl && _tiltEl !== card) { resetTilt(_tiltEl); _tiltEl = null; }
if (!card) return;
_tiltEl = card;
if (_tiltRaf) return;
const x = e.clientX;
const y = e.clientY;
_tiltRaf = requestAnimationFrame(() => {
_tiltRaf = 0;
const r = card.getBoundingClientRect();
if (!r.width || !r.height) return;
const px = (x - r.left) / r.width;
const py = (y - r.top) / r.height;
card.style.setProperty('--pp-tilt-x', `${((0.5 - py) * 10).toFixed(2)}deg`);
card.style.setProperty('--pp-tilt-y', `${((px - 0.5) * 12).toFixed(2)}deg`);
card.style.setProperty('--pp-glint-x', `${(px * 100).toFixed(1)}%`);
});
}
function onTiltLeave() {
// Cancel any queued frame: it closes over the departed card and would
// re-apply tilt vars after the pointer has left.
if (_tiltRaf) { cancelAnimationFrame(_tiltRaf); _tiltRaf = 0; }
if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
}
function openGenre(inst, genre) {
fetch(`${API}/passports/open`, {
method: 'POST',
@@ -669,7 +861,11 @@
function boot() {
const screen = document.getElementById('plugin-career');
if (screen) screen.addEventListener('click', onClick);
if (screen) {
screen.addEventListener('click', onClick);
screen.addEventListener('pointermove', onTiltMove);
screen.addEventListener('pointerleave', onTiltLeave);
}
const sm = window.feedBack;
if (sm && typeof sm.on === 'function') {
// New song stats can add stars → thresholds may cross mid-session.
@@ -690,6 +886,7 @@
// the badge-diff logic; nothing here touches the DOM.
window.__careerPassportTest = {
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
fmtHours, ppFillFraction,
};
if (document.readyState === 'loading') {
+51
View File
@@ -88,6 +88,33 @@ test('detectNewBadges notifies once per badge, never after it is seen', () => {
assert.equal(w2.notifications.length, 0);
});
test('a new badge triggers the crowd celebrate() exactly once', () => {
const w = load();
let calls = 0;
w.v3VenueCrowd = { celebrate: () => { calls += 1; } };
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
w.__careerPassportTest.detectNewBadges(view);
assert.equal(calls, 1);
// Same session, same view: no re-celebration.
w.__careerPassportTest.detectNewBadges(view);
assert.equal(calls, 1);
});
test('ceremony degrades when the crowd layer is absent or throws', () => {
const w = load();
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
// No v3VenueCrowd at all (already exercised elsewhere, explicit here).
w.__careerPassportTest.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// celebrate() throwing must not break detection.
const w2 = load();
w2.v3VenueCrowd = { celebrate: () => { throw new Error('no pack'); } };
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 1);
});
test('seenBadges tolerates corrupt stored values', () => {
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
const w = load({ 'feedBack-career-badges-seen': bad });
@@ -99,3 +126,27 @@ test('seenBadges tolerates corrupt stored values', () => {
assert.equal(w.notifications.length, 1, `stored ${bad}`);
}
});
test('fmtHours: silent under a minute, minutes under an hour, tenths after', () => {
const { fmtHours } = load().__careerPassportTest;
assert.equal(fmtHours(0), '');
assert.equal(fmtHours(59), '');
assert.equal(fmtHours(60), '1 min');
assert.equal(fmtHours(1800), '30 min');
assert.equal(fmtHours(3600), '1 h');
assert.equal(fmtHours(51120), '14.2 h');
assert.equal(fmtHours(null), '');
assert.equal(fmtHours('junk'), '');
});
test('ppFillFraction: song progress toward the bar, in-progress only', () => {
const { ppFillFraction } = load().__careerPassportTest;
const p = (badge, q, songs) => ({ badge, qualifying_count: q, requirement: { songs } });
assert.equal(ppFillFraction(p('in_progress', 3, 5)), 0.6);
assert.equal(ppFillFraction(p('in_progress', 0, 5)), 0);
assert.equal(ppFillFraction(p('in_progress', 7, 5)), 1); // clamped
assert.equal(ppFillFraction(p('earned', 5, 5)), 0); // no fill once earned
assert.equal(ppFillFraction(p('shown_not_judged', 3, 5)), 0);
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
assert.equal(ppFillFraction(null), 0);
});
+77 -3
View File
@@ -27,7 +27,60 @@
let cur = null; // active session
let recordedThisSession = false;
// Wall-clock play time (career hours odometer). Accrued across
// play/resume ↔ pause/stop/ended spans — wall time, NOT song position:
// position deltas double-count A-B loops and mis-read seeks.
let playingSince = 0; // performance.now() at span start, 0 while not playing
let accruedSeconds = 0; // played time not yet sent
// Failed seconds keep their song identity — restoring them into the
// global accumulator would let the NEXT song claim them after a session
// switch. Bounded; oldest dropped beyond the cap (honest loss beats
// misattribution).
let pendingSeconds = []; // [{filename, arrangement, seconds}] awaiting retry
function queuePendingSeconds(filename, arrangement, seconds) {
pendingSeconds.push({ filename, arrangement, seconds });
if (pendingSeconds.length > 20) pendingSeconds.shift();
}
function retryPendingSeconds() {
if (!pendingSeconds.length) return;
const batch = pendingSeconds;
pendingSeconds = [];
for (const body of batch) {
post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, body.seconds); });
}
}
function clockStart() { if (!playingSince) playingSince = performance.now(); }
function clockStop() {
if (!playingSince) return;
const delta = (performance.now() - playingSince) / 1000;
playingSince = 0;
// A single unbroken span beyond 2h of wall clock is a suspend/sleep
// artifact, not practice — clamp it.
if (Number.isFinite(delta) && delta > 0) accruedSeconds += Math.min(delta, 7200);
}
// Take whatever has accrued (closing any open span) for sending; the
// caller restores it if the POST fails so the time isn't lost.
function takeSeconds() {
clockStop();
const s = Math.round(accruedSeconds);
accruedSeconds = 0;
return s > 0 ? s : 0;
}
// Unsent seconds belong to the outgoing song/arrangement — flush before
// a session reset would re-attribute them.
function flushSeconds() {
const s = takeSeconds();
if (!s) return;
if (!cur || !cur.filename) return; // no session to attribute to — drop
const body = { filename: cur.filename, arrangement: cur.arrangement, seconds: s };
post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, s); });
}
function reset(filename, arrangement) {
flushSeconds();
cur = {
filename: filename || null,
arrangement: Number.isFinite(arrangement) ? arrangement : 0,
@@ -84,6 +137,7 @@
if (!cur || !cur.filename || recordedThisSession) return;
if (!cur.scored || (cur.hits + cur.misses) <= 0) return; // no real scoring this session
recordedThisSession = true;
const seconds = takeSeconds();
const body = {
filename: cur.filename,
arrangement: cur.arrangement,
@@ -94,7 +148,9 @@
bestStreak: cur.bestStreak,
lastPlayPosition: Number.isFinite(position) ? position : cur.lastTime,
};
if (seconds) body.seconds = seconds;
post(body).then(async (response) => {
if (response == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds);
await notifyProgression(response, body, !!natural);
// Refresh the profile badge AFTER the progression state moved so
// the rank/dB it renders are post-award values.
@@ -112,7 +168,10 @@
// Allow 0: restarting a song and stopping at the very beginning must be
// able to clear a stale Continue offset. Only negatives are invalid.
if (!Number.isFinite(position) || position < 0) return;
post({ filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position });
const seconds = takeSeconds();
const body = { filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position };
if (seconds) body.seconds = seconds;
post(body).then((r) => { if (r == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds); });
}
// ── Session lifecycle ─────────────────────────────────────────────────--
@@ -164,13 +223,28 @@
});
});
// ── Play-time clock ───────────────────────────────────────────────────--
sm.on('song:play', () => { clockStart(); retryPendingSeconds(); });
sm.on('song:resume', clockStart);
// ── Finalize / resume-position ────────────────────────────────────────--
sm.on('song:ended', (e) => finalizeScored(e && e.detail && e.detail.time, true));
sm.on('song:pause', (e) => touchPosition(e && e.detail && e.detail.time));
sm.on('song:ended', (e) => {
clockStop();
finalizeScored(e && e.detail && e.detail.time, true);
// Unscored natural end: no finalize POST and no position touch
// (Continue must not point at the end of the song) — bank the play
// time on its own.
flushSeconds();
});
sm.on('song:pause', (e) => {
clockStop();
touchPosition(e && e.detail && e.detail.time);
});
sm.on('song:stop', (e) => {
// Record the scored session if it wasn't already (e.g. user closed the
// player before the track ended), then persist the resume position.
// Not a natural end — no calibration-retry prompt for deliberate quits.
clockStop();
const t = e && e.detail && e.detail.time;
finalizeScored(t, false);
touchPosition(t);
+30
View File
@@ -59,6 +59,15 @@
candidate = null;
lastSwitchAt = -Infinity;
},
// Commit a state NOW, bypassing stability/dwell (badge ceremony).
// Stamping lastSwitchAt makes the dwell window hold the forced
// state before the real perf machine can reassert.
force(state, nowMs) {
if (!CROWD_STATES.includes(state)) return;
current = state;
candidate = null;
lastSwitchAt = nowMs;
},
// Feed the latest perf state; returns the new crowd state when a
// transition commits, else null.
update(perfState, nowMs) {
@@ -596,6 +605,26 @@
if (dev && !_manifest) setManifest(dev);
}
// Badge-ceremony hook (career passports): the crowd erupts NOW — ecstatic
// loop bypassing stability/dwell (the dwell window then holds it while
// the real perf state waits its turn) plus a cheer. Degrades to a no-op
// without a pack / outside the player, like every other entry point.
function celebrate() {
if (!_venueActive || !_manifest || !_videos[0]) return false;
machine.force('ecstatic', now());
if (_stingerUntilEnded || _introActive) {
// A stinger/intro owns the idle layer (likely the end-of-song
// accuracy cheer — the crowd is already reacting); queue the
// ecstatic loop for when it ends, same as onPerformanceState.
_pendingLoop = 'ecstatic';
} else {
showLoop('ecstatic', FADE_MS);
_lastStingerAt = -Infinity; // a badge earn always gets its cheer
playStinger('cheer');
}
return true;
}
function getState() {
return {
venueActive: _venueActive,
@@ -621,6 +650,7 @@
setVenueActive,
bindRuntime,
getState,
celebrate,
};
if (root) root.v3VenueCrowd = api;
+19
View File
@@ -128,3 +128,22 @@ test('venue-scene-3d activates/deactivates the crowd layer', () => {
assert.match(src, /syncCrowd\(false\)/);
assert.match(src, /v3VenueCrowd/);
});
test('machine.force commits instantly and dwell holds the forced state', () => {
const m = crowd.createCrowdMachine();
m.force('ecstatic', 100000);
assert.equal(m.current, 'ecstatic');
// The real perf state cannot reassert until the dwell window passes.
m.update('smoke', 100000 + crowd.STABLE_MS);
assert.equal(m.update('smoke', 100000 + crowd.DWELL_MS - 1), null);
assert.equal(m.current, 'ecstatic');
assert.equal(m.update('smoke', 100000 + crowd.DWELL_MS), 'bored');
// Bogus states are ignored.
m.force('confused', 200000);
assert.equal(m.current, 'bored');
});
test('celebrate() is exported and no-ops without a manifest/active venue', () => {
assert.equal(typeof crowd.celebrate, 'function');
assert.equal(crowd.celebrate(), false);
});
+6 -4
View File
@@ -26,7 +26,8 @@ class FakeMetaDb:
self.conn.execute(
"""CREATE TABLE song_stats (
filename TEXT, arrangement TEXT, best_accuracy REAL,
last_played_at TEXT
last_played_at TEXT,
seconds_total REAL NOT NULL DEFAULT 0
)"""
)
self.conn.execute(
@@ -37,9 +38,10 @@ class FakeMetaDb:
)
def add(self, filename, arrangement, best_accuracy, in_library=True,
genre="", arrangements=None, last_played_at=None):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?)",
(filename, arrangement, best_accuracy, last_played_at))
genre="", arrangements=None, last_played_at=None, seconds_total=0):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)",
(filename, arrangement, best_accuracy, last_played_at,
seconds_total))
if in_library:
self.conn.execute(
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
+67 -2
View File
@@ -27,13 +27,47 @@ def _passport(client, instrument="guitar", genre_key="blues"):
def test_badge_earned_at_five_genre_songs_two_stars(client, meta_db):
# Soul has no curated drill requirement — songs alone mint the badge.
for i in range(5):
meta_db.add(f"soul{i}.feedpak", 0, 0.8, genre="Soul", arrangements=LEAD)
_open(client, "guitar", "Soul")
p = _passport(client, "guitar", "soul")
assert p["badge"] == "earned"
assert p["qualifying_count"] == 5
assert all(s["qualifies"] and s["stars"] == 2 for s in p["songs"])
def test_shipped_blues_drill_gates_and_keys_cleared_clears_it(client, meta_db):
# Blues ships a guitar drill (blues_shuffle): songs alone are not enough.
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"] == "in_progress"
assert p["drills"]["required"] == ["blues_shuffle"]
# One key cleared (a top-tier clean pass) counts as cleared — the depth
# rungs are a higher bar than Bronze needs.
res = client.post("/api/plugins/career/drill-state", json={
"mode": "casual", "xp": 10,
"byNode": {"blues_shuffle": {"reps": 12, "keysCleared": ["E"],
"depth": {"travel": None, "clean": None},
"masteredAt": None}}})
assert res.status_code == 200
p = _passport(client)
assert p["drills"]["cleared"] == ["blues_shuffle"]
assert p["badge"] == "earned"
def test_drill_lists_are_per_instrument(client, meta_db):
# Keys is graded but Blues curates only a GUITAR drill — a keys passport
# earns on songs alone.
keys_arr = [{"type": "lead", "name": "Keys"}]
for i in range(5):
meta_db.add(f"kb{i}.feedpak", 0, 0.9, genre="Blues", arrangements=keys_arr)
_open(client, "keys")
p = _passport(client, "keys")
assert p["drills"]["required"] == []
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):
@@ -143,3 +177,34 @@ def test_drill_state_validation(client):
huge = {"byNode": {"pad": "x" * (300 * 1024)}}
assert client.post("/api/plugins/career/drill-state",
json=huge).status_code == 413
def test_hours_odometer_sums_seconds_per_instrument_and_genre(client, meta_db):
both = [{"type": "lead", "name": "Lead"}, {"type": "bass", "name": "Bass"}]
# Two lead arrangements' time sums; the bass row stays on the bass passport.
meta_db.add("a.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=600)
meta_db.add("b.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=300)
meta_db.add("b.feedpak", 1, 0.9, genre="Blues", arrangements=both, seconds_total=1200)
_open(client, "guitar")
_open(client, "bass")
assert _passport(client, "guitar")["seconds_total"] == 900
assert _passport(client, "bass")["seconds_total"] == 1200
def test_drill_state_merge_is_gained_only(client, meta_db):
# A cleared drill survives a later STALE snapshot that lacks it
# (multi-browser race / settings import / the boot relay).
for i in range(5):
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
_open(client)
client.post("/api/plugins/career/drill-state", json={
"byNode": {"blues_shuffle": {"keysCleared": ["E"]}}})
assert _passport(client)["badge"] == "earned"
# Stale relay: empty byNode, then one with the node but nothing earned.
client.post("/api/plugins/career/drill-state", json={"byNode": {}})
client.post("/api/plugins/career/drill-state", json={
"byNode": {"blues_shuffle": {"reps": 2, "keysCleared": [],
"depth": {"travel": None}, "masteredAt": None}}})
p = _passport(client)
assert p["drills"]["cleared"] == ["blues_shuffle"]
assert p["badge"] == "earned"
+49
View File
@@ -452,3 +452,52 @@ def test_award_xp_negative_reversal_clamps_at_zero(server):
db.award_xp(50, "minigames")
assert db.award_xp(-50, "minigames") == 0 # exact reversal
assert db.award_xp(-999, "minigames") == 0 # over-reverse clamps at 0
# ── Wall-clock play-time accrual (career hours odometer) ─────────────────────
def test_seconds_accrue_on_scored_and_position_posts(client):
r = client.post("/api/stats", json={"filename": "s.archive", "score": 400,
"accuracy": 0.6, "seconds": 120})
assert r.status_code == 200
assert r.json()["stats"]["seconds_total"] == pytest.approx(120)
# Position-only touch accrues too.
r2 = client.post("/api/stats", json={"filename": "s.archive",
"lastPlayPosition": 12.5, "seconds": 30})
assert r2.json()["stats"]["seconds_total"] == pytest.approx(150)
# A POST without seconds leaves the total alone.
r3 = client.post("/api/stats", json={"filename": "s.archive", "lastPlayPosition": 20.0})
assert r3.json()["stats"]["seconds_total"] == pytest.approx(150)
def test_seconds_only_post_accrues_without_touching_position(client):
client.post("/api/stats", json={"filename": "s.archive", "lastPlayPosition": 42.0})
r = client.post("/api/stats", json={"filename": "s.archive", "seconds": 90})
assert r.status_code == 200
row = r.json()["stats"]
assert row["seconds_total"] == pytest.approx(90)
# No plays counted, resume position untouched (song:ended must not
# overwrite Continue with the end-of-song offset).
assert row["plays"] == 0
assert row["last_position"] == pytest.approx(42.0)
# Still counts as playing today for the streak.
assert r.json()["progress"]["current_streak"] == 1
@pytest.mark.parametrize("bad", [True, "soon", -5, 0, 6 * 3600 + 1])
def test_seconds_validation_rejects_junk(client, bad):
r = client.post("/api/stats", json={"filename": "s.archive",
"lastPlayPosition": 1.0, "seconds": bad})
assert r.status_code == 400
@pytest.mark.parametrize("token", ["NaN", "Infinity"])
def test_seconds_validation_rejects_nonfinite(client, token):
# json= cannot serialize non-finite floats; python's json.loads (and thus
# the server's body parse) accepts the bare tokens, so send raw.
r = client.post(
"/api/stats",
content=f'{{"filename": "s.archive", "lastPlayPosition": 1.0, "seconds": {token}}}',
headers={"Content-Type": "application/json"},
)
assert r.status_code == 400