mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 08:29:28 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53fa620a70 | ||
|
|
3e57ba0345 | ||
|
|
b85496fe58 | ||
|
|
4027c31a61 | ||
|
|
0fc6a4beed | ||
|
|
d26347981c | ||
|
|
45caa86ab8 | ||
|
|
8f1906a0c1 | ||
|
|
8b6829a946 | ||
|
|
3050c7b1d3 | ||
|
|
ba796b0f27 | ||
|
|
ddc06ff1e7 | ||
|
|
ac5c5ad20d | ||
|
|
f8012a8ce4 | ||
|
|
99b974a5a1 | ||
|
|
7ffa6e2c51 | ||
|
|
3832a5762b | ||
|
|
a60dcd10c2 | ||
|
|
5dcf39cd62 | ||
|
|
c485f02211 | ||
|
|
203f82b6fe | ||
|
|
0158286d06 | ||
|
|
1e2ce29cf6 | ||
|
|
b54b65d35c | ||
|
|
ab2e68a638 | ||
|
|
d806d12c22 | ||
|
|
32d723b774 | ||
|
|
ceb1e143cd | ||
|
|
d0626f5618 | ||
|
|
0dc9fd7ba8 | ||
|
|
22332bef22 | ||
|
|
ffc52f13ce |
@@ -0,0 +1,17 @@
|
|||||||
|
## What
|
||||||
|
|
||||||
|
<!-- What does this PR do, and why? Link the issue it addresses. -->
|
||||||
|
|
||||||
|
## feedpak surface
|
||||||
|
|
||||||
|
<!-- The feedpak spec is sacrosanct: the spec defines the format, this app implements it.
|
||||||
|
Delete this section ONLY if your change doesn't touch how the app reads or writes packs. -->
|
||||||
|
|
||||||
|
- [ ] This PR does **not** change how the app reads/writes feedpaks (manifest keys, pack files, folder layout)
|
||||||
|
- [ ] …or it does, and the spec change landed first via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) — FEP / spec PR: `got-feedback/feedpak-spec#___` (once it merges, re-run this PR's checks and the gate goes green)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `CHANGELOG.md` `[Unreleased]` updated (user-visible changes)
|
||||||
|
- [ ] Tests added/updated for new behaviour
|
||||||
|
- [ ] Commits are DCO signed off (`git commit -s`)
|
||||||
@@ -124,6 +124,94 @@ jobs:
|
|||||||
print(f"Validated {len(manifests)} manifest(s) — OK")
|
print(f"Validated {len(manifests)} manifest(s) — OK")
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
feedpak-spec:
|
||||||
|
# Guard that core stays faithful to the feedpak format spec, which lives in
|
||||||
|
# its own repo (got-feedback/feedpak-spec) and is the contract third-party
|
||||||
|
# packers and players build against. Four surface checks: core reads/writes
|
||||||
|
# only manifest keys the spec declares (and the scanned-module list can't
|
||||||
|
# fall behind); the exception allowlist never grows, so the FEP process is
|
||||||
|
# the only way a new key lands; core ingests the spec's example packs; packs
|
||||||
|
# committed here pass the spec's reference validator. Motivated by
|
||||||
|
# #933, where a manifest key (`original_audio`) shipped in core without ever
|
||||||
|
# reaching the spec.
|
||||||
|
#
|
||||||
|
# The gate checks against the spec repo's HEAD, deliberately: the app must
|
||||||
|
# conform to the LIVING spec, always. The dev flow is self-serve — a gated
|
||||||
|
# PR opens a FEP, the spec PR merges, re-running this job goes green; no
|
||||||
|
# pin file to bump, nothing to maintain. Accepted trade-off: a BREAKING
|
||||||
|
# spec change (rare, deliberate, MAJOR per the spec's compatibility policy)
|
||||||
|
# reddens every PR here until core conforms — which is the correct
|
||||||
|
# org-wide signal that the app is out of conformance. The normal FEP is
|
||||||
|
# additive and can never redden this job.
|
||||||
|
name: feedpak-spec
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# This job runs repository code (tools/check_spec_conformance.py) and
|
||||||
|
# never pushes; don't leave the token in git config for it.
|
||||||
|
# fetch-depth: 0 so the base branch is available — the gate must prove
|
||||||
|
# the exception allowlist didn't grow in this PR.
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
cache: 'pip'
|
||||||
|
|
||||||
|
- name: Check out feedpak-spec at HEAD
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: got-feedback/feedpak-spec
|
||||||
|
ref: main
|
||||||
|
path: .feedpak-spec
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Record the spec commit this run verified against
|
||||||
|
# HEAD-tracking means CI results can differ across time on the same
|
||||||
|
# commit. Log the exact spec SHA so a red run is reproducible.
|
||||||
|
run: git -C .feedpak-spec rev-parse HEAD
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
# CI-only: the spec's reference validator needs jsonschema. Not a
|
||||||
|
# runtime dependency — this gate never runs on the serve/Docker path
|
||||||
|
# (constitution Principle I). Pinned for the same reason the spec SHA
|
||||||
|
# is: an upstream release must not turn this job red on a PR that
|
||||||
|
# changed neither this repo nor the spec.
|
||||||
|
pip install 'jsonschema==4.26.0'
|
||||||
|
|
||||||
|
- name: Fetch the base branch's exception allowlist
|
||||||
|
id: baseline
|
||||||
|
run: |
|
||||||
|
# The allowlist is closed: it grandfathers keys that predate this gate
|
||||||
|
# and may only shrink. Prove that by diffing against the base branch —
|
||||||
|
# without this, anyone could append an entry and route around the FEP
|
||||||
|
# process from inside this repo.
|
||||||
|
#
|
||||||
|
# Resolve the base rather than hardcoding `main`: ship-ci.yml also runs
|
||||||
|
# this workflow for PRs into release/** and for pushes to release/**,
|
||||||
|
# where a main baseline would diff against the wrong branch.
|
||||||
|
# PR -> the branch it merges into
|
||||||
|
# push -> the branch itself (its tip already contains the change, so
|
||||||
|
# this is a no-op; enforcement happens at PR time)
|
||||||
|
BASE="${{ github.event.pull_request.base.ref || github.ref_name }}"
|
||||||
|
echo "diffing the allowlist against origin/$BASE"
|
||||||
|
git fetch --no-tags --depth=1 origin "$BASE"
|
||||||
|
if git cat-file -e FETCH_HEAD:feedpak-spec-exceptions.yml 2>/dev/null; then
|
||||||
|
git show FETCH_HEAD:feedpak-spec-exceptions.yml > "$RUNNER_TEMP/baseline-exceptions.yml"
|
||||||
|
echo "args=--baseline-exceptions $RUNNER_TEMP/baseline-exceptions.yml" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
# Only true until the PR that introduces this gate lands.
|
||||||
|
echo "args=--bootstrap-allowlist" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check feedpak spec conformance
|
||||||
|
run: python tools/check_spec_conformance.py --spec .feedpak-spec ${{ steps.baseline.outputs.args }}
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
|
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
|
||||||
# dev tooling, never on the serve/Docker path — same category as
|
# dev tooling, never on the serve/Docker path — same category as
|
||||||
|
|||||||
@@ -7,6 +7,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
effective genre — Bronze = N genre songs at K★, data-driven in
|
||||||
|
`plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song
|
||||||
|
"ticket stubs", the library genre list, and drill status), `POST /passports/commit`
|
||||||
|
(instrument commitment), `POST /passports/open` (open a genre
|
||||||
|
passport), and `POST /drill-state` (intake for the relayed Virtuoso
|
||||||
|
`virtuoso.progress` snapshot, so drill requirements can gate badges
|
||||||
|
server-side). Badges are never stored; the only persisted state (commitments,
|
||||||
|
opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the
|
||||||
|
settings export/import bundle via `settings.server_files`. Instruments are
|
||||||
|
attributed via the existing progression arrangement→instrument mapping;
|
||||||
|
non-graded instruments (bass, drums) render shown-not-judged — repertoire
|
||||||
|
without a pass bar, never a false badge denial.
|
||||||
|
- **Career passports (UI)** — the Career screen gains a Passports tab beside
|
||||||
|
Venues: a physical per-instrument passport book (embossed leather cover, 3D
|
||||||
|
page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge
|
||||||
|
slam with ink bleed and deterministic per-genre jitter, qualifying songs as
|
||||||
|
collected ticket stubs, and unopened genres as an "Explore next"
|
||||||
|
travel-brochure rack (invitations, never greyed-out slots or completion
|
||||||
|
meters). Badge earns chime + notify immediately; the stamp slam plays when
|
||||||
|
the passport is next opened. Four small synthesized sound effects ship as
|
||||||
|
plugin assets. The career screen also relays the Virtuoso `virtuoso.progress`
|
||||||
|
localStorage snapshot to the drill-state intake on `virtuoso:progress` bus
|
||||||
|
events (debounced, plus a one-time bootstrap), closing the
|
||||||
|
fires-into-a-void seam without touching the virtuoso plugin.
|
||||||
|
- **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as
|
||||||
|
an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing
|
||||||
|
stopped core from reading a manifest key the spec never defined, which is exactly what happened with
|
||||||
|
`original_audio` (#583 → #933). `tools/check_spec_conformance.py` now enforces four surface properties
|
||||||
|
in CI: (1) **key-coverage** — every manifest key core reads *or writes* is declared in the spec's
|
||||||
|
`manifest.schema.json`, found by walking the AST of `lib/sloppak.py`, `lib/enrichment.py`, and
|
||||||
|
`lib/songmeta.py`, `lib/gp2notation.py`, and `lib/routers/ws_highway.py` (writes are gated too — including
|
||||||
|
`setdefault()` — and reported separately: a key core writes lands in every pack we emit, so an undeclared
|
||||||
|
one seeds the ecosystem with non-spec data; a **readers-complete** guard fails the build if that module
|
||||||
|
list falls behind the codebase); (2) **allowlist-closed** — `feedpak-spec-exceptions.yml` never grows;
|
||||||
|
(3) **forward** — core's `load_song()` ingests every example pack the spec ships;
|
||||||
|
(4) **reverse** — every pack committed here passes the spec's own `tools/validate.py` (7/7 pass today).
|
||||||
|
The gate verifies against the spec repo's **HEAD** — the app must conform to the living spec, and the
|
||||||
|
flow is self-serve: a gated PR opens a FEP, the spec PR merges, re-running checks goes green. Nothing to
|
||||||
|
pin, nothing to bump. Each run logs the spec SHA it verified against so results are reproducible.
|
||||||
|
**There is no in-repo escape hatch, by design.** A blocked PR has exactly one route: land the key in the
|
||||||
|
spec via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md), then
|
||||||
|
re-run the PR's checks — the gate verifies against the spec's HEAD, so it goes green once the key is real. `feedpak-spec-exceptions.yml` is a **closed
|
||||||
|
grandfather list** for keys that predate the gate, not a bypass: a fourth check (**allowlist-closed**)
|
||||||
|
diffs it against the base branch and fails any PR that *adds* an entry, so it may only shrink.
|
||||||
|
`original_audio` is grandfathered there against #933 so the gate lands green and starts blocking the
|
||||||
|
*next* instance immediately; the gate takes no position on how #933 resolves (the expected outcome is
|
||||||
|
removing the key, since the spec already carries the mixdown as a stem — not adopting it). Docs:
|
||||||
|
[docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md).
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
|
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
|
||||||
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
|
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
|
||||||
@@ -27,6 +116,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Career passports review polish** — the passport tabs and book overlay carry
|
||||||
|
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
|
||||||
|
`role="dialog"` + `aria-modal` with focus moved to the close button on open
|
||||||
|
and restored on close), and a corrupt stored seen-badges value (e.g. a stray
|
||||||
|
`"null"`) can no longer throw on every passport refresh.
|
||||||
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
|
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
|
||||||
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
|
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
|
||||||
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
|
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
|
||||||
|
|||||||
@@ -588,6 +588,21 @@ tab, key/scale annotations, etc.). Published as **feedpak**; this codebase still
|
|||||||
**sloppak** name internally — same on-disk format. [docs/sloppak-spec.md](docs/sloppak-spec.md) is
|
**sloppak** name internally — same on-disk format. [docs/sloppak-spec.md](docs/sloppak-spec.md) is
|
||||||
a local pointer + code map.
|
a local pointer + code map.
|
||||||
|
|
||||||
|
**The spec is sacrosanct — read it BEFORE changing how this app reads or writes packs.** The
|
||||||
|
spec repo defines the format; this app merely implements it ("a change is not part of the format
|
||||||
|
until it lands here" — feedpak-spec/GOVERNANCE.md). Any new manifest key, file, or directory the
|
||||||
|
app touches must land in the spec **first**, via the
|
||||||
|
[FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) (proposal
|
||||||
|
issue → one spec PR updating spec + schemas + example + changelog → then re-run your PR's checks
|
||||||
|
here; the gate verifies against the spec's HEAD, so it goes green the moment your key is real).
|
||||||
|
CI enforces this: the `feedpak-spec` job
|
||||||
|
([docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md)) fails any PR whose code touches a
|
||||||
|
manifest key the spec doesn't declare, and there is **no in-repo bypass** — the exceptions
|
||||||
|
file is a closed grandfather list that only shrinks. If the format seems to be missing something
|
||||||
|
you need, that's a FEP conversation, not a workaround. (Cautionary tale: `original_audio`, #933 —
|
||||||
|
shipped without a spec entry, and third-party packers reverse-engineered a folder convention out
|
||||||
|
of a code comment.)
|
||||||
|
|
||||||
**Key code:**
|
**Key code:**
|
||||||
- `lib/sloppak.py` — format detection, zip/directory resolution, metadata extraction, song loading
|
- `lib/sloppak.py` — format detection, zip/directory resolution, metadata extraction, song loading
|
||||||
- `lib/sloppak_convert.py` — sloppak assembly pipeline, Demucs stem splitting
|
- `lib/sloppak_convert.py` — sloppak assembly pipeline, Demucs stem splitting
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# The feedpak spec-conformance gate
|
||||||
|
|
||||||
|
`tools/check_spec_conformance.py`, run in CI as the `feedpak-spec` job.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
feedpak is published as an **open format**: its own repo
|
||||||
|
([got-feedback/feedpak-spec](https://github.com/got-feedback/feedpak-spec)), a normative spec, JSON
|
||||||
|
Schemas, and a reference validator. That is a promise to everyone outside this codebase — third-party
|
||||||
|
packers, converters, and players build against the spec, and the spec is meant to be the complete and
|
||||||
|
authoritative description of a pack.
|
||||||
|
|
||||||
|
The moment core reads a manifest key the spec doesn't define, that promise breaks silently:
|
||||||
|
|
||||||
|
- A spec-compliant pack is no longer guaranteed to be a fully-working pack.
|
||||||
|
- The reference validator can't warn authors about a key it has never heard of — it will happily green-light
|
||||||
|
the key, and every misspelling of it.
|
||||||
|
- The format's real definition drifts into our source tree. In the case that motivated this gate
|
||||||
|
([#933](https://github.com/got-feedback/feedback/issues/933)), third-party tooling started emitting an
|
||||||
|
`original/` directory that no code anywhere requires — the convention was reverse-engineered from an
|
||||||
|
example in a *code comment*.
|
||||||
|
|
||||||
|
The rule this gate enforces: **any manifest key core reads _or writes_ must be in the spec before core
|
||||||
|
ships code that depends on it.** Spec first, implementation second. Writes are not exempt — a key core
|
||||||
|
writes lands in every pack we emit, so an undeclared one seeds the ecosystem with non-spec data.
|
||||||
|
|
||||||
|
Note that "get it into the spec" is not automatically the right fix for an existing violation — for
|
||||||
|
`original_audio` it isn't. The spec already carries the pre-separation mixdown as a stem
|
||||||
|
(`{id: full, file: stems/full.ogg}`), so that key added a *second, redundant* location for audio to a format
|
||||||
|
that already had one, and the resolution is to remove it rather than bless it. The gate takes no position on
|
||||||
|
which way a violation resolves; it only insists that one of the two happens deliberately, in the open,
|
||||||
|
before the code merges.
|
||||||
|
|
||||||
|
## What it checks
|
||||||
|
|
||||||
|
We can't mechanically prove core *interprets* a key the way the spec means. We can prove four surface
|
||||||
|
properties, and they cover the drift that actually occurs.
|
||||||
|
|
||||||
|
| Layer | Check | Catches |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. key-coverage | Every manifest key core reads **or writes** is declared in the spec's `manifest.schema.json`. | Core growing a key the spec never defined — the #933 class. |
|
||||||
|
| 2. allowlist-closed | `feedpak-spec-exceptions.yml` has not **grown** relative to the base branch. | Someone routing around the FEP process by allowlisting their own new key. |
|
||||||
|
| 3. forward | Core's `load_song()` ingests every example pack the spec ships. | The spec adding or tightening something core ignores or breaks on. |
|
||||||
|
| 4. reverse | Every pack committed to this repo passes the spec's `tools/validate.py`. | Core (or a contributor) committing a pack the spec would reject. |
|
||||||
|
|
||||||
|
Layer 1 works by walking the AST of the modules listed in `READERS` and collecting every literal key touched
|
||||||
|
on a manifest dict (`manifest.get("x")`, `manifest["x"]`, and the wrapped
|
||||||
|
`(load_manifest(p) or {}).get("x")` form used in `lib/enrichment.py`).
|
||||||
|
|
||||||
|
**Reads and writes are both checked, and reported differently.** A key core *writes*
|
||||||
|
(`manifest["x"] = v`, as `lib/songmeta.py` does) is spec surface pointed outward: it puts a key into every
|
||||||
|
pack we emit, so an undeclared one seeds the ecosystem with non-spec data. Subscripts are classified by AST
|
||||||
|
context — `Store` is a write, `Load` is a read — so `manifest["year"] = ...` is not miscounted as a read.
|
||||||
|
|
||||||
|
## When it fails
|
||||||
|
|
||||||
|
You added a manifest key the spec doesn't define. **There is exactly one way forward, and it is not in this
|
||||||
|
repo.**
|
||||||
|
|
||||||
|
Land the key in the spec through the **feedpak Enhancement Proposal (FEP)** process
|
||||||
|
([feedpak-spec/CONTRIBUTING.md](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md)):
|
||||||
|
|
||||||
|
1. **Open a FEP issue** on `got-feedback/feedpak-spec` — the problem, the proposed on-disk shape (manifest
|
||||||
|
key and/or side-file), backward compatibility, and the version bump it implies.
|
||||||
|
2. **Discuss**, until it has a clear shape and rough consensus.
|
||||||
|
3. **Land one PR there** that updates the normative spec (`spec/feedpak-v1.md`), the relevant JSON
|
||||||
|
Schema(s), an example in `examples/` that exercises it, and the changelog — *together*. A PR touching
|
||||||
|
only one of those is incomplete.
|
||||||
|
4. **Back here**, just re-run your PR's checks. The gate verifies against the spec's HEAD, so the moment
|
||||||
|
your key is genuinely part of the format, your PR goes green — nothing to bump, nothing to maintain.
|
||||||
|
|
||||||
|
That's deliberately the only route — no experimental prefix, no self-serve allowlist — and it's usually a
|
||||||
|
quick one for additive keys. The reason it's worth the round-trip: the gate checks the whole repo against
|
||||||
|
the living spec, so if non-conformance ever lands, it shows up as red CI on *every* teammate's open PR, and
|
||||||
|
only the person who introduced it can clear it. Going through the FEP keeps your change clean and keeps
|
||||||
|
everyone else unblocked.
|
||||||
|
|
||||||
|
The spec's own governance says the same thing:
|
||||||
|
|
||||||
|
> This repository defines the format only. Applications that read or write feedpak ... track this spec as a
|
||||||
|
> dependency; they do not drive it. **A change is not part of the format until it lands here.**
|
||||||
|
> — [feedpak-spec/GOVERNANCE.md](https://github.com/got-feedback/feedpak-spec/blob/main/GOVERNANCE.md)
|
||||||
|
|
||||||
|
### `feedpak-spec-exceptions.yml` is a closed grandfather list, not a hatch
|
||||||
|
|
||||||
|
It exists solely because `original_audio` predates the gate. **CI fails any PR that adds an entry** (layer 2
|
||||||
|
diffs it against the base branch), so the list can only ever shrink. Entries are debt, each carries a
|
||||||
|
tracking issue, and each disappears when the underlying key is removed from core. The gate also fails on a
|
||||||
|
*stale* entry — the spec caught up, or core stopped touching the key — so the file cannot quietly become
|
||||||
|
somewhere drift accumulates.
|
||||||
|
|
||||||
|
Deleting an entry does not, by itself, get you past the gate: layer 1 still fails while core reads the key.
|
||||||
|
The entry goes when the **code** goes.
|
||||||
|
|
||||||
|
## Tracking the spec's HEAD
|
||||||
|
|
||||||
|
The gate checks out `feedpak-spec` at **HEAD**, on purpose: the app must conform to the *living* spec, and
|
||||||
|
nobody should have to maintain a pin. The dev flow is fully self-serve — gated PR → FEP → spec merge →
|
||||||
|
re-run checks → green.
|
||||||
|
|
||||||
|
Two properties to know about:
|
||||||
|
|
||||||
|
- **The normal FEP is additive** (a new optional key), which only ever makes the gate *looser* — it cannot
|
||||||
|
redden anyone's PR. Only a **breaking** spec change (removing/renaming a key the app uses, tightening the
|
||||||
|
validator against committed packs) turns PRs red repo-wide — and per the spec's compatibility policy that
|
||||||
|
is a rare, deliberate MAJOR event, exactly when an org-wide "the app is out of conformance" signal is the
|
||||||
|
right outcome. The CI job logs the exact spec SHA each run verified against, so a red run is reproducible.
|
||||||
|
- **CI results can change over time on the same commit** — that is inherent to tracking a living contract,
|
||||||
|
and it is the point: green means "conformant *now*", not "conformant when written".
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
Known, and worth fixing in follow-ups rather than blocking on:
|
||||||
|
|
||||||
|
- **Layer 1's receiver detection is heuristic.** Locals *assigned from* `load_manifest(...)` are discovered
|
||||||
|
flow-aware whatever they're called (chart.py's `m` taught us that), and the inline
|
||||||
|
`(load_manifest(p) or {}).get(...)` form is recognised — but a manifest that arrives as a **function
|
||||||
|
parameter** is only recognised by name (`MANIFEST_VARS`: `manifest`, `mf`). A parameter called something
|
||||||
|
else would slip. The hardening step is to route all manifest access through a single declared
|
||||||
|
`KNOWN_MANIFEST_KEYS` registry in `lib/sloppak.py`; the gate then compares registry against schema exactly
|
||||||
|
instead of inferring.
|
||||||
|
- **Layer 1 covers top-level keys only.** Nested structure (`arrangements[].file`, `.id`, `.notation`) isn't
|
||||||
|
checked. Extending to it means walking the schema's `$ref` subschemas.
|
||||||
|
- **Layer 1 recognises `get`, `setdefault`, subscripts, and the known gap-fill helper** as key access.
|
||||||
|
`update()` and `pop()` aren't used against a feedpak manifest anywhere in the tree, so they're deliberately
|
||||||
|
not special-cased rather than speculatively handled. `readers-complete` reuses the same scanner
|
||||||
|
(`keys_touched()`), so this blind spot is shared, not doubled: a module using only unrecognised access forms
|
||||||
|
would evade both.
|
||||||
|
- **Layer 4 can't catch unknown keys**, because `manifest.schema.json` sets `additionalProperties: true` and
|
||||||
|
the reference validator deliberately "treats unknown keys/files as forward-compatible". Fixing this
|
||||||
|
properly belongs in the spec (tighten the schema, or give the validator a `--strict` mode). Until then,
|
||||||
|
layer 1 is the only thing standing between us and the next `original_audio`.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# CLOSED grandfather list — manifest keys core reads or writes that predate the
|
||||||
|
# spec-conformance gate and that the feedpak spec does not define.
|
||||||
|
#
|
||||||
|
# Please don't add entries here — CI will flag any PR that grows this list, so
|
||||||
|
# it can only shrink over time. That's by design, not distrust: the moment the
|
||||||
|
# app touches a key the spec doesn't define, every teammate's PR starts failing
|
||||||
|
# the conformance gate too, and whoever added the key is the only person who
|
||||||
|
# can fix it. The FEP process below avoids putting anyone in that spot. The
|
||||||
|
# feedpak spec's own governance is explicit:
|
||||||
|
#
|
||||||
|
# "This repository defines the format only. Applications that read or write
|
||||||
|
# feedpak ... track this spec as a dependency; they do not drive it.
|
||||||
|
# A change is not part of the format until it lands here."
|
||||||
|
# — got-feedback/feedpak-spec, GOVERNANCE.md
|
||||||
|
#
|
||||||
|
# So a new manifest key goes through the feedpak Enhancement Proposal (FEP)
|
||||||
|
# process — see feedpak-spec/CONTRIBUTING.md:
|
||||||
|
#
|
||||||
|
# 1. Open a FEP issue on got-feedback/feedpak-spec describing the problem, the
|
||||||
|
# on-disk shape, backward compatibility, and the version bump implied.
|
||||||
|
# 2. Land one PR there updating the normative spec, the JSON Schemas, an
|
||||||
|
# example that exercises it, and the changelog — together.
|
||||||
|
# 3. Back here, re-run this PR's checks. The gate verifies against the spec's
|
||||||
|
# HEAD, so once your key is in the spec, the gate goes green.
|
||||||
|
#
|
||||||
|
# That's the supported route — and usually a quick one for additive keys. If
|
||||||
|
# your PR is blocked by this gate, a FEP will get you unblocked properly; an
|
||||||
|
# entry here won't (CI rejects it).
|
||||||
|
#
|
||||||
|
# Entries below exist ONLY because they predate the gate. Each is debt with a
|
||||||
|
# tracking issue, and each disappears when its issue is fixed. The gate also
|
||||||
|
# fails if an entry goes stale — the spec caught up, or core no longer reads or
|
||||||
|
# writes the key — so this file cannot quietly become a place drift hides.
|
||||||
|
|
||||||
|
exceptions:
|
||||||
|
- key: original_audio
|
||||||
|
issue: https://github.com/got-feedback/feedback/issues/933
|
||||||
|
reason: >-
|
||||||
|
Added by #583 (the full mix played while every stem fader sits at unity,
|
||||||
|
since demucs recombination is lossy). Core, lib/enrichment.py, and the
|
||||||
|
stems plugin all depend on it, but it never went through a FEP and the
|
||||||
|
spec does not define it — the drift this gate exists to prevent.
|
||||||
|
|
||||||
|
The resolution is REMOVAL, not a FEP: the spec already carries the mixdown
|
||||||
|
as a stem ({id: full, file: stems/full.ogg}), so this key added a second,
|
||||||
|
redundant location for audio to a format that already had one. See #933.
|
||||||
|
|
||||||
|
Grandfathered so the gate can land green and start blocking the *next*
|
||||||
|
instance immediately, rather than blocking on #933. This entry goes away
|
||||||
|
when core no longer reads or writes the key.
|
||||||
+54
-10
@@ -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,62 @@ 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,
|
||||||
|
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), 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 (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). Stamps
|
||||||
|
last_played_at like touch_position does: the song WAS played, so
|
||||||
|
/api/stats/recent and Continue ordering must see it. Accepted skew:
|
||||||
|
the recorder retries FAILED flushes later, which stamps recency at
|
||||||
|
retry time — rare (offline corner), self-healing on the next play,
|
||||||
|
and preferable to the alternative (keep-existing would leave repeat
|
||||||
|
plays looking stale, the common case)."""
|
||||||
|
with self._lock:
|
||||||
|
self.conn.execute(
|
||||||
|
"""INSERT INTO song_stats (filename, arrangement, seconds_total,
|
||||||
last_played_at, updated_at)
|
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,
|
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(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 —
|
||||||
|
|||||||
+4
-2
@@ -101,14 +101,16 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
|
|||||||
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
|
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
|
||||||
"""Return absolute open-string MIDI notes for frequencies at the supplied
|
"""Return absolute open-string MIDI notes for frequencies at the supplied
|
||||||
A4 reference — the inverse of open_midis_to_freqs. None if any entry is
|
A4 reference — the inverse of open_midis_to_freqs. None if any entry is
|
||||||
non-numeric or non-positive (a provider could hand us anything)."""
|
non-numeric, non-finite, or non-positive (a provider could hand us
|
||||||
|
anything; NaN/Infinity would otherwise raise inside int(round(...)) and
|
||||||
|
500 the /api/tunings endpoint)."""
|
||||||
out: list[int] = []
|
out: list[int] = []
|
||||||
for f in freqs:
|
for f in freqs:
|
||||||
try:
|
try:
|
||||||
f = float(f)
|
f = float(f)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
if f <= 0:
|
if not math.isfinite(f) or f <= 0:
|
||||||
return None
|
return None
|
||||||
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
|
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -64,3 +64,498 @@
|
|||||||
.career-star-row .song .artist { color: #9ca3af; }
|
.career-star-row .song .artist { color: #9ca3af; }
|
||||||
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
|
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
|
||||||
.career-star-row .hint.close { color: #22d3ee; }
|
.career-star-row .hint.close { color: #22d3ee; }
|
||||||
|
|
||||||
|
/* ── Passports (badge journey) ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.career-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(55, 65, 81, 0.6);
|
||||||
|
}
|
||||||
|
.career-tab {
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.career-tab:hover { color: #e5e7eb; }
|
||||||
|
.career-tab.active { color: #fff; border-bottom-color: #06b6d4; }
|
||||||
|
|
||||||
|
.pp-instruments { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
|
.pp-inst {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
background-color: rgba(31, 41, 55, 0.7);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.pp-inst:hover { background-color: rgba(55, 65, 81, 0.9); }
|
||||||
|
.pp-inst.active { border-color: #06b6d4; color: #fff; }
|
||||||
|
.pp-inst.uncommitted { color: #6b7280; border-style: dashed; border-color: rgba(107, 114, 128, 0.5); }
|
||||||
|
.pp-inst-badges { color: #fbbf24; font-size: 0.7rem; }
|
||||||
|
.pp-inst-plus { color: #6b7280; }
|
||||||
|
|
||||||
|
/* Leather covers — per-instrument hue, embossed with layered shadows and a
|
||||||
|
subtle grain gradient (no image assets). */
|
||||||
|
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
|
||||||
|
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
|
||||||
|
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
|
||||||
|
.pp-leather-drums { background: linear-gradient(160deg, #3f3f46, #26262b); }
|
||||||
|
|
||||||
|
.pp-shelf { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; }
|
||||||
|
.pp-cover, .pp-commit-cover {
|
||||||
|
position: relative;
|
||||||
|
width: 9.5rem;
|
||||||
|
height: 13rem;
|
||||||
|
border-radius: 0.5rem 0.75rem 0.75rem 0.5rem;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||||
|
inset 0.5rem 0 0.75rem -0.5rem rgba(0, 0, 0, 0.8),
|
||||||
|
0 6px 16px rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
|
||||||
|
.pp-cover: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;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: rgba(240, 226, 195, 0.92);
|
||||||
|
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7), 0 -1px 0 rgba(255, 255, 255, 0.12);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.pp-cover-inst {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(240, 226, 195, 0.55);
|
||||||
|
}
|
||||||
|
.pp-cover-sub {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: rgba(240, 226, 195, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-commit-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid rgba(55, 65, 81, 0.6);
|
||||||
|
background-color: rgba(31, 41, 55, 0.35);
|
||||||
|
}
|
||||||
|
.pp-commit-card .pp-commit-cover { width: 7rem; height: 9.5rem; flex: none; }
|
||||||
|
|
||||||
|
.pp-rack { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); }
|
||||||
|
.pp-brochure {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
background: linear-gradient(165deg, rgba(45, 55, 72, 0.55), rgba(31, 41, 55, 0.55));
|
||||||
|
border: 1px solid rgba(75, 85, 99, 0.5);
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.pp-brochure:hover { transform: translateY(-2px); border-color: #06b6d4; }
|
||||||
|
.pp-brochure-art { font-size: 1.4rem; }
|
||||||
|
.pp-brochure-name { color: #e5e7eb; font-size: 0.85rem; font-weight: 600; }
|
||||||
|
.pp-brochure-sub { color: #6b7280; font-size: 0.65rem; }
|
||||||
|
|
||||||
|
/* The open book */
|
||||||
|
.pp-overlay { position: fixed; inset: 0; z-index: 60; }
|
||||||
|
.pp-book-wrap {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(3, 7, 18, 0.72);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.pp-book {
|
||||||
|
position: relative;
|
||||||
|
width: min(92vw, 720px);
|
||||||
|
height: min(72vh, 470px);
|
||||||
|
perspective: 1800px;
|
||||||
|
}
|
||||||
|
.pp-page {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 50%;
|
||||||
|
background:
|
||||||
|
linear-gradient(105deg, rgba(0, 0, 0, 0.08), transparent 12%),
|
||||||
|
#efe6d0;
|
||||||
|
color: #3f3428;
|
||||||
|
padding: 1.1rem 1.2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.pp-page-left { left: 0; border-radius: 0.6rem 0 0 0.6rem; opacity: 0; transition: opacity 0.35s ease 0.3s; align-items: center; }
|
||||||
|
.pp-page-right { right: 0; border-radius: 0 0.6rem 0.6rem 0; box-shadow: inset 0.4rem 0 0.6rem -0.4rem rgba(0, 0, 0, 0.35); }
|
||||||
|
.pp-book.open .pp-page-left { opacity: 1; }
|
||||||
|
.pp-book-cover {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 50%;
|
||||||
|
border-radius: 0 0.6rem 0.6rem 0;
|
||||||
|
transform-origin: left center;
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transition: transform 0.8s cubic-bezier(0.4, 0.1, 0.2, 1);
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.pp-book.open .pp-book-cover { transform: rotateY(-180deg); }
|
||||||
|
.pp-book-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.75rem;
|
||||||
|
right: -0.75rem;
|
||||||
|
z-index: 8;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(17, 24, 39, 0.95);
|
||||||
|
color: #d1d5db;
|
||||||
|
border: 1px solid rgba(107, 114, 128, 0.5);
|
||||||
|
}
|
||||||
|
.pp-book-close:hover { color: #fff; border-color: #06b6d4; }
|
||||||
|
.pp-page-head {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8a7a5e;
|
||||||
|
border-bottom: 1px solid rgba(138, 122, 94, 0.35);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The rubber stamp */
|
||||||
|
.pp-stamp {
|
||||||
|
--pp-rot: 0deg;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
width: 9rem;
|
||||||
|
height: 9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid #9a5b16;
|
||||||
|
box-shadow: inset 0 0 0 3px #efe6d0, inset 0 0 0 4px #9a5b16;
|
||||||
|
color: #9a5b16;
|
||||||
|
transform: rotate(var(--pp-rot));
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
.pp-stamp-genre { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.16em; overflow-wrap: anywhere; }
|
||||||
|
.pp-stamp-tier { font-size: 0.58rem; letter-spacing: 0.3em; }
|
||||||
|
.pp-stamp-ghost {
|
||||||
|
border-style: dashed;
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: #b3a68b;
|
||||||
|
color: #b3a68b;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.pp-stamp-hidden { opacity: 0; }
|
||||||
|
.pp-stamp-mini {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
border-width: 2px;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: #d9a253;
|
||||||
|
border-color: #d9a253;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
display: inline-block;
|
||||||
|
transform: rotate(var(--pp-rot));
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.pp-stamp-page::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -10%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: radial-gradient(closest-side, rgba(154, 91, 22, 0.25), transparent 72%);
|
||||||
|
filter: blur(5px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.pp-slam { animation: pp-slam 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) forwards; }
|
||||||
|
.pp-slam::after { animation: pp-ink 0.45s ease-out 0.12s forwards; }
|
||||||
|
@keyframes pp-slam {
|
||||||
|
0% { transform: rotate(calc(var(--pp-rot) - 15deg)) scale(2.5); opacity: 0; }
|
||||||
|
55% { transform: rotate(var(--pp-rot)) scale(0.92); opacity: 1; }
|
||||||
|
75% { transform: rotate(var(--pp-rot)) scale(1.05); }
|
||||||
|
100% { transform: rotate(var(--pp-rot)) scale(1); opacity: 0.92; }
|
||||||
|
}
|
||||||
|
@keyframes pp-ink {
|
||||||
|
from { opacity: 0; transform: scale(0.6); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
.pp-shake { animation: pp-shake 0.4s ease-out 0.28s; }
|
||||||
|
@keyframes pp-shake {
|
||||||
|
0%, 100% { transform: translate(0, 0) rotate(0); }
|
||||||
|
25% { transform: translate(2px, 1px) rotate(0.3deg); }
|
||||||
|
50% { transform: translate(-2px, 2px) rotate(-0.25deg); }
|
||||||
|
75% { transform: translate(1px, -1px) rotate(0.15deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-invite, .pp-snj, .pp-gold-note { font-size: 0.75rem; text-align: center; }
|
||||||
|
.pp-invite { color: #6d5d40; }
|
||||||
|
.pp-snj { color: #6d5d40; margin-top: 2rem; font-style: italic; max-width: 15rem; }
|
||||||
|
.pp-gold-note { color: #a8946d; font-size: 0.62rem; margin-top: 0.5rem; }
|
||||||
|
.pp-drills { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.7rem; color: #6d5d40; }
|
||||||
|
.pp-drill.cleared { color: #4d7c0f; }
|
||||||
|
|
||||||
|
/* Ticket stubs */
|
||||||
|
.pp-stubs { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; padding-right: 0.25rem; }
|
||||||
|
.pp-stub {
|
||||||
|
background: #f7f1e3;
|
||||||
|
border: 1px solid #d8cbaa;
|
||||||
|
border-left: 2px dashed #b6a98c;
|
||||||
|
border-radius: 0.25rem 0.4rem 0.4rem 0.25rem;
|
||||||
|
padding: 0.4rem 0.6rem 0.4rem 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
column-gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
box-shadow: 0 1px 2px rgba(63, 52, 40, 0.15);
|
||||||
|
}
|
||||||
|
.pp-stub-stars { color: #b8860b; font-size: 0.7rem; letter-spacing: 0.08em; grid-row: span 2; }
|
||||||
|
.pp-stub-title { font-size: 0.78rem; font-weight: 600; color: #3f3428; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pp-stub-artist { grid-column: 2; font-size: 0.65rem; color: #6d5d40; }
|
||||||
|
.pp-stub-meta { grid-column: 2; font-size: 0.6rem; color: #8a7a5e; }
|
||||||
|
.pp-stub-empty { font-size: 0.72rem; color: #8a7a5e; font-style: italic; padding: 0.75rem 0.25rem; }
|
||||||
|
|
||||||
|
/* Wax-seal commitment ceremony */
|
||||||
|
.pp-ceremony { width: 11rem; height: 15rem; }
|
||||||
|
.pp-wax {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1.4rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.4rem;
|
||||||
|
height: 3.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 32% 30%, #d24545 0%, #a41f1f 42%, #7c1414 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 4px rgba(124, 20, 20, 0.9),
|
||||||
|
inset 0 2px 4px rgba(255, 255, 255, 0.25),
|
||||||
|
0 3px 8px rgba(0, 0, 0, 0.55);
|
||||||
|
color: rgba(255, 235, 235, 0.9);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
animation: pp-seal-drop 0.9s cubic-bezier(0.25, 0.9, 0.3, 1.15) 0.35s backwards;
|
||||||
|
}
|
||||||
|
@keyframes pp-seal-drop {
|
||||||
|
0% { transform: translateY(-120px) scale(2.1); opacity: 0; }
|
||||||
|
60% { transform: translateY(0) scale(0.9); opacity: 1; }
|
||||||
|
80% { transform: translateY(0) scale(1.05); }
|
||||||
|
100% { transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small screens: the spread stacks; the flip cover would straddle both
|
||||||
|
pages, so the book simply opens. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pp-book { height: min(80vh, 620px); }
|
||||||
|
.pp-page { position: static; width: 100%; height: 50%; border-radius: 0; }
|
||||||
|
.pp-page-left { border-radius: 0.6rem 0.6rem 0 0; opacity: 1; }
|
||||||
|
.pp-page-right { border-radius: 0 0 0.6rem 0.6rem; }
|
||||||
|
.pp-book-cover { display: none; }
|
||||||
|
.pp-book { display: flex; flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pp-book-cover, .pp-page-left, .pp-cover { transition: none; }
|
||||||
|
.pp-slam, .pp-slam::after, .pp-shake, .pp-wax { animation: none; }
|
||||||
|
.pp-slam, .pp-stamp-page::after { opacity: 1; }
|
||||||
|
.pp-stamp-hidden { opacity: 0.92; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"badge_requirement": {
|
||||||
|
"songs": 5,
|
||||||
|
"min_stars": 2
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"instruments": [
|
||||||
|
"guitar",
|
||||||
|
"bass",
|
||||||
|
"keys",
|
||||||
|
"drums"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
{
|
{
|
||||||
"id": "career",
|
"id": "career",
|
||||||
"name": "Career",
|
"name": "Career",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Career mode — gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.",
|
"description": "Career mode — gig your way from a local bar to the arena, and build a passport wall of genre badges per instrument. Earn stars per song; the crowd reacts to how you play.",
|
||||||
"screen": "screen.html",
|
"screen": "screen.html",
|
||||||
"script": "screen.js",
|
"script": "screen.js",
|
||||||
"styles": "assets/career.css",
|
"styles": "assets/career.css",
|
||||||
|
"settings": {
|
||||||
|
"html": "settings.html",
|
||||||
|
"server_files": [
|
||||||
|
"career/"
|
||||||
|
]
|
||||||
|
},
|
||||||
"routes": "routes.py"
|
"routes": "routes.py"
|
||||||
}
|
}
|
||||||
|
|||||||
+367
-1
@@ -10,11 +10,22 @@ the plugin under ``venue-packs/<id>/`` or downloaded on demand into
|
|||||||
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
|
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
|
||||||
bundled packs so release assets can replace a built-in starter venue.
|
bundled packs so release assets can replace a built-in starter venue.
|
||||||
|
|
||||||
|
Passports (badge journey per instrument × genre — the identity layer on top
|
||||||
|
of the same stars): badges are COMPUTED on read from ``song_stats`` × the
|
||||||
|
library's effective genre, never stored. The only persisted career state is
|
||||||
|
what cannot be derived — instrument commitment, opened passports, and the
|
||||||
|
relayed virtuoso drill snapshot — as JSON under ``CONFIG_DIR/career/``
|
||||||
|
(exported via ``settings.server_files``).
|
||||||
|
|
||||||
Endpoints (all under /api/plugins/career/):
|
Endpoints (all under /api/plugins/career/):
|
||||||
GET /state stars + per-venue unlock/install/download status
|
GET /state stars + per-venue unlock/install/download status
|
||||||
POST /packs/{venue_id}/download start background pack download (409 if running)
|
POST /packs/{venue_id}/download start background pack download (409 if running)
|
||||||
DELETE /packs/{venue_id} remove an installed pack
|
DELETE /packs/{venue_id} remove an installed pack
|
||||||
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers)
|
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers)
|
||||||
|
GET /passports passport walls: badges, stubs, genres, drill status
|
||||||
|
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
|
||||||
|
POST /passports/open open a genre passport for an instrument
|
||||||
|
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -26,11 +37,14 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import Body, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from progression import instrument_for_arrangement
|
||||||
|
|
||||||
PLUGIN_ID = "career"
|
PLUGIN_ID = "career"
|
||||||
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
|
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
|
||||||
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
||||||
@@ -118,6 +132,288 @@ def _stars():
|
|||||||
return sum(per_song.values()), per_song, detail
|
return sum(per_song.values()), per_song, detail
|
||||||
|
|
||||||
|
|
||||||
|
# ── Passports ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
GENRE_MAX_LEN = 64
|
||||||
|
DRILL_SNAPSHOT_MAX_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso():
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _genre_display(genre):
|
||||||
|
return " ".join(str(genre or "").strip().split())
|
||||||
|
|
||||||
|
|
||||||
|
def _genre_key(genre):
|
||||||
|
return _genre_display(genre).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _state_file() -> Path:
|
||||||
|
return _state["state_dir"] / "passports-state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _drill_file() -> Path:
|
||||||
|
return _state["state_dir"] / "drill-state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path, default):
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _save_json(path: Path, obj):
|
||||||
|
tmp = path.with_name(path.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(obj, indent=2), encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _career_state():
|
||||||
|
st = _load_json(_state_file(), {})
|
||||||
|
if not isinstance(st, dict):
|
||||||
|
st = {}
|
||||||
|
if not isinstance(st.get("instruments"), dict):
|
||||||
|
st["instruments"] = {}
|
||||||
|
if not isinstance(st.get("passports"), dict):
|
||||||
|
st["passports"] = {}
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def _genre_expr(db):
|
||||||
|
# Reuse the host's override-aware effective-genre SQL (Fix-metadata popup
|
||||||
|
# overrides); plain `genre` on stand-ins that don't implement it.
|
||||||
|
fn = getattr(db, "_effective_genre_expr", None)
|
||||||
|
return fn() if callable(fn) else "genre"
|
||||||
|
|
||||||
|
|
||||||
|
def _instrument_of(arrangements, arrangement):
|
||||||
|
"""Progression's arrangement→instrument mapping, via the song_stats
|
||||||
|
arrangement index into the song's arrangements JSON."""
|
||||||
|
entry = None
|
||||||
|
try:
|
||||||
|
idx = int(arrangement)
|
||||||
|
if isinstance(arrangements, list) and 0 <= idx < len(arrangements):
|
||||||
|
entry = arrangements[idx]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
entry = None
|
||||||
|
return instrument_for_arrangement(entry)
|
||||||
|
|
||||||
|
|
||||||
|
def _played_by_instrument_genre():
|
||||||
|
"""((instrument, genre_key) → {filename: stub dict},
|
||||||
|
(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 {}, {}
|
||||||
|
thresholds = _state["content"]["star_accuracy_thresholds"]
|
||||||
|
rows = db.conn.execute(
|
||||||
|
"SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, "
|
||||||
|
" 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 = {}
|
||||||
|
seconds = {}
|
||||||
|
for filename, arrangement, acc, played_at, secs, title, artist, arrs_json, genre in rows:
|
||||||
|
gkey = _genre_key(genre)
|
||||||
|
if not gkey:
|
||||||
|
continue
|
||||||
|
if filename not in arrs_cache:
|
||||||
|
try:
|
||||||
|
arrs_cache[filename] = json.loads(arrs_json) if arrs_json else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
arrs_cache[filename] = None
|
||||||
|
instrument = _instrument_of(arrs_cache[filename], arrangement)
|
||||||
|
key = (instrument, gkey)
|
||||||
|
seconds[key] = seconds.get(key, 0.0) + (secs or 0.0)
|
||||||
|
acc = acc or 0.0
|
||||||
|
stub = out.setdefault(key, {}).get(filename)
|
||||||
|
if stub is None:
|
||||||
|
out[key][filename] = {
|
||||||
|
"filename": filename,
|
||||||
|
"title": title or filename,
|
||||||
|
"artist": artist or "",
|
||||||
|
"best_accuracy": acc,
|
||||||
|
"last_played_at": played_at,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
stub["best_accuracy"] = max(stub["best_accuracy"], acc)
|
||||||
|
stub["last_played_at"] = max(stub["last_played_at"] or "", played_at or "") or None
|
||||||
|
for stubs in out.values():
|
||||||
|
for stub in stubs.values():
|
||||||
|
acc = stub["best_accuracy"]
|
||||||
|
stub["best_accuracy"] = round(acc, 4)
|
||||||
|
stub["stars"] = sum(1 for t in thresholds if acc >= t)
|
||||||
|
return out, seconds
|
||||||
|
|
||||||
|
|
||||||
|
def _library_genres():
|
||||||
|
"""Distinct effective genres across the live library (the brochure rack)."""
|
||||||
|
db = _state["meta_db"]
|
||||||
|
if db is None:
|
||||||
|
return []
|
||||||
|
rows = db.conn.execute(
|
||||||
|
f"SELECT {_genre_expr(db)} AS g, COUNT(*) FROM songs GROUP BY g").fetchall()
|
||||||
|
by_key = {}
|
||||||
|
for genre, count in rows:
|
||||||
|
display = _genre_display(genre)
|
||||||
|
key = display.lower()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
cur = by_key.get(key)
|
||||||
|
if cur: # case-variant duplicates collapse onto the first-seen casing
|
||||||
|
cur["songs_in_library"] += count
|
||||||
|
else:
|
||||||
|
by_key[key] = {"genre_key": key, "genre": display,
|
||||||
|
"songs_in_library": count}
|
||||||
|
return sorted(by_key.values(),
|
||||||
|
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _badge_requirement(gkey, instrument="guitar"):
|
||||||
|
cfg = _state["passports_content"]
|
||||||
|
req = dict(cfg.get("badge_requirement") or {})
|
||||||
|
req.setdefault("songs", 5)
|
||||||
|
req.setdefault("min_stars", 2)
|
||||||
|
override = (cfg.get("genres") or {}).get(gkey)
|
||||||
|
if isinstance(override, dict):
|
||||||
|
req.update(override)
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
def _drill_by_node():
|
||||||
|
doc = _load_json(_drill_file(), {})
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return None, {}
|
||||||
|
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
|
||||||
|
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
|
||||||
|
return doc.get("received_at"), by_node
|
||||||
|
|
||||||
|
|
||||||
|
def _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, 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 {}
|
||||||
|
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_seconds = _played_by_instrument_genre()
|
||||||
|
received_at, by_node = _drill_by_node()
|
||||||
|
instruments = {}
|
||||||
|
for inst in cfg.get("instruments") or []:
|
||||||
|
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
|
||||||
|
opened = st["passports"].get(inst)
|
||||||
|
opened = opened if isinstance(opened, dict) else {}
|
||||||
|
passports = []
|
||||||
|
for gkey, meta in sorted(opened.items(),
|
||||||
|
key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])):
|
||||||
|
meta = meta if isinstance(meta, dict) else {}
|
||||||
|
req = _badge_requirement(gkey, inst)
|
||||||
|
songs = list(played.get((inst, gkey), {}).values())
|
||||||
|
for s in songs:
|
||||||
|
s["qualifies"] = s["stars"] >= req["min_stars"]
|
||||||
|
songs.sort(key=lambda s: (not s["qualifies"], -s["stars"],
|
||||||
|
s["title"].lower()))
|
||||||
|
qualifying = sum(1 for s in songs if s["qualifies"])
|
||||||
|
required = req["virtuoso_nodes"]
|
||||||
|
cleared = [n for n in required if _node_cleared(by_node, n)]
|
||||||
|
is_graded = inst in graded
|
||||||
|
if not is_graded:
|
||||||
|
# Where the engine can't fairly grade the instrument's job
|
||||||
|
# (bass pocket, feel) the passport shows repertoire, never a
|
||||||
|
# false badge denial — the doc's shown-not-judged rule.
|
||||||
|
badge = "shown_not_judged"
|
||||||
|
elif qualifying >= req["songs"] and len(cleared) == len(required):
|
||||||
|
badge = "earned"
|
||||||
|
else:
|
||||||
|
badge = "in_progress"
|
||||||
|
passports.append({
|
||||||
|
"genre_key": gkey,
|
||||||
|
"genre": meta.get("genre") or gkey,
|
||||||
|
"opened_at": meta.get("opened_at"),
|
||||||
|
"requirement": req,
|
||||||
|
"graded": is_graded,
|
||||||
|
"songs": songs,
|
||||||
|
"qualifying_count": qualifying,
|
||||||
|
# Honest hours odometer (Stage 5 post-cap): a true fact that
|
||||||
|
# only grows — never a target, never a meter.
|
||||||
|
"seconds_total": round(played_seconds.get((inst, gkey), 0.0), 1),
|
||||||
|
"drills": {"required": required, "cleared": cleared},
|
||||||
|
"badge": badge,
|
||||||
|
})
|
||||||
|
instruments[inst] = {"committed_at": committed_at, "passports": passports}
|
||||||
|
return {
|
||||||
|
"config": {
|
||||||
|
"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(),
|
||||||
|
"drill_state": {"received_at": received_at},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _validate_pack_dir(pack_dir: Path):
|
def _validate_pack_dir(pack_dir: Path):
|
||||||
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
||||||
manifest_path = pack_dir / "manifest.json"
|
manifest_path = pack_dir / "manifest.json"
|
||||||
@@ -197,6 +493,13 @@ def setup(app, context):
|
|||||||
_state["venues_dir"] = (
|
_state["venues_dir"] = (
|
||||||
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
|
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
|
||||||
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
|
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
|
_state["passports_content"] = json.loads(
|
||||||
|
(plugin_dir / "passports.json").read_text(encoding="utf-8"))
|
||||||
|
# Persisted career state (commitment / opened passports / drill snapshot)
|
||||||
|
# lives under CONFIG_DIR/career/ — declared in settings.server_files so it
|
||||||
|
# rides the settings export/import bundle. Packs stay out (they're media).
|
||||||
|
_state["state_dir"] = Path(context["config_dir"]) / PLUGIN_ID
|
||||||
|
_state["state_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
_state["meta_db"] = context.get("meta_db")
|
_state["meta_db"] = context.get("meta_db")
|
||||||
_state["log"] = context.get("log") or _state["log"]
|
_state["log"] = context.get("log") or _state["log"]
|
||||||
for v in _state["content"]["venues"]:
|
for v in _state["content"]["venues"]:
|
||||||
@@ -229,6 +532,69 @@ def setup(app, context):
|
|||||||
"venues": venues,
|
"venues": venues,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.get(f"/api/plugins/{PLUGIN_ID}/passports")
|
||||||
|
def get_passports():
|
||||||
|
with _lock:
|
||||||
|
return _passports_view()
|
||||||
|
|
||||||
|
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/commit")
|
||||||
|
def commit_instrument(body: dict = Body(...)):
|
||||||
|
inst = str((body or {}).get("instrument") or "")
|
||||||
|
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||||
|
raise HTTPException(400, "Unknown instrument.")
|
||||||
|
with _lock:
|
||||||
|
st = _career_state()
|
||||||
|
entry = st["instruments"].setdefault(inst, {})
|
||||||
|
# Idempotent: the wax seal is pressed once; re-commits keep the
|
||||||
|
# original date (only-gained-never-lost).
|
||||||
|
if not entry.get("committed_at"):
|
||||||
|
entry["committed_at"] = _now_iso()
|
||||||
|
_save_json(_state_file(), st)
|
||||||
|
return {"ok": True, "instrument": inst,
|
||||||
|
"committed_at": entry["committed_at"]}
|
||||||
|
|
||||||
|
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/open")
|
||||||
|
def open_passport(body: dict = Body(...)):
|
||||||
|
inst = str((body or {}).get("instrument") or "")
|
||||||
|
genre = _genre_display((body or {}).get("genre"))
|
||||||
|
gkey = genre.lower()
|
||||||
|
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||||
|
raise HTTPException(400, "Unknown instrument.")
|
||||||
|
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||||
|
raise HTTPException(400, "Provide a genre.")
|
||||||
|
with _lock:
|
||||||
|
st = _career_state()
|
||||||
|
# Opening a passport implies the instrument commitment (permissive
|
||||||
|
# server, ceremony ordering is the UI's job).
|
||||||
|
st["instruments"].setdefault(inst, {}).setdefault(
|
||||||
|
"committed_at", _now_iso())
|
||||||
|
genres = st["passports"].setdefault(inst, {})
|
||||||
|
if gkey not in genres:
|
||||||
|
genres[gkey] = {"genre": genre, "opened_at": _now_iso()}
|
||||||
|
_save_json(_state_file(), st)
|
||||||
|
return {"ok": True, "instrument": inst, "passport": genres[gkey]}
|
||||||
|
|
||||||
|
@app.post(f"/api/plugins/{PLUGIN_ID}/drill-state")
|
||||||
|
def post_drill_state(body: dict = Body(...)):
|
||||||
|
# The relayed virtuoso.progress snapshot (career's screen.js listens to
|
||||||
|
# the virtuoso:progress bus event and forwards the localStorage doc).
|
||||||
|
# Only the fields the badge check reads are kept.
|
||||||
|
if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict):
|
||||||
|
raise HTTPException(400, "Expected a progress snapshot with byNode.")
|
||||||
|
# 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}
|
||||||
|
|
||||||
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
||||||
def start_download(venue_id: str):
|
def start_download(venue_id: str):
|
||||||
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
||||||
|
|||||||
+33
-12
@@ -3,19 +3,40 @@
|
|||||||
<h1 class="text-2xl font-bold text-white">Career</h1>
|
<h1 class="text-2xl font-bold text-white">Career</h1>
|
||||||
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
|
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
|
<div class="career-tabs" role="tablist">
|
||||||
<div id="career-progress-wrap" class="mb-6">
|
<button class="career-tab" data-career-tab="venues" role="tab" id="career-tab-btn-venues" aria-controls="career-tab-venues">Venues</button>
|
||||||
<div class="career-bar-track">
|
<button class="career-tab" data-career-tab="passports" role="tab" id="career-tab-btn-passports" aria-controls="career-tab-passports">Passports</button>
|
||||||
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
|
|
||||||
</div>
|
|
||||||
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="career-venues" class="career-venues"></div>
|
|
||||||
<div class="mt-8">
|
<div id="career-tab-venues" role="tabpanel" aria-labelledby="career-tab-btn-venues">
|
||||||
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
|
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
|
||||||
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
|
<div id="career-progress-wrap" class="mb-6">
|
||||||
<div id="career-star-summary" class="text-xs text-gray-400"></div>
|
<div class="career-bar-track">
|
||||||
|
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
|
||||||
|
</div>
|
||||||
|
<div id="career-venues" class="career-venues"></div>
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
|
||||||
|
<div id="career-star-summary" class="text-xs text-gray-400"></div>
|
||||||
|
</div>
|
||||||
|
<div id="career-star-list" class="career-star-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="career-tab-passports" class="hidden" role="tabpanel" aria-labelledby="career-tab-btn-passports">
|
||||||
|
<p class="text-sm text-gray-400 mb-4">Commit to an instrument, pick a genre, and stamp your way to its badge — five ★★ songs mint a Bronze. Your passport wall is who you are as a musician.</p>
|
||||||
|
<div id="pp-instruments" class="pp-instruments"></div>
|
||||||
|
<div id="pp-shelf-wrap" class="mt-5">
|
||||||
|
<div id="pp-shelf" class="pp-shelf"></div>
|
||||||
|
</div>
|
||||||
|
<div id="pp-rack-wrap" class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-white mb-1">Explore next</h2>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">More genres, whenever you want them — your wall is complete as it is.</p>
|
||||||
|
<div id="pp-rack" class="pp-rack"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="career-star-list" class="career-star-list"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="pp-overlay" class="pp-overlay hidden"></div>
|
||||||
|
|||||||
+618
-1
@@ -17,11 +17,27 @@
|
|||||||
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
|
|
||||||
|
// Passports (the badge-journey layer; see routes.py — badges are computed
|
||||||
|
// server-side, this file only renders and relays).
|
||||||
|
const PP_SEEN_KEY = 'feedBack-career-badges-seen';
|
||||||
|
const PP_INST_KEY = 'feedBack-career-instrument';
|
||||||
|
const PP_TAB_KEY = 'feedBack-career-tab';
|
||||||
|
const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' };
|
||||||
|
const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕'];
|
||||||
|
|
||||||
let _state = null;
|
let _state = null;
|
||||||
let _pollTimer = 0;
|
let _pollTimer = 0;
|
||||||
let _appliedManifestVenue = null;
|
let _appliedManifestVenue = null;
|
||||||
let _manifestReqGen = 0; // invalidates in-flight manifest fetches
|
let _manifestReqGen = 0; // invalidates in-flight manifest fetches
|
||||||
let _prevUnlockedIds = null;
|
let _prevUnlockedIds = null;
|
||||||
|
let _pp = null; // last /passports view
|
||||||
|
let _ppRelayTimer = 0;
|
||||||
|
let _ppBook = null; // {inst, gkey} of the open spread
|
||||||
|
let _ppReturnFocus = null; // element to refocus when the book closes
|
||||||
|
let _ppCeremonyQueue = []; // badges awaiting their ceremony overlay
|
||||||
|
let _ppCeremonyActive = false;
|
||||||
|
let _ppBootstrapped = false;
|
||||||
|
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
@@ -219,9 +235,591 @@
|
|||||||
render(state);
|
render(state);
|
||||||
schedulePoll(state);
|
schedulePoll(state);
|
||||||
pushCrowdManifest(state);
|
pushCrowdManifest(state);
|
||||||
|
refreshPassports(); // independent fetch; failures don't touch venues
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passports ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function lsGet(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
|
||||||
|
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* ok */ } }
|
||||||
|
|
||||||
|
function ppLabel(inst) {
|
||||||
|
return PP_LABELS[inst] || (inst.charAt(0).toUpperCase() + inst.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ppKey(genre) {
|
||||||
|
return String(genre || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ppHash(seed) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic per-key jitter (sin-hash): stamps and stubs land slightly
|
||||||
|
// askew, the same way on every visit.
|
||||||
|
function ppJitter(seed, range) {
|
||||||
|
return (Math.abs(Math.sin(ppHash(seed))) * 2 - 1) * range;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sfx(name) {
|
||||||
|
try {
|
||||||
|
const a = new Audio(`${API}/assets/sfx/${name}.mp3`);
|
||||||
|
a.volume = 0.45;
|
||||||
|
a.play().catch(() => { /* autoplay policy — silent is fine */ });
|
||||||
|
} catch (_) { /* no Audio — fine */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCareerTab(tab) {
|
||||||
|
lsSet(PP_TAB_KEY, tab);
|
||||||
|
const venues = $('career-tab-venues');
|
||||||
|
const pp = $('career-tab-passports');
|
||||||
|
if (!venues || !pp) return;
|
||||||
|
venues.classList.toggle('hidden', tab !== 'venues');
|
||||||
|
pp.classList.toggle('hidden', tab !== 'passports');
|
||||||
|
document.querySelectorAll('#plugin-career .career-tab').forEach((b) => {
|
||||||
|
const active = b.dataset.careerTab === tab;
|
||||||
|
b.classList.toggle('active', active);
|
||||||
|
b.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeInstrument() {
|
||||||
|
const list = (_pp && _pp.config && _pp.config.instruments) || [];
|
||||||
|
const saved = lsGet(PP_INST_KEY);
|
||||||
|
if (saved && list.includes(saved)) return saved;
|
||||||
|
const committed = list.find((i) => ((_pp.instruments || {})[i] || {}).committed_at);
|
||||||
|
return committed || list[0] || 'guitar';
|
||||||
|
}
|
||||||
|
|
||||||
|
function seenBadges() {
|
||||||
|
try {
|
||||||
|
const seen = JSON.parse(lsGet(PP_SEEN_KEY) || '{}');
|
||||||
|
// Guard non-object JSON (a stray "null" or array) — a broken
|
||||||
|
// stored value must not throw on every passport refresh.
|
||||||
|
return seen && typeof seen === 'object' && !Array.isArray(seen) ? seen : {};
|
||||||
|
} catch (_) { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeId(inst, gkey) { return inst + '/' + gkey; }
|
||||||
|
|
||||||
|
function markBadgeSeen(inst, gkey) {
|
||||||
|
const seen = seenBadges();
|
||||||
|
seen[badgeId(inst, gkey)] = 1;
|
||||||
|
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
// New badge → chime + notification + 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 || {})) {
|
||||||
|
for (const p of (view.instruments[inst].passports || [])) {
|
||||||
|
const id = badgeId(inst, p.genre_key);
|
||||||
|
if (p.badge !== 'earned' || seen[id] || _ppNotified[id]) continue;
|
||||||
|
_ppNotified[id] = true;
|
||||||
|
sfx('chime');
|
||||||
|
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||||
|
window.fbNotify.show({
|
||||||
|
big: true, icon: '🛂', accent: '#b45309',
|
||||||
|
title: 'Badge earned!',
|
||||||
|
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
clearTimeout(_ppRelayTimer);
|
||||||
|
_ppRelayTimer = setTimeout(() => {
|
||||||
|
let snap = null;
|
||||||
|
try { snap = JSON.parse(lsGet('virtuoso.progress') || 'null'); } catch (_) { /* corrupt */ }
|
||||||
|
if (!snap || typeof snap !== 'object' || !snap.byNode || typeof snap.byNode !== 'object') return;
|
||||||
|
fetch(`${API}/drill-state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode: snap.mode, xp: snap.xp, byNode: snap.byNode }),
|
||||||
|
}).then(() => refreshPassports()).catch(() => { /* next event retries */ });
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPassports() {
|
||||||
|
let view;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/passports`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
view = await res.json();
|
||||||
|
} catch (_) { return; }
|
||||||
|
_pp = view;
|
||||||
|
detectNewBadges(view);
|
||||||
|
renderPassports();
|
||||||
|
if (!_ppBootstrapped) {
|
||||||
|
_ppBootstrapped = true;
|
||||||
|
// 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 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`;
|
||||||
|
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}${hours ? ` · ${hours}` : ''}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShelf(inst, data) {
|
||||||
|
const shelf = $('pp-shelf');
|
||||||
|
if (!shelf) return;
|
||||||
|
if (!data.committed_at) {
|
||||||
|
shelf.innerHTML = `<div class="pp-commit-card">
|
||||||
|
<div class="pp-commit-cover pp-leather-${esc(inst)}">
|
||||||
|
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
|
||||||
|
<span class="pp-cover-inst">passport</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-200 font-medium mb-1">Pick up the ${esc(ppLabel(inst).toLowerCase())}.</div>
|
||||||
|
<div class="text-xs text-gray-400 mb-2">Press your seal to commit — then choose a genre below and go deep.</div>
|
||||||
|
<button class="career-btn career-btn-primary" data-pp-commit="${esc(inst)}">Press the seal</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const books = (data.passports || []).map((p) => ppCoverHTML(inst, p)).join('');
|
||||||
|
shelf.innerHTML = books ||
|
||||||
|
'<div class="text-xs text-gray-500">Your shelf is ready — open your first genre passport below.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRack(inst, data) {
|
||||||
|
const rack = $('pp-rack');
|
||||||
|
if (!rack || !_pp) return;
|
||||||
|
const openedKeys = new Set((data.passports || []).map((p) => p.genre_key));
|
||||||
|
const genres = (_pp.genres || []).filter((g) => !openedKeys.has(g.genre_key));
|
||||||
|
if (!genres.length) {
|
||||||
|
rack.innerHTML = '<div class="text-xs text-gray-500">No further genres in your library yet — new songs bring new brochures.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rack.innerHTML = genres.map((g) => {
|
||||||
|
const art = PP_BROCHURE_ART[Math.abs(ppHash(g.genre_key)) % PP_BROCHURE_ART.length];
|
||||||
|
return `<button class="pp-brochure" data-pp-genre="${esc(g.genre)}">
|
||||||
|
<span class="pp-brochure-art" aria-hidden="true">${art}</span>
|
||||||
|
<span class="pp-brochure-name">${esc(g.genre)}</span>
|
||||||
|
<span class="pp-brochure-sub">${g.songs_in_library === 1 ? '1 song' : `${g.songs_in_library} songs`} in your library</span>
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPassports() {
|
||||||
|
const host = $('pp-instruments');
|
||||||
|
if (!host || !_pp) return;
|
||||||
|
const inst = activeInstrument();
|
||||||
|
const data = (_pp.instruments || {})[inst] || { passports: [] };
|
||||||
|
host.innerHTML = ((_pp.config || {}).instruments || []).map((i) => {
|
||||||
|
const d = (_pp.instruments || {})[i] || {};
|
||||||
|
const earned = (d.passports || []).filter((p) => p.badge === 'earned').length;
|
||||||
|
const committed = !!d.committed_at;
|
||||||
|
return `<button class="pp-inst${i === inst ? ' active' : ''}${committed ? '' : ' uncommitted'}" data-pp-inst="${esc(i)}">
|
||||||
|
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
renderShelf(inst, data);
|
||||||
|
renderRack(inst, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ppStubHTML(s) {
|
||||||
|
const date = (s.last_played_at || '').slice(0, 10);
|
||||||
|
return `<div class="pp-stub" style="transform:rotate(${ppJitter(s.filename, 1.2).toFixed(2)}deg)">
|
||||||
|
<span class="pp-stub-stars">${'★'.repeat(s.stars)}</span>
|
||||||
|
<span class="pp-stub-title">${esc(s.title)}</span>
|
||||||
|
${s.artist ? `<span class="pp-stub-artist">${esc(s.artist)}</span>` : ''}
|
||||||
|
<span class="pp-stub-meta">${date ? `${esc(date)} · ` : ''}best ${(s.best_accuracy * 100).toFixed(0)}%</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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' : ' 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 {
|
||||||
|
const fill = (ppFillFraction(p) * 100).toFixed(0);
|
||||||
|
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
|
||||||
|
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
|
<span class="pp-stamp-tier">BRONZE</span>
|
||||||
|
</div>
|
||||||
|
<div class="pp-invite">${esc(invite)}</div>`;
|
||||||
|
}
|
||||||
|
const hours = fmtHours(p.seconds_total);
|
||||||
|
const odometer = hours
|
||||||
|
? `<div class="pp-hours">${hours} in ${esc(p.genre)}</div>` : '';
|
||||||
|
let drills = '';
|
||||||
|
if (reqNodes.length) {
|
||||||
|
drills = `<div class="pp-drills">${reqNodes.map((n) =>
|
||||||
|
`<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.
|
||||||
|
const stubs = p.badge === 'shown_not_judged'
|
||||||
|
? (p.songs || [])
|
||||||
|
: (p.songs || []).filter((s) => s.qualifies);
|
||||||
|
const emptyLine = p.badge === 'shown_not_judged'
|
||||||
|
? `Play ${esc(p.genre)} songs to fill this page.`
|
||||||
|
: `Play ${esc(p.genre)} songs at ${starGl} to collect ticket stubs.`;
|
||||||
|
const stubsHTML = stubs.length ? stubs.map(ppStubHTML).join('')
|
||||||
|
: `<div class="pp-stub-empty">${emptyLine}</div>`;
|
||||||
|
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="${esc(p.genre)} ${esc(ppLabel(inst))} passport">
|
||||||
|
<div class="pp-book">
|
||||||
|
<div class="pp-page pp-page-left">
|
||||||
|
<div class="pp-page-head">${esc(p.genre)} — ${esc(ppLabel(inst))}</div>
|
||||||
|
${badgeArea}${odometer}${drills}
|
||||||
|
</div>
|
||||||
|
<div class="pp-page pp-page-right">
|
||||||
|
<div class="pp-page-head">Ticket stubs</div>
|
||||||
|
<div class="pp-stubs">${stubsHTML}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pp-book-cover pp-leather-${esc(inst)}">
|
||||||
|
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||||||
|
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
||||||
|
</div>
|
||||||
|
<button class="pp-book-close" data-pp-close="1" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBook(inst, gkey) {
|
||||||
|
if (!_pp) return;
|
||||||
|
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
|
||||||
|
.find((x) => x.genre_key === gkey);
|
||||||
|
const overlay = $('pp-overlay');
|
||||||
|
if (!p || !overlay) return;
|
||||||
|
_ppBook = { inst, gkey };
|
||||||
|
_ppReturnFocus = document.activeElement;
|
||||||
|
const pending = p.badge === 'earned' && !seenBadges()[badgeId(inst, gkey)];
|
||||||
|
overlay.innerHTML = ppBookHTML(inst, p, pending);
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
const close = overlay.querySelector('.pp-book-close');
|
||||||
|
if (close) close.focus();
|
||||||
|
sfx('page');
|
||||||
|
// Double rAF so the cover's closed state paints before the transition.
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
const book = overlay.querySelector('.pp-book');
|
||||||
|
if (book) book.classList.add('open');
|
||||||
|
}));
|
||||||
|
if (pending) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!_ppBook || _ppBook.gkey !== gkey || _ppBook.inst !== inst) return;
|
||||||
|
const stamp = overlay.querySelector('.pp-stamp-page');
|
||||||
|
const book = overlay.querySelector('.pp-book');
|
||||||
|
if (!stamp) return;
|
||||||
|
stamp.classList.remove('pp-stamp-hidden');
|
||||||
|
stamp.classList.add('pp-slam');
|
||||||
|
stamp.classList.add('pp-tilt'); // freshly slammed = trading card too
|
||||||
|
if (book) book.classList.add('pp-shake');
|
||||||
|
sfx('stamp');
|
||||||
|
markBadgeSeen(inst, gkey);
|
||||||
|
renderPassports(); // the shelf cover gains its mini-stamp
|
||||||
|
}, 950);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBook() {
|
||||||
|
_ppBook = null;
|
||||||
|
const overlay = $('pp-overlay');
|
||||||
|
if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; }
|
||||||
|
if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' &&
|
||||||
|
document.contains(_ppReturnFocus)) {
|
||||||
|
_ppReturnFocus.focus();
|
||||||
|
}
|
||||||
|
_ppReturnFocus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitInstrument(inst, after) {
|
||||||
|
fetch(`${API}/passports/commit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ instrument: inst }),
|
||||||
|
}).then(() => refreshPassports())
|
||||||
|
.then(() => { if (after) after(); })
|
||||||
|
.catch(() => { /* server restarting; user retries */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 0 — the wax seal. Purely theatrical: the overlay plays the press,
|
||||||
|
// the POST commits, the shelf re-renders committed.
|
||||||
|
function sealCeremony(inst, after) {
|
||||||
|
const overlay = $('pp-overlay');
|
||||||
|
if (!overlay) { commitInstrument(inst, after); return; }
|
||||||
|
overlay.innerHTML = `<div class="pp-book-wrap">
|
||||||
|
<div class="pp-commit-cover pp-ceremony pp-leather-${esc(inst)}">
|
||||||
|
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
|
||||||
|
<span class="pp-cover-inst">passport</span>
|
||||||
|
<span class="pp-wax"><span>${esc(ppLabel(inst).charAt(0))}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
setTimeout(() => sfx('seal'), 450);
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
overlay.innerHTML = '';
|
||||||
|
commitInstrument(inst, after);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ instrument: inst, genre }),
|
||||||
|
}).then((res) => { if (!res.ok) throw new Error('open ' + res.status); })
|
||||||
|
.then(() => refreshPassports())
|
||||||
|
.then(() => openBook(inst, ppKey(genre)))
|
||||||
|
.catch(() => { /* validation/restart; rack stays */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClick(e) {
|
function onClick(e) {
|
||||||
|
const tabBtn = e.target.closest('[data-career-tab]');
|
||||||
|
const instBtn = e.target.closest('[data-pp-inst]');
|
||||||
|
const commitBtn = e.target.closest('[data-pp-commit]');
|
||||||
|
const coverBtn = e.target.closest('[data-pp-open]');
|
||||||
|
const brochureBtn = e.target.closest('[data-pp-genre]');
|
||||||
|
if (tabBtn) {
|
||||||
|
showCareerTab(tabBtn.dataset.careerTab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (instBtn) {
|
||||||
|
lsSet(PP_INST_KEY, instBtn.dataset.ppInst);
|
||||||
|
renderPassports();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (commitBtn) {
|
||||||
|
sealCeremony(commitBtn.dataset.ppCommit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coverBtn) {
|
||||||
|
openBook(activeInstrument(), coverBtn.dataset.ppOpen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (brochureBtn) {
|
||||||
|
const inst = activeInstrument();
|
||||||
|
const genre = brochureBtn.dataset.ppGenre;
|
||||||
|
const committed = _pp && ((_pp.instruments || {})[inst] || {}).committed_at;
|
||||||
|
// Opening your first passport on an instrument IS the commitment —
|
||||||
|
// the seal ceremony runs first, then the passport opens.
|
||||||
|
if (committed) openGenre(inst, genre);
|
||||||
|
else sealCeremony(inst, () => openGenre(inst, genre));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target.closest('[data-pp-close]') ||
|
||||||
|
(e.target.dataset && e.target.dataset.ppCloseBg)) {
|
||||||
|
closeBook();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dlBtn = e.target.closest('[data-career-download]');
|
const dlBtn = e.target.closest('[data-career-download]');
|
||||||
const delBtn = e.target.closest('[data-career-delete]');
|
const delBtn = e.target.closest('[data-career-delete]');
|
||||||
const playBtn = e.target.closest('[data-career-play]');
|
const playBtn = e.target.closest('[data-career-play]');
|
||||||
@@ -263,15 +861,34 @@
|
|||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
const screen = document.getElementById('plugin-career');
|
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;
|
const sm = window.feedBack;
|
||||||
if (sm && typeof sm.on === 'function') {
|
if (sm && typeof sm.on === 'function') {
|
||||||
// New song stats can add stars → thresholds may cross mid-session.
|
// New song stats can add stars → thresholds may cross mid-session.
|
||||||
sm.on('stats:recorded', () => refresh());
|
sm.on('stats:recorded', () => refresh());
|
||||||
|
// Virtuoso's progress emits are the drill-state relay trigger; the
|
||||||
|
// payload is a thin delta, so the relay reads the full localStorage
|
||||||
|
// snapshot instead (see relayDrillState).
|
||||||
|
sm.on('virtuoso:progress', relayDrillState);
|
||||||
}
|
}
|
||||||
|
showCareerTab(lsGet(PP_TAB_KEY) === 'passports' ? 'passports' : 'venues');
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && _ppBook) closeBook();
|
||||||
|
});
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test seam (bare-vm harness, see plugins/career/tests/): pure helpers +
|
||||||
|
// the badge-diff logic; nothing here touches the DOM.
|
||||||
|
window.__careerPassportTest = {
|
||||||
|
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||||
|
fmtHours, ppFillFraction,
|
||||||
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', boot);
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!-- Career plugin — data panel. Exists so the passport/drill state declared in
|
||||||
|
settings.server_files has a visible home in Settings; nothing to configure. -->
|
||||||
|
<div class="text-sm text-gray-300 space-y-2">
|
||||||
|
<p><strong>Career</strong> computes stars and genre badges from your play
|
||||||
|
stats — they are never stored, so there is nothing to back up or reset.</p>
|
||||||
|
<p class="text-gray-400">What <em>is</em> saved server-side: your instrument
|
||||||
|
commitments, opened genre passports, and the practice-drill snapshot the
|
||||||
|
Virtuoso plugin reports. These ride along in
|
||||||
|
<em>Settings → Export</em> automatically.</p>
|
||||||
|
</div>
|
||||||
|
<hr class="border-gray-800 my-3">
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<label class="flex items-center justify-between gap-4">
|
||||||
|
<span>
|
||||||
|
<span class="text-gray-200 font-medium">Crowd sound reactions</span>
|
||||||
|
<span class="block text-xs text-gray-500">Cheers when the crowd's mood rises, boos when it drops. Uses each venue's own recordings.</span>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" id="career-sfx-toggle" class="accent-cyan-500 w-4 h-4">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var KEY = 'feedBack-venue-crowd-sfx';
|
||||||
|
var box = document.getElementById('career-sfx-toggle');
|
||||||
|
if (!box) return;
|
||||||
|
try { box.checked = localStorage.getItem(KEY) === 'on'; } catch (e) { /* ok */ }
|
||||||
|
box.addEventListener('change', function () {
|
||||||
|
try { localStorage.setItem(KEY, box.checked ? 'on' : 'off'); } catch (e) { /* ok */ }
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Passport UI pure-logic tests: load screen.js in a bare vm window and
|
||||||
|
// exercise the __careerPassportTest seam (no DOM beyond stubs, no network).
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
function load(seed) {
|
||||||
|
const store = Object.assign({}, seed);
|
||||||
|
const window = {
|
||||||
|
console,
|
||||||
|
localStorage: {
|
||||||
|
getItem: (k) => (k in store ? store[k] : null),
|
||||||
|
setItem: (k, v) => { store[k] = String(v); },
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
readyState: 'complete',
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
notifications: [],
|
||||||
|
};
|
||||||
|
window.window = window;
|
||||||
|
window.globalThis = window;
|
||||||
|
window.fbNotify = { show: (n) => window.notifications.push(n) };
|
||||||
|
const context = vm.createContext(window);
|
||||||
|
// `document` and `localStorage` resolve as bare names inside the IIFE.
|
||||||
|
context.document = window.document;
|
||||||
|
context.localStorage = window.localStorage;
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||||
|
vm.runInContext(src, context, { filename: 'career/screen.js' });
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('module loads (and boots) in a bare vm window', () => {
|
||||||
|
const w = load();
|
||||||
|
assert.equal(typeof w.__careerPassportTest.ppKey, 'function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ppKey normalizes case and whitespace', () => {
|
||||||
|
const { ppKey } = load().__careerPassportTest;
|
||||||
|
assert.equal(ppKey(' Blues Rock '), 'blues rock');
|
||||||
|
assert.equal(ppKey('FUNK'), 'funk');
|
||||||
|
assert.equal(ppKey(''), '');
|
||||||
|
assert.equal(ppKey(null), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ppJitter is deterministic and bounded', () => {
|
||||||
|
const { ppJitter } = load().__careerPassportTest;
|
||||||
|
assert.equal(ppJitter('blues', 8), ppJitter('blues', 8));
|
||||||
|
for (const seed of ['blues', 'funk', 'jazz', 'metal']) {
|
||||||
|
const j = ppJitter(seed, 8);
|
||||||
|
assert.ok(j >= -8 && j <= 8, `${seed} → ${j}`);
|
||||||
|
}
|
||||||
|
assert.notEqual(ppJitter('blues', 8), ppJitter('funk', 8));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectNewBadges notifies once per badge, never after it is seen', () => {
|
||||||
|
const w = load();
|
||||||
|
const t = w.__careerPassportTest;
|
||||||
|
const view = {
|
||||||
|
instruments: {
|
||||||
|
guitar: {
|
||||||
|
passports: [
|
||||||
|
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' },
|
||||||
|
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
t.detectNewBadges(view);
|
||||||
|
assert.equal(w.notifications.length, 1);
|
||||||
|
assert.match(w.notifications[0].message, /Blues/);
|
||||||
|
// Same view again in the same session: no duplicate notification.
|
||||||
|
t.detectNewBadges(view);
|
||||||
|
assert.equal(w.notifications.length, 1);
|
||||||
|
// Seen (slam played) → a fresh session stays quiet too.
|
||||||
|
t.markBadgeSeen('guitar', 'blues');
|
||||||
|
// JSON-compare: vm objects carry a foreign Object prototype.
|
||||||
|
assert.equal(JSON.stringify(t.seenBadges()), '{"guitar/blues":1}');
|
||||||
|
|
||||||
|
// Fresh session (new vm, empty notify cache) with the badge already seen:
|
||||||
|
// detection must stay silent.
|
||||||
|
const w2 = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
|
||||||
|
w2.__careerPassportTest.detectNewBadges(view);
|
||||||
|
assert.equal(w2.notifications.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('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 });
|
||||||
|
const t = w.__careerPassportTest;
|
||||||
|
assert.equal(JSON.stringify(t.seenBadges()), '{}', `stored ${bad}`);
|
||||||
|
// And detection still works on top of the recovered empty state.
|
||||||
|
t.detectNewBadges({ instruments: { guitar: { passports: [
|
||||||
|
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } });
|
||||||
|
assert.equal(w.notifications.length, 1, `stored ${bad}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,5 +14,9 @@
|
|||||||
"intro": {
|
"intro": {
|
||||||
"video": "intro.mp4",
|
"video": "intro.mp4",
|
||||||
"audio": "bar-ambience.mp3"
|
"audio": "bar-ambience.mp3"
|
||||||
|
},
|
||||||
|
"sfx": {
|
||||||
|
"up": "sfx-up.mp3",
|
||||||
|
"down": "sfx-down.mp3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -218,6 +218,7 @@ import {
|
|||||||
setAvOffsetMs,
|
setAvOffsetMs,
|
||||||
setInstrumentPathway,
|
setInstrumentPathway,
|
||||||
setupAppUpdates,
|
setupAppUpdates,
|
||||||
|
setupWindowOptions,
|
||||||
syncDefaultArrangementPin,
|
syncDefaultArrangementPin,
|
||||||
} from './js/settings.js';
|
} from './js/settings.js';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export async function loadSettings() {
|
|||||||
// failed fetch below still leaves the desktop updater wired up.
|
// failed fetch below still leaves the desktop updater wired up.
|
||||||
// setupAppUpdates() is idempotent via _appUpdatesWired.
|
// setupAppUpdates() is idempotent via _appUpdatesWired.
|
||||||
setupAppUpdates();
|
setupAppUpdates();
|
||||||
|
setupWindowOptions();
|
||||||
const resp = await fetch('/api/settings');
|
const resp = await fetch('/api/settings');
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
// Null-guard the form fields: on the v3 tabbed settings page the markup is
|
// Null-guard the form fields: on the v3 tabbed settings page the markup is
|
||||||
@@ -167,6 +168,47 @@ export async function loadSettings() {
|
|||||||
hwcInitSettingsUI();
|
hwcInitSettingsUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Window options (desktop-only) ────────────────────────────────────────
|
||||||
|
// Desktop-only window preferences (start-in-fullscreen, …). The whole block
|
||||||
|
// stays hidden in the plain web / Docker app; unhide + wire only when the
|
||||||
|
// feedBack-desktop bridge (window.feedBackDesktop.window) exposes the getter
|
||||||
|
// and setter. Persistence lives desktop-side because only the Electron main
|
||||||
|
// process can read the pref at window-creation time — core just proxies.
|
||||||
|
export let _windowOptionsWired = false;
|
||||||
|
|
||||||
|
export function setupWindowOptions() {
|
||||||
|
const block = document.getElementById('window-options-block');
|
||||||
|
if (!block) return;
|
||||||
|
const winApi = window.feedBackDesktop?.window;
|
||||||
|
// Per-method capability check: a partial/older bridge may expose `window`
|
||||||
|
// without this shape. Leave the block hidden rather than half-wiring it.
|
||||||
|
if (!winApi
|
||||||
|
|| typeof winApi.getStartFullscreen !== 'function'
|
||||||
|
|| typeof winApi.setStartFullscreen !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
block.classList.remove('hidden');
|
||||||
|
|
||||||
|
const cb = document.getElementById('setting-start-fullscreen');
|
||||||
|
if (!cb) return;
|
||||||
|
|
||||||
|
// Hydrate from the desktop-persisted value. The getter may be sync or
|
||||||
|
// async (IPC round-trip); Promise.resolve normalises both.
|
||||||
|
Promise.resolve(winApi.getStartFullscreen()).then(function (on) {
|
||||||
|
cb.checked = !!on;
|
||||||
|
}).catch(function () { /* leave unchecked on error */ });
|
||||||
|
|
||||||
|
// Guard only the listener against double-binding; unhide + re-hydrate
|
||||||
|
// stay idempotent so re-entering Settings refreshes the checkbox.
|
||||||
|
if (!_windowOptionsWired) {
|
||||||
|
_windowOptionsWired = true;
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
try { winApi.setStartFullscreen(cb.checked); } catch (_) { /* best-effort */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha'];
|
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha'];
|
||||||
|
|
||||||
export let _appUpdatesWired = false;
|
export let _appUpdatesWired = false;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+11
-2
@@ -120,11 +120,20 @@
|
|||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
_tuningsByKey = data.tunings || {};
|
_tuningsByKey = data.tunings || {};
|
||||||
// Build TUNING_NOTE from the first (lowest) string frequency of each tuning.
|
// Build TUNING_NOTE from the lowest string of each tuning. Prefer the
|
||||||
|
// exact integer midis the server now sends (tuningMidis, #829) — the
|
||||||
|
// frequency path reconstructs the note via log2 against a hardcoded
|
||||||
|
// 440 and can land a semitone off at non-440 reference pitches.
|
||||||
|
// Frequencies remain the fallback for older cached responses.
|
||||||
|
const midisByKey = data.tuningMidis || {};
|
||||||
TUNING_NOTE = {};
|
TUNING_NOTE = {};
|
||||||
for (const key of Object.keys(_tuningsByKey)) {
|
for (const key of Object.keys(_tuningsByKey)) {
|
||||||
for (const [name, freqs] of Object.entries(_tuningsByKey[key])) {
|
for (const [name, freqs] of Object.entries(_tuningsByKey[key])) {
|
||||||
if (!(name in TUNING_NOTE) && Array.isArray(freqs) && freqs.length > 0) {
|
if (name in TUNING_NOTE) continue;
|
||||||
|
const midis = midisByKey[key] && midisByKey[key][name];
|
||||||
|
if (Array.isArray(midis) && midis.length > 0 && Number.isFinite(midis[0])) {
|
||||||
|
TUNING_NOTE[name] = NOTE_NAMES[((midis[0] % 12) + 12) % 12];
|
||||||
|
} else if (Array.isArray(freqs) && freqs.length > 0) {
|
||||||
TUNING_NOTE[name] = _freqToNote(freqs[0]);
|
TUNING_NOTE[name] = _freqToNote(freqs[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -753,6 +753,21 @@
|
|||||||
<a href="https://github.com/got-feedback/feedback-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
|
<a href="https://github.com/got-feedback/feedback-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Window options — desktop-only; setupWindowOptions() unhides. -->
|
||||||
|
<div id="window-options-block" class="hidden">
|
||||||
|
<div class="fb-srow">
|
||||||
|
<div class="fb-srow-main">
|
||||||
|
<div class="fb-srow-title">Fullscreen</div>
|
||||||
|
<div class="fb-srow-desc">Run fee[dB]ack in fullscreen mode. On macOS, changes take effect on the next launch.</div>
|
||||||
|
</div>
|
||||||
|
<div class="fb-srow-control">
|
||||||
|
<label class="fb-switch">
|
||||||
|
<input type="checkbox" id="setting-start-fullscreen">
|
||||||
|
<span class="fb-switch-track"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Library folder path -->
|
<!-- Library folder path -->
|
||||||
<div class="fb-srow fb-srow-stack">
|
<div class="fb-srow fb-srow-stack">
|
||||||
<div class="fb-srow-main">
|
<div class="fb-srow-main">
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -48,6 +101,10 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
// A 4xx/5xx JSON error body must read as FAILURE — callers
|
||||||
|
// re-queue accrued seconds on null, and a parsed error object
|
||||||
|
// would silently drop them.
|
||||||
|
if (!r.ok) return null;
|
||||||
try { return await r.json(); } catch (e) { return null; }
|
try { return await r.json(); } catch (e) { return null; }
|
||||||
} catch (e) { return null; /* offline / endpoint absent — non-fatal */ }
|
} catch (e) { return null; /* offline / endpoint absent — non-fatal */ }
|
||||||
}
|
}
|
||||||
@@ -84,6 +141,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 +152,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 +172,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 +227,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);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
const STREAK_MILESTONES = [25, 50, 100];
|
const STREAK_MILESTONES = [25, 50, 100];
|
||||||
const CANPLAY_TIMEOUT_MS = 4000;
|
const CANPLAY_TIMEOUT_MS = 4000;
|
||||||
const DEV_FLAG_KEY = 'feedBack-venue-crowd-dev';
|
const DEV_FLAG_KEY = 'feedBack-venue-crowd-dev';
|
||||||
|
const SFX_KEY = 'feedBack-venue-crowd-sfx'; // 'on' | 'off' (default off)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Pure, clock-injected decision logic (unit-tested in
|
// Pure, clock-injected decision logic (unit-tested in
|
||||||
@@ -58,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) {
|
||||||
@@ -144,7 +154,11 @@
|
|||||||
video: abs(m.intro && m.intro.video),
|
video: abs(m.intro && m.intro.video),
|
||||||
audio: abs(m.intro && m.intro.audio),
|
audio: abs(m.intro && m.intro.audio),
|
||||||
};
|
};
|
||||||
return { loops, stingers, intro };
|
const sfx = {
|
||||||
|
up: abs(m.sfx && m.sfx.up),
|
||||||
|
down: abs(m.sfx && m.sfx.down),
|
||||||
|
};
|
||||||
|
return { loops, stingers, intro, sfx };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureVideos() {
|
function ensureVideos() {
|
||||||
@@ -410,6 +424,30 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _sfxEl = null;
|
||||||
|
|
||||||
|
function sfxEnabled() {
|
||||||
|
try { return localStorage.getItem(SFX_KEY) === 'on'; } catch (_) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot crowd reaction on committed mood transitions (toggleable):
|
||||||
|
// up the ladder → cheer, down → boos. Committed transitions are already
|
||||||
|
// hysteresis-limited, so this can't spam.
|
||||||
|
function playMoodSfx(direction) {
|
||||||
|
if (!sfxEnabled() || !_manifest || !_manifest.sfx || _introActive) return;
|
||||||
|
const url = direction > 0 ? _manifest.sfx.up : _manifest.sfx.down;
|
||||||
|
if (!url || typeof document === 'undefined') return;
|
||||||
|
if (!_sfxEl) {
|
||||||
|
_sfxEl = document.createElement('audio');
|
||||||
|
_sfxEl.preload = 'auto';
|
||||||
|
_sfxEl.style.display = 'none';
|
||||||
|
document.body.appendChild(_sfxEl);
|
||||||
|
}
|
||||||
|
_sfxEl.src = url;
|
||||||
|
_sfxEl.volume = 0.6;
|
||||||
|
_sfxEl.play().catch(() => { /* pre-gesture; skip silently */ });
|
||||||
|
}
|
||||||
|
|
||||||
function onSongPlay() {
|
function onSongPlay() {
|
||||||
// Song audio starting is the hard cue: the ambience must yield.
|
// Song audio starting is the hard cue: the ambience must yield.
|
||||||
fadeAudioOut(1000);
|
fadeAudioOut(1000);
|
||||||
@@ -430,8 +468,10 @@
|
|||||||
if (sting && !_introActive && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
|
if (sting && !_introActive && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
|
||||||
playStinger(sting);
|
playStinger(sting);
|
||||||
}
|
}
|
||||||
|
const prevRank = CROWD_RANK[machine.current];
|
||||||
const next = machine.update(d.state, now());
|
const next = machine.update(d.state, now());
|
||||||
if (next) {
|
if (next) {
|
||||||
|
playMoodSfx(CROWD_RANK[next] - prevRank);
|
||||||
// A stinger or the intro owns the idle layer; defer the switch.
|
// A stinger or the intro owns the idle layer; defer the switch.
|
||||||
if (_stingerUntilEnded || _introActive) _pendingLoop = next;
|
if (_stingerUntilEnded || _introActive) _pendingLoop = next;
|
||||||
else showLoop(next, FADE_MS);
|
else showLoop(next, FADE_MS);
|
||||||
@@ -489,6 +529,7 @@
|
|||||||
_introGen++;
|
_introGen++;
|
||||||
_introActive = false;
|
_introActive = false;
|
||||||
stopAudio();
|
stopAudio();
|
||||||
|
if (_sfxEl && !_sfxEl.paused) _sfxEl.pause();
|
||||||
_stingerUntilEnded = false;
|
_stingerUntilEnded = false;
|
||||||
_pendingLoop = null;
|
_pendingLoop = null;
|
||||||
_loadingLoop = null;
|
_loadingLoop = null;
|
||||||
@@ -564,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,
|
||||||
@@ -589,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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -14,25 +15,47 @@ import routes as career_routes
|
|||||||
|
|
||||||
|
|
||||||
class FakeMetaDb:
|
class FakeMetaDb:
|
||||||
"""song_stats-only stand-in for MetadataDB (the plugin reads nothing else)."""
|
"""song_stats/songs stand-in for MetadataDB (the plugin reads nothing else).
|
||||||
|
|
||||||
|
The real song_stats.arrangement is an INTEGER index into the song's
|
||||||
|
arrangements JSON; the legacy star tests pass strings ("guitar"), which
|
||||||
|
the passport code treats as index-less → instrument defaults to guitar."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""CREATE TABLE song_stats (
|
"""CREATE TABLE song_stats (
|
||||||
filename TEXT, arrangement TEXT, best_accuracy REAL
|
filename TEXT, arrangement TEXT, best_accuracy REAL,
|
||||||
|
last_played_at TEXT,
|
||||||
|
seconds_total REAL NOT NULL DEFAULT 0
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"""CREATE TABLE songs (
|
||||||
|
filename TEXT, title TEXT, artist TEXT,
|
||||||
|
genre TEXT DEFAULT '', arrangements TEXT
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)")
|
|
||||||
|
|
||||||
def add(self, filename, arrangement, best_accuracy, in_library=True):
|
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
||||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)",
|
genre="", arrangements=None, last_played_at=None, seconds_total=0):
|
||||||
(filename, arrangement, best_accuracy))
|
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(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 "
|
||||||
"(SELECT 1 FROM songs WHERE filename = ?)",
|
"(SELECT 1 FROM songs WHERE filename = ?)",
|
||||||
(filename, filename.replace(".feedpak", "").title(), "Test Artist", filename))
|
(filename, filename.replace(".feedpak", "").title(), "Test Artist",
|
||||||
|
genre,
|
||||||
|
json.dumps(arrangements) if arrangements is not None else None,
|
||||||
|
filename))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def add_song_only(self, filename, genre=""):
|
||||||
|
"""A library song with no plays — feeds the genre (brochure) list."""
|
||||||
|
self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(filename, filename, "Test Artist", genre, None))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""HTTP-level tests for the passport layer: badges, stubs, genres, drill intake.
|
||||||
|
|
||||||
|
Badges are computed on read (never stored): N genre songs at min_stars — with
|
||||||
|
stars ≥2 meaning best_accuracy ≥ 0.75 under the default 0.6/0.75/0.85
|
||||||
|
thresholds — plus any configured virtuoso drills.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import routes as career_routes
|
||||||
|
|
||||||
|
LEAD = [{"type": "lead", "name": "Lead"}]
|
||||||
|
BASS = [{"type": "bass", "name": "Bass"}]
|
||||||
|
|
||||||
|
|
||||||
|
def _open(client, instrument="guitar", genre="Blues"):
|
||||||
|
res = client.post("/api/plugins/career/passports/open",
|
||||||
|
json={"instrument": instrument, "genre": genre})
|
||||||
|
assert res.status_code == 200
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _passport(client, instrument="guitar", genre_key="blues"):
|
||||||
|
view = client.get("/api/plugins/career/passports").json()
|
||||||
|
for p in view["instruments"][instrument]["passports"]:
|
||||||
|
if p["genre_key"] == genre_key:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_badge_earned_at_five_genre_songs_two_stars(client, meta_db):
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_badge_in_progress_below_the_bar(client, meta_db):
|
||||||
|
for i in range(4):
|
||||||
|
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
|
||||||
|
meta_db.add("weak.feedpak", 0, 0.65, genre="Blues", arrangements=LEAD) # 1★
|
||||||
|
_open(client)
|
||||||
|
p = _passport(client)
|
||||||
|
assert p["badge"] == "in_progress"
|
||||||
|
assert p["qualifying_count"] == 4
|
||||||
|
# Qualifying stubs sort ahead of the near-misses.
|
||||||
|
assert [s["qualifies"] for s in p["songs"]] == [True] * 4 + [False]
|
||||||
|
|
||||||
|
|
||||||
|
def test_instruments_split_and_bass_is_shown_not_judged(client, meta_db):
|
||||||
|
# Same 5 songs but played on the BASS arrangement: no guitar badge credit.
|
||||||
|
for i in range(5):
|
||||||
|
meta_db.add(f"blues{i}.feedpak", 0, 0.9, genre="Blues", arrangements=BASS)
|
||||||
|
_open(client, "guitar")
|
||||||
|
_open(client, "bass")
|
||||||
|
guitar = _passport(client, "guitar")
|
||||||
|
bass = _passport(client, "bass")
|
||||||
|
assert guitar["qualifying_count"] == 0 and guitar["badge"] == "in_progress"
|
||||||
|
assert bass["qualifying_count"] == 5
|
||||||
|
# Bass isn't a graded instrument: repertoire shows, no pass/fail bar.
|
||||||
|
assert bass["badge"] == "shown_not_judged" and bass["graded"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_accuracy_per_instrument_across_arrangements(client, meta_db):
|
||||||
|
both = [{"type": "lead", "name": "Lead"}, {"type": "lead", "name": "Alt. Lead"}]
|
||||||
|
meta_db.add("song.feedpak", 0, 0.7, genre="Blues", arrangements=both)
|
||||||
|
meta_db.add("song.feedpak", 1, 0.9, genre="Blues", arrangements=both)
|
||||||
|
_open(client)
|
||||||
|
p = _passport(client)
|
||||||
|
assert len(p["songs"]) == 1
|
||||||
|
assert p["songs"][0]["best_accuracy"] == 0.9
|
||||||
|
assert p["songs"][0]["stars"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_orphaned_songs_do_not_feed_stubs(client, meta_db):
|
||||||
|
meta_db.add("gone.feedpak", 0, 0.9, genre="Blues", arrangements=LEAD,
|
||||||
|
in_library=False)
|
||||||
|
_open(client)
|
||||||
|
assert _passport(client)["songs"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_genre_rack_collapses_case_and_skips_blank(client, meta_db):
|
||||||
|
meta_db.add_song_only("a.feedpak", genre="Blues")
|
||||||
|
meta_db.add_song_only("b.feedpak", genre="blues")
|
||||||
|
meta_db.add_song_only("c.feedpak", genre="Funk")
|
||||||
|
meta_db.add_song_only("d.feedpak", genre="")
|
||||||
|
genres = client.get("/api/plugins/career/passports").json()["genres"]
|
||||||
|
assert genres == [
|
||||||
|
{"genre_key": "blues", "genre": "Blues", "songs_in_library": 2},
|
||||||
|
{"genre_key": "funk", "genre": "Funk", "songs_in_library": 1},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_is_idempotent_and_open_implies_commit(client):
|
||||||
|
first = client.post("/api/plugins/career/passports/commit",
|
||||||
|
json={"instrument": "guitar"}).json()
|
||||||
|
again = client.post("/api/plugins/career/passports/commit",
|
||||||
|
json={"instrument": "guitar"}).json()
|
||||||
|
assert first["committed_at"] == again["committed_at"]
|
||||||
|
_open(client, "bass", "Funk")
|
||||||
|
view = client.get("/api/plugins/career/passports").json()
|
||||||
|
assert view["instruments"]["bass"]["committed_at"]
|
||||||
|
# Re-opening the same passport keeps the original opened_at.
|
||||||
|
opened = view["instruments"]["bass"]["passports"][0]["opened_at"]
|
||||||
|
_open(client, "bass", " funk ") # normalizes to the same key
|
||||||
|
view = client.get("/api/plugins/career/passports").json()
|
||||||
|
assert [p["opened_at"] for p in view["instruments"]["bass"]["passports"]] == [opened]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_and_commit_validation(client):
|
||||||
|
assert client.post("/api/plugins/career/passports/commit",
|
||||||
|
json={"instrument": "theremin"}).status_code == 400
|
||||||
|
assert client.post("/api/plugins/career/passports/open",
|
||||||
|
json={"instrument": "guitar", "genre": " "}).status_code == 400
|
||||||
|
assert client.post("/api/plugins/career/passports/open",
|
||||||
|
json={"instrument": "guitar", "genre": "x" * 65}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_drill_requirement_gates_badge_until_snapshot_clears_it(client, meta_db):
|
||||||
|
for i in range(5):
|
||||||
|
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
|
||||||
|
career_routes._state["passports_content"]["genres"]["blues"] = {
|
||||||
|
"virtuoso_nodes": ["node.shuffle"]}
|
||||||
|
_open(client)
|
||||||
|
p = _passport(client)
|
||||||
|
assert p["badge"] == "in_progress"
|
||||||
|
assert p["drills"] == {"required": ["node.shuffle"], "cleared": []}
|
||||||
|
|
||||||
|
res = client.post("/api/plugins/career/drill-state", json={
|
||||||
|
"mode": "casual", "xp": 120,
|
||||||
|
"byNode": {"node.shuffle": {"masteredAt": 1720000000,
|
||||||
|
"depth": {"travel": None}}}})
|
||||||
|
assert res.status_code == 200
|
||||||
|
p = _passport(client)
|
||||||
|
assert p["drills"]["cleared"] == ["node.shuffle"]
|
||||||
|
assert p["badge"] == "earned"
|
||||||
|
|
||||||
|
|
||||||
|
def test_drill_state_validation(client):
|
||||||
|
assert client.post("/api/plugins/career/drill-state",
|
||||||
|
json={"mode": "casual"}).status_code == 400
|
||||||
|
huge = {"byNode": {"pad": "x" * (300 * 1024)}}
|
||||||
|
assert client.post("/api/plugins/career/drill-state",
|
||||||
|
json=huge).status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
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,57 @@ 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)
|
||||||
|
# Recency must come from the seconds-only POST itself — prove it on a
|
||||||
|
# FRESH row (the position touch above already stamps last_played_at,
|
||||||
|
# which would make an assertion here vacuous).
|
||||||
|
r2 = client.post("/api/stats", json={"filename": "fresh.archive", "seconds": 30})
|
||||||
|
assert r2.json()["stats"]["last_played_at"]
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Regression tests for the feedpak spec-conformance gate.
|
||||||
|
|
||||||
|
The gate (tools/check_spec_conformance.py) is what keeps the app from drifting
|
||||||
|
away from the feedpak spec, so the gate itself must not be weakenable by a
|
||||||
|
quiet refactor: these tests pin its load-bearing behaviours — read/write
|
||||||
|
classification, the closed allowlist, and the duplicate/malformed-entry
|
||||||
|
rejections. If one of these fails, the spec's protection regressed.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_SPEC_GATE = Path(__file__).resolve().parent.parent / "tools" / "check_spec_conformance.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("check_spec_conformance", _SPEC_GATE)
|
||||||
|
gate = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(gate)
|
||||||
|
|
||||||
|
|
||||||
|
def _touch(tmp_path, source):
|
||||||
|
p = tmp_path / "mod.py"
|
||||||
|
p.write_text(textwrap.dedent(source), encoding="utf-8")
|
||||||
|
return gate.keys_touched(p)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- keys_touched
|
||||||
|
|
||||||
|
def test_get_is_a_read(tmp_path):
|
||||||
|
reads, writes = _touch(tmp_path, 'x = manifest.get("title")')
|
||||||
|
assert reads == {"title"} and writes == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscript_load_is_a_read(tmp_path):
|
||||||
|
reads, writes = _touch(tmp_path, 'x = manifest["artist"]')
|
||||||
|
assert reads == {"artist"} and writes == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscript_store_is_a_write_not_a_read(tmp_path):
|
||||||
|
# The original scan ignored ctx and scored this as a read (lib/songmeta.py
|
||||||
|
# pattern). A regression here reopens the emitted-key blind spot.
|
||||||
|
reads, writes = _touch(tmp_path, 'manifest["year"] = 1999')
|
||||||
|
assert writes == {"year"} and reads == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_setdefault_is_a_write(tmp_path):
|
||||||
|
# lib/gp2notation.py stamps feedpak_version this way; a subscript-only scan
|
||||||
|
# missed it entirely.
|
||||||
|
reads, writes = _touch(tmp_path, 'manifest.setdefault("feedpak_version", "1.2.0")')
|
||||||
|
assert writes == {"feedpak_version"} and reads == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_manifest_wrapped_get_is_seen(tmp_path):
|
||||||
|
# lib/enrichment.py idiom: (load_manifest(p) or {}).get("key")
|
||||||
|
reads, writes = _touch(
|
||||||
|
tmp_path, 'rel = (sloppak_mod.load_manifest(p) or {}).get("original_audio")'
|
||||||
|
)
|
||||||
|
assert "original_audio" in reads
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_aware_receiver_any_name(tmp_path):
|
||||||
|
# lib/routers/chart.py binds `m = load_manifest(p) or {}` — a fixed name
|
||||||
|
# list missed it and the module's reads went entirely unscanned. Locals
|
||||||
|
# assigned from load_manifest must be receivers whatever they're called.
|
||||||
|
reads, writes = _touch(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
pak_info = sloppak_mod.load_manifest(p) or {}
|
||||||
|
x = pak_info.get("stems")
|
||||||
|
pak_info["genres"] = ["metal"]
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert reads == {"stems"} and writes == {"genres"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_dict_named_m_is_not_a_receiver(tmp_path):
|
||||||
|
# Flow-awareness must not make every short local a manifest: `m` bound to
|
||||||
|
# something other than load_manifest stays out of the scan.
|
||||||
|
reads, writes = _touch(tmp_path, 'm = {}\nx = m.get("title")')
|
||||||
|
assert reads == set() and writes == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_dicts_are_ignored(tmp_path):
|
||||||
|
reads, writes = _touch(tmp_path, 'x = config.get("title"); settings["artist"] = 1')
|
||||||
|
assert reads == set() and writes == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_literal_keys_are_ignored(tmp_path):
|
||||||
|
reads, writes = _touch(tmp_path, 'x = manifest.get(key_var); manifest[key_var] = 1')
|
||||||
|
assert reads == set() and writes == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_key_read_helper_is_seen(tmp_path):
|
||||||
|
# lib/routers/song.py uses this helper for gap-fill proposals. If helpers
|
||||||
|
# are invisible, adding a new literal key through that path bypasses both
|
||||||
|
# key-coverage and readers-complete.
|
||||||
|
reads, writes = _touch(
|
||||||
|
tmp_path,
|
||||||
|
'_gap_fill_manifest_absent(manifest, "album")\n'
|
||||||
|
'_gap_fill_manifest_absent(manifest, dynamic_key)\n',
|
||||||
|
)
|
||||||
|
assert reads == {"album"} and writes == set()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ exceptions file
|
||||||
|
|
||||||
|
def test_duplicate_exception_key_is_rejected():
|
||||||
|
doc = """
|
||||||
|
exceptions:
|
||||||
|
- key: original_audio
|
||||||
|
issue: https://example.com/1
|
||||||
|
- key: original_audio
|
||||||
|
issue: https://example.com/2
|
||||||
|
"""
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
gate._parse_exceptions(textwrap.dedent(doc), "test")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("doc", [
|
||||||
|
"- just\n- a\n- list\n", # list at top level
|
||||||
|
"exceptions: not-a-list\n", # scalar where list expected
|
||||||
|
"exceptions:\n - just-a-string\n", # non-mapping entry
|
||||||
|
"exceptions: [\n", # invalid YAML
|
||||||
|
])
|
||||||
|
def test_malformed_exceptions_fail_legibly(doc):
|
||||||
|
# Malformed shapes must exit with a ::error::, not an AttributeError
|
||||||
|
# traceback — CI output has to say what to fix.
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
gate._parse_exceptions(doc, "test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_exception_without_issue_is_rejected():
|
||||||
|
# No tracking issue, no exception — entries are debt and debt is tracked.
|
||||||
|
doc = """
|
||||||
|
exceptions:
|
||||||
|
- key: original_audio
|
||||||
|
"""
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
gate._parse_exceptions(textwrap.dedent(doc), "test")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- allowlist is CLOSED
|
||||||
|
|
||||||
|
def _yml(tmp_path, name, keys):
|
||||||
|
p = tmp_path / name
|
||||||
|
entries = "".join(
|
||||||
|
f" - key: {k}\n issue: https://example.com/{k}\n" for k in keys
|
||||||
|
)
|
||||||
|
p.write_text("exceptions:\n" + (entries or " []\n"), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowlist_may_not_grow(tmp_path, monkeypatch):
|
||||||
|
# THE core property: adding an entry must fail, or the FEP process has an
|
||||||
|
# in-repo bypass and the gate is a speed bump with a signed excuse note.
|
||||||
|
baseline = _yml(tmp_path, "base.yml", ["original_audio"])
|
||||||
|
current = _yml(tmp_path, "current.yml", ["original_audio", "sneaky_new_key"])
|
||||||
|
monkeypatch.setattr(gate, "EXCEPTIONS_FILE", current)
|
||||||
|
assert gate.check_allowlist_closed(baseline, bootstrap=False) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowlist_may_shrink(tmp_path, monkeypatch):
|
||||||
|
baseline = _yml(tmp_path, "base.yml", ["original_audio"])
|
||||||
|
current = _yml(tmp_path, "current.yml", [])
|
||||||
|
monkeypatch.setattr(gate, "EXCEPTIONS_FILE", current)
|
||||||
|
assert gate.check_allowlist_closed(baseline, bootstrap=False) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowlist_steady_state_passes(tmp_path, monkeypatch):
|
||||||
|
baseline = _yml(tmp_path, "base.yml", ["original_audio"])
|
||||||
|
current = _yml(tmp_path, "current.yml", ["original_audio"])
|
||||||
|
monkeypatch.setattr(gate, "EXCEPTIONS_FILE", current)
|
||||||
|
assert gate.check_allowlist_closed(baseline, bootstrap=False) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_skips_the_diff(tmp_path, monkeypatch):
|
||||||
|
current = _yml(tmp_path, "current.yml", ["original_audio"])
|
||||||
|
monkeypatch.setattr(gate, "EXCEPTIONS_FILE", current)
|
||||||
|
assert gate.check_allowlist_closed(None, bootstrap=True) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ readers-complete
|
||||||
|
|
||||||
|
def test_readers_list_matches_the_codebase():
|
||||||
|
# If this fails, a module started touching feedpak manifests without being
|
||||||
|
# added to READERS — its keys are going unchecked. Same check CI runs.
|
||||||
|
assert gate.check_readers_complete() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_undeclared_keys_beyond_the_grandfathered(monkeypatch):
|
||||||
|
# Every key core touches is either spec-declared or grandfathered with a
|
||||||
|
# tracking issue. New keys go through the FEP process, full stop.
|
||||||
|
reads, writes = set(), set()
|
||||||
|
for rel in gate.READERS:
|
||||||
|
r, w = gate.keys_touched(gate.REPO / rel)
|
||||||
|
reads |= r
|
||||||
|
writes |= w
|
||||||
|
grandfathered = set(gate.load_exceptions())
|
||||||
|
# Not asserting against the spec here (no spec checkout in unit tests) —
|
||||||
|
# asserting the *shape*: the only non-spec keys tolerated are grandfathered,
|
||||||
|
# and today that is exactly {original_audio}.
|
||||||
|
assert grandfathered == {"original_audio"}
|
||||||
|
assert "original_audio" in reads
|
||||||
@@ -275,4 +275,7 @@ def test_freqs_to_midis_rejects_garbage():
|
|||||||
from tunings import freqs_to_midis
|
from tunings import freqs_to_midis
|
||||||
assert freqs_to_midis([82.41, 0]) is None # non-positive
|
assert freqs_to_midis([82.41, 0]) is None # non-positive
|
||||||
assert freqs_to_midis([82.41, "x"]) is None # non-numeric
|
assert freqs_to_midis([82.41, "x"]) is None # non-numeric
|
||||||
|
assert freqs_to_midis([float("nan")]) is None # non-finite (would raise in int(round(...)))
|
||||||
|
assert freqs_to_midis([float("inf")]) is None # non-finite
|
||||||
|
assert freqs_to_midis([float("-inf")]) is None # non-finite
|
||||||
assert freqs_to_midis([]) == [] # vacuously fine
|
assert freqs_to_midis([]) == [] # vacuously fine
|
||||||
|
|||||||
@@ -0,0 +1,537 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""feedpak spec-conformance gate.
|
||||||
|
|
||||||
|
feedpak is an open, versioned format with its own normative spec, JSON Schemas,
|
||||||
|
and reference validator (https://github.com/got-feedback/feedpak-spec). That
|
||||||
|
makes the spec a contract with everyone outside this repo: third-party packers,
|
||||||
|
converters, and players build against it. When core reads a manifest key the
|
||||||
|
spec never defined, the contract quietly breaks — a spec-compliant pack stops
|
||||||
|
being a fully-working pack, and the format's real definition migrates into our
|
||||||
|
source tree. See #933 for the instance that motivated this gate.
|
||||||
|
|
||||||
|
We cannot mechanically prove core *interprets* a key the way the spec means. We
|
||||||
|
can prove four surface properties, and those cover the drift that actually
|
||||||
|
happens:
|
||||||
|
|
||||||
|
1. key-coverage — every manifest key core reads OR WRITES is declared by the
|
||||||
|
spec. (Guarded by check_readers_complete(), so the list of
|
||||||
|
scanned modules cannot quietly fall behind the codebase.)
|
||||||
|
2. allowlist-closed— feedpak-spec-exceptions.yml never grows. It grandfathers
|
||||||
|
keys that predate this gate; it is not a way to merge a
|
||||||
|
new one. The only route for a new key is the FEP process.
|
||||||
|
3. forward — core ingests the spec's own example packs.
|
||||||
|
4. reverse — packs committed here satisfy the spec's reference validator.
|
||||||
|
|
||||||
|
Dev/CI tooling only: never imported on the serve or Docker path (constitution
|
||||||
|
Principle I — same category as scripts/build-tailwind.sh). `jsonschema` is
|
||||||
|
therefore a CI-only dependency, not a runtime requirement.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/check_spec_conformance.py --spec <path-to-feedpak-spec-checkout>
|
||||||
|
|
||||||
|
Exit status is 0 only when every layer passes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Modules that read or write a feedpak manifest dict. Explicit rather than
|
||||||
|
# globbed, because `manifest` is an overloaded name in this codebase: the
|
||||||
|
# loose-folder format (lib/loosefolder.py) and the diagnostics bundle
|
||||||
|
# (lib/diagnostics_bundle.py) both have their own unrelated `manifest`, and
|
||||||
|
# scanning those would flag *their* keys as feedpak drift.
|
||||||
|
#
|
||||||
|
# A hand-maintained list is itself a blind spot, so check_readers_complete()
|
||||||
|
# below re-derives the set and fails if this list has fallen behind. A missing
|
||||||
|
# file here is a hard error too, so a rename cannot silently disable the scan.
|
||||||
|
READERS = [
|
||||||
|
"lib/sloppak.py",
|
||||||
|
"lib/enrichment.py",
|
||||||
|
"lib/songmeta.py",
|
||||||
|
"lib/gp2notation.py", # rewrites manifest.yaml; stamps feedpak_version
|
||||||
|
"lib/routers/ws_highway.py", # reads `authors` off a feedpak manifest
|
||||||
|
"lib/routers/chart.py", # Get-info panel: binds `m = load_manifest(...)`
|
||||||
|
"lib/routers/song.py", # enrichment gap-fill: reads the manifest directly
|
||||||
|
]
|
||||||
|
|
||||||
|
# Where check_readers_complete() looks for modules READERS may have missed.
|
||||||
|
READER_SEARCH = ["lib/**/*.py", "server.py"]
|
||||||
|
|
||||||
|
# A module is handling a *feedpak* manifest (rather than some other manifest) if
|
||||||
|
# it shows one of these signals. lib/loosefolder.py and lib/diagnostics_bundle.py
|
||||||
|
# score zero on all of them, which is what keeps their keys out of the scan.
|
||||||
|
FEEDPAK_SIGNALS = re.compile(r"import sloppak|from sloppak|load_manifest|manifest\.yaml|feedpak")
|
||||||
|
|
||||||
|
# Locals assumed to hold a manifest dict by NAME. This is only the fallback for
|
||||||
|
# manifests that arrive as function parameters (ws_highway's `manifest` arg);
|
||||||
|
# locals ASSIGNED from load_manifest(...) are discovered flow-aware in
|
||||||
|
# keys_touched(), whatever they are called — chart.py's `m` taught us that a
|
||||||
|
# name list alone silently misses real readers.
|
||||||
|
MANIFEST_VARS = {"manifest", "mf"}
|
||||||
|
|
||||||
|
# Helper functions that take `(manifest, "literal_key", ...)` and read the
|
||||||
|
# manifest for that key. Keep this narrow: only helpers whose first argument is
|
||||||
|
# the manifest dict and whose second argument is a top-level manifest key belong
|
||||||
|
# here.
|
||||||
|
MANIFEST_KEY_READ_HELPERS = {"_gap_fill_manifest_absent"}
|
||||||
|
|
||||||
|
# Packs committed to this repo, checked against the spec's reference validator.
|
||||||
|
PACK_GLOBS = ["content/starter/*.feedpak", "docs/**/*.sloppak", "docs/**/*.feedpak"]
|
||||||
|
|
||||||
|
EXCEPTIONS_FILE = REPO / "feedpak-spec-exceptions.yml"
|
||||||
|
|
||||||
|
# How a new manifest key gets into core. There is no in-repo shortcut, by design:
|
||||||
|
# the spec's own governance says "a change is not part of the format until it
|
||||||
|
# lands here", and the FEP process is how it lands.
|
||||||
|
FEP = (
|
||||||
|
"New manifest keys go through the feedpak Enhancement Proposal process "
|
||||||
|
"(https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md): land a PR on "
|
||||||
|
"feedpak-spec that updates the normative spec, the JSON Schemas, an example, and the "
|
||||||
|
"changelog together — then re-run this PR's checks; the gate verifies against the "
|
||||||
|
"spec's HEAD, so once your key is in the spec, this PR goes green. It matters beyond "
|
||||||
|
"this PR: the whole repo is checked against the living spec, so non-conformance that "
|
||||||
|
"slips in shows up as red CI on every teammate's PR until it's resolved — sorting it "
|
||||||
|
"out here keeps everyone else unblocked."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(msg: str) -> None:
|
||||||
|
print(f"::error::{msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_locals(tree: ast.AST) -> set[str]:
|
||||||
|
"""Names of locals assigned from `load_manifest(...)` anywhere in `tree`.
|
||||||
|
|
||||||
|
Flow-aware receiver discovery: chart.py binds `m = load_manifest(p) or {}`,
|
||||||
|
and a fixed name list (`manifest`, `mf`) silently missed it — the module's
|
||||||
|
reads went entirely unscanned. Whatever the local is called, an assignment
|
||||||
|
whose right-hand side mentions load_manifest marks it as a manifest dict.
|
||||||
|
"""
|
||||||
|
names: set[str] = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rhs = ast.unparse(node.value) if node.value else ""
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if "load_manifest" not in rhs:
|
||||||
|
continue
|
||||||
|
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||||
|
for t in targets:
|
||||||
|
if isinstance(t, ast.Name):
|
||||||
|
names.add(t.id)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _is_manifest_receiver(node: ast.expr, receivers: set[str]) -> bool:
|
||||||
|
"""True when `node` evaluates to a manifest dict.
|
||||||
|
|
||||||
|
Covers named receivers (fixed names + flow-discovered locals) plus the
|
||||||
|
inline wrapped form used in lib/enrichment.py:
|
||||||
|
`(sloppak_mod.load_manifest(p) or {}).get("key")`.
|
||||||
|
"""
|
||||||
|
if isinstance(node, ast.Name) and node.id in receivers:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
src = ast.unparse(node)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return "load_manifest" in src
|
||||||
|
|
||||||
|
|
||||||
|
def keys_touched(path: Path) -> tuple[set[str], set[str]]:
|
||||||
|
"""Literal top-level manifest keys `path` reads and writes, separately.
|
||||||
|
|
||||||
|
Writes matter as much as reads: `manifest["k"] = v` means core *emits* `k`
|
||||||
|
into a pack it ships, so an undeclared key there puts non-spec surface into
|
||||||
|
the wild — the same drift, pointed outward. `manifest["k"]` in a subscript
|
||||||
|
is a read only when its context is a Load; an `ast.walk` that ignores `ctx`
|
||||||
|
would score `manifest["year"] = ...` (lib/songmeta.py) as a read.
|
||||||
|
"""
|
||||||
|
reads: set[str] = set()
|
||||||
|
writes: set[str] = set()
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
receivers = MANIFEST_VARS | _manifest_locals(tree)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if (
|
||||||
|
isinstance(node, ast.Call)
|
||||||
|
and isinstance(node.func, ast.Attribute)
|
||||||
|
# `setdefault("k", v)` writes k when absent — lib/gp2notation.py
|
||||||
|
# stamps feedpak_version that way, and a subscript-only scan misses
|
||||||
|
# it entirely, letting an emitted key slip past the gate.
|
||||||
|
and node.func.attr in ("get", "setdefault")
|
||||||
|
and _is_manifest_receiver(node.func.value, receivers)
|
||||||
|
and node.args
|
||||||
|
and isinstance(node.args[0], ast.Constant)
|
||||||
|
and isinstance(node.args[0].value, str)
|
||||||
|
):
|
||||||
|
bucket = writes if node.func.attr == "setdefault" else reads
|
||||||
|
bucket.add(node.args[0].value)
|
||||||
|
elif (
|
||||||
|
isinstance(node, ast.Call)
|
||||||
|
and isinstance(node.func, ast.Name)
|
||||||
|
and node.func.id in MANIFEST_KEY_READ_HELPERS
|
||||||
|
and len(node.args) >= 2
|
||||||
|
and _is_manifest_receiver(node.args[0], receivers)
|
||||||
|
and isinstance(node.args[1], ast.Constant)
|
||||||
|
and isinstance(node.args[1].value, str)
|
||||||
|
):
|
||||||
|
reads.add(node.args[1].value)
|
||||||
|
elif (
|
||||||
|
isinstance(node, ast.Subscript)
|
||||||
|
and _is_manifest_receiver(node.value, receivers)
|
||||||
|
and isinstance(node.slice, ast.Constant)
|
||||||
|
and isinstance(node.slice.value, str)
|
||||||
|
):
|
||||||
|
target = writes if isinstance(node.ctx, ast.Store) else reads
|
||||||
|
target.add(node.slice.value)
|
||||||
|
return reads, writes
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_exceptions(text: str, origin: str) -> dict[str, str]:
|
||||||
|
"""Parse an exceptions document into {key: tracking issue}."""
|
||||||
|
import yaml # runtime dep (PyYAML is already in requirements.txt)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(text) or {}
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
_fail(f"{origin}: not valid YAML — {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
# A malformed shape (list/string at top level, non-mapping entry) must fail
|
||||||
|
# with a CI-legible error, not an AttributeError traceback.
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
_fail(f"{origin}: top level must be a mapping with an 'exceptions' list, got {type(data).__name__}")
|
||||||
|
sys.exit(1)
|
||||||
|
entries = data.get("exceptions") or []
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
_fail(f"{origin}: 'exceptions' must be a list, got {type(entries).__name__}")
|
||||||
|
sys.exit(1)
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
_fail(f"{origin}: each exception must be a mapping with 'key' and 'issue', got {type(entry).__name__}")
|
||||||
|
sys.exit(1)
|
||||||
|
key, issue = entry.get("key"), entry.get("issue")
|
||||||
|
if not key or not issue:
|
||||||
|
_fail(f"{origin}: every exception needs both 'key' and 'issue'")
|
||||||
|
sys.exit(1)
|
||||||
|
# A duplicate would silently take the last issue link, quietly retargeting
|
||||||
|
# the debt this file exists to track. Fail instead.
|
||||||
|
if key in out:
|
||||||
|
_fail(
|
||||||
|
f"{origin}: '{key}' is listed more than once. "
|
||||||
|
f"Keep one entry per key so the tracking issue is unambiguous."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
out[key] = issue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_exceptions() -> dict[str, str]:
|
||||||
|
"""Map of grandfathered key -> tracking issue URL, as of this working tree."""
|
||||||
|
if not EXCEPTIONS_FILE.exists():
|
||||||
|
return {}
|
||||||
|
return _parse_exceptions(
|
||||||
|
EXCEPTIONS_FILE.read_text(encoding="utf-8"), EXCEPTIONS_FILE.name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_allowlist_closed(baseline: Path | None, bootstrap: bool) -> bool:
|
||||||
|
"""The allowlist is CLOSED: it may shrink, never grow.
|
||||||
|
|
||||||
|
`feedpak-spec-exceptions.yml` grandfathers keys that predate this gate. It is
|
||||||
|
not a way to merge a new one. Without this check the gate would be a speed
|
||||||
|
bump with a signed excuse note — anyone could append an entry and route
|
||||||
|
around the FEP process from inside this repo, which is exactly the drift that
|
||||||
|
produced #933.
|
||||||
|
|
||||||
|
So: removing an entry is fine (that's the debt being paid down); adding one
|
||||||
|
fails the build, and the error points at the FEP process instead.
|
||||||
|
"""
|
||||||
|
if bootstrap:
|
||||||
|
print(" allowlist-closed: bootstrapping (no baseline on the base branch) — skipped")
|
||||||
|
return True
|
||||||
|
if baseline is None:
|
||||||
|
print(" allowlist-closed: no baseline supplied (local run) — skipped")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not baseline.is_file():
|
||||||
|
_fail(
|
||||||
|
f"--baseline-exceptions {baseline} does not exist. CI derives this from the base "
|
||||||
|
f"branch; for a local run, omit the flag to skip the allowlist diff."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
base_keys = set(
|
||||||
|
_parse_exceptions(baseline.read_text(encoding="utf-8"), f"{EXCEPTIONS_FILE.name} (base)")
|
||||||
|
)
|
||||||
|
now_keys = set(load_exceptions())
|
||||||
|
added = sorted(now_keys - base_keys)
|
||||||
|
removed = sorted(base_keys - now_keys)
|
||||||
|
|
||||||
|
for key in added:
|
||||||
|
_fail(
|
||||||
|
f"{EXCEPTIONS_FILE.name}: this PR adds an exception for '{key}', and the allowlist "
|
||||||
|
f"can't take new entries — it only grandfathers keys that predate the gate. {FEP}"
|
||||||
|
)
|
||||||
|
if removed:
|
||||||
|
print(f" allowlist shrank (debt paid down): {', '.join(removed)}")
|
||||||
|
print(f" allowlist-closed: {'FAILED' if added else 'OK'}")
|
||||||
|
return not added
|
||||||
|
|
||||||
|
|
||||||
|
def check_readers_complete() -> bool:
|
||||||
|
"""READERS must not fall behind the codebase.
|
||||||
|
|
||||||
|
The key-coverage scan is only as good as the list of modules it scans, and a
|
||||||
|
hand-maintained list rots: `lib/routers/ws_highway.py` and
|
||||||
|
`lib/gp2notation.py` both touched feedpak manifests for a while without being
|
||||||
|
on it. So re-derive the set — any module that both touches manifest keys and
|
||||||
|
shows a feedpak signal must be listed — and fail if one is missing.
|
||||||
|
|
||||||
|
This is a guard on the gate itself, not on the format.
|
||||||
|
"""
|
||||||
|
listed = set(READERS)
|
||||||
|
missing: list[str] = []
|
||||||
|
for pattern in READER_SEARCH:
|
||||||
|
for path in sorted(REPO.glob(pattern)):
|
||||||
|
rel = path.relative_to(REPO).as_posix()
|
||||||
|
if rel in listed:
|
||||||
|
continue
|
||||||
|
src = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
if not FEEDPAK_SIGNALS.search(src):
|
||||||
|
continue
|
||||||
|
# Same scanner the coverage check uses — a separate "does it touch
|
||||||
|
# keys" regex diverged from it once already (`m = load_manifest(...)`
|
||||||
|
# in chart.py matched neither `manifest` nor `mf`, so the module
|
||||||
|
# went unlisted AND unscanned). One detector, one truth.
|
||||||
|
try:
|
||||||
|
reads, writes = keys_touched(path)
|
||||||
|
except SyntaxError:
|
||||||
|
continue
|
||||||
|
if reads or writes:
|
||||||
|
missing.append(rel)
|
||||||
|
|
||||||
|
for rel in missing:
|
||||||
|
_fail(
|
||||||
|
f"{rel} touches feedpak manifest keys but is not in READERS "
|
||||||
|
f"({Path(__file__).name}) — its keys are going unchecked. Add it."
|
||||||
|
)
|
||||||
|
print(f" scanning {len(listed)} modules; readers-complete: {'FAILED' if missing else 'OK'}")
|
||||||
|
return not missing
|
||||||
|
|
||||||
|
|
||||||
|
def check_key_coverage(spec: Path) -> bool:
|
||||||
|
"""Layer 1 — core must not read or write a manifest key the spec does not declare."""
|
||||||
|
schema = json.loads((spec / "schemas" / "manifest.schema.json").read_text(encoding="utf-8"))
|
||||||
|
declared = set(schema.get("properties") or {})
|
||||||
|
if not declared:
|
||||||
|
_fail("spec manifest.schema.json declares no properties — wrong path or bad checkout?")
|
||||||
|
return False
|
||||||
|
|
||||||
|
reads: set[str] = set()
|
||||||
|
writes: set[str] = set()
|
||||||
|
for rel in READERS:
|
||||||
|
path = REPO / rel
|
||||||
|
if not path.exists():
|
||||||
|
_fail(f"reader {rel} not found — was it renamed? Update READERS in {Path(__file__).name}.")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
r, w = keys_touched(path)
|
||||||
|
except SyntaxError as e:
|
||||||
|
# A module that doesn't parse can't be scanned — but it also can't
|
||||||
|
# pass pytest, so this is belt-and-braces for a CI-legible message
|
||||||
|
# rather than a traceback if this job runs first.
|
||||||
|
_fail(f"could not scan {rel}: {e}")
|
||||||
|
return False
|
||||||
|
reads |= r
|
||||||
|
writes |= w
|
||||||
|
|
||||||
|
exceptions = load_exceptions()
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
def _undeclared(keys: set[str]) -> list[str]:
|
||||||
|
return sorted((keys - declared) - set(exceptions))
|
||||||
|
|
||||||
|
for key in _undeclared(reads):
|
||||||
|
_fail(f"core reads manifest key '{key}', which the feedpak spec does not define. {FEP}")
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
for key in _undeclared(writes):
|
||||||
|
_fail(
|
||||||
|
f"core writes manifest key '{key}', which the feedpak spec does not define — that "
|
||||||
|
f"puts non-spec surface into every pack we emit. {FEP}"
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
# A stale exception is its own bug: it means the spec caught up and nobody
|
||||||
|
# cleaned up, so the allowlist slowly becomes a place drift hides.
|
||||||
|
touched = reads | writes
|
||||||
|
for key, issue in exceptions.items():
|
||||||
|
if key in declared:
|
||||||
|
_fail(
|
||||||
|
f"'{key}' is listed in {EXCEPTIONS_FILE.name} but the spec now declares it. "
|
||||||
|
f"Remove the exception and close {issue}."
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
elif key not in touched:
|
||||||
|
_fail(
|
||||||
|
f"'{key}' is listed in {EXCEPTIONS_FILE.name} but core no longer reads or writes "
|
||||||
|
f"it. Remove the exception."
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
print(f" spec declares {len(declared)} keys; core reads {len(reads)}, writes {len(writes)}")
|
||||||
|
if exceptions:
|
||||||
|
print(f" grandfathered (tracked debt): {', '.join(sorted(exceptions))}")
|
||||||
|
print(f" key-coverage: {'OK' if ok else 'FAILED'}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def check_forward(spec: Path) -> bool:
|
||||||
|
"""Layer 3 — core must ingest every example pack the spec ships."""
|
||||||
|
examples_dir = spec / "examples"
|
||||||
|
if not examples_dir.is_dir():
|
||||||
|
_fail(f"{examples_dir} is missing — wrong path or bad checkout?")
|
||||||
|
return False
|
||||||
|
# rglob, not iterdir: the contract is "every example pack the spec ships", so
|
||||||
|
# a pack nested under examples/<group>/ must not slip through.
|
||||||
|
#
|
||||||
|
# Deliberately NOT filtered by is_file(): a feedpak is dual-form — a zip
|
||||||
|
# (`foo.feedpak`) *or* a directory (`foo.feedpak/`) — and the spec's own
|
||||||
|
# examples ship as directories today. An is_file() guard here would silently
|
||||||
|
# match zero packs. Matching on the suffix covers both forms, and rglob does
|
||||||
|
# not smuggle in a pack's innards because files inside a pack don't carry a
|
||||||
|
# pack suffix.
|
||||||
|
examples = sorted(
|
||||||
|
p for p in examples_dir.rglob("*")
|
||||||
|
if p.suffix in (".feedpak", ".sloppak")
|
||||||
|
)
|
||||||
|
if not examples:
|
||||||
|
_fail("spec ships no example packs — wrong path or bad checkout?")
|
||||||
|
return False
|
||||||
|
|
||||||
|
sys.path.insert(0, str(REPO / "lib"))
|
||||||
|
try:
|
||||||
|
import sloppak # noqa: E402 (path must be set first — flat imports, no package)
|
||||||
|
except Exception as e:
|
||||||
|
_fail(
|
||||||
|
f"could not import core's sloppak loader ({type(e).__name__}: {e}). "
|
||||||
|
f"Are requirements.txt deps installed?"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
ok = True
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cache = Path(tmp)
|
||||||
|
for pack in examples:
|
||||||
|
try:
|
||||||
|
loaded = sloppak.load_song(pack.name, pack.parent, cache)
|
||||||
|
except Exception as e:
|
||||||
|
_fail(
|
||||||
|
f"core failed to load the spec's own example pack {pack.name}: "
|
||||||
|
f"{type(e).__name__}: {e}. A spec-valid pack must load."
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
continue
|
||||||
|
if not loaded.song.arrangements:
|
||||||
|
_fail(f"core loaded {pack.name} but found no arrangements")
|
||||||
|
ok = False
|
||||||
|
continue
|
||||||
|
print(f" loaded {pack.name}: {len(loaded.song.arrangements)} arrangement(s)")
|
||||||
|
print(f" forward: {'OK' if ok else 'FAILED'}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def check_reverse(spec: Path) -> bool:
|
||||||
|
"""Layer 4 — packs committed here must pass the spec's reference validator."""
|
||||||
|
packs = sorted({p for g in PACK_GLOBS for p in REPO.glob(g)})
|
||||||
|
if not packs:
|
||||||
|
print(" reverse: no committed packs — skipped")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(spec / "tools" / "validate.py"), *[str(p) for p in packs]],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
# The validator takes seconds for all committed packs; a pathological
|
||||||
|
# pack or validator bug must fail the job, not hang the runner until
|
||||||
|
# the Actions-level timeout.
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
_fail("the spec's reference validator did not finish within 300s — pathological pack or validator bug?")
|
||||||
|
print(" reverse: FAILED")
|
||||||
|
return False
|
||||||
|
sys.stdout.write("".join(f" {ln}\n" for ln in proc.stdout.splitlines() if ln.strip()))
|
||||||
|
if proc.returncode != 0:
|
||||||
|
_fail(
|
||||||
|
"a pack committed to this repo does not satisfy the feedpak spec "
|
||||||
|
"(see the reference validator output above)."
|
||||||
|
)
|
||||||
|
if proc.stderr.strip():
|
||||||
|
sys.stderr.write(proc.stderr)
|
||||||
|
print(f" reverse: {'OK' if proc.returncode == 0 else 'FAILED'}")
|
||||||
|
return proc.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument(
|
||||||
|
"--spec",
|
||||||
|
required=True,
|
||||||
|
type=Path,
|
||||||
|
help="path to a feedpak-spec checkout (CI checks out the spec repo's HEAD)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--baseline-exceptions",
|
||||||
|
type=Path,
|
||||||
|
help="the exceptions file as it exists on the base branch. Supplied by CI so the "
|
||||||
|
"allowlist can be proven to have not grown. Omit for a local run.",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--bootstrap-allowlist",
|
||||||
|
action="store_true",
|
||||||
|
help="the base branch has no exceptions file yet (this PR introduces the gate), so "
|
||||||
|
"there is nothing to diff against. CI passes this only in that case.",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
spec = args.spec.resolve()
|
||||||
|
if not (spec / "schemas" / "manifest.schema.json").exists():
|
||||||
|
_fail(f"{spec} does not look like a feedpak-spec checkout")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("[1/4] key-coverage — core reads/writes only keys the spec declares")
|
||||||
|
# Both run, always: a stale READERS list and an undeclared key are separate
|
||||||
|
# failures, and reporting only the first would hide the second. Hence two
|
||||||
|
# calls and an explicit `and` over the results, not a short-circuiting one.
|
||||||
|
readers_ok = check_readers_complete()
|
||||||
|
coverage_ok = check_key_coverage(spec)
|
||||||
|
ok1 = readers_ok and coverage_ok
|
||||||
|
print("[2/4] allowlist-closed — the grandfather list may shrink, never grow")
|
||||||
|
ok2 = check_allowlist_closed(args.baseline_exceptions, args.bootstrap_allowlist)
|
||||||
|
print("[3/4] forward — core ingests the spec's example packs")
|
||||||
|
ok3 = check_forward(spec)
|
||||||
|
print("[4/4] reverse — committed packs satisfy the reference validator")
|
||||||
|
ok4 = check_reverse(spec)
|
||||||
|
|
||||||
|
if ok1 and ok2 and ok3 and ok4:
|
||||||
|
print("\nfeedpak spec conformance: OK")
|
||||||
|
return 0
|
||||||
|
print("\nfeedpak spec conformance: FAILED")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user