mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 10:38:32 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d014a5575 | ||
|
|
0fc6a4beed | ||
|
|
d26347981c |
@@ -8,6 +8,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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 passports (backend)** — the badge-journey layer on top of career stars.
|
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
||||||
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
||||||
passport walls: genre badges computed on read from `song_stats` × the library's
|
passport walls: genre badges computed on read from `song_stats` × the library's
|
||||||
|
|||||||
+46
-11
@@ -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)")
|
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.
|
# Playlists + the reserved "Saved for Later" system playlist. Additive.
|
||||||
self.conn.execute("""
|
self.conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS playlists (
|
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),
|
"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_score": newer["last_score"], "last_accuracy": newer["last_accuracy"],
|
||||||
"last_position": newer["last_position"],
|
"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"],
|
"last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"],
|
||||||
}
|
}
|
||||||
# Atomic swap: clear and reinsert the canonicalized set in one txn.
|
# Atomic swap: clear and reinsert the canonicalized set in one txn.
|
||||||
@@ -1693,7 +1704,8 @@ class MetadataDB:
|
|||||||
# ── Per-song practice stats ───────────────────────────────────────────---
|
# ── Per-song practice stats ───────────────────────────────────────────---
|
||||||
_STATS_COLS = (
|
_STATS_COLS = (
|
||||||
"filename", "arrangement", "plays", "best_score", "best_accuracy",
|
"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:
|
def _stats_row(self, filename: str, arrangement: int) -> dict | None:
|
||||||
@@ -2060,8 +2072,9 @@ class MetadataDB:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def record_session(self, filename: str, arrangement: int, *, score: int,
|
def record_session(self, filename: str, arrangement: int, *, score: int,
|
||||||
accuracy: float, last_position=None) -> dict:
|
accuracy: float, last_position=None, seconds: float = 0) -> dict:
|
||||||
"""Record a scored play: plays += 1, best_* = max, last_* = new."""
|
"""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
|
from song_score import merge_stats
|
||||||
with self._lock:
|
with self._lock:
|
||||||
existing = self._stats_row(filename, int(arrangement))
|
existing = self._stats_row(filename, int(arrangement))
|
||||||
@@ -2071,8 +2084,9 @@ class MetadataDB:
|
|||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""INSERT INTO song_stats
|
"""INSERT INTO song_stats
|
||||||
(filename, arrangement, plays, best_score, best_accuracy,
|
(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,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
last_played_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
|
strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||||
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
||||||
plays = excluded.plays,
|
plays = excluded.plays,
|
||||||
@@ -2081,32 +2095,53 @@ class MetadataDB:
|
|||||||
last_score = excluded.last_score,
|
last_score = excluded.last_score,
|
||||||
last_accuracy = excluded.last_accuracy,
|
last_accuracy = excluded.last_accuracy,
|
||||||
last_position = excluded.last_position,
|
last_position = excluded.last_position,
|
||||||
|
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
||||||
last_played_at = excluded.last_played_at,
|
last_played_at = excluded.last_played_at,
|
||||||
updated_at = excluded.updated_at""",
|
updated_at = excluded.updated_at""",
|
||||||
(filename, int(arrangement), merged["plays"], merged["best_score"],
|
(filename, int(arrangement), merged["plays"], merged["best_score"],
|
||||||
merged["best_accuracy"], merged["last_score"], merged["last_accuracy"],
|
merged["best_accuracy"], merged["last_score"], merged["last_accuracy"],
|
||||||
merged["last_position"]),
|
merged["last_position"], float(seconds or 0)),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return self._stats_row(filename, int(arrangement))
|
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
|
"""Persist just the resume position (no plays/score change), so
|
||||||
Continue-Playing works for non-scored plays. Also stamps
|
Continue-Playing works for non-scored plays. Also stamps
|
||||||
last_played_at — both /api/stats/recent and /api/session/continue
|
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
|
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:
|
with self._lock:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""INSERT INTO song_stats (filename, arrangement, last_position,
|
"""INSERT INTO song_stats (filename, arrangement, last_position,
|
||||||
last_played_at, updated_at)
|
seconds_total, last_played_at, updated_at)
|
||||||
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
|
VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
|
||||||
strftime('%Y-%m-%d %H:%M:%f','now'))
|
strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||||
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
||||||
last_position = excluded.last_position,
|
last_position = excluded.last_position,
|
||||||
|
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
||||||
last_played_at = excluded.last_played_at,
|
last_played_at = excluded.last_played_at,
|
||||||
updated_at = excluded.updated_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()
|
self.conn.commit()
|
||||||
return self._stats_row(filename, int(arrangement))
|
return self._stats_row(filename, int(arrangement))
|
||||||
|
|||||||
+34
-2
@@ -76,6 +76,22 @@ def api_record_stats(data: dict):
|
|||||||
last_pos = data.get("lastPlayPosition", data.get("last_position"))
|
last_pos = data.get("lastPlayPosition", data.get("last_position"))
|
||||||
if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus 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)
|
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
|
# A scored session needs BOTH score and accuracy. Exactly one provided is
|
||||||
# ambiguous — don't silently fall through to the position-only branch.
|
# 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):
|
except (TypeError, ValueError, OverflowError):
|
||||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||||
row = appstate.meta_db.record_session(filename, arrangement, score=score,
|
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.
|
# Unified XP + streak side-effects — never let these drop the stat write.
|
||||||
progress = None
|
progress = None
|
||||||
try:
|
try:
|
||||||
@@ -152,6 +169,21 @@ def api_record_stats(data: dict):
|
|||||||
log.warning("stats side-effects (progression) failed", exc_info=True)
|
log.warning("stats side-effects (progression) failed", exc_info=True)
|
||||||
return {"stats": row, "progress": progress, "progression": progression_summary}
|
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.
|
# Position-only touch.
|
||||||
if last_pos is None:
|
if last_pos is None:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -162,7 +194,7 @@ def api_record_stats(data: dict):
|
|||||||
pos = float(last_pos)
|
pos = float(last_pos)
|
||||||
if not math.isfinite(pos):
|
if not math.isfinite(pos):
|
||||||
raise ValueError("non-finite")
|
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):
|
except (TypeError, ValueError, OverflowError):
|
||||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
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 —
|
# A resume session still counts as playing today: advance the streak (no XP —
|
||||||
|
|||||||
@@ -409,3 +409,69 @@
|
|||||||
.pp-slam, .pp-stamp-page::after { opacity: 1; }
|
.pp-slam, .pp-stamp-page::after { opacity: 1; }
|
||||||
.pp-stamp-hidden { opacity: 0.92; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,20 @@
|
|||||||
"songs": 5,
|
"songs": 5,
|
||||||
"min_stars": 2
|
"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": [
|
"graded_instruments": [
|
||||||
"guitar",
|
"guitar",
|
||||||
"keys"
|
"keys"
|
||||||
|
|||||||
+77
-19
@@ -203,21 +203,24 @@ def _instrument_of(arrangements, arrangement):
|
|||||||
|
|
||||||
|
|
||||||
def _played_by_instrument_genre():
|
def _played_by_instrument_genre():
|
||||||
"""(instrument, genre_key) → {filename: stub dict}. Best accuracy per
|
"""((instrument, genre_key) → {filename: stub dict},
|
||||||
(instrument, song); the JOIN keeps the same dead-song filter as _stars()."""
|
(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"]
|
db = _state["meta_db"]
|
||||||
if db is None:
|
if db is None:
|
||||||
return {}
|
return {}, {}
|
||||||
thresholds = _state["content"]["star_accuracy_thresholds"]
|
thresholds = _state["content"]["star_accuracy_thresholds"]
|
||||||
rows = db.conn.execute(
|
rows = db.conn.execute(
|
||||||
"SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, "
|
"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)} "
|
f" {_genre_expr(db)} "
|
||||||
"FROM song_stats s JOIN songs ON songs.filename = s.filename"
|
"FROM song_stats s JOIN songs ON songs.filename = s.filename"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
arrs_cache = {}
|
arrs_cache = {}
|
||||||
out = {}
|
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)
|
gkey = _genre_key(genre)
|
||||||
if not gkey:
|
if not gkey:
|
||||||
continue
|
continue
|
||||||
@@ -227,10 +230,12 @@ def _played_by_instrument_genre():
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
arrs_cache[filename] = None
|
arrs_cache[filename] = None
|
||||||
instrument = _instrument_of(arrs_cache[filename], arrangement)
|
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
|
acc = acc or 0.0
|
||||||
stub = out.setdefault((instrument, gkey), {}).get(filename)
|
stub = out.setdefault(key, {}).get(filename)
|
||||||
if stub is None:
|
if stub is None:
|
||||||
out[(instrument, gkey)][filename] = {
|
out[key][filename] = {
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"title": title or filename,
|
"title": title or filename,
|
||||||
"artist": artist or "",
|
"artist": artist or "",
|
||||||
@@ -245,7 +250,7 @@ def _played_by_instrument_genre():
|
|||||||
acc = stub["best_accuracy"]
|
acc = stub["best_accuracy"]
|
||||||
stub["best_accuracy"] = round(acc, 4)
|
stub["best_accuracy"] = round(acc, 4)
|
||||||
stub["stars"] = sum(1 for t in thresholds if acc >= t)
|
stub["stars"] = sum(1 for t in thresholds if acc >= t)
|
||||||
return out
|
return out, seconds
|
||||||
|
|
||||||
|
|
||||||
def _library_genres():
|
def _library_genres():
|
||||||
@@ -271,7 +276,7 @@ def _library_genres():
|
|||||||
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
|
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
|
||||||
|
|
||||||
|
|
||||||
def _badge_requirement(gkey):
|
def _badge_requirement(gkey, instrument="guitar"):
|
||||||
cfg = _state["passports_content"]
|
cfg = _state["passports_content"]
|
||||||
req = dict(cfg.get("badge_requirement") or {})
|
req = dict(cfg.get("badge_requirement") or {})
|
||||||
req.setdefault("songs", 5)
|
req.setdefault("songs", 5)
|
||||||
@@ -279,8 +284,15 @@ def _badge_requirement(gkey):
|
|||||||
override = (cfg.get("genres") or {}).get(gkey)
|
override = (cfg.get("genres") or {}).get(gkey)
|
||||||
if isinstance(override, dict):
|
if isinstance(override, dict):
|
||||||
req.update(override)
|
req.update(override)
|
||||||
req["virtuoso_nodes"] = [n for n in (req.get("virtuoso_nodes") or [])
|
# virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its
|
||||||
if isinstance(n, str)]
|
# 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
|
return req
|
||||||
|
|
||||||
|
|
||||||
@@ -293,21 +305,57 @@ def _drill_by_node():
|
|||||||
return doc.get("received_at"), 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):
|
def _node_cleared(by_node, node_id):
|
||||||
"""A drill counts as cleared on real completion evidence: mastered, or any
|
"""A drill counts as cleared on real completion evidence: mastered, any
|
||||||
depth rung flipped true (virtuoso's gained-only false→true artifacts)."""
|
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)
|
entry = by_node.get(node_id)
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
return False
|
return False
|
||||||
depth = entry.get("depth") if isinstance(entry.get("depth"), dict) else {}
|
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():
|
def _passports_view():
|
||||||
cfg = _state["passports_content"]
|
cfg = _state["passports_content"]
|
||||||
graded = set(cfg.get("graded_instruments") or [])
|
graded = set(cfg.get("graded_instruments") or [])
|
||||||
st = _career_state()
|
st = _career_state()
|
||||||
played = _played_by_instrument_genre()
|
played, played_seconds = _played_by_instrument_genre()
|
||||||
received_at, by_node = _drill_by_node()
|
received_at, by_node = _drill_by_node()
|
||||||
instruments = {}
|
instruments = {}
|
||||||
for inst in cfg.get("instruments") or []:
|
for inst in cfg.get("instruments") or []:
|
||||||
@@ -318,7 +366,7 @@ def _passports_view():
|
|||||||
for gkey, meta in sorted(opened.items(),
|
for gkey, meta in sorted(opened.items(),
|
||||||
key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])):
|
key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])):
|
||||||
meta = meta if isinstance(meta, dict) else {}
|
meta = meta if isinstance(meta, dict) else {}
|
||||||
req = _badge_requirement(gkey)
|
req = _badge_requirement(gkey, inst)
|
||||||
songs = list(played.get((inst, gkey), {}).values())
|
songs = list(played.get((inst, gkey), {}).values())
|
||||||
for s in songs:
|
for s in songs:
|
||||||
s["qualifies"] = s["stars"] >= req["min_stars"]
|
s["qualifies"] = s["stars"] >= req["min_stars"]
|
||||||
@@ -345,6 +393,9 @@ def _passports_view():
|
|||||||
"graded": is_graded,
|
"graded": is_graded,
|
||||||
"songs": songs,
|
"songs": songs,
|
||||||
"qualifying_count": qualifying,
|
"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},
|
"drills": {"required": required, "cleared": cleared},
|
||||||
"badge": badge,
|
"badge": badge,
|
||||||
})
|
})
|
||||||
@@ -354,6 +405,8 @@ def _passports_view():
|
|||||||
"badge_requirement": cfg.get("badge_requirement") or {},
|
"badge_requirement": cfg.get("badge_requirement") or {},
|
||||||
"graded_instruments": sorted(graded),
|
"graded_instruments": sorted(graded),
|
||||||
"instruments": list(cfg.get("instruments") or []),
|
"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,
|
"instruments": instruments,
|
||||||
"genres": _library_genres(),
|
"genres": _library_genres(),
|
||||||
@@ -528,11 +581,16 @@ def setup(app, context):
|
|||||||
# Only the fields the badge check reads are kept.
|
# Only the fields the badge check reads are kept.
|
||||||
if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict):
|
if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict):
|
||||||
raise HTTPException(400, "Expected a progress snapshot with byNode.")
|
raise HTTPException(400, "Expected a progress snapshot with byNode.")
|
||||||
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
|
# Bound the INCOMING snapshot before the merge — the gained-only merge
|
||||||
"byNode": body["byNode"]}
|
# drops junk entries, which must not become a size-guard bypass.
|
||||||
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
|
if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||||
raise HTTPException(413, "Snapshot too large.")
|
raise HTTPException(413, "Snapshot too large.")
|
||||||
with _lock:
|
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(),
|
_save_json(_drill_file(), {"received_at": _now_iso(),
|
||||||
"snapshot": snapshot})
|
"snapshot": snapshot})
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
+142
-12
@@ -34,6 +34,8 @@
|
|||||||
let _ppRelayTimer = 0;
|
let _ppRelayTimer = 0;
|
||||||
let _ppBook = null; // {inst, gkey} of the open spread
|
let _ppBook = null; // {inst, gkey} of the open spread
|
||||||
let _ppReturnFocus = null; // element to refocus when the book closes
|
let _ppReturnFocus = null; // element to refocus when the book closes
|
||||||
|
let _ppCeremonyQueue = []; // badges awaiting their ceremony overlay
|
||||||
|
let _ppCeremonyActive = false;
|
||||||
let _ppBootstrapped = false;
|
let _ppBootstrapped = false;
|
||||||
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
||||||
|
|
||||||
@@ -308,9 +310,10 @@
|
|||||||
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
|
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
|
||||||
}
|
}
|
||||||
|
|
||||||
// New badge → chime + notification once per session; the stamp SLAM plays
|
// New badge → chime + notification + the venue ceremony, once per
|
||||||
// when the passport is next opened (and only then is the badge marked
|
// session; the stamp SLAM plays when the passport is next opened (and
|
||||||
// seen, so a pending slam survives a reload).
|
// only then is the badge marked seen, so a pending slam survives a
|
||||||
|
// reload).
|
||||||
function detectNewBadges(view) {
|
function detectNewBadges(view) {
|
||||||
const seen = seenBadges();
|
const seen = seenBadges();
|
||||||
for (const inst of Object.keys(view.instruments || {})) {
|
for (const inst of Object.keys(view.instruments || {})) {
|
||||||
@@ -326,10 +329,109 @@
|
|||||||
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
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
|
// Relay the Virtuoso drill snapshot (localStorage doc, not the thin bus
|
||||||
// payload) to the server intake, debounced across event bursts.
|
// payload) to the server intake, debounced across event bursts.
|
||||||
function relayDrillState() {
|
function relayDrillState() {
|
||||||
@@ -358,23 +460,35 @@
|
|||||||
renderPassports();
|
renderPassports();
|
||||||
if (!_ppBootstrapped) {
|
if (!_ppBootstrapped) {
|
||||||
_ppBootstrapped = true;
|
_ppBootstrapped = true;
|
||||||
// First run on this browser: seed the server with the local drill
|
// Sync the local drill snapshot once per session — drill progress
|
||||||
// snapshot if it has never received one.
|
// made before the career plugin existed (or a relay POST that
|
||||||
if (!(view.drill_state || {}).received_at) relayDrillState();
|
// 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) {
|
function ppCoverHTML(inst, p) {
|
||||||
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
||||||
const stamp = p.badge === 'earned'
|
const stamp = p.badge === 'earned'
|
||||||
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
|
? `<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`;
|
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
|
||||||
|
const hours = fmtHours(p.seconds_total);
|
||||||
return `<button class="pp-cover pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="transform:rotate(${rot}deg)">
|
return `<button class="pp-cover pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="transform:rotate(${rot}deg)">
|
||||||
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||||||
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
||||||
${stamp}
|
${stamp}
|
||||||
<span class="pp-cover-sub">${stubs}</span>
|
<span class="pp-cover-sub">${stubs}${hours ? ` · ${hours}` : ''}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,6 +564,20 @@
|
|||||||
const req = p.requirement || {};
|
const req = p.requirement || {};
|
||||||
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
|
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
|
||||||
const starGl = '★'.repeat(req.min_stars || 0);
|
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 = '';
|
let badgeArea = '';
|
||||||
if (p.badge === 'shown_not_judged') {
|
if (p.badge === 'shown_not_judged') {
|
||||||
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
||||||
@@ -464,14 +592,15 @@
|
|||||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
<span class="pp-stamp-tier">BRONZE</span>
|
<span class="pp-stamp-tier">BRONZE</span>
|
||||||
</div>
|
</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 = '';
|
let drills = '';
|
||||||
const reqNodes = (p.drills || {}).required || [];
|
|
||||||
if (reqNodes.length) {
|
if (reqNodes.length) {
|
||||||
const cleared = new Set((p.drills || {}).cleared || []);
|
|
||||||
drills = `<div class="pp-drills">${reqNodes.map((n) =>
|
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
|
// Graded instruments collect stubs at the badge bar; shown-not-judged
|
||||||
// instruments have no bar — every played genre song is repertoire.
|
// instruments have no bar — every played genre song is repertoire.
|
||||||
@@ -487,7 +616,7 @@
|
|||||||
<div class="pp-book">
|
<div class="pp-book">
|
||||||
<div class="pp-page pp-page-left">
|
<div class="pp-page pp-page-left">
|
||||||
<div class="pp-page-head">${esc(p.genre)} — ${esc(ppLabel(inst))}</div>
|
<div class="pp-page-head">${esc(p.genre)} — ${esc(ppLabel(inst))}</div>
|
||||||
${badgeArea}${drills}
|
${badgeArea}${odometer}${drills}
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-page pp-page-right">
|
<div class="pp-page pp-page-right">
|
||||||
<div class="pp-page-head">Ticket stubs</div>
|
<div class="pp-page-head">Ticket stubs</div>
|
||||||
@@ -690,6 +819,7 @@
|
|||||||
// the badge-diff logic; nothing here touches the DOM.
|
// the badge-diff logic; nothing here touches the DOM.
|
||||||
window.__careerPassportTest = {
|
window.__careerPassportTest = {
|
||||||
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||||
|
fmtHours,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -88,6 +88,33 @@ test('detectNewBadges notifies once per badge, never after it is seen', () => {
|
|||||||
assert.equal(w2.notifications.length, 0);
|
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', () => {
|
test('seenBadges tolerates corrupt stored values', () => {
|
||||||
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
|
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
|
||||||
const w = load({ 'feedBack-career-badges-seen': bad });
|
const w = load({ 'feedBack-career-badges-seen': bad });
|
||||||
@@ -99,3 +126,15 @@ test('seenBadges tolerates corrupt stored values', () => {
|
|||||||
assert.equal(w.notifications.length, 1, `stored ${bad}`);
|
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'), '');
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,7 +27,60 @@
|
|||||||
let cur = null; // active session
|
let cur = null; // active session
|
||||||
let recordedThisSession = false;
|
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) {
|
function reset(filename, arrangement) {
|
||||||
|
flushSeconds();
|
||||||
cur = {
|
cur = {
|
||||||
filename: filename || null,
|
filename: filename || null,
|
||||||
arrangement: Number.isFinite(arrangement) ? arrangement : 0,
|
arrangement: Number.isFinite(arrangement) ? arrangement : 0,
|
||||||
@@ -84,6 +137,7 @@
|
|||||||
if (!cur || !cur.filename || recordedThisSession) return;
|
if (!cur || !cur.filename || recordedThisSession) return;
|
||||||
if (!cur.scored || (cur.hits + cur.misses) <= 0) return; // no real scoring this session
|
if (!cur.scored || (cur.hits + cur.misses) <= 0) return; // no real scoring this session
|
||||||
recordedThisSession = true;
|
recordedThisSession = true;
|
||||||
|
const seconds = takeSeconds();
|
||||||
const body = {
|
const body = {
|
||||||
filename: cur.filename,
|
filename: cur.filename,
|
||||||
arrangement: cur.arrangement,
|
arrangement: cur.arrangement,
|
||||||
@@ -94,7 +148,9 @@
|
|||||||
bestStreak: cur.bestStreak,
|
bestStreak: cur.bestStreak,
|
||||||
lastPlayPosition: Number.isFinite(position) ? position : cur.lastTime,
|
lastPlayPosition: Number.isFinite(position) ? position : cur.lastTime,
|
||||||
};
|
};
|
||||||
|
if (seconds) body.seconds = seconds;
|
||||||
post(body).then(async (response) => {
|
post(body).then(async (response) => {
|
||||||
|
if (response == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds);
|
||||||
await notifyProgression(response, body, !!natural);
|
await notifyProgression(response, body, !!natural);
|
||||||
// Refresh the profile badge AFTER the progression state moved so
|
// Refresh the profile badge AFTER the progression state moved so
|
||||||
// the rank/dB it renders are post-award values.
|
// 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
|
// Allow 0: restarting a song and stopping at the very beginning must be
|
||||||
// able to clear a stale Continue offset. Only negatives are invalid.
|
// able to clear a stale Continue offset. Only negatives are invalid.
|
||||||
if (!Number.isFinite(position) || position < 0) return;
|
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 ─────────────────────────────────────────────────--
|
// ── Session lifecycle ─────────────────────────────────────────────────--
|
||||||
@@ -164,13 +223,28 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Play-time clock ───────────────────────────────────────────────────--
|
||||||
|
sm.on('song:play', () => { clockStart(); retryPendingSeconds(); });
|
||||||
|
sm.on('song:resume', clockStart);
|
||||||
|
|
||||||
// ── Finalize / resume-position ────────────────────────────────────────--
|
// ── Finalize / resume-position ────────────────────────────────────────--
|
||||||
sm.on('song:ended', (e) => finalizeScored(e && e.detail && e.detail.time, true));
|
sm.on('song:ended', (e) => {
|
||||||
sm.on('song:pause', (e) => touchPosition(e && e.detail && e.detail.time));
|
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) => {
|
sm.on('song:stop', (e) => {
|
||||||
// Record the scored session if it wasn't already (e.g. user closed the
|
// Record the scored session if it wasn't already (e.g. user closed the
|
||||||
// player before the track ended), then persist the resume position.
|
// player before the track ended), then persist the resume position.
|
||||||
// Not a natural end — no calibration-retry prompt for deliberate quits.
|
// Not a natural end — no calibration-retry prompt for deliberate quits.
|
||||||
|
clockStop();
|
||||||
const t = e && e.detail && e.detail.time;
|
const t = e && e.detail && e.detail.time;
|
||||||
finalizeScored(t, false);
|
finalizeScored(t, false);
|
||||||
touchPosition(t);
|
touchPosition(t);
|
||||||
|
|||||||
@@ -59,6 +59,15 @@
|
|||||||
candidate = null;
|
candidate = null;
|
||||||
lastSwitchAt = -Infinity;
|
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
|
// Feed the latest perf state; returns the new crowd state when a
|
||||||
// transition commits, else null.
|
// transition commits, else null.
|
||||||
update(perfState, nowMs) {
|
update(perfState, nowMs) {
|
||||||
@@ -596,6 +605,26 @@
|
|||||||
if (dev && !_manifest) setManifest(dev);
|
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() {
|
function getState() {
|
||||||
return {
|
return {
|
||||||
venueActive: _venueActive,
|
venueActive: _venueActive,
|
||||||
@@ -621,6 +650,7 @@
|
|||||||
setVenueActive,
|
setVenueActive,
|
||||||
bindRuntime,
|
bindRuntime,
|
||||||
getState,
|
getState,
|
||||||
|
celebrate,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (root) root.v3VenueCrowd = api;
|
if (root) root.v3VenueCrowd = api;
|
||||||
|
|||||||
@@ -128,3 +128,22 @@ test('venue-scene-3d activates/deactivates the crowd layer', () => {
|
|||||||
assert.match(src, /syncCrowd\(false\)/);
|
assert.match(src, /syncCrowd\(false\)/);
|
||||||
assert.match(src, /v3VenueCrowd/);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class FakeMetaDb:
|
|||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""CREATE TABLE song_stats (
|
"""CREATE TABLE song_stats (
|
||||||
filename TEXT, arrangement TEXT, best_accuracy REAL,
|
filename TEXT, arrangement TEXT, best_accuracy REAL,
|
||||||
last_played_at TEXT
|
last_played_at TEXT,
|
||||||
|
seconds_total REAL NOT NULL DEFAULT 0
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
@@ -37,9 +38,10 @@ class FakeMetaDb:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
||||||
genre="", arrangements=None, last_played_at=None):
|
genre="", arrangements=None, last_played_at=None, seconds_total=0):
|
||||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?)",
|
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)",
|
||||||
(filename, arrangement, best_accuracy, last_played_at))
|
(filename, arrangement, best_accuracy, last_played_at,
|
||||||
|
seconds_total))
|
||||||
if in_library:
|
if in_library:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
|
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
|
||||||
|
|||||||
@@ -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):
|
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):
|
for i in range(5):
|
||||||
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
|
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
|
||||||
_open(client)
|
_open(client)
|
||||||
p = _passport(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["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):
|
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)}}
|
huge = {"byNode": {"pad": "x" * (300 * 1024)}}
|
||||||
assert client.post("/api/plugins/career/drill-state",
|
assert client.post("/api/plugins/career/drill-state",
|
||||||
json=huge).status_code == 413
|
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"
|
||||||
|
|||||||
@@ -452,3 +452,52 @@ def test_award_xp_negative_reversal_clamps_at_zero(server):
|
|||||||
db.award_xp(50, "minigames")
|
db.award_xp(50, "minigames")
|
||||||
assert db.award_xp(-50, "minigames") == 0 # exact reversal
|
assert db.award_xp(-50, "minigames") == 0 # exact reversal
|
||||||
assert db.award_xp(-999, "minigames") == 0 # over-reverse clamps at 0
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user