feedBack/plugins/minigames/routes.py
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

676 lines
29 KiB
Python

"""Minigames framework — leaderboard + profile backend.
State lives under `<config_dir>/minigames/`:
- `runs.db` SQLite, one row per run, indexed by game_id + created_at.
- `profile.json` Cross-minigame profile (xp, level, unlocks, totals).
Endpoints (all under /api/plugins/minigames/):
POST /runs submit a finished run; awards XP, evaluates unlocks
GET /runs list runs (filter by game_id, scope, limit)
GET /profile current XP/level/unlocks/totals
POST /profile/reset wipe profile + runs
GET /registry list of installed minigame plugins (server-side mirror
of what the frontend can also see)
"""
import json
import logging
import math
import os
import sqlite3
import tempfile
import threading
import time
from pathlib import Path
from fastapi import HTTPException
from pydantic import BaseModel, Field
_lock = threading.Lock()
# Separate lock for the registry cache so _list_minigame_plugins() can be
# called safely from within a _lock-held section (e.g. submit_run) without
# deadlocking.
_registry_lock = threading.Lock()
_state = {
"db_path": None,
"profile_path": None,
"plugins_dir_resolver": None,
"log": logging.getLogger("feedBack.plugin.minigames"),
# fee[dB]ack v0.3.0 unified XP: when running inside core these point at the
# single core XP store (server.py plugin_context). XP then flows to ONE
# store the profile badge reads. Absent when the plugin runs standalone,
# in which case the legacy profile.json xp accounting is used.
"award_xp": None, # (amount, source) -> progress dict {xp, level, ...}
"get_xp_progress": None, # () -> progress dict
"seed_xp": None, # (amount, marker) -> bool (one-time migration)
"reset_xp": None, # (source) -> progress dict (reset this source's core XP)
}
# TTL cache for the minigame plugin scan. Walking the filesystem on every
# /registry call and run submission is cheap for small plugin counts but adds
# up on repeated calls. Cache results for _REGISTRY_TTL_S seconds; callers
# that need a fresh scan (e.g. after a plugin hot-reload) can use
# _list_minigame_plugins(force_refresh=True).
_REGISTRY_TTL_S = 10
_registry_cache: dict = {"ts": 0.0, "data": []}
# Maximum byte-length of the serialised `modifiers` and `meta` JSON fields on a
# run submission. Prevents a single call from bloating runs.db with arbitrary
# payload (32 KB is generous for game-side metadata, while still being a
# concrete limit).
_MAX_RUN_JSON_BYTES = 32 * 1024
# ── XP / level math ───────────────────────────────────────────────────────────
def xp_for_run(score: int) -> int:
"""Default XP formula: floor(sqrt(score) * 10). Override per-game in
the minigame manifest via `xp_formula` (not implemented in v1)."""
if score <= 0:
return 0
return int(math.floor(math.sqrt(score) * 10))
def level_for_xp(xp: int) -> int:
"""Level grows with sqrt(xp): L1 at 0, L2 at 100, L3 at 400, L4 at 900..."""
if xp <= 0:
return 1
return int(math.floor(math.sqrt(xp / 100))) + 1
def xp_to_next_level(xp: int) -> int:
next_lvl = level_for_xp(xp) + 1
threshold = (next_lvl - 1) ** 2 * 100
return max(0, threshold - xp)
# ── Persistence helpers ───────────────────────────────────────────────────────
def _get_conn():
db_path = _state["db_path"]
if not db_path:
raise RuntimeError("minigames plugin not initialised")
conn = sqlite3.connect(db_path, timeout=5)
conn.row_factory = sqlite3.Row
# WAL mode: reduces read/write contention under concurrent access (same
# practice as MetadataDB in server.py). busy_timeout is set via the
# connect() timeout= arg above (5 s), which maps to PRAGMA busy_timeout
# in Python's sqlite3 module when the connection opens.
# PRAGMA returns the *active* journal mode; WAL can silently fall back
# (e.g. on a read-only filesystem) so log a warning when that happens.
row = conn.execute("PRAGMA journal_mode=WAL").fetchone()
actual_mode = row[0] if row else "unknown"
if actual_mode.lower() != "wal":
_state["log"].warning(
"WAL mode not available for runs.db (active mode: %s); "
"concurrent access may see increased lock contention",
actual_mode,
)
return conn
def _init_db():
conn = _get_conn()
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id TEXT NOT NULL,
score INTEGER NOT NULL,
duration_ms INTEGER NOT NULL DEFAULT 0,
modifiers TEXT NOT NULL DEFAULT '{}',
meta TEXT NOT NULL DEFAULT '{}',
xp_awarded INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS runs_game_idx ON runs(game_id, created_at DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS runs_score_idx ON runs(game_id, score DESC)")
conn.commit()
finally:
conn.close()
def _load_profile() -> dict:
path = _state["profile_path"]
if path and path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return data
_state["log"].warning(
"profile.json contained %s instead of an object; recreating",
type(data).__name__,
)
except Exception as e:
_state["log"].warning("profile.json unreadable, recreating: %s", e)
return {
"xp": 0,
"level": 1,
"unlocks": [],
"totals": {"runs": 0, "score": 0, "per_game": {}},
"created_at": int(time.time()),
}
def _save_profile(profile: dict) -> None:
path = _state["profile_path"]
if not path:
raise RuntimeError("minigames plugin not initialised")
path.parent.mkdir(parents=True, exist_ok=True)
# Atomic write: temp file + rename. Per Principle VII (versioned settings),
# safety-critical writes go through temp+rename so a crash mid-write does
# not leave a partial profile.
fd, tmp_name = tempfile.mkstemp(prefix=".profile-", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(profile, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_name, path)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
def _evaluate_unlocks(profile: dict, manifest_unlocks_by_game: dict) -> list:
"""Given current XP and per-game unlock definitions, return the new list
of unlocked IDs (game-scoped to avoid collision: 'game_id:unlock_id')."""
raw_unlocks = profile.get("unlocks")
# Coerce to list and filter to strings only — non-string items (e.g. ints
# from a manual edit) would cause sorted() to fail on mixed-type comparison
# in Python 3, and are invalid unlock IDs anyway.
earned = set(
v for v in (raw_unlocks if isinstance(raw_unlocks, list) else [])
if isinstance(v, str)
)
# Coerce xp to numeric; non-numeric values (e.g. "100" from a manual edit)
# would raise TypeError in the comparison `if xp >= unlock_xp` below.
try:
xp = float(profile.get("xp", 0))
except (TypeError, ValueError):
xp = 0
for game_id, unlocks in manifest_unlocks_by_game.items():
for u in unlocks or []:
if not isinstance(u, dict):
_state["log"].warning(
"minigame %s has a non-object unlock entry (%r); skipping",
game_id, type(u).__name__,
)
continue
unlock_id = u.get("id")
if not unlock_id:
_state["log"].warning("minigame %s has an unlock entry missing 'id'; skipping", game_id)
continue
key = f"{game_id}:{unlock_id}"
try:
unlock_xp = float(u.get("xp", 0))
except (TypeError, ValueError):
_state["log"].warning(
"minigame %s has an unlock entry with non-numeric xp threshold; skipping",
game_id,
)
continue
if xp >= unlock_xp:
earned.add(key)
return sorted(earned)
# ── Minigame discovery (server-side) ──────────────────────────────────────────
def _list_minigame_plugins(force_refresh: bool = False) -> list:
"""Walk the plugin directories, return the `minigame` block of every
plugin whose plugin.json declares one. Tolerates missing/invalid JSON.
Results are cached for _REGISTRY_TTL_S seconds to avoid rescanning the
filesystem on every run submission and /registry request. Pass
force_refresh=True to bypass the cache (e.g. after a hot-reload).
Thread-safety: cache reads/writes are serialised under _registry_lock.
The filesystem walk itself runs outside the lock (I/O can be slow) and
the result is committed atomically at the end. Using a dedicated lock
(not _lock) means this can safely be called from within a _lock-held
section such as submit_run without deadlocking.
"""
now = time.monotonic()
# Fast path: read under lock, return cached data if still fresh.
with _registry_lock:
if not force_refresh and (now - _registry_cache["ts"]) < _REGISTRY_TTL_S:
return list(_registry_cache["data"])
# Slow path: scan the filesystem outside the lock (I/O can be slow).
# Concurrent callers that also miss the cache will each do their own scan;
# for a small plugin count this is harmless, and it avoids holding the lock
# during disk I/O. The winner is whoever commits last — always consistent.
resolver = _state["plugins_dir_resolver"]
if not resolver:
return []
out = []
seen_ids: set = set()
for pdir in resolver():
manifest_path = pdir / "plugin.json"
if not manifest_path.exists():
continue
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as e:
_state["log"].warning(
"failed to parse minigame manifest at %s: %s", manifest_path, e
)
continue
if not isinstance(data, dict):
continue
spec = data.get("minigame")
if not isinstance(spec, dict):
continue
plugin_id = data.get("id")
if not isinstance(plugin_id, str) or not plugin_id:
_state["log"].warning(
"minigame plugin at %s has no valid string 'id' in plugin.json; skipping",
pdir,
)
continue
# Spread spec first so authoritative top-level fields (plugin_id,
# version) win if the minigame block contains conflicting keys.
entry = {
**spec,
"plugin_id": plugin_id,
"version": data.get("version"),
}
# Deduplicate by plugin_id: first entry wins (resolver returns
# FEEDBACK_PLUGINS_DIR before the bundled siblings, so an explicit
# override takes precedence over the in-tree snapshot — same winner
# selection as the core plugin loader).
if plugin_id not in seen_ids:
seen_ids.add(plugin_id)
out.append(entry)
# Commit: write the fresh data under lock so ts and data are always updated
# atomically and no reader sees a new ts with old data.
with _registry_lock:
_registry_cache["ts"] = time.monotonic()
_registry_cache["data"] = out
return list(out)
# ── Request models ────────────────────────────────────────────────────────────
class RunSubmission(BaseModel):
game_id: str
score: int = Field(ge=0)
duration_ms: int = Field(ge=0, default=0)
modifiers: dict = Field(default_factory=dict)
meta: dict = Field(default_factory=dict)
# ── FastAPI wiring ────────────────────────────────────────────────────────────
def setup(app, context):
config_dir = context["config_dir"]
base = Path(config_dir) / "minigames"
base.mkdir(parents=True, exist_ok=True)
_state["db_path"] = str(base / "runs.db")
_state["profile_path"] = base / "profile.json"
_state["log"] = context.get("log") or _state["log"]
# fee[dB]ack v0.3.0: adopt the unified core XP store when available.
_state["award_xp"] = context.get("award_xp")
_state["get_xp_progress"] = context.get("get_xp_progress")
_state["seed_xp"] = context.get("seed_xp")
_state["reset_xp"] = context.get("reset_xp")
# Progression engine (spec 010): report runs so minigame challenges/quests
# advance. Optional — standalone mode keeps working without it.
_state["record_progression_event"] = context.get("record_progression_event")
# The plugin loader doesn't currently expose a list-other-plugins helper,
# so derive the plugin directories from environment + conventions:
# 1. FEEDBACK_PLUGINS_DIR env var (explicit override)
# 2. The directory that contains this plugin (plugin_self.parent) —
# covers the common case where all plugins live in one flat dir.
# 3. plugin_self.parent.parent / "plugins" — covers the layout where
# the repo root is one level above the plugins directory.
# Duplicates are removed via a seen-set keyed on resolved paths.
def _resolve_plugin_dirs():
roots = []
env_dir = os.environ.get("FEEDBACK_PLUGINS_DIR") or os.environ.get("SLOPSMITH_PLUGINS_DIR")
if env_dir:
roots.append(Path(env_dir))
# Built-in plugins/ next to server.py (one level above this file's
# parent when installed as a sibling).
plugin_self = Path(__file__).resolve().parent
for cand in (plugin_self.parent, plugin_self.parent.parent / "plugins"):
if cand.exists() and cand.is_dir():
roots.append(cand)
seen = set()
out = []
for root in roots:
try:
children = sorted(root.iterdir())
except OSError:
continue
for child in children:
if not child.is_dir():
continue
key = child.resolve()
if key in seen:
continue
seen.add(key)
out.append(child)
return out
_state["plugins_dir_resolver"] = _resolve_plugin_dirs
# Invalidate the registry cache so that if setup() is called again (e.g.
# on a plugin hot-reload with a different resolver) the next /registry or
# run-submission call triggers a fresh scan rather than serving stale data.
with _registry_lock:
_registry_cache["ts"] = 0.0
_registry_cache["data"] = []
_init_db()
# One-time migration: carry any XP already earned in the legacy
# profile.json into the unified core store, so promoting XP to core
# doesn't reset the player's level. Core no-ops if it's already seeded or
# already has XP. (Core never reads plugin files — minigames pushes.)
if _state.get("seed_xp"):
try:
existing = _load_profile()
local_xp = max(0, int(existing.get("xp", 0) or 0))
if local_xp > 0:
seeded = _state["seed_xp"](local_xp, "minigames")
if seeded:
_state["log"].info("seeded unified XP store with %d minigames XP", local_xp)
except Exception as e:
_state["log"].warning("minigames XP seed skipped: %s", e)
log = _state["log"]
log.info("minigames backend ready: db=%s profile=%s",
_state["db_path"], _state["profile_path"])
@app.post("/api/plugins/minigames/runs")
def submit_run(submission: RunSubmission):
# Whitelist game_id against installed minigames. This is a soft
# check — an uninstalled minigame can still submit if its plugin
# was loaded earlier in the session — but it catches typos.
installed = {p["plugin_id"]: p for p in _list_minigame_plugins()}
if submission.game_id not in installed:
log.warning("run submitted for unknown game_id=%s; accepting anyway",
submission.game_id)
# Serialise modifiers + meta up-front so we can enforce the byte-size cap
# before touching the database.
try:
modifiers_json = json.dumps(submission.modifiers, separators=(",", ":"))
meta_json = json.dumps(submission.meta, separators=(",", ":"))
except (TypeError, ValueError) as e:
raise HTTPException(status_code=400,
detail="modifiers/meta must be JSON-serialisable objects") from e
if (len(modifiers_json.encode("utf-8")) > _MAX_RUN_JSON_BYTES
or len(meta_json.encode("utf-8")) > _MAX_RUN_JSON_BYTES):
raise HTTPException(status_code=400,
detail=f"modifiers/meta too large (max {_MAX_RUN_JSON_BYTES} bytes each)")
xp_gained = xp_for_run(submission.score)
created_at = int(time.time())
with _lock:
conn = _get_conn()
try:
conn.execute(
"""INSERT INTO runs (game_id, score, duration_ms,
modifiers, meta, xp_awarded, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
submission.game_id,
submission.score,
submission.duration_ms,
modifiers_json,
meta_json,
xp_gained,
created_at,
),
)
conn.commit()
run_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
finally:
conn.close()
profile = _load_profile()
# Coerce types with fallbacks so manual edits or import/export with
# wrong types (e.g. "xp": "100" or "xp": null) don't raise here.
def _int(val, default=0):
try:
return int(val)
except (TypeError, ValueError):
return default
# Unified XP: when running inside core, the single core store is the
# source of truth. Award there and mirror its total into profile.json
# so unlock thresholds + legacy readers stay consistent with the
# badge. Standalone (no core service) falls back to local accounting.
core_awarded = False # so a later profile-save failure can reverse it
if _state.get("award_xp"):
try:
core_prog = _state["award_xp"](xp_gained, "minigames")
core_awarded = True
profile["xp"] = max(0, _int(core_prog.get("xp"), 0))
profile["level"] = _int(core_prog.get("level"), 1)
except Exception as e:
_state["log"].warning("core XP award failed, using local: %s", e)
profile["xp"] = max(0, _int(profile.get("xp"), 0) + xp_gained)
profile["level"] = level_for_xp(profile["xp"])
else:
profile["xp"] = max(0, _int(profile.get("xp"), 0) + xp_gained)
profile["level"] = level_for_xp(profile["xp"])
# Validate that totals / per_game / per-game entry are dicts; reset
# to defaults when a manual edit or import left wrong types.
existing_totals = profile.get("totals")
if not isinstance(existing_totals, dict):
profile["totals"] = {"runs": 0, "score": 0, "per_game": {}}
totals = profile["totals"]
totals["runs"] = _int(totals.get("runs"), 0) + 1
totals["score"] = _int(totals.get("score"), 0) + submission.score
existing_per_game = totals.get("per_game")
if not isinstance(existing_per_game, dict):
totals["per_game"] = {}
per_game = totals["per_game"]
existing_g = per_game.get(submission.game_id)
if not isinstance(existing_g, dict):
per_game[submission.game_id] = {"runs": 0, "best_score": 0, "total_score": 0}
g = per_game[submission.game_id]
g["runs"] = _int(g.get("runs"), 0) + 1
g["total_score"] = _int(g.get("total_score"), 0) + submission.score
g["best_score"] = max(_int(g.get("best_score"), 0), submission.score)
manifest_unlocks = {
p["plugin_id"]: p.get("unlocks", []) for p in installed.values()
}
profile["unlocks"] = _evaluate_unlocks(profile, manifest_unlocks)
try:
_save_profile(profile)
except Exception:
# Profile save failed (e.g. disk full). Roll back the run insert
# AND reverse the core XP award — otherwise the core total stays
# incremented while the run/profile didn't persist, so a client
# retry would double-count this run's XP.
try:
conn2 = _get_conn()
try:
conn2.execute("DELETE FROM runs WHERE id = ?", (run_id,))
conn2.commit()
finally:
conn2.close()
except Exception as del_err:
_state["log"].error(
"failed to roll back run %s after profile-save failure: %s",
run_id, del_err,
)
if core_awarded and _state.get("award_xp"):
try:
_state["award_xp"](-xp_gained, "minigames") # reverse the award
except Exception as rev_err:
_state["log"].error(
"failed to reverse core XP award after profile-save failure: %s", rev_err)
raise
# Progression (spec 010): a persisted run is a `minigame_run` event so
# challenges/quests advance. Never let progression break the submission
# (the run + XP are already committed).
progression_summary = None
if _state.get("record_progression_event"):
try:
progression_summary = _state["record_progression_event"](
"minigame_run",
{"game_id": submission.game_id, "score": submission.score},
)
except Exception as e:
_state["log"].warning("progression event failed for run %s: %s", run_id, e)
return {
"ok": True,
"run_id": run_id,
"xp_gained": xp_gained,
"profile": {
"xp": profile["xp"],
"level": profile["level"],
"xp_to_next_level": xp_to_next_level(profile["xp"]),
"unlocks": profile["unlocks"],
},
"progression": progression_summary,
}
@app.get("/api/plugins/minigames/runs")
def list_runs(game_id: str = "", scope: str = "self", limit: int = 50):
if limit < 1 or limit > 500:
raise HTTPException(status_code=400, detail="limit out of range")
# `scope` is reserved for future cross-user comparisons. v1 is
# single-user so 'self' and 'global' both return this install's runs.
if scope not in ("self", "global"):
raise HTTPException(status_code=400, detail="scope must be 'self' or 'global'")
q = "SELECT id, game_id, score, duration_ms, modifiers, meta, xp_awarded, created_at FROM runs"
params: list = []
if game_id:
q += " WHERE game_id = ?"
params.append(game_id)
q += " ORDER BY score DESC, created_at DESC LIMIT ?"
params.append(limit)
conn = _get_conn()
try:
rows = conn.execute(q, params).fetchall()
finally:
conn.close()
def _safe_loads(raw, default=None):
try:
return json.loads(raw or "{}")
except (ValueError, TypeError):
return default if default is not None else {}
return {
"runs": [
{
"id": r["id"],
"game_id": r["game_id"],
"score": r["score"],
"duration_ms": r["duration_ms"],
"modifiers": _safe_loads(r["modifiers"]),
"meta": _safe_loads(r["meta"]),
"xp_awarded": r["xp_awarded"],
"created_at": r["created_at"],
}
for r in rows
]
}
@app.get("/api/plugins/minigames/profile")
def get_profile():
profile = _load_profile()
# Coerce xp and level to ints so xp_to_next_level / level_for_xp don't
# crash on non-integer values that can appear after a manual edit or
# import. Recompute level from coerced xp so the two are always in sync
# (stale or wrong-type level in the file is ignored).
try:
xp = max(0, int(profile.get("xp", 0)))
except (TypeError, ValueError):
xp = 0
# fee[dB]ack v0.3.0 unified XP: when core is the source of truth, report
# its total (the profile.json xp is mirrored on write, but core wins on
# read so song-play XP earned outside minigames is reflected here too).
if _state.get("get_xp_progress"):
try:
xp = max(0, int(_state["get_xp_progress"]().get("xp", xp)))
except Exception:
pass
level = level_for_xp(xp)
return {
**profile,
"xp": xp,
"level": level,
"xp_to_next_level": xp_to_next_level(xp),
}
@app.post("/api/plugins/minigames/profile/reset")
def reset_profile():
with _lock:
fresh = {
"xp": 0,
"level": 1,
"unlocks": [],
"totals": {"runs": 0, "score": 0, "per_game": {}},
"created_at": int(time.time()),
}
path = _state["profile_path"]
if not path:
raise RuntimeError("minigames plugin not initialised")
path.parent.mkdir(parents=True, exist_ok=True)
# Two-phase commit: stage the fresh profile to a temp file first,
# then wipe the DB, then rename the temp into place.
# Phase 1 failure → nothing changed (consistent).
# Phase 2 (DB delete) failure → temp file cleaned up, nothing
# changed (consistent).
# Phase 3 (rename) failure → DB wiped but old profile survives;
# runs are gone but profile still holds stale totals. This is
# acceptable because os.replace is virtually atomic on POSIX and
# the rename failure case requires a full filesystem error.
fd, tmp_name = tempfile.mkstemp(prefix=".profile-reset-", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(fresh, f, indent=2)
f.flush()
os.fsync(f.fileno())
# Phase 2: wipe run history.
conn = _get_conn()
try:
conn.execute("DELETE FROM runs")
conn.commit()
finally:
conn.close()
# Phase 3: atomically install the fresh profile.
os.replace(tmp_name, path)
tmp_name = None # consumed
finally:
if tmp_name is not None:
try:
os.unlink(tmp_name)
except OSError:
pass
# Reset this source's contribution to the UNIFIED core XP store too —
# otherwise get_profile() (which mirrors core when integrated) would keep
# reporting the old XP/level after a minigames reset. Subtracts only the
# minigames share, leaving song-play / tutorials XP intact.
if _state.get("reset_xp"):
try:
_state["reset_xp"]("minigames")
except Exception as e:
_state["log"].warning("core XP reset failed during minigames reset: %s", e)
return {"ok": True}
@app.get("/api/plugins/minigames/registry")
def registry():
return {"minigames": _list_minigame_plugins()}