Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
orbs,
afflictions,
modifiers,
mods,
achievements,
badges,
epochs,
Expand Down Expand Up @@ -544,6 +545,7 @@ async def dispatch(self, request: Request, call_next):
app.include_router(orbs.router)
app.include_router(afflictions.router)
app.include_router(modifiers.router)
app.include_router(mods.router)
app.include_router(achievements.router)
app.include_router(badges.router)
app.include_router(epochs.router)
Expand Down
63 changes: 63 additions & 0 deletions backend/app/routers/mods.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Mod asset directory.

Curated list of community mods whose image repos supply entity art as the third
fallback after main and beta on the run and live pages. Each mod's images are
named by the in-game entity id (lowercased), so the frontend builds
`<raw_base>/<paths[type]>/<id>.<ext>` for an id that resolves in neither the
main nor beta catalog, and shows modded card/relic/potion art with no per-mod
code. The directory lives in data/mods.json; only vetted repos go in.
"""

import json
import os
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request
from slowapi import Limiter
from slowapi.util import get_remote_address

from ..services.data_service import DEFAULT_LANG, load_mod_entities

router = APIRouter(prefix="/api/mods", tags=["Mods"])
limiter = Limiter(key_func=get_remote_address)

# Entity catalogs a mod can supply (parsed into data-mod/<key>/<lang>/).
_MOD_ENTITIES = {"cards", "relics", "potions"}

_DATA_DIR = Path(
os.environ.get("DATA_DIR", Path(__file__).resolve().parents[3] / "data")
)


def _load_mods() -> list[dict]:
"""The vetted mod entries from data/mods.json, or [] if absent/unreadable."""
path = _DATA_DIR / "mods.json"
if not path.exists():
return []
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f).get("mods", [])
except Exception:
return []


@router.get("", tags=["Mods"])
@limiter.limit("120/minute")
def list_mods(request: Request):
"""The curated mod directory: each mod's repo art base and per-type
subpaths, so clients can fall back to modded entity art by id."""
return {"mods": _load_mods()}


@router.get("/{key}/{entity}", tags=["Mods"])
@limiter.limit("120/minute")
def mod_entities(request: Request, key: str, entity: str, lang: str = DEFAULT_LANG):
"""A mod's catalog for one entity type (cards/relics/potions) in the given
language, so clients can resolve modded entities as the third fallback
after stable and beta. Returns a bare list, the same shape the stable
/api/<entity> endpoints return."""
if entity not in _MOD_ENTITIES:
raise HTTPException(status_code=404, detail="unknown entity")
if not any(m.get("key") == key for m in _load_mods()):
raise HTTPException(status_code=404, detail="unknown mod")
return load_mod_entities(key, entity, lang)
31 changes: 31 additions & 0 deletions backend/app/services/data_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
BETA_DATA_DIR = Path(
os.environ.get("BETA_DATA_DIR", Path(__file__).resolve().parents[3] / "data-beta")
)
# Community mod catalogs (data-mod/<key>/<lang>/*.json), produced by
# tools/parse_mod.py. Served as the third entity source after stable and beta,
# keyed by mod, so the run/live pages can resolve modded card/relic/potion
# metadata that exists in neither the stable nor beta tree.
MOD_DATA_DIR = Path(
os.environ.get("MOD_DATA_DIR", Path(__file__).resolve().parents[3] / "data-mod")
)
DEFAULT_LANG = "eng"

# ContextVar set by VersionMiddleware — allows version-aware loading without changing router signatures
Expand Down Expand Up @@ -107,6 +114,30 @@ def _load_json_beta(lang: str, entity: str, beta_version: str) -> list[dict]:
return data


@lru_cache(maxsize=512)
def _load_json_mod(key: str, lang: str, entity: str) -> list[dict]:
"""Load a mod catalog file (data-mod/<key>/<lang>/<entity>.json), per-language
fallback to English. Unlike beta, there is no stable fallback: modded
entities exist only in the mod tree. Returns [] when the mod or file is
absent so callers degrade to placeholder art."""
base = MOD_DATA_DIR / key
filepath = base / lang / f"{entity}.json"
if not filepath.exists():
filepath = base / DEFAULT_LANG / f"{entity}.json"
if not filepath.exists():
return []
start = time.perf_counter()
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
data_load_duration.labels(entity_type=entity).observe(time.perf_counter() - start)
return data


def load_mod_entities(key: str, entity: str, lang: str = DEFAULT_LANG) -> list[dict]:
"""The mod's catalog for one entity type (cards/relics/potions)."""
return _load_json_mod(key, lang, entity)


def _load_json(lang: str, entity: str) -> list[dict]:
"""Load JSON using the channel + version from the current request context."""
if get_channel() == "beta":
Expand Down
Loading
Loading