feat(landing): join-flow rework, player rosters, account page, mod list
Execute plan/18 (WS1-6) + plan/17.
- WS1: homepage join = 3 steps off packwiz (register -> UlicraftLauncher
-> join); per-OS downloads from a synced launcher-manifest.json (schema +
example committed); Fjord moved to its own /fjord page. Drop packwizUrl.
- WS2/3: mc-status gains an isolated /players endpoint (admin login ->
Drasl GET /players, own client+TTL+token cache, never blocks the status
path); online + registered rosters render shared name+avatar tiles;
AVATAR_MODE env (default headiso).
- WS4: /account skin CRUD via browser-direct Drasl (login -> upload ->
reset), in-memory token, players[0]; register relinks "Manage it here".
- WS5: footer link to status.${BASE_DOMAIN} in every footer.
- WS6: tooling/build-modlist.py generates mods.json + extracted logos from
the distribution forgemods; ModList.astro renders a flat list; stat tile
fed from the count.
Manifest/mods loaders read at build with a process.cwd() fallback (the
import.meta.url-only path does not resolve in Astro's bundled build).
New env: AVATAR_MODE, DRASL_ADMIN_USERNAME, DRASL_ADMIN_PASSWORD (mc-status
only). Generated mods.json / launcher-manifest.json / logos are gitignored;
.example.json files document the shapes. Build: 12 pages; mc-status builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
253
tooling/build-modlist.py
Executable file
253
tooling/build-modlist.py
Executable file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
# build-modlist.py — generate landing/src/data/mods.json (+ extracted logos)
|
||||
# from the distribution's full client modset (offline, tomllib + zipfile only).
|
||||
#
|
||||
# Mirrors classify-mods.py: enumerates the distribution's forgemods/required
|
||||
# jars, reads each jar's META-INF/neoforge.mods.toml, takes the PRIMARY [[mods]]
|
||||
# entry, and emits a flat, alphabetically-sorted catalogue for the landing
|
||||
# mod-list component (no categories, no external links). Each mod's logoFile PNG
|
||||
# (if declared and present in the jar) is extracted to
|
||||
# landing/public/mod-logos/<modid>.png. No network calls. Pure stdlib (Python
|
||||
# 3.11+ for tomllib).
|
||||
#
|
||||
# Source dir resolution (first match wins) — identical to classify-mods.py:
|
||||
# 1. CLI arg: build-modlist.py <forgemods-required-dir-or-dist-root>
|
||||
# 2. env MODS_DIST_DIR
|
||||
# 3. $DISTRIBUTION_WEB_ROOT from .env / environment
|
||||
# The path may point at the dist ROOT or directly at the
|
||||
# …/servers/ulicraft-1.21.1/forgemods/required dir — both are accepted.
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
sys.exit("build-modlist.py requires Python 3.11+ (tomllib is stdlib)")
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LANDING = os.path.join(REPO_ROOT, "landing")
|
||||
MODS_JSON = os.path.join(LANDING, "src", "data", "mods.json")
|
||||
LOGOS_DIR = os.path.join(LANDING, "public", "mod-logos")
|
||||
REQUIRED_SUBPATH = os.path.join("servers", "ulicraft-1.21.1", "forgemods", "required")
|
||||
MANIFEST = "META-INF/neoforge.mods.toml"
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def read_env_dist_root():
|
||||
"""Read DISTRIBUTION_WEB_ROOT from environment, else from repo .env."""
|
||||
val = os.environ.get("DISTRIBUTION_WEB_ROOT")
|
||||
if val:
|
||||
return val
|
||||
env_path = os.path.join(REPO_ROOT, ".env")
|
||||
if os.path.isfile(env_path):
|
||||
with open(env_path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line.startswith("DISTRIBUTION_WEB_ROOT="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_required_dir():
|
||||
"""Resolve the forgemods/required dir from override or .env. Halt if missing."""
|
||||
src = None
|
||||
origin = None
|
||||
if len(sys.argv) > 1 and sys.argv[1]:
|
||||
src, origin = sys.argv[1], "CLI arg"
|
||||
elif os.environ.get("MODS_DIST_DIR"):
|
||||
src, origin = os.environ["MODS_DIST_DIR"], "MODS_DIST_DIR env"
|
||||
else:
|
||||
src = read_env_dist_root()
|
||||
origin = "$DISTRIBUTION_WEB_ROOT"
|
||||
if not src:
|
||||
sys.exit(
|
||||
"no source dir: pass a path, set MODS_DIST_DIR, or DISTRIBUTION_WEB_ROOT in .env"
|
||||
)
|
||||
candidates = [os.path.join(src, REQUIRED_SUBPATH), src]
|
||||
for cand in candidates:
|
||||
if os.path.isdir(cand) and os.path.basename(os.path.normpath(cand)) == "required":
|
||||
return cand, origin
|
||||
for cand in candidates:
|
||||
if os.path.isdir(cand):
|
||||
return cand, origin
|
||||
sys.exit(
|
||||
f"forgemods/required dir not found (from {origin}={src!r}). "
|
||||
f"Tried: {', '.join(candidates)}"
|
||||
)
|
||||
|
||||
|
||||
def stringify_authors(value):
|
||||
"""authors may be a string or a list; normalise to a comma-joined string."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, list):
|
||||
parts = [str(v).strip() for v in value if str(v).strip()]
|
||||
return ", ".join(parts)
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def resolve_version(version, manifest):
|
||||
"""Resolve a trivial ${file.jarVersion} placeholder; else leave as-is.
|
||||
|
||||
NeoForge substitutes ${file.jarVersion} from the jar's MANIFEST.MF at load
|
||||
time. We don't have that here, so only resolve when there's nothing better;
|
||||
leave other ${...} placeholders untouched rather than guess.
|
||||
"""
|
||||
if not isinstance(version, str):
|
||||
return str(version) if version is not None else ""
|
||||
v = version.strip()
|
||||
if v in ("${file.jarVersion}", "${global.mcVersion}"):
|
||||
# Can't resolve from the toml alone — surface empty, caller falls back.
|
||||
return ""
|
||||
return v
|
||||
|
||||
|
||||
def primary_mod_entry(manifest):
|
||||
"""Return the first [[mods]] table, or None."""
|
||||
mods = manifest.get("mods")
|
||||
if isinstance(mods, list) and mods and isinstance(mods[0], dict):
|
||||
return mods[0]
|
||||
return None
|
||||
|
||||
|
||||
def extract_logo(zf, manifest, mod, modid):
|
||||
"""Extract the logoFile PNG to LOGOS_DIR/<modid>.png. Return public path or None.
|
||||
|
||||
logoFile may be declared per-[[mods]] or as a top-level key. If absent, or
|
||||
the named entry isn't in the jar, return None (logo: null).
|
||||
"""
|
||||
logo_file = None
|
||||
if isinstance(mod, dict) and mod.get("logoFile"):
|
||||
logo_file = mod["logoFile"]
|
||||
elif manifest.get("logoFile"):
|
||||
logo_file = manifest["logoFile"]
|
||||
if not logo_file or not isinstance(logo_file, str):
|
||||
return None
|
||||
logo_file = logo_file.strip().lstrip("/")
|
||||
if not logo_file:
|
||||
return None
|
||||
# Logos in NeoForge jars live at the resource root (e.g. "icon.png" or
|
||||
# "assets/<id>/icon.png"). Try the declared path, then a couple of common
|
||||
# fallbacks, all by exact zip member name.
|
||||
candidates = [logo_file]
|
||||
if "/" not in logo_file:
|
||||
candidates.append(f"assets/{modid}/{logo_file}")
|
||||
member = None
|
||||
names = set(zf.namelist())
|
||||
for cand in candidates:
|
||||
if cand in names:
|
||||
member = cand
|
||||
break
|
||||
if member is None:
|
||||
return None
|
||||
try:
|
||||
data = zf.read(member)
|
||||
except (KeyError, zipfile.BadZipFile, OSError):
|
||||
return None
|
||||
if not data:
|
||||
return None
|
||||
out_path = os.path.join(LOGOS_DIR, f"{modid}.png")
|
||||
with open(out_path, "wb") as fh:
|
||||
fh.write(data)
|
||||
return f"/mod-logos/{modid}.png"
|
||||
|
||||
|
||||
def read_jar(jar_path):
|
||||
"""Parse one jar → mod dict, or None to skip (with a warning printed)."""
|
||||
name = os.path.basename(jar_path)
|
||||
try:
|
||||
with zipfile.ZipFile(jar_path) as zf:
|
||||
try:
|
||||
raw = zf.read(MANIFEST)
|
||||
except KeyError:
|
||||
print(f" skip (no {MANIFEST}): {name}", file=sys.stderr)
|
||||
return None
|
||||
try:
|
||||
manifest = tomllib.loads(raw.decode("utf-8"))
|
||||
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
||||
print(f" skip (bad toml: {exc}): {name}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
mod = primary_mod_entry(manifest)
|
||||
if mod is None or not mod.get("modId"):
|
||||
print(f" skip (no [[mods]] / modId): {name}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
modid = str(mod["modId"]).strip()
|
||||
display = mod.get("displayName")
|
||||
display = str(display).strip() if display else modid
|
||||
display = display.replace("${file.jarVersion}", "").strip() or modid
|
||||
|
||||
version = resolve_version(mod.get("version"), manifest) or ""
|
||||
authors = stringify_authors(
|
||||
mod.get("authors", manifest.get("authors"))
|
||||
)
|
||||
description = mod.get("description")
|
||||
description = str(description).strip() if description else ""
|
||||
|
||||
logo = extract_logo(zf, manifest, mod, modid)
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
print(f" skip (unreadable jar: {exc}): {name}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": modid,
|
||||
"name": display,
|
||||
"version": version,
|
||||
"authors": authors,
|
||||
"description": description,
|
||||
"logo": logo,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
required_dir, origin = resolve_required_dir()
|
||||
jars = sorted(glob.glob(os.path.join(required_dir, "*.jar")))
|
||||
if not jars:
|
||||
sys.exit(f"no jars found in {required_dir} (from {origin})")
|
||||
|
||||
# Deterministic logo dir: clear stale PNGs, recreate.
|
||||
if os.path.isdir(LOGOS_DIR):
|
||||
for f in glob.glob(os.path.join(LOGOS_DIR, "*.png")):
|
||||
os.remove(f)
|
||||
else:
|
||||
os.makedirs(LOGOS_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.dirname(MODS_JSON), exist_ok=True)
|
||||
|
||||
mods = []
|
||||
skipped = 0
|
||||
by_id = {}
|
||||
for jar in jars:
|
||||
entry = read_jar(jar)
|
||||
if entry is None:
|
||||
skipped += 1
|
||||
continue
|
||||
# Dedupe by modId (a modset shouldn't have two, but be safe — first wins).
|
||||
if entry["id"] in by_id:
|
||||
print(f" warn (duplicate modId {entry['id']!r}), keeping first", file=sys.stderr)
|
||||
continue
|
||||
by_id[entry["id"]] = entry
|
||||
mods.append(entry)
|
||||
|
||||
mods.sort(key=lambda m: m["name"].casefold())
|
||||
with_logos = sum(1 for m in mods if m["logo"])
|
||||
|
||||
out = {"schemaVersion": SCHEMA_VERSION, "count": len(mods), "mods": mods}
|
||||
with open(MODS_JSON, "w", encoding="utf-8") as fh:
|
||||
json.dump(out, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
|
||||
print(f"source: {required_dir} (from {origin})")
|
||||
print(f"jars: {len(jars)} | mods written: {len(mods)} | skipped: {skipped}")
|
||||
print(f"logos extracted: {with_logos}/{len(mods)} -> {LOGOS_DIR}")
|
||||
print(f"wrote {MODS_JSON}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user