mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 18:24:31 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d217ffecd8 | ||
|
|
4cc8fa3b4d | ||
|
|
f9f33320ac | ||
|
|
5f58af4faa | ||
|
|
ea8834862d | ||
|
|
2281cac438 | ||
|
|
b6098e3695 | ||
|
|
cbc65458e3 | ||
|
|
7c87538d6b |
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
next root-level module can't ship broken.
|
||||
|
||||
### Added
|
||||
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
|
||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
|
||||
- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping
|
||||
endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a
|
||||
|
||||
@@ -37,6 +37,31 @@ the hooks, they just need real songs in `DLC_DIR`.
|
||||
|
||||
## Results
|
||||
|
||||
### R3c pre-lift baseline — 2026-07-10 (2D highway draw cost)
|
||||
|
||||
The gate for the `highway.js` split. Captured on a seeded library (the 33 MB
|
||||
Arcturus feedpak) with the new `--song` mode, which measures **per-frame draw
|
||||
cost** — rAF callbacks are tagged via `highway.addDrawHook`, so only frames the
|
||||
highway actually painted count (the other ~half are cheap no-op loops that would
|
||||
otherwise mask a regression). Any `highway.js` change must re-run this on the
|
||||
same machine and stay within noise of these numbers.
|
||||
|
||||
```bash
|
||||
node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 \
|
||||
--song "Arcturus - The Sham Mirrors - Kinetic.feedpak"
|
||||
```
|
||||
|
||||
| run | draw frames | p50 | p95 | p99 | max |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 53/106 | 2.2 | 3.2 | 3.6 | 3.6 |
|
||||
| 2 | 50/100 | 2.2 | 2.9 | 3.2 | 3.2 |
|
||||
| 3 | 53/106 | 2.1 | 2.7 | 3.5 | 3.5 |
|
||||
|
||||
**p50 ≈ 2.2 ms · p95 spread 2.7–3.2 ms** (3 runs × 10 s playback, headless
|
||||
chromium on the dev box). The `H`-container lift changes each closure-slot read
|
||||
to a `H.<slot>` property load; this is the number that proves it doesn't cost the
|
||||
hot loop.
|
||||
|
||||
### R0 baseline — 2026-07-08 (branch `feat/r0-plugin-module-rails`)
|
||||
|
||||
> ⚠️ A quick capture (`--n 50 --soak 8`) against an **empty** library (no charts
|
||||
|
||||
@@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable.
|
||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||
|
||||
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
||||
(7,909 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and six `routers/` modules) ·
|
||||
(7,275 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and twelve `routers/` modules) ·
|
||||
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
||||
and is a monolith in its own right, to be split per-table once the router train
|
||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
|
||||
@@ -82,10 +82,17 @@ static_dir = None
|
||||
sloppak_cache_dir = None
|
||||
audio_cache_dir = None
|
||||
|
||||
# Injected callables (not values): server owns the impl + its state, routers call
|
||||
# through the seam. get_progression_content wraps a lazy content cache that stays
|
||||
# in server.py (its `setattr(server, "_progression_content")` test is untouched).
|
||||
get_progression_content = None
|
||||
builtin_diagnostic_filename = None
|
||||
|
||||
_SLOTS = frozenset({
|
||||
"meta_db", "audio_effect_mappings",
|
||||
"config_dir", "dlc_dir", "dlc_dir_env",
|
||||
"static_dir", "sloppak_cache_dir", "audio_cache_dir",
|
||||
"get_progression_content", "builtin_diagnostic_filename",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Small meta_db-backed library / user-state endpoints — work keeper-chart
|
||||
prefs, favorites, personal tags, saved-for-later, and continue-playing.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``. All paths
|
||||
are distinct and non-overlapping, so mounting them together (rather than at each
|
||||
original scattered site) does not change routing.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/work/{work_key:path}/charts")
|
||||
def api_get_work_charts(work_key: str):
|
||||
"""All charts in a work + which is the keeper (your pick vs auto-pick)."""
|
||||
return appstate.meta_db.work_charts(work_key)
|
||||
|
||||
|
||||
@router.put("/api/work/{work_key:path}/preferred")
|
||||
def api_set_work_preferred(work_key: str, data: dict):
|
||||
"""Set the keeper chart of a work: body {filename}. The filename must be a
|
||||
current member of the work. Returns the refreshed chart list."""
|
||||
fn = (data.get("filename") or "").strip()
|
||||
if not fn:
|
||||
return JSONResponse({"error": "filename is required"}, 400)
|
||||
members = {c["filename"] for c in appstate.meta_db.work_charts(work_key)["charts"]}
|
||||
if fn not in members:
|
||||
return JSONResponse({"error": "filename is not a chart of this work"}, 400)
|
||||
appstate.meta_db.set_chart_preferred(work_key, fn)
|
||||
return appstate.meta_db.work_charts(work_key)
|
||||
|
||||
|
||||
@router.delete("/api/work/{work_key:path}/preferred")
|
||||
def api_reset_work_preferred(work_key: str):
|
||||
"""Reset a work to auto-pick (drop the explicit preferred)."""
|
||||
appstate.meta_db.clear_chart_preferred(work_key)
|
||||
return appstate.meta_db.work_charts(work_key)
|
||||
|
||||
|
||||
@router.post("/api/favorites/toggle")
|
||||
def toggle_favorite(data: dict):
|
||||
"""Toggle a song's favorite status."""
|
||||
filename = data.get("filename", "")
|
||||
if not filename:
|
||||
return {"error": "No filename"}
|
||||
new_state = appstate.meta_db.toggle_favorite(filename)
|
||||
return {"favorite": new_state}
|
||||
|
||||
|
||||
@router.get("/api/tags")
|
||||
def list_tags():
|
||||
"""All personal tags in use (over still-present songs), most-used first —
|
||||
powers the tag filter UI."""
|
||||
return {"tags": appstate.meta_db.all_tags()}
|
||||
|
||||
|
||||
@router.post("/api/saved/toggle")
|
||||
def api_toggle_saved(data: dict):
|
||||
"""Add/remove a song on the reserved Saved-for-Later playlist."""
|
||||
filename = _clean_str(data.get("filename"))
|
||||
if not filename:
|
||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||
return {"saved": appstate.meta_db.toggle_saved(filename)}
|
||||
|
||||
|
||||
@router.get("/api/session/continue")
|
||||
def api_session_continue():
|
||||
"""The Continue-Playing card's song (most recent play) or null."""
|
||||
return appstate.meta_db.continue_session()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Player profile — identity, avatars (bundled + custom uploads), and progress.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``CONFIG_DIR``/``STATIC_DIR`` ->
|
||||
``appstate.config_dir``/``appstate.static_dir`` (seam), ``_clean_str`` from
|
||||
``reqfields``, ``_get_progression_content()`` ->
|
||||
``appstate.get_progression_content()``. The bundled-avatar lister moves with it.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_bundled_avatars() -> list[str]:
|
||||
"""Bundled default avatar filenames under static/v3/avatars/."""
|
||||
d = appstate.static_dir / "v3" / "avatars"
|
||||
if not d.is_dir():
|
||||
return []
|
||||
exts = {".svg", ".png", ".webp"}
|
||||
return sorted(
|
||||
p.name for p in d.iterdir()
|
||||
if p.is_file() and p.suffix.lower() in exts and not p.name.startswith(".")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/profile")
|
||||
def api_get_profile():
|
||||
profile = appstate.meta_db.get_profile()
|
||||
# Equipped cosmetics ride along (resolved to their payloads) so the theme
|
||||
# and avatar frame apply at boot without an extra request. Never let a
|
||||
# cosmetics/content problem break the profile read.
|
||||
cosmetics = {}
|
||||
try:
|
||||
shop = appstate.get_progression_content()["shop"]
|
||||
for slot, item_id in appstate.meta_db.get_equipped().items():
|
||||
item = shop.get(item_id)
|
||||
if item:
|
||||
cosmetics[slot] = {"item_id": item_id, "payload": item["payload"]}
|
||||
except Exception:
|
||||
log.warning("profile cosmetics enrich failed", exc_info=True)
|
||||
profile["cosmetics"] = cosmetics
|
||||
return profile
|
||||
|
||||
|
||||
|
||||
@router.post("/api/profile")
|
||||
def api_set_profile(data: dict):
|
||||
"""Set/update the player profile. Body: {display_name, avatar:{type,value}}.
|
||||
avatar.type is 'default' (value = bundled filename) or 'upload' (value =
|
||||
the /api/profile/avatar/<name> URL returned by the upload endpoint); omit
|
||||
avatar to keep the existing one (name-only edit)."""
|
||||
name = _clean_str(data.get("display_name"))
|
||||
if not (1 <= len(name) <= 32):
|
||||
return JSONResponse({"error": "Display name must be 1–32 characters."}, status_code=400)
|
||||
avatar = data.get("avatar")
|
||||
if avatar is None:
|
||||
avatar = {} # omitted → keep the current avatar (name-only edit)
|
||||
elif not isinstance(avatar, dict):
|
||||
return JSONResponse({"error": "avatar must be an object."}, status_code=400)
|
||||
atype = avatar.get("type")
|
||||
aval = _clean_str(avatar.get("value"))
|
||||
avatar_url = None
|
||||
if atype == "default":
|
||||
if aval not in _list_bundled_avatars():
|
||||
return JSONResponse({"error": "Unknown default avatar."}, status_code=400)
|
||||
avatar_url = f"/static/v3/avatars/{aval}"
|
||||
elif atype == "upload":
|
||||
from safepath import safe_join
|
||||
fname = aval.rsplit("/", 1)[-1] if aval.startswith("/api/profile/avatar/") else ""
|
||||
target = safe_join(appstate.config_dir / "avatars", fname) if fname else None
|
||||
if target is None or not target.is_file():
|
||||
return JSONResponse({"error": "Uploaded avatar not found."}, status_code=400)
|
||||
avatar_url = f"/api/profile/avatar/{fname}"
|
||||
elif atype:
|
||||
return JSONResponse({"error": "Unknown avatar type."}, status_code=400)
|
||||
# atype None/missing → keep the current avatar (name-only edit).
|
||||
return appstate.meta_db.set_profile(name, avatar_url)
|
||||
|
||||
|
||||
@router.get("/api/profile/avatars")
|
||||
def api_list_avatars():
|
||||
return [{"name": n, "url": f"/static/v3/avatars/{n}"} for n in _list_bundled_avatars()]
|
||||
|
||||
|
||||
@router.post("/api/profile/avatar")
|
||||
def api_upload_avatar(data: dict):
|
||||
"""Upload a custom avatar as base64 (mirrors the album-art upload pattern).
|
||||
Re-encodes to a ≤512px PNG under appstate.config_dir/avatars/."""
|
||||
import base64
|
||||
import io
|
||||
b64 = data.get("image", "")
|
||||
if not isinstance(b64, str) or not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
try:
|
||||
raw = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid base64"}, status_code=400)
|
||||
if len(raw) > 6 * 1024 * 1024:
|
||||
return JSONResponse({"error": "Image too large (max 6 MB)."}, status_code=400)
|
||||
avatars_dir = appstate.config_dir / "avatars"
|
||||
avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
img.thumbnail((512, 512))
|
||||
fname = f"upload-{secrets.token_hex(4)}.png" # token busts caches on change
|
||||
img.save(str(avatars_dir / fname), "PNG")
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400)
|
||||
return {"url": f"/api/profile/avatar/{fname}"}
|
||||
|
||||
|
||||
@router.get("/api/profile/avatar/{name}")
|
||||
def api_get_avatar(name: str):
|
||||
from safepath import safe_join
|
||||
target = safe_join(appstate.config_dir / "avatars", name)
|
||||
if target is None or not target.is_file():
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return FileResponse(str(target), media_type="image/png")
|
||||
|
||||
|
||||
@router.get("/api/profile/progress")
|
||||
def api_profile_progress():
|
||||
"""One call for the whole profile badge: {level, xp, xp_in_level,
|
||||
xp_to_next, current_streak, best_streak, last_active_date}."""
|
||||
return appstate.meta_db.get_progress()
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Progression (spec 010) — mastery rank, challenges, quests, onboarding paths.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``, and the
|
||||
two shared server accessors read through the seam:
|
||||
``_get_progression_content()`` -> ``appstate.get_progression_content()`` and
|
||||
``_builtin_diagnostic_filename()`` -> ``appstate.builtin_diagnostic_filename()``.
|
||||
The exclusive helpers (_goal_ui_progress, _progression_overview) + the
|
||||
_PROGRESSION_EVENT_TYPES whitelist move with it.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _goal_ui_progress(goal: dict, state: dict, streak: int, xp_total: int) -> tuple:
|
||||
"""(count, target) for a challenge/quest progress bar. Count goals show
|
||||
n/target; threshold goals show how far the live stat is along the line."""
|
||||
import progression as progression_mod
|
||||
gtype = goal.get("type")
|
||||
if gtype in progression_mod.COUNT_GOAL_TYPES:
|
||||
target = int(goal.get("target") or 1)
|
||||
count = target if state.get("completed") else min(int(state.get("count") or 0), target)
|
||||
return count, target
|
||||
if gtype == "streak_reached":
|
||||
target = int(goal.get("days") or 1)
|
||||
return (target if state.get("completed") else min(streak, target)), target
|
||||
if gtype == "db_earned":
|
||||
target = int(goal.get("amount") or 1)
|
||||
return (target if state.get("completed") else min(xp_total, target)), target
|
||||
return 0, 1
|
||||
|
||||
|
||||
def _progression_overview() -> dict:
|
||||
"""The full GET /api/progression payload (also the capability `inspect`
|
||||
result): rank, onboarding, per-path challenge checklists, quests, wallet."""
|
||||
import progression as progression_mod
|
||||
from datetime import datetime as _dt
|
||||
content = appstate.get_progression_content()
|
||||
now = _dt.now()
|
||||
appstate.meta_db.ensure_quest_period(content, now)
|
||||
|
||||
state = appstate.meta_db.get_progression_state()
|
||||
player_paths = appstate.meta_db.get_player_paths()
|
||||
challenge_state = appstate.meta_db.get_challenge_state()
|
||||
wallet = appstate.meta_db.get_wallet()
|
||||
streak_progress = appstate.meta_db.get_progress()
|
||||
streak = int(streak_progress.get("current_streak") or 0)
|
||||
xp_total = wallet["lifetime_db"]
|
||||
keys = progression_mod.period_keys(now)
|
||||
|
||||
def _path_order(pid):
|
||||
pdef = content["paths"].get(pid) or {}
|
||||
return (pdef.get("order") or 0, pid)
|
||||
|
||||
paths_payload = []
|
||||
for pid in sorted(player_paths, key=_path_order):
|
||||
pdef = content["paths"].get(pid)
|
||||
level = player_paths[pid]
|
||||
if not pdef:
|
||||
# Path selected under older content that no longer ships: keep its
|
||||
# rank contribution visible rather than silently dropping it.
|
||||
paths_payload.append({"id": pid, "name": pid, "icon": "", "level": level,
|
||||
"max_level": level, "next": None})
|
||||
continue
|
||||
next_block = None
|
||||
active = progression_mod.active_challenges(content, pid, level)
|
||||
if active:
|
||||
level_def = next(e for e in pdef["levels"] if e["level"] == level + 1)
|
||||
challenges = []
|
||||
completed_count = 0
|
||||
for ch in active:
|
||||
st = challenge_state.get(ch["id"]) or {}
|
||||
count, target = _goal_ui_progress(ch["goal"], st, streak, xp_total)
|
||||
if st.get("completed"):
|
||||
completed_count += 1
|
||||
challenges.append({
|
||||
"id": ch["id"],
|
||||
"title": ch["title"],
|
||||
"description": ch["description"],
|
||||
"count": count,
|
||||
"target": target,
|
||||
"completed": bool(st.get("completed")),
|
||||
"completed_at": st.get("completed_at"),
|
||||
})
|
||||
next_block = {
|
||||
"level": level + 1,
|
||||
"required": level_def["required"],
|
||||
"completed": completed_count,
|
||||
"challenges": challenges,
|
||||
}
|
||||
paths_payload.append({
|
||||
"id": pid,
|
||||
"name": pdef["name"],
|
||||
"icon": pdef["icon"],
|
||||
"level": level,
|
||||
"max_level": progression_mod.path_max_level(content, pid),
|
||||
"next": next_block,
|
||||
})
|
||||
|
||||
available = [
|
||||
{"id": pid, "name": pdef["name"], "icon": pdef["icon"]}
|
||||
for pid, pdef in sorted(content["paths"].items(), key=lambda kv: (kv[1].get("order") or 0, kv[0]))
|
||||
if pid not in player_paths
|
||||
]
|
||||
|
||||
quest_rows = appstate.meta_db.get_quest_rows(keys)
|
||||
quests_payload = {}
|
||||
for period_type in ("daily", "weekly"):
|
||||
pool = content["quests"][period_type]["pool"]
|
||||
quests = []
|
||||
for row in quest_rows:
|
||||
if row["period_type"] != period_type:
|
||||
continue
|
||||
qdef = pool.get(row["quest_id"])
|
||||
if not qdef:
|
||||
continue # removed from the pool mid-period: hide, keep the row
|
||||
count, target = _goal_ui_progress(qdef["goal"], row, streak, xp_total)
|
||||
quests.append({
|
||||
"id": row["quest_id"],
|
||||
"title": qdef["title"],
|
||||
"description": qdef["description"],
|
||||
"reward_db": row["reward_db"],
|
||||
"count": count,
|
||||
"target": target,
|
||||
"completed": row["completed"],
|
||||
"completed_at": row["completed_at"],
|
||||
})
|
||||
quests_payload[period_type] = {
|
||||
"period_key": keys[period_type],
|
||||
"resets_at": progression_mod.period_resets_at(period_type, now).isoformat(),
|
||||
"quests": quests,
|
||||
}
|
||||
|
||||
return {
|
||||
"mastery_rank": progression_mod.mastery_rank(state["calibration_status"], player_paths),
|
||||
"onboarding": {
|
||||
"calibration_status": state["calibration_status"],
|
||||
"calibration_completed_at": state["calibration_completed_at"],
|
||||
"diagnostic_filename": appstate.builtin_diagnostic_filename(),
|
||||
},
|
||||
"paths": paths_payload,
|
||||
"available_paths": available,
|
||||
"quests": quests_payload,
|
||||
"wallet": wallet,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/progression")
|
||||
def api_progression():
|
||||
return _progression_overview()
|
||||
|
||||
|
||||
@router.post("/api/progression/paths")
|
||||
def api_progression_add_paths(data: dict):
|
||||
"""Select instrument paths. Body: {add: [path_id, ...]}. Idempotent;
|
||||
removal is unsupported (Mastery Rank never decreases)."""
|
||||
add = data.get("add")
|
||||
if not isinstance(add, list) or not add:
|
||||
return JSONResponse({"error": "add must be a non-empty list of path ids"}, status_code=400)
|
||||
content = appstate.get_progression_content()
|
||||
for pid in add:
|
||||
if not isinstance(pid, str) or pid not in content["paths"]:
|
||||
return JSONResponse({"error": f"unknown path: {pid!r}"}, status_code=400)
|
||||
appstate.meta_db.add_player_paths(add)
|
||||
return _progression_overview()
|
||||
|
||||
|
||||
@router.post("/api/progression/onboarding")
|
||||
def api_progression_onboarding(data: dict):
|
||||
"""Onboarding calibration choice. Body: {action: "skip"} — completing the
|
||||
calibration needs no endpoint, it flows through the normal /api/stats path."""
|
||||
if _clean_str(data.get("action")) != "skip":
|
||||
return JSONResponse({"error": "action must be 'skip'"}, status_code=400)
|
||||
# Spec invariant: onboarding requires picking at least one instrument path
|
||||
# before finishing, so skipping straight to rank 1 with no paths would
|
||||
# leave a rank that can never grow. Only enforced when the content bundle
|
||||
# actually defines paths — broken/empty content must never brick onboarding.
|
||||
if appstate.get_progression_content()["paths"] and not appstate.meta_db.get_player_paths():
|
||||
return JSONResponse(
|
||||
{"error": "select at least one instrument path before skipping calibration"},
|
||||
status_code=400,
|
||||
)
|
||||
appstate.meta_db.skip_calibration()
|
||||
return _progression_overview()
|
||||
|
||||
|
||||
# Externally postable progression events. song_completed is deliberately NOT
|
||||
# here: it is server-derived inside /api/stats so the scored-session authority
|
||||
# stays in one place.
|
||||
_PROGRESSION_EVENT_TYPES = {"minigame_run"}
|
||||
|
||||
|
||||
@router.post("/api/progression/events")
|
||||
def api_progression_events(data: dict):
|
||||
"""Generic progression-event intake for plugins (capability `record-event`).
|
||||
Body: {type, payload}. Whitelisted types, scalar payload values only."""
|
||||
etype = _clean_str(data.get("type"))
|
||||
if etype not in _PROGRESSION_EVENT_TYPES:
|
||||
return JSONResponse(
|
||||
{"error": f"event type must be one of {sorted(_PROGRESSION_EVENT_TYPES)}"},
|
||||
status_code=400,
|
||||
)
|
||||
payload = data.get("payload")
|
||||
if payload is None:
|
||||
payload = {}
|
||||
if not isinstance(payload, dict) or len(payload) > 16:
|
||||
return JSONResponse({"error": "payload must be a small object"}, status_code=400)
|
||||
clean = {}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or len(key) > 64:
|
||||
return JSONResponse({"error": "payload keys must be short strings"}, status_code=400)
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool) or (
|
||||
not isinstance(value, (int, float, str))
|
||||
) or (isinstance(value, float) and not math.isfinite(value)) or (
|
||||
isinstance(value, str) and len(value) > 256
|
||||
):
|
||||
return JSONResponse({"error": "payload values must be short strings or finite numbers"}, status_code=400)
|
||||
clean[key] = value
|
||||
summary = appstate.meta_db.record_progression_event(etype, clean, appstate.get_progression_content())
|
||||
return {"ok": True, "progression": summary}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Cosmetics shop (spec 010) — buy/equip avatars & themes with earned currency.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_get_progression_content()`` ->
|
||||
``appstate.get_progression_content()`` (the accessor is injected into the seam;
|
||||
its lazy content cache stays in server.py).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/shop")
|
||||
def api_shop():
|
||||
content = appstate.get_progression_content()
|
||||
owned = appstate.meta_db.get_owned_items()
|
||||
equipped = appstate.meta_db.get_equipped()
|
||||
items = [
|
||||
{**item, "owned": iid in owned, "equipped": equipped.get(item["slot"]) == iid}
|
||||
for iid, item in sorted(content["shop"].items())
|
||||
]
|
||||
return {"items": items, "wallet": appstate.meta_db.get_wallet()}
|
||||
|
||||
|
||||
@router.post("/api/shop/buy")
|
||||
def api_shop_buy(data: dict):
|
||||
"""Spend Decibels on a cosmetic. Atomic: balance check + spend + ownership
|
||||
in one transaction. Decibels are earned by playing only — never purchasable."""
|
||||
item_id = _clean_str(data.get("item_id"))
|
||||
item = appstate.get_progression_content()["shop"].get(item_id)
|
||||
if not item:
|
||||
return JSONResponse({"error": f"unknown item: {item_id!r}"}, status_code=400)
|
||||
status, wallet = appstate.meta_db.buy_shop_item(item)
|
||||
if status == "owned":
|
||||
return JSONResponse({"error": "already owned", "wallet": wallet}, status_code=409)
|
||||
if status == "insufficient":
|
||||
return JSONResponse({"error": "insufficient balance", "wallet": wallet}, status_code=402)
|
||||
return {"ok": True, "item_id": item_id, "wallet": wallet}
|
||||
|
||||
|
||||
@router.post("/api/shop/equip")
|
||||
def api_shop_equip(data: dict):
|
||||
"""Equip an owned cosmetic into its slot. Body: {slot, item_id|null}
|
||||
(null unequips, restoring the default look)."""
|
||||
import progression as progression_mod
|
||||
slot = _clean_str(data.get("slot"))
|
||||
if slot not in progression_mod.SHOP_SLOTS:
|
||||
return JSONResponse({"error": f"slot must be one of {sorted(progression_mod.SHOP_SLOTS)}"}, status_code=400)
|
||||
item_id = data.get("item_id")
|
||||
if item_id is not None:
|
||||
item_id = _clean_str(item_id)
|
||||
item = appstate.get_progression_content()["shop"].get(item_id)
|
||||
if not item or item["slot"] != slot:
|
||||
return JSONResponse({"error": f"unknown item for slot {slot}: {item_id!r}"}, status_code=400)
|
||||
if item_id not in appstate.meta_db.get_owned_items():
|
||||
return JSONResponse({"error": "item not owned"}, status_code=403)
|
||||
return {"ok": True, "equipped": appstate.meta_db.equip_item(slot, item_id)}
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Gameplay scoring — XP award + per-song practice stats (record / recent / best /
|
||||
top / per-song). The `/api/stats/{filename:path}` route is registered LAST so its
|
||||
catch-all doesn't shadow the fixed /recent /best /top paths.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_get_progression_content()`` /
|
||||
``_builtin_diagnostic_filename()`` read through the seam.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from metadata_db import _as_int
|
||||
from reqfields import _clean_str
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/api/xp/award")
|
||||
def api_award_xp(data: dict):
|
||||
"""Award XP into the unified store. Body: {source, amount}. Returns the
|
||||
new progress payload. The single XP authority — song-play, minigames, and
|
||||
tutorials all feed this (no second curve)."""
|
||||
try:
|
||||
amount = _as_int(data.get("amount", 0)) # rejects bool / non-integral / inf
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "amount must be an integer"}, status_code=400)
|
||||
# Upper-bound it: an unbounded value overflows SQLite's 64-bit INTEGER on
|
||||
# bind (→ 500) and no real run awards anywhere near this.
|
||||
if not (0 <= amount <= 10_000_000):
|
||||
return JSONResponse({"error": "amount must be between 0 and 10,000,000"}, status_code=400)
|
||||
appstate.meta_db.award_xp(amount)
|
||||
return appstate.meta_db.get_progress()
|
||||
|
||||
|
||||
@router.post("/api/stats")
|
||||
def api_record_stats(data: dict):
|
||||
"""Record a play. With `score`+`accuracy` → a scored session (plays += 1,
|
||||
best_* = max, last_* = new) plus unified-XP + streak side-effects. With
|
||||
only `lastPlayPosition`/`last_position` → a lightweight resume-position
|
||||
touch (no plays change) so Continue-Playing works for non-scored plays."""
|
||||
filename = _clean_str(data.get("filename"))
|
||||
if not filename:
|
||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||
# The recorder hands us URL-encoded filenames; canonicalize to the library
|
||||
# key so stored rows line up with `songs` (and so the arrangement-count bound
|
||||
# below resolves the real song). See MetadataDB._canonical_song_filename.
|
||||
filename = appstate.meta_db._canonical_song_filename(filename)
|
||||
arr_raw = data.get("arrangement", 0)
|
||||
if arr_raw is None:
|
||||
arrangement = 0
|
||||
else:
|
||||
try:
|
||||
arrangement = _as_int(arr_raw) # rejects bool / non-integral (1.9) / inf
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400)
|
||||
# Reject (don't silently coerce to 0) so a malformed/out-of-range index
|
||||
# can't corrupt arrangement 0's stats; also keeps it bindable to INTEGER.
|
||||
if not (0 <= arrangement < 2**63):
|
||||
return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400)
|
||||
# Bound against the song's real arrangement count when it's a known library
|
||||
# song, so a bad index can't create fake arrangement buckets that poison the
|
||||
# per-song aggregate / Continue. Skipped when the song isn't in the library
|
||||
# yet (count unknown — dead-song reads are filtered anyway).
|
||||
_acount = appstate.meta_db.arrangement_count(filename)
|
||||
if _acount and arrangement >= _acount:
|
||||
return JSONResponse({"error": "arrangement out of range for this song"}, status_code=400)
|
||||
score = data.get("score")
|
||||
accuracy = data.get("accuracy")
|
||||
last_pos = data.get("lastPlayPosition", data.get("last_position"))
|
||||
if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
|
||||
# A scored session needs BOTH score and accuracy. Exactly one provided is
|
||||
# ambiguous — don't silently fall through to the position-only branch.
|
||||
if (score is None) != (accuracy is None):
|
||||
return JSONResponse({"error": "score and accuracy must be provided together"}, status_code=400)
|
||||
|
||||
if score is not None and accuracy is not None:
|
||||
# Reject booleans explicitly — float(True) would otherwise record a play.
|
||||
if isinstance(score, bool) or isinstance(accuracy, bool):
|
||||
return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400)
|
||||
# Reject NaN/Inf too: round(inf) raises OverflowError (→ 500), and a
|
||||
# stored Inf/NaN later breaks JSON serialization of /api/stats reads.
|
||||
try:
|
||||
score = float(score)
|
||||
accuracy = float(accuracy)
|
||||
if not (math.isfinite(score) and math.isfinite(accuracy)):
|
||||
raise ValueError("non-finite")
|
||||
score = int(round(score))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400)
|
||||
# A huge-but-finite score passes isfinite() yet overflows SQLite's
|
||||
# 64-bit INTEGER on bind (→ 500). Bound it to the int64 range.
|
||||
if not (0 <= score < 2**63):
|
||||
return JSONResponse({"error": "score out of range"}, status_code=400)
|
||||
# accuracy is a 0..1 fraction (the recorder's contract); reject
|
||||
# out-of-range values so they don't surface as >100% / negative in
|
||||
# /api/stats/best and the badge UI.
|
||||
if not (0 <= accuracy <= 1):
|
||||
return JSONResponse({"error": "accuracy must be between 0 and 1"}, status_code=400)
|
||||
# Validate the optional resume position in this branch too (the
|
||||
# position-only branch below already rejects non-finite).
|
||||
if last_pos is not None:
|
||||
try:
|
||||
last_pos = float(last_pos)
|
||||
if not math.isfinite(last_pos):
|
||||
raise ValueError("non-finite")
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
row = appstate.meta_db.record_session(filename, arrangement, score=score,
|
||||
accuracy=accuracy, last_position=last_pos)
|
||||
# Unified XP + streak side-effects — never let these drop the stat write.
|
||||
progress = None
|
||||
try:
|
||||
from xp import xp_for_run
|
||||
from datetime import date
|
||||
appstate.meta_db.award_xp(xp_for_run(score))
|
||||
appstate.meta_db.record_active_day(date.today().isoformat())
|
||||
progress = appstate.meta_db.get_progress()
|
||||
except Exception:
|
||||
log.warning("stats side-effects (xp/streak) failed", exc_info=True)
|
||||
# Progression engine (spec 010) — same never-drop-the-stat-write
|
||||
# contract. Scored sessions are the server-derived `song_completed`
|
||||
# authority (scored == note detection by construction); instrument is
|
||||
# resolved from library arrangement metadata, after the XP award so
|
||||
# db_earned goals see this run's Decibels.
|
||||
progression_summary = None
|
||||
try:
|
||||
import progression as progression_mod
|
||||
instrument = progression_mod.instrument_for_arrangement(
|
||||
appstate.meta_db.arrangement_entry(filename, arrangement)
|
||||
)
|
||||
progression_summary = appstate.meta_db.record_progression_event(
|
||||
"song_completed",
|
||||
{
|
||||
"filename": filename,
|
||||
"instrument": instrument,
|
||||
"accuracy": accuracy,
|
||||
"score": score,
|
||||
"is_diagnostic": filename == appstate.builtin_diagnostic_filename(),
|
||||
},
|
||||
appstate.get_progression_content(),
|
||||
)
|
||||
except Exception:
|
||||
log.warning("stats side-effects (progression) failed", exc_info=True)
|
||||
return {"stats": row, "progress": progress, "progression": progression_summary}
|
||||
|
||||
# Position-only touch.
|
||||
if last_pos is None:
|
||||
return JSONResponse(
|
||||
{"error": "provide score+accuracy (scored) or lastPlayPosition (resume)"},
|
||||
status_code=400,
|
||||
)
|
||||
try:
|
||||
pos = float(last_pos)
|
||||
if not math.isfinite(pos):
|
||||
raise ValueError("non-finite")
|
||||
row = appstate.meta_db.touch_position(filename, arrangement, pos)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
# A resume session still counts as playing today: advance the streak (no XP —
|
||||
# that's scoring-only) so a non-scored practice day keeps the streak alive,
|
||||
# consistent with these sessions also surfacing in recent / continue.
|
||||
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}
|
||||
|
||||
|
||||
@router.get("/api/stats/recent")
|
||||
def api_recent_stats(limit: int = 12):
|
||||
"""Recently-played rows joined to song metadata for 'Jump back in'."""
|
||||
from urllib.parse import quote
|
||||
out = []
|
||||
for r in appstate.meta_db.recent_stats(limit):
|
||||
meta = appstate.meta_db.conn.execute(
|
||||
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
|
||||
(r["filename"],),
|
||||
).fetchone()
|
||||
title, artist, tuning_name = meta if meta else (None, None, None)
|
||||
out.append({
|
||||
**r,
|
||||
"title": title or r["filename"],
|
||||
"artist": artist or "",
|
||||
"tuning_name": tuning_name or "",
|
||||
"art_url": f"/api/song/{quote(r['filename'])}/art",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/stats/best")
|
||||
def api_stats_best():
|
||||
"""{filename: best_accuracy} for all songs with a recorded best — one call
|
||||
to badge the library grid (defined before the {filename} catch-all)."""
|
||||
return appstate.meta_db.best_accuracy_map()
|
||||
|
||||
|
||||
@router.get("/api/stats/top")
|
||||
def api_top_stats(limit: int = 5):
|
||||
"""Top scored songs (best first), joined to song metadata, for the profile
|
||||
'Your best scores' panel (defined before the {filename} catch-all)."""
|
||||
from urllib.parse import quote
|
||||
out = []
|
||||
for r in appstate.meta_db.top_stats(limit):
|
||||
meta = appstate.meta_db.conn.execute(
|
||||
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
|
||||
(r["filename"],),
|
||||
).fetchone()
|
||||
title, artist, tuning_name = meta if meta else (None, None, None)
|
||||
out.append({
|
||||
**r,
|
||||
"title": title or r["filename"],
|
||||
"artist": artist or "",
|
||||
"tuning_name": tuning_name or "",
|
||||
"art_url": f"/api/song/{quote(r['filename'])}/art",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/stats/{filename:path}")
|
||||
def api_song_stats(filename: str):
|
||||
return appstate.meta_db.get_song_stats(filename)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Wishlist / "wanted" API (feedBack#636) — songs the user wants but doesn't own.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/wanted")
|
||||
def api_list_wanted():
|
||||
"""The wishlist — songs the user wants but doesn't own yet (newest first)."""
|
||||
return {"wanted": appstate.meta_db.list_wanted()}
|
||||
|
||||
|
||||
@router.post("/api/wanted")
|
||||
def api_add_wanted(data: dict):
|
||||
"""Add a not-owned song to the wishlist. `artist`/`title` are required (at
|
||||
least one non-empty); `source`/`source_ref`/`note` are optional. Idempotent
|
||||
on identity so producers (find_more ownership-diff, manual add) can re-post."""
|
||||
if not isinstance(data, dict):
|
||||
return JSONResponse({"error": "body must be an object"}, status_code=400)
|
||||
artist = _clean_str(data.get("artist"))
|
||||
title = _clean_str(data.get("title"))
|
||||
if not artist and not title:
|
||||
return JSONResponse({"error": "artist or title required"}, status_code=400)
|
||||
row = appstate.meta_db.add_wanted(
|
||||
artist=artist, title=title,
|
||||
source=_clean_str(data.get("source")) or "manual",
|
||||
source_ref=_clean_str(data.get("source_ref")),
|
||||
note=_clean_str(data.get("note")),
|
||||
)
|
||||
return {"ok": True, "wanted": row}
|
||||
|
||||
|
||||
@router.delete("/api/wanted/{wanted_id}")
|
||||
def api_remove_wanted(wanted_id: int):
|
||||
"""Remove a wishlist entry by id."""
|
||||
return {"ok": appstate.meta_db.remove_wanted(wanted_id)}
|
||||
@@ -6,6 +6,16 @@
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 [--n 60] [--soak 30]
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 --song "Arcturus ….feedpak"
|
||||
//
|
||||
// With --song, it additionally measures the 2D highway's PER-FRAME DRAW cost:
|
||||
// it wraps requestAnimationFrame before any page script runs, tags the frames
|
||||
// in which the highway actually painted (via highway.addDrawHook), starts
|
||||
// playback, and reports draw-frame p50/p95/p99 over --frames seconds. Tagging
|
||||
// matters — roughly half the rAF callbacks belong to other cheap loops, and
|
||||
// averaging them in hides a renderer regression behind ~0.1 ms no-op frames.
|
||||
// This is the metric that gates the highway.js split (R3c); run it before AND
|
||||
// after each highway change on the same machine.
|
||||
//
|
||||
// Maintainer/CI-only dev tooling (uses the committed @playwright/test browser);
|
||||
// never part of the serve or Docker path. Metrics that need a seeded library
|
||||
@@ -21,6 +31,9 @@ for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replac
|
||||
const BASE = args.get('base') || 'http://127.0.0.1:8000';
|
||||
const N = parseInt(args.get('n') || '60', 10);
|
||||
const SOAK_S = parseInt(args.get('soak') || '30', 10);
|
||||
const SONG = args.get('song') || null;
|
||||
const FRAME_S = parseInt(args.get('frames') || '10', 10);
|
||||
const RUNS = parseInt(args.get('runs') || '3', 10); // repeat frame sampling to show spread
|
||||
|
||||
const pct = (xs, p) => {
|
||||
if (!xs.length) return null;
|
||||
@@ -69,6 +82,64 @@ async function clientMetrics() {
|
||||
return { bootMs, memStartMB: mem0 && mem0 / 1048576, memSoakMB: mem1 && mem1 / 1048576, scripts };
|
||||
}
|
||||
|
||||
// ── 2D highway per-frame draw cost (needs --song + a seeded library) ──────────
|
||||
async function frameTimeOnce(browser) {
|
||||
const page = await browser.newPage();
|
||||
let f, t2, notes;
|
||||
try {
|
||||
// Wrap rAF before any page script; mark frames the highway actually drew.
|
||||
await page.addInitScript(() => {
|
||||
window.__f = [];
|
||||
window.__drew = false;
|
||||
const raf = window.requestAnimationFrame.bind(window);
|
||||
window.requestAnimationFrame = (cb) => raf((t) => {
|
||||
window.__drew = false;
|
||||
const t0 = performance.now();
|
||||
try { cb(t); } finally { window.__f.push({ ms: performance.now() - t0, drew: window.__drew }); }
|
||||
});
|
||||
});
|
||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
await page.evaluate(() => document.getElementById('v3-onboarding')?.remove());
|
||||
await page.evaluate((s) => window.playSong(s), SONG);
|
||||
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length >= 0 && window.highway?.getSongInfo?.(),
|
||||
null, { timeout: 45000 }).catch(() => {});
|
||||
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length > 0, null, { timeout: 45000 });
|
||||
await page.evaluate(() => window.highway.addDrawHook(() => { window.__drew = true; }));
|
||||
// Start playback so draw() leaves its paused-throttle path; confirm the clock advances.
|
||||
await page.evaluate(async () => { const a = window.highway.getAudioElement?.(); if (a) a.muted = true; await a?.play?.(); });
|
||||
await page.waitForTimeout(1500);
|
||||
const t1 = await page.evaluate(() => window.highway.getTime());
|
||||
await page.evaluate(() => { window.__f.length = 0; });
|
||||
await page.waitForTimeout(FRAME_S * 1000);
|
||||
({ f, t2, notes } = await page.evaluate(() => ({
|
||||
f: window.__f.slice(), t2: window.highway.getTime(), notes: window.highway.getNotes().length,
|
||||
})));
|
||||
if (!(t2 > t1 + 1)) throw new Error(`clock did not advance (${t1}→${t2}) — measured the paused path`);
|
||||
} finally {
|
||||
// Always close, even when an await above throws — otherwise a failing
|
||||
// run leaks its page until the final browser.close().
|
||||
await page.close();
|
||||
}
|
||||
const drew = f.filter((x) => x.drew).map((x) => x.ms);
|
||||
return { drew, total: f.length, notes };
|
||||
}
|
||||
|
||||
async function frameTime() {
|
||||
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
|
||||
const rows = [];
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
try {
|
||||
const r = await frameTimeOnce(browser);
|
||||
rows.push({
|
||||
p50: pct(r.drew, 50), p95: pct(r.drew, 95), p99: pct(r.drew, 99),
|
||||
max: Math.max(...r.drew), n: r.drew.length, total: r.total, notes: r.notes,
|
||||
});
|
||||
} catch (e) { rows.push({ error: String(e.message || e) }); }
|
||||
}
|
||||
await browser.close();
|
||||
return rows;
|
||||
}
|
||||
|
||||
const server = await serverLatency([
|
||||
'/api/version',
|
||||
'/api/plugins',
|
||||
@@ -86,9 +157,21 @@ out += `| Cold boot → networkidle | ${client.bootMs} ms |\n`;
|
||||
out += `| JS heap after load | ${client.memStartMB ? client.memStartMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| JS heap after ${SOAK_S}s idle soak | ${client.memSoakMB ? client.memSoakMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| Plugin scripts injected | ${client.scripts} |\n`;
|
||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
||||
out += `> on the 2D + 3D highway, and screen-entry (plugin inject → interactive) for\n`;
|
||||
out += `> editor / notedetect / highway_3d with a real chart loaded. Run this harness\n`;
|
||||
out += `> against an environment with charts in \`DLC_DIR\` to fill those in.\n`;
|
||||
if (SONG) {
|
||||
const frames = await frameTime();
|
||||
out += `\n### 2D highway per-frame draw cost — \`${SONG}\` (${RUNS} runs × ${FRAME_S}s)\n\n`;
|
||||
out += `| run | draw frames | p50 | p95 | p99 | max |\n|---|---|---|---|---|---|\n`;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const r = frames[i];
|
||||
if (r.error) { out += `| ${i + 1} | — | \`${r.error}\` | | | |\n`; continue; }
|
||||
out += `| ${i + 1} | ${r.n}/${r.total} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} | ${ms(r.max)} |\n`;
|
||||
}
|
||||
const p95s = frames.filter((r) => !r.error).map((r) => r.p95);
|
||||
if (p95s.length) out += `\n**p95 spread across runs: ${ms(Math.min(...p95s))}–${ms(Math.max(...p95s))} ms**\n`;
|
||||
} else {
|
||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
||||
out += `> on the 2D highway — pass \`--song "<filename in DLC_DIR>"\` to capture it.\n`;
|
||||
out += `> (3D highway_3d + screen-entry timings are a separate R4 concern.)\n`;
|
||||
}
|
||||
|
||||
console.log(out);
|
||||
|
||||
@@ -55,7 +55,7 @@ from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
# Lives in lib/ because that is the one core dir every packaging path copies.
|
||||
import appstate
|
||||
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
||||
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart
|
||||
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats
|
||||
import sloppak as sloppak_mod
|
||||
import loosefolder as loosefolder_mod
|
||||
# Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/
|
||||
@@ -1164,6 +1164,16 @@ def _get_progression_content() -> dict:
|
||||
return _progression_content
|
||||
|
||||
|
||||
# Publish the progression-content accessor into the seam now that it's defined
|
||||
# (the main configure() at import-top runs before this def). The cache global +
|
||||
# lock stay in server.py, so the `setattr(server, "_progression_content")` test
|
||||
# path is unchanged; routers call `appstate.get_progression_content()`.
|
||||
appstate.configure(
|
||||
get_progression_content=_get_progression_content,
|
||||
builtin_diagnostic_filename=_builtin_diagnostic_filename,
|
||||
)
|
||||
|
||||
|
||||
def _copy_builtin_packs(
|
||||
root: Path,
|
||||
dest_dir: Path,
|
||||
@@ -4229,31 +4239,10 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
|
||||
# views is deferred to P5d — there's no server-side library event bus today, and
|
||||
# the drawer updates itself from these responses.
|
||||
|
||||
@app.get("/api/work/{work_key:path}/charts")
|
||||
def api_get_work_charts(work_key: str):
|
||||
"""All charts in a work + which is the keeper (your pick vs auto-pick)."""
|
||||
return meta_db.work_charts(work_key)
|
||||
|
||||
|
||||
@app.put("/api/work/{work_key:path}/preferred")
|
||||
def api_set_work_preferred(work_key: str, data: dict):
|
||||
"""Set the keeper chart of a work: body {filename}. The filename must be a
|
||||
current member of the work. Returns the refreshed chart list."""
|
||||
fn = (data.get("filename") or "").strip()
|
||||
if not fn:
|
||||
return JSONResponse({"error": "filename is required"}, 400)
|
||||
members = {c["filename"] for c in meta_db.work_charts(work_key)["charts"]}
|
||||
if fn not in members:
|
||||
return JSONResponse({"error": "filename is not a chart of this work"}, 400)
|
||||
meta_db.set_chart_preferred(work_key, fn)
|
||||
return meta_db.work_charts(work_key)
|
||||
|
||||
|
||||
@app.delete("/api/work/{work_key:path}/preferred")
|
||||
def api_reset_work_preferred(work_key: str):
|
||||
"""Reset a work to auto-pick (drop the explicit preferred)."""
|
||||
meta_db.clear_chart_preferred(work_key)
|
||||
return meta_db.work_charts(work_key)
|
||||
# ── Small library / user-state endpoints (work prefs, favorites, tags, saved, session)
|
||||
# Mounted here; implementation in lib/routers/library_extras.py. Paths are all
|
||||
# distinct, so registering them together does not change routing.
|
||||
app.include_router(library_extras.router)
|
||||
|
||||
|
||||
# ── Chart-level endpoints (split/work/fileinfo) ──────────────────────────────
|
||||
@@ -4394,14 +4383,6 @@ async def list_tuning_names(provider: str = "local"):
|
||||
return await _call_library_provider_async(library_provider, "tuning_names")
|
||||
|
||||
|
||||
@app.post("/api/favorites/toggle")
|
||||
def toggle_favorite(data: dict):
|
||||
"""Toggle a song's favorite status."""
|
||||
filename = data.get("filename", "")
|
||||
if not filename:
|
||||
return {"error": "No filename"}
|
||||
new_state = meta_db.toggle_favorite(filename)
|
||||
return {"favorite": new_state}
|
||||
|
||||
|
||||
# ── Personal per-song metadata (difficulty / notes / tags) ───────────────────
|
||||
@@ -4564,11 +4545,6 @@ def batch_song_user_meta(data: dict):
|
||||
return {"updated": n, "tags": meta_db.all_tags()}
|
||||
|
||||
|
||||
@app.get("/api/tags")
|
||||
def list_tags():
|
||||
"""All personal tags in use (over still-present songs), most-used first —
|
||||
powers the tag filter UI."""
|
||||
return {"tags": meta_db.all_tags()}
|
||||
|
||||
|
||||
# ── Artist aliases / Tidy-up (P4) ────────────────────────────────────────────
|
||||
@@ -4689,592 +4665,27 @@ def api_artist_links(name: str):
|
||||
def api_artist_links_refresh(name: str):
|
||||
"""Explicit re-fetch of the cached links (the page's manual Refresh)."""
|
||||
return _artist_links_payload(name, force=True)
|
||||
# ── Player profile (identity / avatars / progress) ───────────────────────────
|
||||
# Mounted here (registration order). Implementation in lib/routers/profile.py.
|
||||
app.include_router(profile.router)
|
||||
|
||||
|
||||
# ── Player profile / unified XP / streak (fee[dB]ack v0.3.0) ──────────────────
|
||||
|
||||
def _list_bundled_avatars() -> list[str]:
|
||||
"""Bundled default avatar filenames under static/v3/avatars/."""
|
||||
d = STATIC_DIR / "v3" / "avatars"
|
||||
if not d.is_dir():
|
||||
return []
|
||||
exts = {".svg", ".png", ".webp"}
|
||||
return sorted(
|
||||
p.name for p in d.iterdir()
|
||||
if p.is_file() and p.suffix.lower() in exts and not p.name.startswith(".")
|
||||
)
|
||||
# ── Gameplay scoring: XP award + per-song practice stats ─────────────────────
|
||||
# Mounted here (registration order; /api/stats/{path} is last inside the router).
|
||||
# Implementation in lib/routers/stats.py.
|
||||
app.include_router(stats.router)
|
||||
|
||||
|
||||
@app.get("/api/profile")
|
||||
def api_get_profile():
|
||||
profile = meta_db.get_profile()
|
||||
# Equipped cosmetics ride along (resolved to their payloads) so the theme
|
||||
# and avatar frame apply at boot without an extra request. Never let a
|
||||
# cosmetics/content problem break the profile read.
|
||||
cosmetics = {}
|
||||
try:
|
||||
shop = _get_progression_content()["shop"]
|
||||
for slot, item_id in meta_db.get_equipped().items():
|
||||
item = shop.get(item_id)
|
||||
if item:
|
||||
cosmetics[slot] = {"item_id": item_id, "payload": item["payload"]}
|
||||
except Exception:
|
||||
log.warning("profile cosmetics enrich failed", exc_info=True)
|
||||
profile["cosmetics"] = cosmetics
|
||||
return profile
|
||||
# ── Progression (spec 010) ───────────────────────────────────────────────────
|
||||
# Mounted here (registration order). Implementation in lib/routers/progression.py.
|
||||
app.include_router(progression.router)
|
||||
|
||||
|
||||
|
||||
@app.post("/api/profile")
|
||||
def api_set_profile(data: dict):
|
||||
"""Set/update the player profile. Body: {display_name, avatar:{type,value}}.
|
||||
avatar.type is 'default' (value = bundled filename) or 'upload' (value =
|
||||
the /api/profile/avatar/<name> URL returned by the upload endpoint); omit
|
||||
avatar to keep the existing one (name-only edit)."""
|
||||
name = _clean_str(data.get("display_name"))
|
||||
if not (1 <= len(name) <= 32):
|
||||
return JSONResponse({"error": "Display name must be 1–32 characters."}, status_code=400)
|
||||
avatar = data.get("avatar")
|
||||
if avatar is None:
|
||||
avatar = {} # omitted → keep the current avatar (name-only edit)
|
||||
elif not isinstance(avatar, dict):
|
||||
return JSONResponse({"error": "avatar must be an object."}, status_code=400)
|
||||
atype = avatar.get("type")
|
||||
aval = _clean_str(avatar.get("value"))
|
||||
avatar_url = None
|
||||
if atype == "default":
|
||||
if aval not in _list_bundled_avatars():
|
||||
return JSONResponse({"error": "Unknown default avatar."}, status_code=400)
|
||||
avatar_url = f"/static/v3/avatars/{aval}"
|
||||
elif atype == "upload":
|
||||
from safepath import safe_join
|
||||
fname = aval.rsplit("/", 1)[-1] if aval.startswith("/api/profile/avatar/") else ""
|
||||
target = safe_join(CONFIG_DIR / "avatars", fname) if fname else None
|
||||
if target is None or not target.is_file():
|
||||
return JSONResponse({"error": "Uploaded avatar not found."}, status_code=400)
|
||||
avatar_url = f"/api/profile/avatar/{fname}"
|
||||
elif atype:
|
||||
return JSONResponse({"error": "Unknown avatar type."}, status_code=400)
|
||||
# atype None/missing → keep the current avatar (name-only edit).
|
||||
return meta_db.set_profile(name, avatar_url)
|
||||
# ── Cosmetics shop (spec 010) ────────────────────────────────────────────────
|
||||
# Mounted here (registration order). Implementation in lib/routers/shop.py.
|
||||
app.include_router(shop.router)
|
||||
|
||||
|
||||
@app.get("/api/profile/avatars")
|
||||
def api_list_avatars():
|
||||
return [{"name": n, "url": f"/static/v3/avatars/{n}"} for n in _list_bundled_avatars()]
|
||||
|
||||
|
||||
@app.post("/api/profile/avatar")
|
||||
def api_upload_avatar(data: dict):
|
||||
"""Upload a custom avatar as base64 (mirrors the album-art upload pattern).
|
||||
Re-encodes to a ≤512px PNG under CONFIG_DIR/avatars/."""
|
||||
import base64
|
||||
import io
|
||||
b64 = data.get("image", "")
|
||||
if not isinstance(b64, str) or not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
try:
|
||||
raw = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid base64"}, status_code=400)
|
||||
if len(raw) > 6 * 1024 * 1024:
|
||||
return JSONResponse({"error": "Image too large (max 6 MB)."}, status_code=400)
|
||||
avatars_dir = CONFIG_DIR / "avatars"
|
||||
avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
img.thumbnail((512, 512))
|
||||
fname = f"upload-{secrets.token_hex(4)}.png" # token busts caches on change
|
||||
img.save(str(avatars_dir / fname), "PNG")
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400)
|
||||
return {"url": f"/api/profile/avatar/{fname}"}
|
||||
|
||||
|
||||
@app.get("/api/profile/avatar/{name}")
|
||||
def api_get_avatar(name: str):
|
||||
from safepath import safe_join
|
||||
target = safe_join(CONFIG_DIR / "avatars", name)
|
||||
if target is None or not target.is_file():
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return FileResponse(str(target), media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/api/profile/progress")
|
||||
def api_profile_progress():
|
||||
"""One call for the whole profile badge: {level, xp, xp_in_level,
|
||||
xp_to_next, current_streak, best_streak, last_active_date}."""
|
||||
return meta_db.get_progress()
|
||||
|
||||
|
||||
@app.post("/api/xp/award")
|
||||
def api_award_xp(data: dict):
|
||||
"""Award XP into the unified store. Body: {source, amount}. Returns the
|
||||
new progress payload. The single XP authority — song-play, minigames, and
|
||||
tutorials all feed this (no second curve)."""
|
||||
try:
|
||||
amount = _as_int(data.get("amount", 0)) # rejects bool / non-integral / inf
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "amount must be an integer"}, status_code=400)
|
||||
# Upper-bound it: an unbounded value overflows SQLite's 64-bit INTEGER on
|
||||
# bind (→ 500) and no real run awards anywhere near this.
|
||||
if not (0 <= amount <= 10_000_000):
|
||||
return JSONResponse({"error": "amount must be between 0 and 10,000,000"}, status_code=400)
|
||||
meta_db.award_xp(amount)
|
||||
return meta_db.get_progress()
|
||||
|
||||
|
||||
# ── Progression (spec 010): mastery rank, challenges, quests, shop ───────────
|
||||
|
||||
def _goal_ui_progress(goal: dict, state: dict, streak: int, xp_total: int) -> tuple:
|
||||
"""(count, target) for a challenge/quest progress bar. Count goals show
|
||||
n/target; threshold goals show how far the live stat is along the line."""
|
||||
import progression as progression_mod
|
||||
gtype = goal.get("type")
|
||||
if gtype in progression_mod.COUNT_GOAL_TYPES:
|
||||
target = int(goal.get("target") or 1)
|
||||
count = target if state.get("completed") else min(int(state.get("count") or 0), target)
|
||||
return count, target
|
||||
if gtype == "streak_reached":
|
||||
target = int(goal.get("days") or 1)
|
||||
return (target if state.get("completed") else min(streak, target)), target
|
||||
if gtype == "db_earned":
|
||||
target = int(goal.get("amount") or 1)
|
||||
return (target if state.get("completed") else min(xp_total, target)), target
|
||||
return 0, 1
|
||||
|
||||
|
||||
def _progression_overview() -> dict:
|
||||
"""The full GET /api/progression payload (also the capability `inspect`
|
||||
result): rank, onboarding, per-path challenge checklists, quests, wallet."""
|
||||
import progression as progression_mod
|
||||
from datetime import datetime as _dt
|
||||
content = _get_progression_content()
|
||||
now = _dt.now()
|
||||
meta_db.ensure_quest_period(content, now)
|
||||
|
||||
state = meta_db.get_progression_state()
|
||||
player_paths = meta_db.get_player_paths()
|
||||
challenge_state = meta_db.get_challenge_state()
|
||||
wallet = meta_db.get_wallet()
|
||||
streak_progress = meta_db.get_progress()
|
||||
streak = int(streak_progress.get("current_streak") or 0)
|
||||
xp_total = wallet["lifetime_db"]
|
||||
keys = progression_mod.period_keys(now)
|
||||
|
||||
def _path_order(pid):
|
||||
pdef = content["paths"].get(pid) or {}
|
||||
return (pdef.get("order") or 0, pid)
|
||||
|
||||
paths_payload = []
|
||||
for pid in sorted(player_paths, key=_path_order):
|
||||
pdef = content["paths"].get(pid)
|
||||
level = player_paths[pid]
|
||||
if not pdef:
|
||||
# Path selected under older content that no longer ships: keep its
|
||||
# rank contribution visible rather than silently dropping it.
|
||||
paths_payload.append({"id": pid, "name": pid, "icon": "", "level": level,
|
||||
"max_level": level, "next": None})
|
||||
continue
|
||||
next_block = None
|
||||
active = progression_mod.active_challenges(content, pid, level)
|
||||
if active:
|
||||
level_def = next(e for e in pdef["levels"] if e["level"] == level + 1)
|
||||
challenges = []
|
||||
completed_count = 0
|
||||
for ch in active:
|
||||
st = challenge_state.get(ch["id"]) or {}
|
||||
count, target = _goal_ui_progress(ch["goal"], st, streak, xp_total)
|
||||
if st.get("completed"):
|
||||
completed_count += 1
|
||||
challenges.append({
|
||||
"id": ch["id"],
|
||||
"title": ch["title"],
|
||||
"description": ch["description"],
|
||||
"count": count,
|
||||
"target": target,
|
||||
"completed": bool(st.get("completed")),
|
||||
"completed_at": st.get("completed_at"),
|
||||
})
|
||||
next_block = {
|
||||
"level": level + 1,
|
||||
"required": level_def["required"],
|
||||
"completed": completed_count,
|
||||
"challenges": challenges,
|
||||
}
|
||||
paths_payload.append({
|
||||
"id": pid,
|
||||
"name": pdef["name"],
|
||||
"icon": pdef["icon"],
|
||||
"level": level,
|
||||
"max_level": progression_mod.path_max_level(content, pid),
|
||||
"next": next_block,
|
||||
})
|
||||
|
||||
available = [
|
||||
{"id": pid, "name": pdef["name"], "icon": pdef["icon"]}
|
||||
for pid, pdef in sorted(content["paths"].items(), key=lambda kv: (kv[1].get("order") or 0, kv[0]))
|
||||
if pid not in player_paths
|
||||
]
|
||||
|
||||
quest_rows = meta_db.get_quest_rows(keys)
|
||||
quests_payload = {}
|
||||
for period_type in ("daily", "weekly"):
|
||||
pool = content["quests"][period_type]["pool"]
|
||||
quests = []
|
||||
for row in quest_rows:
|
||||
if row["period_type"] != period_type:
|
||||
continue
|
||||
qdef = pool.get(row["quest_id"])
|
||||
if not qdef:
|
||||
continue # removed from the pool mid-period: hide, keep the row
|
||||
count, target = _goal_ui_progress(qdef["goal"], row, streak, xp_total)
|
||||
quests.append({
|
||||
"id": row["quest_id"],
|
||||
"title": qdef["title"],
|
||||
"description": qdef["description"],
|
||||
"reward_db": row["reward_db"],
|
||||
"count": count,
|
||||
"target": target,
|
||||
"completed": row["completed"],
|
||||
"completed_at": row["completed_at"],
|
||||
})
|
||||
quests_payload[period_type] = {
|
||||
"period_key": keys[period_type],
|
||||
"resets_at": progression_mod.period_resets_at(period_type, now).isoformat(),
|
||||
"quests": quests,
|
||||
}
|
||||
|
||||
return {
|
||||
"mastery_rank": progression_mod.mastery_rank(state["calibration_status"], player_paths),
|
||||
"onboarding": {
|
||||
"calibration_status": state["calibration_status"],
|
||||
"calibration_completed_at": state["calibration_completed_at"],
|
||||
"diagnostic_filename": _builtin_diagnostic_filename(),
|
||||
},
|
||||
"paths": paths_payload,
|
||||
"available_paths": available,
|
||||
"quests": quests_payload,
|
||||
"wallet": wallet,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/progression")
|
||||
def api_progression():
|
||||
return _progression_overview()
|
||||
|
||||
|
||||
@app.post("/api/progression/paths")
|
||||
def api_progression_add_paths(data: dict):
|
||||
"""Select instrument paths. Body: {add: [path_id, ...]}. Idempotent;
|
||||
removal is unsupported (Mastery Rank never decreases)."""
|
||||
add = data.get("add")
|
||||
if not isinstance(add, list) or not add:
|
||||
return JSONResponse({"error": "add must be a non-empty list of path ids"}, status_code=400)
|
||||
content = _get_progression_content()
|
||||
for pid in add:
|
||||
if not isinstance(pid, str) or pid not in content["paths"]:
|
||||
return JSONResponse({"error": f"unknown path: {pid!r}"}, status_code=400)
|
||||
meta_db.add_player_paths(add)
|
||||
return _progression_overview()
|
||||
|
||||
|
||||
@app.post("/api/progression/onboarding")
|
||||
def api_progression_onboarding(data: dict):
|
||||
"""Onboarding calibration choice. Body: {action: "skip"} — completing the
|
||||
calibration needs no endpoint, it flows through the normal /api/stats path."""
|
||||
if _clean_str(data.get("action")) != "skip":
|
||||
return JSONResponse({"error": "action must be 'skip'"}, status_code=400)
|
||||
# Spec invariant: onboarding requires picking at least one instrument path
|
||||
# before finishing, so skipping straight to rank 1 with no paths would
|
||||
# leave a rank that can never grow. Only enforced when the content bundle
|
||||
# actually defines paths — broken/empty content must never brick onboarding.
|
||||
if _get_progression_content()["paths"] and not meta_db.get_player_paths():
|
||||
return JSONResponse(
|
||||
{"error": "select at least one instrument path before skipping calibration"},
|
||||
status_code=400,
|
||||
)
|
||||
meta_db.skip_calibration()
|
||||
return _progression_overview()
|
||||
|
||||
|
||||
# Externally postable progression events. song_completed is deliberately NOT
|
||||
# here: it is server-derived inside /api/stats so the scored-session authority
|
||||
# stays in one place.
|
||||
_PROGRESSION_EVENT_TYPES = {"minigame_run"}
|
||||
|
||||
|
||||
@app.post("/api/progression/events")
|
||||
def api_progression_events(data: dict):
|
||||
"""Generic progression-event intake for plugins (capability `record-event`).
|
||||
Body: {type, payload}. Whitelisted types, scalar payload values only."""
|
||||
etype = _clean_str(data.get("type"))
|
||||
if etype not in _PROGRESSION_EVENT_TYPES:
|
||||
return JSONResponse(
|
||||
{"error": f"event type must be one of {sorted(_PROGRESSION_EVENT_TYPES)}"},
|
||||
status_code=400,
|
||||
)
|
||||
payload = data.get("payload")
|
||||
if payload is None:
|
||||
payload = {}
|
||||
if not isinstance(payload, dict) or len(payload) > 16:
|
||||
return JSONResponse({"error": "payload must be a small object"}, status_code=400)
|
||||
clean = {}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or len(key) > 64:
|
||||
return JSONResponse({"error": "payload keys must be short strings"}, status_code=400)
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool) or (
|
||||
not isinstance(value, (int, float, str))
|
||||
) or (isinstance(value, float) and not math.isfinite(value)) or (
|
||||
isinstance(value, str) and len(value) > 256
|
||||
):
|
||||
return JSONResponse({"error": "payload values must be short strings or finite numbers"}, status_code=400)
|
||||
clean[key] = value
|
||||
summary = meta_db.record_progression_event(etype, clean, _get_progression_content())
|
||||
return {"ok": True, "progression": summary}
|
||||
|
||||
|
||||
@app.get("/api/shop")
|
||||
def api_shop():
|
||||
content = _get_progression_content()
|
||||
owned = meta_db.get_owned_items()
|
||||
equipped = meta_db.get_equipped()
|
||||
items = [
|
||||
{**item, "owned": iid in owned, "equipped": equipped.get(item["slot"]) == iid}
|
||||
for iid, item in sorted(content["shop"].items())
|
||||
]
|
||||
return {"items": items, "wallet": meta_db.get_wallet()}
|
||||
|
||||
|
||||
@app.post("/api/shop/buy")
|
||||
def api_shop_buy(data: dict):
|
||||
"""Spend Decibels on a cosmetic. Atomic: balance check + spend + ownership
|
||||
in one transaction. Decibels are earned by playing only — never purchasable."""
|
||||
item_id = _clean_str(data.get("item_id"))
|
||||
item = _get_progression_content()["shop"].get(item_id)
|
||||
if not item:
|
||||
return JSONResponse({"error": f"unknown item: {item_id!r}"}, status_code=400)
|
||||
status, wallet = meta_db.buy_shop_item(item)
|
||||
if status == "owned":
|
||||
return JSONResponse({"error": "already owned", "wallet": wallet}, status_code=409)
|
||||
if status == "insufficient":
|
||||
return JSONResponse({"error": "insufficient balance", "wallet": wallet}, status_code=402)
|
||||
return {"ok": True, "item_id": item_id, "wallet": wallet}
|
||||
|
||||
|
||||
@app.post("/api/shop/equip")
|
||||
def api_shop_equip(data: dict):
|
||||
"""Equip an owned cosmetic into its slot. Body: {slot, item_id|null}
|
||||
(null unequips, restoring the default look)."""
|
||||
import progression as progression_mod
|
||||
slot = _clean_str(data.get("slot"))
|
||||
if slot not in progression_mod.SHOP_SLOTS:
|
||||
return JSONResponse({"error": f"slot must be one of {sorted(progression_mod.SHOP_SLOTS)}"}, status_code=400)
|
||||
item_id = data.get("item_id")
|
||||
if item_id is not None:
|
||||
item_id = _clean_str(item_id)
|
||||
item = _get_progression_content()["shop"].get(item_id)
|
||||
if not item or item["slot"] != slot:
|
||||
return JSONResponse({"error": f"unknown item for slot {slot}: {item_id!r}"}, status_code=400)
|
||||
if item_id not in meta_db.get_owned_items():
|
||||
return JSONResponse({"error": "item not owned"}, status_code=403)
|
||||
return {"ok": True, "equipped": meta_db.equip_item(slot, item_id)}
|
||||
|
||||
|
||||
# ── Per-song practice stats (fee[dB]ack v0.3.0) ───────────────────────────────
|
||||
|
||||
@app.post("/api/stats")
|
||||
def api_record_stats(data: dict):
|
||||
"""Record a play. With `score`+`accuracy` → a scored session (plays += 1,
|
||||
best_* = max, last_* = new) plus unified-XP + streak side-effects. With
|
||||
only `lastPlayPosition`/`last_position` → a lightweight resume-position
|
||||
touch (no plays change) so Continue-Playing works for non-scored plays."""
|
||||
filename = _clean_str(data.get("filename"))
|
||||
if not filename:
|
||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||
# The recorder hands us URL-encoded filenames; canonicalize to the library
|
||||
# key so stored rows line up with `songs` (and so the arrangement-count bound
|
||||
# below resolves the real song). See MetadataDB._canonical_song_filename.
|
||||
filename = meta_db._canonical_song_filename(filename)
|
||||
arr_raw = data.get("arrangement", 0)
|
||||
if arr_raw is None:
|
||||
arrangement = 0
|
||||
else:
|
||||
try:
|
||||
arrangement = _as_int(arr_raw) # rejects bool / non-integral (1.9) / inf
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400)
|
||||
# Reject (don't silently coerce to 0) so a malformed/out-of-range index
|
||||
# can't corrupt arrangement 0's stats; also keeps it bindable to INTEGER.
|
||||
if not (0 <= arrangement < 2**63):
|
||||
return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400)
|
||||
# Bound against the song's real arrangement count when it's a known library
|
||||
# song, so a bad index can't create fake arrangement buckets that poison the
|
||||
# per-song aggregate / Continue. Skipped when the song isn't in the library
|
||||
# yet (count unknown — dead-song reads are filtered anyway).
|
||||
_acount = meta_db.arrangement_count(filename)
|
||||
if _acount and arrangement >= _acount:
|
||||
return JSONResponse({"error": "arrangement out of range for this song"}, status_code=400)
|
||||
score = data.get("score")
|
||||
accuracy = data.get("accuracy")
|
||||
last_pos = data.get("lastPlayPosition", data.get("last_position"))
|
||||
if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
|
||||
# A scored session needs BOTH score and accuracy. Exactly one provided is
|
||||
# ambiguous — don't silently fall through to the position-only branch.
|
||||
if (score is None) != (accuracy is None):
|
||||
return JSONResponse({"error": "score and accuracy must be provided together"}, status_code=400)
|
||||
|
||||
if score is not None and accuracy is not None:
|
||||
# Reject booleans explicitly — float(True) would otherwise record a play.
|
||||
if isinstance(score, bool) or isinstance(accuracy, bool):
|
||||
return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400)
|
||||
# Reject NaN/Inf too: round(inf) raises OverflowError (→ 500), and a
|
||||
# stored Inf/NaN later breaks JSON serialization of /api/stats reads.
|
||||
try:
|
||||
score = float(score)
|
||||
accuracy = float(accuracy)
|
||||
if not (math.isfinite(score) and math.isfinite(accuracy)):
|
||||
raise ValueError("non-finite")
|
||||
score = int(round(score))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400)
|
||||
# A huge-but-finite score passes isfinite() yet overflows SQLite's
|
||||
# 64-bit INTEGER on bind (→ 500). Bound it to the int64 range.
|
||||
if not (0 <= score < 2**63):
|
||||
return JSONResponse({"error": "score out of range"}, status_code=400)
|
||||
# accuracy is a 0..1 fraction (the recorder's contract); reject
|
||||
# out-of-range values so they don't surface as >100% / negative in
|
||||
# /api/stats/best and the badge UI.
|
||||
if not (0 <= accuracy <= 1):
|
||||
return JSONResponse({"error": "accuracy must be between 0 and 1"}, status_code=400)
|
||||
# Validate the optional resume position in this branch too (the
|
||||
# position-only branch below already rejects non-finite).
|
||||
if last_pos is not None:
|
||||
try:
|
||||
last_pos = float(last_pos)
|
||||
if not math.isfinite(last_pos):
|
||||
raise ValueError("non-finite")
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
row = meta_db.record_session(filename, arrangement, score=score,
|
||||
accuracy=accuracy, last_position=last_pos)
|
||||
# Unified XP + streak side-effects — never let these drop the stat write.
|
||||
progress = None
|
||||
try:
|
||||
from xp import xp_for_run
|
||||
from datetime import date
|
||||
meta_db.award_xp(xp_for_run(score))
|
||||
meta_db.record_active_day(date.today().isoformat())
|
||||
progress = meta_db.get_progress()
|
||||
except Exception:
|
||||
log.warning("stats side-effects (xp/streak) failed", exc_info=True)
|
||||
# Progression engine (spec 010) — same never-drop-the-stat-write
|
||||
# contract. Scored sessions are the server-derived `song_completed`
|
||||
# authority (scored == note detection by construction); instrument is
|
||||
# resolved from library arrangement metadata, after the XP award so
|
||||
# db_earned goals see this run's Decibels.
|
||||
progression_summary = None
|
||||
try:
|
||||
import progression as progression_mod
|
||||
instrument = progression_mod.instrument_for_arrangement(
|
||||
meta_db.arrangement_entry(filename, arrangement)
|
||||
)
|
||||
progression_summary = meta_db.record_progression_event(
|
||||
"song_completed",
|
||||
{
|
||||
"filename": filename,
|
||||
"instrument": instrument,
|
||||
"accuracy": accuracy,
|
||||
"score": score,
|
||||
"is_diagnostic": filename == _builtin_diagnostic_filename(),
|
||||
},
|
||||
_get_progression_content(),
|
||||
)
|
||||
except Exception:
|
||||
log.warning("stats side-effects (progression) failed", exc_info=True)
|
||||
return {"stats": row, "progress": progress, "progression": progression_summary}
|
||||
|
||||
# Position-only touch.
|
||||
if last_pos is None:
|
||||
return JSONResponse(
|
||||
{"error": "provide score+accuracy (scored) or lastPlayPosition (resume)"},
|
||||
status_code=400,
|
||||
)
|
||||
try:
|
||||
pos = float(last_pos)
|
||||
if not math.isfinite(pos):
|
||||
raise ValueError("non-finite")
|
||||
row = meta_db.touch_position(filename, arrangement, pos)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
# A resume session still counts as playing today: advance the streak (no XP —
|
||||
# that's scoring-only) so a non-scored practice day keeps the streak alive,
|
||||
# consistent with these sessions also surfacing in recent / continue.
|
||||
progress = None
|
||||
try:
|
||||
from datetime import date
|
||||
meta_db.record_active_day(date.today().isoformat())
|
||||
progress = meta_db.get_progress()
|
||||
except Exception:
|
||||
log.warning("stats side-effects (streak) failed", exc_info=True)
|
||||
return {"stats": row, "progress": progress}
|
||||
|
||||
|
||||
@app.get("/api/stats/recent")
|
||||
def api_recent_stats(limit: int = 12):
|
||||
"""Recently-played rows joined to song metadata for 'Jump back in'."""
|
||||
from urllib.parse import quote
|
||||
out = []
|
||||
for r in meta_db.recent_stats(limit):
|
||||
meta = meta_db.conn.execute(
|
||||
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
|
||||
(r["filename"],),
|
||||
).fetchone()
|
||||
title, artist, tuning_name = meta if meta else (None, None, None)
|
||||
out.append({
|
||||
**r,
|
||||
"title": title or r["filename"],
|
||||
"artist": artist or "",
|
||||
"tuning_name": tuning_name or "",
|
||||
"art_url": f"/api/song/{quote(r['filename'])}/art",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/stats/best")
|
||||
def api_stats_best():
|
||||
"""{filename: best_accuracy} for all songs with a recorded best — one call
|
||||
to badge the library grid (defined before the {filename} catch-all)."""
|
||||
return meta_db.best_accuracy_map()
|
||||
|
||||
|
||||
@app.get("/api/stats/top")
|
||||
def api_top_stats(limit: int = 5):
|
||||
"""Top scored songs (best first), joined to song metadata, for the profile
|
||||
'Your best scores' panel (defined before the {filename} catch-all)."""
|
||||
from urllib.parse import quote
|
||||
out = []
|
||||
for r in meta_db.top_stats(limit):
|
||||
meta = meta_db.conn.execute(
|
||||
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
|
||||
(r["filename"],),
|
||||
).fetchone()
|
||||
title, artist, tuning_name = meta if meta else (None, None, None)
|
||||
out.append({
|
||||
**r,
|
||||
"title": title or r["filename"],
|
||||
"artist": artist or "",
|
||||
"tuning_name": tuning_name or "",
|
||||
"art_url": f"/api/song/{quote(r['filename'])}/art",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/library/practice-suggestions")
|
||||
@@ -5301,9 +4712,6 @@ def api_practice_suggestions(limit: int = 8):
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/stats/{filename:path}")
|
||||
def api_song_stats(filename: str):
|
||||
return meta_db.get_song_stats(filename)
|
||||
|
||||
|
||||
# ── Playlists / custom covers (fee[dB]ack v0.3.0) ─────────────────────────────
|
||||
@@ -5359,53 +4767,11 @@ def api_delete_collection(pid: int):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/saved/toggle")
|
||||
def api_toggle_saved(data: dict):
|
||||
"""Add/remove a song on the reserved Saved-for-Later playlist."""
|
||||
filename = _clean_str(data.get("filename"))
|
||||
if not filename:
|
||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||
return {"saved": meta_db.toggle_saved(filename)}
|
||||
|
||||
|
||||
@app.get("/api/session/continue")
|
||||
def api_session_continue():
|
||||
"""The Continue-Playing card's song (most recent play) or null."""
|
||||
return meta_db.continue_session()
|
||||
|
||||
|
||||
# ── Wishlist / "wanted" API (feedBack#636 item 4) ─────────────────────────────
|
||||
|
||||
@app.get("/api/wanted")
|
||||
def api_list_wanted():
|
||||
"""The wishlist — songs the user wants but doesn't own yet (newest first)."""
|
||||
return {"wanted": meta_db.list_wanted()}
|
||||
|
||||
|
||||
@app.post("/api/wanted")
|
||||
def api_add_wanted(data: dict):
|
||||
"""Add a not-owned song to the wishlist. `artist`/`title` are required (at
|
||||
least one non-empty); `source`/`source_ref`/`note` are optional. Idempotent
|
||||
on identity so producers (find_more ownership-diff, manual add) can re-post."""
|
||||
if not isinstance(data, dict):
|
||||
return JSONResponse({"error": "body must be an object"}, status_code=400)
|
||||
artist = _clean_str(data.get("artist"))
|
||||
title = _clean_str(data.get("title"))
|
||||
if not artist and not title:
|
||||
return JSONResponse({"error": "artist or title required"}, status_code=400)
|
||||
row = meta_db.add_wanted(
|
||||
artist=artist, title=title,
|
||||
source=_clean_str(data.get("source")) or "manual",
|
||||
source_ref=_clean_str(data.get("source_ref")),
|
||||
note=_clean_str(data.get("note")),
|
||||
)
|
||||
return {"ok": True, "wanted": row}
|
||||
|
||||
|
||||
@app.delete("/api/wanted/{wanted_id}")
|
||||
def api_remove_wanted(wanted_id: int):
|
||||
"""Remove a wishlist entry by id."""
|
||||
return {"ok": meta_db.remove_wanted(wanted_id)}
|
||||
# Mounted here (registration order). Implementation in lib/routers/wanted.py.
|
||||
app.include_router(wanted.router)
|
||||
|
||||
|
||||
# ── Loops API ────────────────────────────────────────────────────────────────
|
||||
|
||||
+894
-891
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ test('beats:loaded emit is wired into the WS beats case', () => {
|
||||
);
|
||||
assert.match(
|
||||
block,
|
||||
/count:\s*beats\.length/,
|
||||
/count:\s*hwState\.beats\.length/,
|
||||
'beats:loaded payload must include count = beats.length',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ test('handshapes WS case accumulates incoming chunks into handShapes', () => {
|
||||
const block = getCaseBlock(src, 'handshapes');
|
||||
assert.match(
|
||||
block,
|
||||
/handShapes\s*=\s*handShapes\.concat\(\s*msg\.data\s*\)/,
|
||||
/hwState\.handShapes\s*=\s*hwState\.handShapes\.concat\(\s*msg\.data\s*\)/,
|
||||
'handshapes case must concat msg.data into the handShapes accumulator',
|
||||
);
|
||||
});
|
||||
@@ -62,7 +62,7 @@ test('bundle exposes handShapes to renderers with flat-list fallback', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/\bhandShapes\s*[:=]\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
|
||||
/\bhandShapes\s*[:=]\s*\([^)]*hwState\._filteredHandShapes[^)]*\)\s*\?\s*hwState\._filteredHandShapes\s*:\s*hwState\.handShapes\b/,
|
||||
'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ function src(file) {
|
||||
test('highway renderer bundles surface the core lefty flag', () => {
|
||||
assert.match(
|
||||
src(HIGHWAY_JS),
|
||||
/lefty\s*[:=]\s*_lefty/,
|
||||
/lefty\s*[:=]\s*hwState\._lefty/,
|
||||
'custom renderer bundles must include lefty: _lefty',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,12 +49,12 @@ test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', (
|
||||
// within the interp cap.
|
||||
assert.match(
|
||||
fn,
|
||||
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
|
||||
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*hwState\._chartAnchorPerfNow\s*\)/,
|
||||
'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)',
|
||||
);
|
||||
assert.match(
|
||||
fn,
|
||||
/_chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
|
||||
/hwState\._chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
|
||||
'isPlaying must require the clock advanced within _CHART_MAX_INTERP_MS',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares adaptive-scale state with a floor', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_autoScale\s*=\s*1/, 'missing _autoScale multiplier');
|
||||
assert.match(src, /hwState\._autoScale\s*=\s*1/, 'missing _autoScale multiplier');
|
||||
assert.match(src, /const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
|
||||
@@ -49,18 +49,18 @@ test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () =
|
||||
test('min render scale floor is user-configurable + exposed on the api (#654)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
// Hard floor constant kept; configurable floor read from localStorage.
|
||||
assert.match(src, /let\s+_autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
|
||||
assert.match(src, /hwState\._autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
|
||||
assert.match(src, /localStorage\.getItem\('highwayMinRenderScale'\)/,
|
||||
'configurable floor must load from localStorage.highwayMinRenderScale');
|
||||
assert.match(src, /setMinRenderScale\(/, 'api.setMinRenderScale missing');
|
||||
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+_autoScaleMin/, 'api.getMinRenderScale missing');
|
||||
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+hwState\._autoScaleMin/, 'api.getMinRenderScale missing');
|
||||
// Floor is clamped to the user ceiling so it can never exceed the manual cap.
|
||||
const eff = extractBlock(src, 'function _effectiveRenderScale()');
|
||||
assert.match(eff, /Math\.min\(\s*_autoScaleMin\s*,\s*user\s*\)/,
|
||||
assert.match(eff, /Math\.min\(\s*hwState\._autoScaleMin\s*,\s*user\s*\)/,
|
||||
'effective scale must clamp the floor to the user ceiling');
|
||||
// _adaptRenderScale must cap the lo bound at 1 so _autoScale stays in [_,1].
|
||||
const adapt = extractBlock(src, 'function _adaptRenderScale(');
|
||||
assert.match(adapt, /Math\.min\(\s*1\s*,\s*_autoScaleMin\s*\/\s*_renderScale\s*\)/,
|
||||
assert.match(adapt, /Math\.min\(\s*1\s*,\s*hwState\._autoScaleMin\s*\/\s*hwState\._renderScale\s*\)/,
|
||||
'lo bound must be capped at 1 to keep _autoScale a [0,1] multiplier');
|
||||
});
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
|
||||
const neqEither = (a, b) => new RegExp(
|
||||
`\\b${a}\\b\\s*!==\\s*\\b${b}\\b|\\b${b}\\b\\s*!==\\s*\\b${a}\\b`
|
||||
);
|
||||
assert.match(src, eqEither('_chordRenderCacheSrc', 'src'), 'cache must key on src');
|
||||
assert.match(src, eqEither('_chordRenderCacheInverted', '_inverted'), 'cache must key on _inverted');
|
||||
assert.match(src, neqEither('_chordRenderCacheTemplates', 'chordTemplates'),
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheSrc', 'src'), 'cache must key on src');
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheInverted', 'hwState\\._inverted'), 'cache must key on _inverted');
|
||||
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'hwState\\.chordTemplates'),
|
||||
'cache must key on chordTemplates (detected via !== for change-flag)');
|
||||
});
|
||||
|
||||
@@ -50,9 +50,9 @@ test('chordTemplates change resets fretline preview and frame-mismatch warner',
|
||||
// block inside the `if (templatesChanged) { … }` branch (e.g. an
|
||||
// inner conditional reset) doesn't break the match by introducing
|
||||
// a `}` before the symbol we're checking for.
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?hwState\._chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
|
||||
'templatesChanged branch must reset _chordFretLineNotes');
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?hwState\._lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
|
||||
'templatesChanged branch must null _lastChordOnFretLine');
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_frameMismatchWarned\.clear\(\)[\s\S]*?\}/,
|
||||
'templatesChanged branch must clear _frameMismatchWarned');
|
||||
|
||||
@@ -23,7 +23,7 @@ test('getFilteredNotes falls through to notes when _filteredNotes is null', () =
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*notes/,
|
||||
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*hwState\.notes/,
|
||||
'getFilteredNotes must return notes as fallback',
|
||||
);
|
||||
});
|
||||
@@ -41,7 +41,7 @@ test('getFilteredChords falls through to chords when _filteredChords is null', (
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*chords/,
|
||||
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*hwState\.chords/,
|
||||
'getFilteredChords must return chords as fallback',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -35,7 +35,10 @@ function extractBlock(src, signature) {
|
||||
// + getTime methods so behavioral tests can exercise the real
|
||||
// implementation in isolation.
|
||||
function buildClockSandbox(perfNowImpl) {
|
||||
const sandbox = {
|
||||
// The lifted per-instance state now lives on `hwState` (the R3c H lift);
|
||||
// the extracted setTime/getTime bodies reference hwState.<slot>. The const
|
||||
// _CHART_MAX_INTERP_MS was NOT lifted, so it stays a top-level global here.
|
||||
const hwState = {
|
||||
chartTime: 0,
|
||||
currentTime: 0,
|
||||
avOffsetSec: 0,
|
||||
@@ -50,6 +53,9 @@ function buildClockSandbox(perfNowImpl) {
|
||||
_chartAnchorPerfNow: NaN,
|
||||
_chartLastAdvanceAt: 0,
|
||||
_chartObservedRate: 1,
|
||||
};
|
||||
const sandbox = {
|
||||
hwState,
|
||||
_CHART_MAX_INTERP_MS: 100,
|
||||
performance: { now: perfNowImpl },
|
||||
};
|
||||
@@ -72,10 +78,10 @@ test('highway declares chart anchor + stall-detect + rate state', () => {
|
||||
// particular MUST start as NaN, not 0, otherwise setTime(0) on the
|
||||
// very first 60 Hz tick fails the `t !== _chartAnchorAudioT` check
|
||||
// and never re-anchors, leaving the clock uninitialized.
|
||||
assert.match(src, /let\s+_chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
|
||||
assert.match(src, /let\s+_chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
|
||||
assert.match(src, /let\s+_chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
|
||||
assert.match(src, /let\s+_chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
|
||||
assert.match(src, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
|
||||
assert.match(src, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
|
||||
assert.match(src, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
|
||||
assert.match(src, /hwState\._chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
|
||||
assert.match(src, /const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
|
||||
});
|
||||
|
||||
@@ -86,7 +92,7 @@ test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', (
|
||||
const slice = m[0];
|
||||
assert.match(
|
||||
slice,
|
||||
/_chartObservedRate\s*\*\s*elapsedMs/,
|
||||
/hwState\._chartObservedRate\s*\*\s*elapsedMs/,
|
||||
'getTime must scale interpolation by observed rate so audio.playbackRate != 1 stays accurate',
|
||||
);
|
||||
});
|
||||
@@ -100,12 +106,12 @@ test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually ch
|
||||
// The implementation may capture performance.now() into a local
|
||||
// (e.g. newPerfNow) and assign that to both fields; accept either
|
||||
// direct or via-local writes.
|
||||
const m = src.match(/if\s*\(\s*t\s*!==\s*_chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
|
||||
const m = src.match(/if\s*\(\s*t\s*!==\s*hwState\._chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
|
||||
assert.ok(m, 'if (t !== _chartAnchorAudioT) block not found inside setTime');
|
||||
const block = m[0];
|
||||
assert.match(block, /_chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
|
||||
assert.match(block, /_chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
|
||||
assert.match(block, /_chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
|
||||
assert.match(block, /hwState\._chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
|
||||
assert.match(block, /hwState\._chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
|
||||
assert.match(block, /hwState\._chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
|
||||
});
|
||||
|
||||
test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
@@ -119,7 +125,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
// Must check stall-since-last-advance against the cap.
|
||||
assert.match(
|
||||
slice,
|
||||
/nowP\s*-\s*_chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
|
||||
/nowP\s*-\s*hwState\._chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
|
||||
'getTime must short-circuit when audio has stalled past the cap',
|
||||
);
|
||||
// Must interpolate when active.
|
||||
@@ -127,7 +133,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
// Rate-scaled formula: _chartAnchorAudioT + (_chartObservedRate * elapsedMs) / 1000
|
||||
assert.match(
|
||||
slice,
|
||||
/_chartAnchorAudioT\s*\+\s*\(\s*_chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
|
||||
/_chartAnchorAudioT\s*\+\s*\(\s*hwState\._chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
|
||||
'getTime must compute anchor + rate-scaled elapsed during play',
|
||||
);
|
||||
});
|
||||
@@ -138,10 +144,10 @@ test('api.stop() clears the chart anchor state so re-init starts fresh', () => {
|
||||
// the actual stop() body — a fixed-size slice would falsely match
|
||||
// resets that landed in an adjacent method.
|
||||
const stopBlock = extractBlock(src, 'stop() {');
|
||||
assert.match(stopBlock, /_chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
|
||||
assert.match(stopBlock, /_chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
|
||||
assert.match(stopBlock, /_chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
|
||||
assert.match(stopBlock, /_chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
|
||||
assert.match(stopBlock, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
|
||||
assert.match(stopBlock, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
|
||||
assert.match(stopBlock, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
|
||||
assert.match(stopBlock, /hwState\._chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
|
||||
});
|
||||
|
||||
// ── Behavioral tests (run extracted setTime/getTime in vm sandbox) ──────
|
||||
@@ -195,12 +201,12 @@ test('behavior: seek discontinuity resets observed rate to 1x', () => {
|
||||
sb.setTime(10);
|
||||
now = 50;
|
||||
sb.setTime(10.025); // observed rate ≈ 0.5
|
||||
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb._chartObservedRate}`);
|
||||
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb.hwState._chartObservedRate}`);
|
||||
// Seek: large t jump in same perf delta — observed-rate clamp
|
||||
// rejects this segment, resets to 1.
|
||||
now = 70;
|
||||
sb.setTime(120); // dPerf=20ms, dT=110s → observed=5500 (out of clamp)
|
||||
assert.equal(sb._chartObservedRate, 1, 'seek must reset rate to 1x');
|
||||
assert.equal(sb.hwState._chartObservedRate, 1, 'seek must reset rate to 1x');
|
||||
});
|
||||
|
||||
test('behavior: getTime caps interpolation at _CHART_MAX_INTERP_MS', () => {
|
||||
@@ -225,8 +231,8 @@ test('behavior: setTime(0) on first tick anchors correctly (boot edge case)', ()
|
||||
const sb = buildClockSandbox(() => now);
|
||||
sb.setTime(0);
|
||||
// Anchor must now be initialized.
|
||||
assert.equal(sb._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
|
||||
assert.equal(sb._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
|
||||
assert.equal(sb.hwState._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
|
||||
assert.equal(sb.hwState._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
|
||||
// getTime should return a finite value, not NaN.
|
||||
const t = sb.getTime();
|
||||
assert.ok(!Number.isNaN(t), `getTime must not return NaN after setTime(0); got ${t}`);
|
||||
@@ -252,10 +258,10 @@ test('behavior: long anchor gap resets observed rate to 1x', () => {
|
||||
sb.setTime(10);
|
||||
now = 50;
|
||||
sb.setTime(10.025); // observed rate ≈ 0.5
|
||||
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
|
||||
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
|
||||
// Long gap (1 second) before next setTime — out of the dPerf < 0.5
|
||||
// window, so the rate must reset to 1.
|
||||
now = 1100;
|
||||
sb.setTime(10.5);
|
||||
assert.equal(sb._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
|
||||
assert.equal(sb.hwState._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
|
||||
});
|
||||
|
||||
@@ -34,16 +34,16 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares the note-state provider slot', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
|
||||
assert.match(src, /hwState\._noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
|
||||
});
|
||||
|
||||
test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteState / isDefaultRenderer', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*_noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*hwState\._noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*typeof\s+fn\s*===\s*['"]function['"][^}]*:\s*null/, 'setNoteStateProvider must coerce non-functions (incl. null) to null');
|
||||
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider/, 'getNoteStateProvider must return the slot');
|
||||
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider/, 'getNoteStateProvider must return the slot');
|
||||
assert.match(src, /getNoteState\s*\(\s*note\s*,\s*chartTime\s*\)\s*\{\s*return\s+_noteState\s*\(/, 'getNoteState must delegate to _noteState');
|
||||
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+_renderer\s*===\s*_defaultRenderer\s*\|\|\s*_renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
|
||||
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+hwState\._renderer\s*===\s*_defaultRenderer\s*\|\|\s*hwState\._renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', () => {
|
||||
@@ -72,7 +72,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
// slot, so renderers see a live "is a provider registered?" view.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider\s*;?\s*\}/,
|
||||
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider\s*;?\s*\}/,
|
||||
'_getNoteStateProvider must be defined as a stable named function returning _noteStateProvider'
|
||||
);
|
||||
});
|
||||
@@ -80,7 +80,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
test('_noteState normalizes provider output as documented', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _noteState(note, chartTime)');
|
||||
assert.match(fn, /if\s*\(\s*!_noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /if\s*\(\s*!hwState\._noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw');
|
||||
assert.match(fn, /state\s*!==\s*['"]hit['"]\s*&&\s*state\s*!==\s*['"]active['"]\s*&&\s*state\s*!==\s*['"]miss['"]/, 'must reject states other than hit/active/miss');
|
||||
assert.match(fn, /Math\.max\(\s*0\s*,\s*Math\.min\(\s*1\s*,\s*raw\.alpha\s*\)\s*\)/, 'must clamp alpha to [0,1]');
|
||||
|
||||
@@ -32,7 +32,7 @@ function extractBlock(src, signature) {
|
||||
test('highway declares the paused-render throttle state', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap');
|
||||
assert.match(src, /let\s+_lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
|
||||
assert.match(src, /hwState\._lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
|
||||
});
|
||||
|
||||
test('draw() throttles full renders while the audio clock is stalled', () => {
|
||||
@@ -42,7 +42,7 @@ test('draw() throttles full renders while the audio clock is stalled', () => {
|
||||
assert.match(fn, /_chartLastAdvanceAt/, 'throttle must key off _chartLastAdvanceAt (the advance timestamp)');
|
||||
assert.match(fn, /_CHART_MAX_INTERP_MS/, 'throttle must reuse the _CHART_MAX_INTERP_MS pause threshold');
|
||||
assert.match(fn, /_PAUSED_FRAME_INTERVAL_MS/, 'throttle must cap paused draws to _PAUSED_FRAME_INTERVAL_MS');
|
||||
assert.match(fn, /_lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
|
||||
assert.match(fn, /hwState\._lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
|
||||
});
|
||||
|
||||
test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
@@ -51,7 +51,7 @@ test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
// Regex landmarks (not exact-string indexOf) so harmless spacing /
|
||||
// semicolon changes don't break the ordering guard — matches the
|
||||
// search-based style of the other highway source-guard tests.
|
||||
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return;/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!hwState\.ready\s*\)\s*return;/);
|
||||
const throttleIdx = fn.search(/_PAUSED_FRAME_INTERVAL_MS/);
|
||||
const drawIdx = fn.search(/_renderer\.draw\s*\(/);
|
||||
assert.ok(readyIdx !== -1, 'ready gate not found');
|
||||
|
||||
@@ -22,7 +22,7 @@ test('getPhrases returns null when _phrases is falsy or empty', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*_phrases[^}]*return null/,
|
||||
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*hwState\._phrases[^}]*return null/,
|
||||
'getPhrases must return null when no phrase data is available',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,9 +44,9 @@ test('_setRenderer captures the outgoing renderer before overwriting it', () =>
|
||||
// prev must be captured BEFORE _destroyCurrentIfInited and the
|
||||
// `_renderer = next` assignment, otherwise the swap detection below
|
||||
// would always compare next against itself.
|
||||
const prevIdx = fn.search(/const\s+prev\s*=\s*_renderer/);
|
||||
const prevIdx = fn.search(/const\s+prev\s*=\s*hwState\._renderer/);
|
||||
const destroyIdx = fn.search(/_destroyCurrentIfInited\(\)/);
|
||||
const assignIdx = fn.search(/^\s*_renderer\s*=\s*next\s*;/m);
|
||||
const assignIdx = fn.search(/^\s*hwState\._renderer\s*=\s*next\s*;/m);
|
||||
assert.ok(prevIdx !== -1, 'must capture `const prev = _renderer`');
|
||||
assert.ok(destroyIdx !== -1, 'must call _destroyCurrentIfInited');
|
||||
assert.ok(assignIdx !== -1, 'must assign `_renderer = next`');
|
||||
@@ -67,7 +67,7 @@ test('_setRenderer replaces the canvas on a context-type change OR a viz change'
|
||||
);
|
||||
assert.match(
|
||||
fn,
|
||||
/if\s*\(\s*nextType\s*!==\s*_currentCanvasContextType\s*\|\|\s*_vizChanged\s*\)\s*\{\s*_replaceCanvas\(nextType\)/,
|
||||
/if\s*\(\s*nextType\s*!==\s*hwState\._currentCanvasContextType\s*\|\|\s*_vizChanged\s*\)\s*\{\s*_replaceCanvas\(nextType\)/,
|
||||
'replace guard must be `nextType !== _currentCanvasContextType || _vizChanged`',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,20 +40,20 @@ test('2D palette arrays are mutable (let) with frozen DEFAULT_* originals', () =
|
||||
assert.match(src, /const\s+DEFAULT_STRING_COLORS\s*=/, 'DEFAULT_STRING_COLORS must exist for reset');
|
||||
assert.match(src, /const\s+DEFAULT_STRING_DIM\s*=/, 'DEFAULT_STRING_DIM must exist for reset');
|
||||
assert.match(src, /const\s+DEFAULT_STRING_BRIGHT\s*=/, 'DEFAULT_STRING_BRIGHT must exist for reset');
|
||||
assert.match(src, /let\s+STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
|
||||
assert.match(src, /let\s+STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
|
||||
assert.match(src, /let\s+STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
|
||||
assert.match(src, /hwState\.STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
|
||||
assert.match(src, /hwState\.STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
|
||||
assert.match(src, /hwState\.STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
|
||||
});
|
||||
|
||||
test('2D public API exposes getStringColors / setStringColors', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
|
||||
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+hwState\.STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
|
||||
const fn = extractBlock(src, 'setStringColors(arr)');
|
||||
// Each provided index sets base + derived dim/bright; missing → default.
|
||||
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
|
||||
assert.match(fn, /STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
|
||||
assert.match(fn, /STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
|
||||
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
|
||||
assert.match(fn, /hwState\.STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
|
||||
assert.match(fn, /hwState\.STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
|
||||
assert.match(fn, /hwState\.STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
|
||||
assert.match(fn, /hwState\.STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
|
||||
});
|
||||
|
||||
// ── 3D highway (plugins/highway_3d/screen.js) ─────────────────────────────
|
||||
|
||||
@@ -32,14 +32,14 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares visibility state (_visibleOverride + _lastVisible)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
|
||||
assert.match(src, /let\s+_lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
|
||||
assert.match(src, /hwState\._visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
|
||||
assert.match(src, /hwState\._lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
|
||||
});
|
||||
|
||||
test('_isHighwayVisible respects _visibleOverride and falls back to offsetParent', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _isHighwayVisible()');
|
||||
assert.match(fn, /_visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
|
||||
assert.match(fn, /hwState\._visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
|
||||
assert.match(fn, /canvas\.offsetParent\s*!==\s*null/, 'DOM fallback must use offsetParent !== null');
|
||||
});
|
||||
|
||||
@@ -47,9 +47,9 @@ test('_emitVisibilityIfChanged is transition-only (no per-frame spam)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _emitVisibilityIfChanged()');
|
||||
// Must short-circuit when the current state equals the cached one.
|
||||
assert.match(fn, /v\s*===\s*_lastVisible/, 'must compare current vs _lastVisible and bail when equal');
|
||||
assert.match(fn, /v\s*===\s*hwState\._lastVisible/, 'must compare current vs _lastVisible and bail when equal');
|
||||
// Must update the cache and emit the event with the documented payload shape.
|
||||
assert.match(fn, /_lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
|
||||
assert.match(fn, /hwState\._lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
|
||||
assert.match(
|
||||
fn,
|
||||
/window\.feedBack\.emit\(\s*['"]highway:visibility['"][\s\S]*?visible:\s*v[\s\S]*?canvas/,
|
||||
@@ -66,7 +66,7 @@ test('rAF draw() loop calls _emitVisibilityIfChanged and skips when hidden', ()
|
||||
// transitions during loading/reconnect windows still propagate.
|
||||
const emitIdx = fn.search(/_emitVisibilityIfChanged\(\)/);
|
||||
const skipIdx = fn.search(/if\s*\(\s*!_rendering\s*\)\s*return/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!hwState\.ready\s*\)\s*return/);
|
||||
const drawIdx = fn.search(/_renderer\.draw\(/);
|
||||
assert.ok(emitIdx !== -1 && skipIdx !== -1 && readyIdx !== -1 && drawIdx !== -1, 'all four landmarks must be present');
|
||||
assert.ok(emitIdx < readyIdx, 'emit must run BEFORE the !ready gate (transitions during loading must still fire)');
|
||||
@@ -84,19 +84,19 @@ test('draw() keeps an active custom renderer painting through an override-hide (
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
// Single render decision drives both the perf-HUD reset and the gate.
|
||||
assert.match(fn, /let\s+_rendering\s*=\s*_lastVisible/, 'must derive a single _rendering decision from _lastVisible');
|
||||
assert.match(fn, /let\s+_rendering\s*=\s*hwState\._lastVisible/, 'must derive a single _rendering decision from _lastVisible');
|
||||
// Assert the exact boolean RELATIONSHIP, not just the tokens (CodeRabbit):
|
||||
// the exemption must AND together override-hide, an active custom renderer,
|
||||
// and the canvas still in layout. A weakened guard (e.g. `||`, or a dropped
|
||||
// offsetParent clause) must fail this — that's the regression being fixed.
|
||||
assert.match(
|
||||
fn,
|
||||
/!_rendering\s*&&\s*_visibleOverride\s*===\s*false\s*&&\s*_renderer\s*!==\s*_defaultRenderer\s*&&\s*canvas\s*&&\s*canvas\.offsetParent\s*!==\s*null/,
|
||||
/!_rendering\s*&&\s*hwState\._visibleOverride\s*===\s*false\s*&&\s*hwState\._renderer\s*!==\s*_defaultRenderer\s*&&\s*hwState\.canvas\s*&&\s*hwState\.canvas\.offsetParent\s*!==\s*null/,
|
||||
'exemption must AND override-hide + active custom renderer + canvas-in-layout (genuine off-screen still pauses, #246)',
|
||||
);
|
||||
// Both the HUD reset and the gate key off _rendering, not _lastVisible,
|
||||
// so the HUD doesn\'t churn while the custom renderer is actually drawing.
|
||||
assert.match(fn, /_perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
|
||||
assert.match(fn, /hwState\._perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
|
||||
assert.match(fn, /if\s*\(\s*!_rendering\s*\)\s*return/, 'the draw gate must bail on !_rendering');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user