public site and containers for that.
Build and Publish Container / Publish Linux container (push) Failing after 1m52s
Build and Publish Container / Publish Linux container (push) Failing after 1m52s
This commit is contained in:
+16
-1
@@ -4,4 +4,19 @@ CONTENT_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/fi
|
||||
COSMETICS_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646
|
||||
|
||||
# ModsConfig.xml Path Template
|
||||
MODSCONFIG_PATH_TEMPLATE=%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml
|
||||
MODSCONFIG_PATH_TEMPLATE=%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml
|
||||
|
||||
# Web backend
|
||||
PROGRESSION_HOST=127.0.0.1
|
||||
PROGRESSION_PORT=8000
|
||||
PROGRESSION_DB_PATH=.data/progression.db
|
||||
# Windows default; use `steamcmd` when it is installed on PATH on Linux.
|
||||
STEAMCMD_PATH=.steamcmd/steamcmd.exe
|
||||
STEAMCMD_WORKSHOP_DIR=.steamcmd/steamapps/workshop/content/294100
|
||||
STEAMCMD_BATCH_SIZE=25
|
||||
STEAMCMD_TIMEOUT_SECONDS=1800
|
||||
REFRESH_INTERVAL_SECONDS=3600
|
||||
REFRESH_ON_STARTUP=true
|
||||
MODLIST_GAME_VERSION=1.6
|
||||
# Optional bearer token required by POST /api/refresh when set.
|
||||
PROGRESSION_ADMIN_TOKEN=
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Build and Publish Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: container-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Linux container
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve registry image name
|
||||
id: image
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
registry="${SERVER_URL#http://}"
|
||||
registry="${registry#https://}"
|
||||
registry="${registry%%/*}"
|
||||
repository="$(printf '%s' "$REPOSITORY" | tr '[:upper:]' '[:lower:]')"
|
||||
echo "registry=$registry" >> "$GITHUB_OUTPUT"
|
||||
echo "image=$registry/$repository" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.image.outputs.registry }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME || github.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.image.outputs.image }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=sha-
|
||||
labels: |
|
||||
org.opencontainers.image.title=Progression Mods
|
||||
org.opencontainers.image.description=Progression RimWorld mod-list service
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and publish
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
|
||||
+5
-1
@@ -200,4 +200,8 @@ art/thumbnails/
|
||||
|
||||
# Steam Workshop related (if any cache files are generated)
|
||||
workshop_cache/
|
||||
*.cache
|
||||
*.cache
|
||||
|
||||
# Progression web backend runtime data
|
||||
.data/
|
||||
.steamcmd/
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN dpkg --add-architecture i386 \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl lib32gcc-s1 python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --create-home --shell /bin/bash progression \
|
||||
&& mkdir -p /opt/steamcmd/steamapps /srv/progression/.data \
|
||||
&& curl -fsSL https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz \
|
||||
| tar -xz -C /opt/steamcmd \
|
||||
&& chown -R progression:progression /opt/steamcmd /srv/progression
|
||||
|
||||
WORKDIR /srv/progression
|
||||
COPY --chown=progression:progression requirements-web.txt ./
|
||||
RUN python3 -m venv /srv/venv \
|
||||
&& /srv/venv/bin/pip install --no-cache-dir -r requirements-web.txt
|
||||
|
||||
COPY --chown=progression:progression progression_backend ./progression_backend
|
||||
COPY --chown=progression:progression art ./art
|
||||
USER progression
|
||||
|
||||
ENV PROGRESSION_HOST=0.0.0.0 \
|
||||
PROGRESSION_PORT=8000 \
|
||||
PROGRESSION_DB_PATH=.data/progression.db \
|
||||
STEAMCMD_PATH=/opt/steamcmd/steamcmd.sh \
|
||||
STEAMCMD_WORKSHOP_DIR=/opt/steamcmd/steamapps/workshop/content/294100 \
|
||||
REFRESH_INTERVAL_SECONDS=3600 \
|
||||
REFRESH_ON_STARTUP=true
|
||||
|
||||
VOLUME ["/srv/progression/.data", "/opt/steamcmd/steamapps"]
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8000/api/health || exit 1
|
||||
|
||||
CMD ["/srv/venv/bin/python", "-m", "progression_backend"]
|
||||
@@ -15,4 +15,72 @@ python version_manager.py 1.0.1
|
||||
|
||||
build
|
||||
|
||||
pyinstaller --clean --onefile --windowed --icon=art/Progression.ico --add-data "art;art" --name ProgressionLoader steam_workshop_gui.py
|
||||
pyinstaller --clean --onefile --windowed --icon=art/Progression.ico --add-data "art;art" --name ProgressionLoader steam_workshop_gui.py
|
||||
|
||||
## Web dashboard and backend
|
||||
|
||||
The repository now includes a FastAPI backend and responsive dashboard in
|
||||
`progression_backend`. It resolves the three Workshop collections through
|
||||
Steam's `GetCollectionDetails` API, downloads missing or updated RimWorld mods
|
||||
with anonymous SteamCMD, reads `About/About.xml`, and generates the same RimWorld
|
||||
`.rml` format as the desktop loader.
|
||||
|
||||
The backend refreshes once per hour and records each run in SQLite, including:
|
||||
|
||||
- complete refresh time and status
|
||||
- requested, successful, and failed downloads
|
||||
- Workshop cache size on disk
|
||||
- size added during the run and average throughput
|
||||
- per-mod size, validation status, timestamps, and SteamCMD batch diagnostics
|
||||
|
||||
### Run locally
|
||||
|
||||
Install SteamCMD, copy `.env.example` to `.env`, and set `STEAMCMD_PATH` plus
|
||||
`STEAMCMD_WORKSHOP_DIR` to the matching SteamCMD Workshop directory. Then run:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements-web.txt
|
||||
python -m progression_backend
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000`. API documentation is available at
|
||||
`http://127.0.0.1:8000/api/docs`.
|
||||
|
||||
Use one application process/worker because that process owns the hourly
|
||||
scheduler. For an externally scheduled deployment, set `REFRESH_ON_STARTUP=false`
|
||||
and call `POST /api/refresh` once per hour.
|
||||
|
||||
### Run with Docker
|
||||
|
||||
Docker provisions the SteamCMD binary and persists both analytics and downloaded
|
||||
Workshop content:
|
||||
|
||||
```powershell
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
The SteamCMD Linux runtime requires an amd64 host or amd64 emulation. Set
|
||||
`PROGRESSION_ADMIN_TOKEN` before exposing the manual refresh endpoint publicly.
|
||||
|
||||
### Published container
|
||||
|
||||
Gitea Actions builds and publishes the `linux/amd64` image on every push to
|
||||
`main` and every `v*` version tag:
|
||||
|
||||
```text
|
||||
git.hudsonriggs.systems/hriggs/progressionmods:latest
|
||||
```
|
||||
|
||||
Published tags include `latest`, the branch or semantic version, and
|
||||
`sha-<commit>`. The workflow uses the Gitea action token by default. If the
|
||||
server does not grant that token package-write access, configure repository
|
||||
secrets named `REGISTRY_USERNAME` and `REGISTRY_TOKEN`.
|
||||
|
||||
### Mod-list endpoints
|
||||
|
||||
- `GET /api/modlists/vanilla?dlc=royalty&dlc=biotech`
|
||||
- `POST /api/modlists/homebrew` with a `mods_config` XML upload
|
||||
- `GET /api/status`, `/api/analytics`, and `/api/mods`
|
||||
|
||||
Generation fails closed when any current collection item is unavailable. An
|
||||
operator can explicitly request `allow_partial=true` for diagnostics.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
progression-web:
|
||||
build: .
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
PROGRESSION_ADMIN_TOKEN: ${PROGRESSION_ADMIN_TOKEN:-}
|
||||
MODLIST_GAME_VERSION: ${MODLIST_GAME_VERSION:-1.6}
|
||||
volumes:
|
||||
- progression-data:/srv/progression/.data
|
||||
- progression-workshop:/opt/steamcmd/steamapps
|
||||
|
||||
volumes:
|
||||
progression-data:
|
||||
progression-workshop:
|
||||
@@ -0,0 +1,23 @@
|
||||
# Published image. Use a version such as 1.0.0 instead of latest to pin a release.
|
||||
PROGRESSION_IMAGE=git.hudsonriggs.systems/hriggs/progressionmods
|
||||
PROGRESSION_IMAGE_TAG=latest
|
||||
|
||||
# Public listener. Change the external port without changing the container.
|
||||
PROGRESSION_BIND_ADDRESS=0.0.0.0
|
||||
PROGRESSION_EXTERNAL_PORT=8000
|
||||
|
||||
# Protects POST /api/refresh. Set a long random value before public deployment.
|
||||
PROGRESSION_ADMIN_TOKEN=change-me
|
||||
MODLIST_GAME_VERSION=1.6
|
||||
|
||||
# Progression Steam Workshop collections.
|
||||
CORE_COLLECTION_URL=https://steamcommunity.com/workshop/filedetails/?id=3521297585
|
||||
CONTENT_COLLECTION_URL=https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712
|
||||
COSMETICS_COLLECTION_URL=https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646
|
||||
|
||||
# SteamCMD download and hourly refresh behavior.
|
||||
STEAMCMD_BATCH_SIZE=25
|
||||
STEAMCMD_TIMEOUT_SECONDS=1800
|
||||
REFRESH_INTERVAL_SECONDS=3600
|
||||
REFRESH_ON_STARTUP=true
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Progression deployment
|
||||
|
||||
This deployment pulls the published container from the Gitea registry. No
|
||||
repository checkout or local image build is required on the server.
|
||||
|
||||
1. Copy `.env.example` to `.env` and change `PROGRESSION_ADMIN_TOKEN`.
|
||||
2. Set `PROGRESSION_EXTERNAL_PORT` if port `8000` is already in use.
|
||||
3. Start the service:
|
||||
|
||||
```sh
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The SQLite database and Steam Workshop files are stored in named Docker volumes
|
||||
and survive image upgrades. To update later, run the same two commands again.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: progression-mods
|
||||
|
||||
services:
|
||||
progression-web:
|
||||
image: ${PROGRESSION_IMAGE:-git.hudsonriggs.systems/hriggs/progressionmods}:${PROGRESSION_IMAGE_TAG:-latest}
|
||||
platform: linux/amd64
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PROGRESSION_BIND_ADDRESS:-0.0.0.0}:${PROGRESSION_EXTERNAL_PORT:-8000}:8000"
|
||||
environment:
|
||||
PROGRESSION_HOST: 0.0.0.0
|
||||
PROGRESSION_PORT: 8000
|
||||
PROGRESSION_DB_PATH: /srv/progression/.data/progression.db
|
||||
PROGRESSION_ADMIN_TOKEN: ${PROGRESSION_ADMIN_TOKEN:-}
|
||||
MODLIST_GAME_VERSION: ${MODLIST_GAME_VERSION:-1.6}
|
||||
CORE_COLLECTION_URL: ${CORE_COLLECTION_URL:-https://steamcommunity.com/workshop/filedetails/?id=3521297585}
|
||||
CONTENT_COLLECTION_URL: ${CONTENT_COLLECTION_URL:-https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712}
|
||||
COSMETICS_COLLECTION_URL: ${COSMETICS_COLLECTION_URL:-https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646}
|
||||
STEAMCMD_PATH: /opt/steamcmd/steamcmd.sh
|
||||
STEAMCMD_WORKSHOP_DIR: /opt/steamcmd/steamapps/workshop/content/294100
|
||||
STEAMCMD_BATCH_SIZE: ${STEAMCMD_BATCH_SIZE:-25}
|
||||
STEAMCMD_TIMEOUT_SECONDS: ${STEAMCMD_TIMEOUT_SECONDS:-1800}
|
||||
REFRESH_INTERVAL_SECONDS: ${REFRESH_INTERVAL_SECONDS:-3600}
|
||||
REFRESH_ON_STARTUP: ${REFRESH_ON_STARTUP:-true}
|
||||
volumes:
|
||||
- progression-data:/srv/progression/.data
|
||||
- progression-workshop:/opt/steamcmd/steamapps
|
||||
|
||||
volumes:
|
||||
progression-data:
|
||||
progression-workshop:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Progression web backend."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,18 @@
|
||||
import uvicorn
|
||||
|
||||
from .config import Settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
settings = Settings.load()
|
||||
uvicorn.run(
|
||||
"progression_backend.app:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=False,
|
||||
workers=1,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,284 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from fastapi import (
|
||||
FastAPI,
|
||||
File,
|
||||
Header,
|
||||
HTTPException,
|
||||
Query,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .config import Settings
|
||||
from .database import Database
|
||||
from .modlist import core_items, generate_rml, parse_active_mods, workshop_items
|
||||
from .refresh import RefreshCoordinator, RefreshService
|
||||
from .workshop import SteamCmdRunner
|
||||
|
||||
|
||||
DESKTOP_RELEASES_API = (
|
||||
"https://git.hudsonriggs.systems/api/v1/repos/HRiggs/ProgressionMods/releases"
|
||||
)
|
||||
DESKTOP_RELEASE_HOST = "git.hudsonriggs.systems"
|
||||
|
||||
|
||||
def latest_windows_release_url() -> str | None:
|
||||
"""Return the newest Windows executable, including prerelease builds."""
|
||||
response = requests.get(
|
||||
DESKTOP_RELEASES_API,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
releases = response.json()
|
||||
if not isinstance(releases, list):
|
||||
raise ValueError("Unexpected Gitea releases response")
|
||||
|
||||
published = sorted(
|
||||
(release for release in releases if not release.get("draft")),
|
||||
key=lambda release: release.get("published_at") or release.get("created_at") or "",
|
||||
reverse=True,
|
||||
)
|
||||
stable = [release for release in published if not release.get("prerelease")]
|
||||
candidates = stable or published
|
||||
for release in candidates:
|
||||
assets = release.get("assets") or []
|
||||
executables = [
|
||||
asset
|
||||
for asset in assets
|
||||
if str(asset.get("name") or "").lower().endswith(".exe")
|
||||
]
|
||||
executables.sort(
|
||||
key=lambda asset: (
|
||||
str(asset.get("name") or "").lower() != "progressionloader.exe",
|
||||
str(asset.get("name") or "").lower(),
|
||||
)
|
||||
)
|
||||
for asset in executables:
|
||||
download_url = str(asset.get("browser_download_url") or "")
|
||||
parsed = urlparse(download_url)
|
||||
if parsed.scheme == "https" and parsed.netloc == DESKTOP_RELEASE_HOST:
|
||||
return download_url
|
||||
return None
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
settings = settings or Settings.load()
|
||||
database = Database(settings.database_path)
|
||||
database.initialize()
|
||||
runner = SteamCmdRunner(
|
||||
settings.steamcmd_path,
|
||||
settings.workshop_content_dir,
|
||||
settings.steamcmd_timeout_seconds,
|
||||
)
|
||||
service = RefreshService(settings, database, runner=runner)
|
||||
coordinator = RefreshCoordinator(
|
||||
service, settings.refresh_interval_seconds, settings.refresh_on_startup
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
await coordinator.start()
|
||||
yield
|
||||
await coordinator.stop()
|
||||
|
||||
app = FastAPI(
|
||||
title="Progression Mods Backend",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
redoc_url=None,
|
||||
)
|
||||
app.state.settings = settings
|
||||
app.state.database = database
|
||||
app.state.coordinator = coordinator
|
||||
app.state.runner = runner
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"steamcmd_available": runner.available(),
|
||||
"workshop_content_dir": str(settings.workshop_content_dir),
|
||||
"scheduler": coordinator.state(),
|
||||
}
|
||||
|
||||
@app.get("/api/status")
|
||||
async def dashboard_status() -> dict:
|
||||
return {
|
||||
**database.dashboard(),
|
||||
"scheduler": coordinator.state(),
|
||||
"steamcmd_available": runner.available(),
|
||||
"game_version": settings.game_version,
|
||||
}
|
||||
|
||||
@app.get("/api/analytics")
|
||||
async def analytics(limit: int = Query(24, ge=1, le=168)) -> dict:
|
||||
return {"runs": database.runs(limit)}
|
||||
|
||||
@app.get("/api/analytics/downloads")
|
||||
async def download_analytics(
|
||||
run_id: str | None = None,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
) -> dict:
|
||||
if not run_id:
|
||||
runs = database.runs(1)
|
||||
run_id = runs[0]["id"] if runs else None
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"attempts": database.download_attempts(run_id, limit) if run_id else [],
|
||||
}
|
||||
|
||||
@app.get("/api/mods")
|
||||
async def mods(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
state: str | None = Query(None, pattern="^(ready|pending|failed)$"),
|
||||
) -> dict:
|
||||
clauses = [
|
||||
"workshop_id IN (SELECT DISTINCT workshop_id FROM collection_members)"
|
||||
]
|
||||
parameters: list[object] = []
|
||||
if state:
|
||||
clauses.append("status = ?")
|
||||
parameters.append(state)
|
||||
where = " AND ".join(clauses)
|
||||
total = database.fetch_one(
|
||||
f"SELECT COUNT(*) AS count FROM mods WHERE {where}", parameters
|
||||
) or {"count": 0}
|
||||
rows = database.fetch_all(
|
||||
f"""
|
||||
SELECT workshop_id, title, package_id, display_name, time_updated,
|
||||
metadata_file_size, disk_size, status, error, last_checked_at,
|
||||
last_downloaded_at
|
||||
FROM mods WHERE {where}
|
||||
ORDER BY CAST(workshop_id AS INTEGER)
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
[*parameters, limit, offset],
|
||||
)
|
||||
return {
|
||||
"total": total["count"],
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"items": rows,
|
||||
}
|
||||
|
||||
@app.post("/api/refresh", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def refresh(authorization: str | None = Header(None)) -> dict:
|
||||
if settings.admin_token:
|
||||
expected = f"Bearer {settings.admin_token}"
|
||||
if not authorization or not secrets.compare_digest(authorization, expected):
|
||||
raise HTTPException(status_code=401, detail="Invalid admin token")
|
||||
run_id, started = await coordinator.trigger("manual")
|
||||
return {"run_id": run_id, "started": started}
|
||||
|
||||
def ready_workshop_rows(allow_partial: bool) -> list[dict]:
|
||||
rows = database.current_mods()
|
||||
failed = [row for row in rows if row.get("status") != "ready"]
|
||||
if failed and not allow_partial:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "The mod list is incomplete",
|
||||
"not_ready": len(failed),
|
||||
"hint": "Run a refresh or request allow_partial=true",
|
||||
},
|
||||
)
|
||||
if not rows:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="No collection data is available yet"
|
||||
)
|
||||
return rows
|
||||
|
||||
@app.get("/api/modlists/vanilla")
|
||||
async def vanilla_modlist(
|
||||
dlc: Annotated[list[str] | None, Query()] = None,
|
||||
allow_partial: Annotated[bool, Query()] = False,
|
||||
) -> Response:
|
||||
rows = ready_workshop_rows(allow_partial)
|
||||
rml = generate_rml(
|
||||
[*core_items(dlc or []), *workshop_items(rows)], settings.game_version
|
||||
)
|
||||
return Response(
|
||||
rml,
|
||||
media_type="application/xml",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="ProgressionVanilla.rml"'
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/api/downloads/windows", include_in_schema=False)
|
||||
def latest_windows_download() -> RedirectResponse:
|
||||
try:
|
||||
download_url = latest_windows_release_url()
|
||||
except (requests.RequestException, TypeError, ValueError) as error:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="The Windows download could not be resolved from Gitea",
|
||||
) from error
|
||||
if not download_url:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No Windows executable is attached to a published release",
|
||||
)
|
||||
return RedirectResponse(download_url, status_code=307)
|
||||
|
||||
@app.post("/api/modlists/homebrew")
|
||||
async def homebrew_modlist(
|
||||
mods_config: Annotated[UploadFile, File()],
|
||||
allow_partial: Annotated[bool, Query()] = False,
|
||||
) -> Response:
|
||||
if not mods_config.filename or not mods_config.filename.lower().endswith(
|
||||
".xml"
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Upload ModsConfig.xml")
|
||||
content = await mods_config.read(5 * 1024 * 1024 + 1)
|
||||
try:
|
||||
current = parse_active_mods(content)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
rows = ready_workshop_rows(allow_partial)
|
||||
by_package = {
|
||||
str(row.get("package_id") or "").lower(): row
|
||||
for row in rows
|
||||
if row.get("package_id")
|
||||
}
|
||||
hydrated = []
|
||||
for item in current:
|
||||
match = by_package.get(item.package_id.lower())
|
||||
if match:
|
||||
hydrated.extend(workshop_items([match]))
|
||||
else:
|
||||
hydrated.append(item)
|
||||
rml = generate_rml([*hydrated, *workshop_items(rows)], settings.game_version)
|
||||
return Response(
|
||||
rml,
|
||||
media_type="application/xml",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="ProgressionHomebrew.rml"'
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse(settings.static_dir / "index.html")
|
||||
|
||||
app.mount("/static", StaticFiles(directory=settings.static_dir), name="static")
|
||||
art_dir = Path(settings.static_dir).parents[1] / "art"
|
||||
if art_dir.is_dir():
|
||||
app.mount("/art", StaticFiles(directory=art_dir), name="art")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
DEFAULT_COLLECTIONS = {
|
||||
"Core": "3521297585",
|
||||
"Content": "3521319712",
|
||||
"Cosmetics": "3637541646",
|
||||
}
|
||||
|
||||
|
||||
def _as_bool(value: str | None, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _collection_id(value: str | None, fallback: str) -> str:
|
||||
"""Accept either a raw Workshop id or one of the existing URL settings."""
|
||||
if not value:
|
||||
return fallback
|
||||
digits = "".join(character for character in value if character.isdigit())
|
||||
return digits or fallback
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
host: str
|
||||
port: int
|
||||
database_path: Path
|
||||
steamcmd_path: str
|
||||
workshop_content_dir: Path
|
||||
steamcmd_batch_size: int
|
||||
steamcmd_timeout_seconds: int
|
||||
refresh_interval_seconds: int
|
||||
refresh_on_startup: bool
|
||||
game_version: str
|
||||
admin_token: str | None
|
||||
collections: dict[str, str]
|
||||
static_dir: Path
|
||||
|
||||
@classmethod
|
||||
def load(cls, root: Path | None = None) -> Settings:
|
||||
root = (root or Path.cwd()).resolve()
|
||||
load_dotenv(root / ".env")
|
||||
default_steamcmd = ".steamcmd/steamcmd.exe" if os.name == "nt" else "steamcmd"
|
||||
|
||||
return cls(
|
||||
host=os.getenv("PROGRESSION_HOST", "127.0.0.1"),
|
||||
port=int(os.getenv("PROGRESSION_PORT", "8000")),
|
||||
database_path=(
|
||||
root / os.getenv("PROGRESSION_DB_PATH", ".data/progression.db")
|
||||
).resolve(),
|
||||
steamcmd_path=os.getenv("STEAMCMD_PATH", default_steamcmd),
|
||||
workshop_content_dir=(
|
||||
root
|
||||
/ os.getenv(
|
||||
"STEAMCMD_WORKSHOP_DIR",
|
||||
".steamcmd/steamapps/workshop/content/294100",
|
||||
)
|
||||
).resolve(),
|
||||
steamcmd_batch_size=max(1, int(os.getenv("STEAMCMD_BATCH_SIZE", "25"))),
|
||||
steamcmd_timeout_seconds=max(
|
||||
60, int(os.getenv("STEAMCMD_TIMEOUT_SECONDS", "1800"))
|
||||
),
|
||||
refresh_interval_seconds=max(
|
||||
60, int(os.getenv("REFRESH_INTERVAL_SECONDS", "3600"))
|
||||
),
|
||||
refresh_on_startup=_as_bool(os.getenv("REFRESH_ON_STARTUP"), True),
|
||||
game_version=os.getenv("MODLIST_GAME_VERSION", "1.6").strip() or "1.6",
|
||||
admin_token=os.getenv("PROGRESSION_ADMIN_TOKEN") or None,
|
||||
collections={
|
||||
"Core": _collection_id(
|
||||
os.getenv("CORE_COLLECTION_URL"), DEFAULT_COLLECTIONS["Core"]
|
||||
),
|
||||
"Content": _collection_id(
|
||||
os.getenv("CONTENT_COLLECTION_URL"), DEFAULT_COLLECTIONS["Content"]
|
||||
),
|
||||
"Cosmetics": _collection_id(
|
||||
os.getenv("COSMETICS_COLLECTION_URL"),
|
||||
DEFAULT_COLLECTIONS["Cosmetics"],
|
||||
),
|
||||
},
|
||||
static_dir=root / "progression_backend" / "static",
|
||||
)
|
||||
@@ -0,0 +1,335 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS refresh_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
trigger TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
collection_items INTEGER NOT NULL DEFAULT 0,
|
||||
unique_items INTEGER NOT NULL DEFAULT 0,
|
||||
ready_items INTEGER NOT NULL DEFAULT 0,
|
||||
downloads_requested INTEGER NOT NULL DEFAULT 0,
|
||||
downloads_succeeded INTEGER NOT NULL DEFAULT 0,
|
||||
downloads_failed INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_before INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_after INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_downloaded INTEGER NOT NULL DEFAULT 0,
|
||||
cache_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
elapsed_seconds REAL NOT NULL DEFAULT 0,
|
||||
average_mbps REAL NOT NULL DEFAULT 0,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mods (
|
||||
workshop_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
package_id TEXT,
|
||||
steam_app_id TEXT,
|
||||
display_name TEXT,
|
||||
time_updated INTEGER NOT NULL DEFAULT 0,
|
||||
metadata_file_size INTEGER NOT NULL DEFAULT 0,
|
||||
disk_size INTEGER NOT NULL DEFAULT 0,
|
||||
local_path TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
last_checked_at TEXT,
|
||||
last_downloaded_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_members (
|
||||
collection_id TEXT NOT NULL,
|
||||
collection_name TEXT NOT NULL,
|
||||
workshop_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
PRIMARY KEY (collection_id, workshop_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL,
|
||||
batch_number INTEGER NOT NULL,
|
||||
item_count INTEGER NOT NULL,
|
||||
workshop_ids TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT NOT NULL,
|
||||
elapsed_seconds REAL NOT NULL,
|
||||
exit_code INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
output_tail TEXT,
|
||||
FOREIGN KEY (run_id) REFERENCES refresh_runs(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_started ON refresh_runs(started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_mods_status ON mods(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_run ON download_attempts(run_id);
|
||||
"""
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
|
||||
@contextmanager
|
||||
def connect(self) -> Iterator[sqlite3.Connection]:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
connection.execute("PRAGMA journal_mode = WAL")
|
||||
try:
|
||||
yield connection
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def initialize(self) -> None:
|
||||
with self.connect() as connection:
|
||||
connection.executescript(SCHEMA)
|
||||
|
||||
def execute(self, sql: str, parameters: Sequence[Any] = ()) -> None:
|
||||
with self.connect() as connection:
|
||||
connection.execute(sql, parameters)
|
||||
|
||||
def fetch_one(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> dict[str, Any] | None:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(sql, parameters).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def fetch_all(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(sql, parameters).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def create_run(self, run_id: str, trigger: str, started_at: str) -> None:
|
||||
self.execute(
|
||||
"INSERT INTO refresh_runs (id, trigger, started_at, status) VALUES (?, ?, ?, 'running')",
|
||||
(run_id, trigger, started_at),
|
||||
)
|
||||
|
||||
def finish_run(self, run_id: str, values: dict[str, Any]) -> None:
|
||||
allowed = {
|
||||
"finished_at",
|
||||
"status",
|
||||
"collection_items",
|
||||
"unique_items",
|
||||
"ready_items",
|
||||
"downloads_requested",
|
||||
"downloads_succeeded",
|
||||
"downloads_failed",
|
||||
"bytes_before",
|
||||
"bytes_after",
|
||||
"bytes_downloaded",
|
||||
"cache_bytes",
|
||||
"elapsed_seconds",
|
||||
"average_mbps",
|
||||
"error",
|
||||
}
|
||||
selected = {key: value for key, value in values.items() if key in allowed}
|
||||
assignments = ", ".join(f"{key} = ?" for key in selected)
|
||||
self.execute(
|
||||
f"UPDATE refresh_runs SET {assignments} WHERE id = ?",
|
||||
(*selected.values(), run_id),
|
||||
)
|
||||
|
||||
def replace_collection_members(
|
||||
self, collections: dict[str, tuple[str, list[str]]]
|
||||
) -> None:
|
||||
with self.connect() as connection:
|
||||
connection.execute("DELETE FROM collection_members")
|
||||
for name, (collection_id, workshop_ids) in collections.items():
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO collection_members
|
||||
(collection_id, collection_name, workshop_id, position)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(collection_id, name, workshop_id, position)
|
||||
for position, workshop_id in enumerate(workshop_ids)
|
||||
],
|
||||
)
|
||||
|
||||
def _upsert_mod(
|
||||
self, connection: sqlite3.Connection, values: dict[str, Any]
|
||||
) -> None:
|
||||
columns = [
|
||||
"workshop_id",
|
||||
"title",
|
||||
"package_id",
|
||||
"steam_app_id",
|
||||
"display_name",
|
||||
"time_updated",
|
||||
"metadata_file_size",
|
||||
"disk_size",
|
||||
"local_path",
|
||||
"status",
|
||||
"error",
|
||||
"last_checked_at",
|
||||
"last_downloaded_at",
|
||||
]
|
||||
workshop_id = str(values["workshop_id"])
|
||||
existing_row = connection.execute(
|
||||
"SELECT * FROM mods WHERE workshop_id = ?", (workshop_id,)
|
||||
).fetchone()
|
||||
existing = dict(existing_row) if existing_row else {}
|
||||
defaults = {
|
||||
"title": "",
|
||||
"time_updated": 0,
|
||||
"metadata_file_size": 0,
|
||||
"disk_size": 0,
|
||||
"status": "pending",
|
||||
}
|
||||
payload: dict[str, Any] = {"workshop_id": workshop_id}
|
||||
for column in columns[1:]:
|
||||
candidate = values.get(column)
|
||||
if candidate is not None:
|
||||
payload[column] = candidate
|
||||
elif column in existing:
|
||||
payload[column] = existing[column]
|
||||
else:
|
||||
payload[column] = defaults.get(column)
|
||||
updates = ", ".join(
|
||||
f"{column} = excluded.{column}"
|
||||
for column in columns
|
||||
if column != "workshop_id"
|
||||
)
|
||||
placeholders = ", ".join("?" for _ in columns)
|
||||
connection.execute(
|
||||
f"""
|
||||
INSERT INTO mods ({", ".join(columns)}) VALUES ({placeholders})
|
||||
ON CONFLICT(workshop_id) DO UPDATE SET {updates}
|
||||
""",
|
||||
tuple(payload[column] for column in columns),
|
||||
)
|
||||
|
||||
def upsert_mod(self, values: dict[str, Any]) -> None:
|
||||
with self.connect() as connection:
|
||||
self._upsert_mod(connection, values)
|
||||
|
||||
def upsert_mods(self, values: Sequence[dict[str, Any]]) -> None:
|
||||
"""Upsert a metadata page in one transaction instead of per-mod commits."""
|
||||
with self.connect() as connection:
|
||||
for value in values:
|
||||
self._upsert_mod(connection, value)
|
||||
|
||||
def add_download_attempt(self, values: dict[str, Any]) -> None:
|
||||
self.execute(
|
||||
"""
|
||||
INSERT INTO download_attempts
|
||||
(run_id, batch_number, item_count, workshop_ids, started_at,
|
||||
finished_at, elapsed_seconds, exit_code, status, output_tail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
values["run_id"],
|
||||
values["batch_number"],
|
||||
values["item_count"],
|
||||
json.dumps(values["workshop_ids"]),
|
||||
values["started_at"],
|
||||
values["finished_at"],
|
||||
values["elapsed_seconds"],
|
||||
values.get("exit_code"),
|
||||
values["status"],
|
||||
values.get("output_tail"),
|
||||
),
|
||||
)
|
||||
|
||||
def dashboard(self) -> dict[str, Any]:
|
||||
recent_runs = self.runs(1)
|
||||
latest = recent_runs[0] if recent_runs else None
|
||||
last_update = self.fetch_one(
|
||||
"""
|
||||
SELECT finished_at
|
||||
FROM refresh_runs
|
||||
WHERE finished_at IS NOT NULL AND unique_items > 0
|
||||
ORDER BY finished_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
totals = (
|
||||
self.fetch_one(
|
||||
"""
|
||||
SELECT COUNT(*) AS total_mods,
|
||||
SUM(CASE WHEN status = 'ready' THEN 1 ELSE 0 END) AS ready_mods,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_mods,
|
||||
COALESCE(SUM(disk_size), 0) AS cache_bytes
|
||||
FROM mods
|
||||
WHERE workshop_id IN (SELECT DISTINCT workshop_id FROM collection_members)
|
||||
"""
|
||||
)
|
||||
or {}
|
||||
)
|
||||
collections = self.fetch_all(
|
||||
"""
|
||||
SELECT collection_name AS name, collection_id AS id, COUNT(*) AS item_count
|
||||
FROM collection_members
|
||||
GROUP BY collection_name, collection_id
|
||||
ORDER BY CASE collection_name
|
||||
WHEN 'Core' THEN 1 WHEN 'Content' THEN 2 WHEN 'Cosmetics' THEN 3 ELSE 4 END
|
||||
"""
|
||||
)
|
||||
return {
|
||||
"latest_run": latest,
|
||||
"rml_updated_at": last_update["finished_at"] if last_update else None,
|
||||
"totals": totals,
|
||||
"collections": collections,
|
||||
}
|
||||
|
||||
def runs(self, limit: int = 48) -> list[dict[str, Any]]:
|
||||
return self.fetch_all(
|
||||
"""
|
||||
SELECT r.*,
|
||||
COALESCE(SUM(a.elapsed_seconds), 0) AS download_elapsed_seconds,
|
||||
COUNT(a.id) AS download_batches
|
||||
FROM refresh_runs r
|
||||
LEFT JOIN download_attempts a ON a.run_id = r.id
|
||||
GROUP BY r.id
|
||||
ORDER BY r.started_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
def download_attempts(self, run_id: str, limit: int = 100) -> list[dict[str, Any]]:
|
||||
return self.fetch_all(
|
||||
"""
|
||||
SELECT batch_number, item_count, workshop_ids, started_at, finished_at,
|
||||
elapsed_seconds, exit_code, status
|
||||
FROM download_attempts
|
||||
WHERE run_id = ?
|
||||
ORDER BY batch_number
|
||||
LIMIT ?
|
||||
""",
|
||||
(run_id, limit),
|
||||
)
|
||||
|
||||
def current_mods(self) -> list[dict[str, Any]]:
|
||||
return self.fetch_all(
|
||||
"""
|
||||
SELECT * FROM mods
|
||||
WHERE workshop_id IN (SELECT DISTINCT workshop_id FROM collection_members)
|
||||
ORDER BY CAST(workshop_id AS INTEGER)
|
||||
"""
|
||||
)
|
||||
|
||||
def mod_map(self, workshop_ids: Sequence[str]) -> dict[str, dict[str, Any]]:
|
||||
if not workshop_ids:
|
||||
return {}
|
||||
placeholders = ",".join("?" for _ in workshop_ids)
|
||||
rows = self.fetch_all(
|
||||
f"SELECT * FROM mods WHERE workshop_id IN ({placeholders})", workshop_ids
|
||||
)
|
||||
return {row["workshop_id"]: row for row in rows}
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
DLC = {
|
||||
"royalty": ("ludeon.rimworld.royalty", "Royalty"),
|
||||
"ideology": ("ludeon.rimworld.ideology", "Ideology"),
|
||||
"biotech": ("ludeon.rimworld.biotech", "Biotech"),
|
||||
"anomaly": ("ludeon.rimworld.anomaly", "Anomaly"),
|
||||
"odyssey": ("ludeon.rimworld.odyssey", "Odyssey"),
|
||||
}
|
||||
BASE_GAME = ("ludeon.rimworld", "Core")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModListItem:
|
||||
package_id: str
|
||||
workshop_id: str
|
||||
name: str
|
||||
|
||||
|
||||
def core_items(selected_dlc: Iterable[str]) -> list[ModListItem]:
|
||||
result = [ModListItem(BASE_GAME[0], "0", BASE_GAME[1])]
|
||||
normalized = {value.strip().lower() for value in selected_dlc}
|
||||
for key, (package_id, name) in DLC.items():
|
||||
if key in normalized:
|
||||
result.append(ModListItem(package_id, "0", name))
|
||||
return result
|
||||
|
||||
|
||||
def workshop_items(rows: Iterable[dict]) -> list[ModListItem]:
|
||||
result: list[ModListItem] = []
|
||||
for row in rows:
|
||||
if row.get("status") != "ready" or not row.get("package_id"):
|
||||
continue
|
||||
# The Workshop id is the correct RimWorld modSteamIds value. The old loader
|
||||
# sometimes substituted About.xml's steamAppId, which can be the game app id.
|
||||
result.append(
|
||||
ModListItem(
|
||||
package_id=str(row["package_id"]),
|
||||
workshop_id=str(row["workshop_id"]),
|
||||
name=str(
|
||||
row.get("display_name") or row.get("title") or row["package_id"]
|
||||
),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def deduplicate(items: Iterable[ModListItem]) -> list[ModListItem]:
|
||||
result: list[ModListItem] = []
|
||||
seen_packages: set[str] = set()
|
||||
seen_workshop_ids: set[str] = set()
|
||||
for item in items:
|
||||
package_key = item.package_id.lower()
|
||||
if package_key in seen_packages:
|
||||
continue
|
||||
if item.workshop_id != "0" and item.workshop_id in seen_workshop_ids:
|
||||
continue
|
||||
seen_packages.add(package_key)
|
||||
if item.workshop_id != "0":
|
||||
seen_workshop_ids.add(item.workshop_id)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def parse_active_mods(xml_bytes: bytes) -> list[ModListItem]:
|
||||
if len(xml_bytes) > 5 * 1024 * 1024:
|
||||
raise ValueError("ModsConfig.xml is larger than 5 MB")
|
||||
try:
|
||||
root = ET.fromstring(xml_bytes)
|
||||
except ET.ParseError as error:
|
||||
raise ValueError(f"ModsConfig.xml is invalid: {error}") from error
|
||||
active = root.find("activeMods")
|
||||
if active is None:
|
||||
raise ValueError("ModsConfig.xml has no activeMods section")
|
||||
items: list[ModListItem] = []
|
||||
for entry in active.findall("li"):
|
||||
package_id = (entry.text or "").strip()
|
||||
if package_id:
|
||||
items.append(ModListItem(package_id, "0", package_id))
|
||||
return items
|
||||
|
||||
|
||||
def _append_list(parent: ET.Element, name: str, values: Iterable[str]) -> None:
|
||||
element = ET.SubElement(parent, name)
|
||||
for value in values:
|
||||
ET.SubElement(element, "li").text = value
|
||||
|
||||
|
||||
def generate_rml(items: Iterable[ModListItem], game_version: str) -> bytes:
|
||||
normalized = deduplicate(items)
|
||||
root = ET.Element("savedModList")
|
||||
meta = ET.SubElement(root, "meta")
|
||||
ET.SubElement(meta, "gameVersion").text = game_version
|
||||
_append_list(meta, "modIds", (item.package_id for item in normalized))
|
||||
_append_list(meta, "modSteamIds", (item.workshop_id for item in normalized))
|
||||
_append_list(meta, "modNames", (item.name for item in normalized))
|
||||
|
||||
mod_list = ET.SubElement(root, "modList")
|
||||
_append_list(mod_list, "ids", (item.package_id for item in normalized))
|
||||
_append_list(mod_list, "names", (item.name for item in normalized))
|
||||
|
||||
ET.indent(root, space="\t")
|
||||
output = io.BytesIO()
|
||||
ET.ElementTree(root).write(output, encoding="utf-8", xml_declaration=True)
|
||||
return output.getvalue()
|
||||
@@ -0,0 +1,335 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from .config import Settings
|
||||
from .database import Database
|
||||
from .steam_api import SteamWorkshopClient, WorkshopDetails
|
||||
from .workshop import SteamCmdRunner, directory_size, parse_about_xml, utc_now
|
||||
|
||||
|
||||
def _batches(values: list[str], size: int) -> list[list[str]]:
|
||||
return [values[index : index + size] for index in range(0, len(values), size)]
|
||||
|
||||
|
||||
class RefreshService:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
database: Database,
|
||||
steam: SteamWorkshopClient | None = None,
|
||||
runner: SteamCmdRunner | None = None,
|
||||
):
|
||||
self.settings = settings
|
||||
self.database = database
|
||||
self.steam = steam or SteamWorkshopClient()
|
||||
self.runner = runner or SteamCmdRunner(
|
||||
settings.steamcmd_path,
|
||||
settings.workshop_content_dir,
|
||||
settings.steamcmd_timeout_seconds,
|
||||
)
|
||||
|
||||
def _store_metadata(
|
||||
self,
|
||||
detail: WorkshopDetails,
|
||||
previous: dict[str, Any] | None,
|
||||
stale: bool,
|
||||
) -> dict[str, Any]:
|
||||
invalid_reason = None
|
||||
if detail.result != 1:
|
||||
invalid_reason = f"Steam returned result {detail.result}"
|
||||
elif detail.consumer_app_id not in (0, 294100):
|
||||
invalid_reason = f"Workshop item belongs to app {detail.consumer_app_id}"
|
||||
return {
|
||||
"workshop_id": detail.workshop_id,
|
||||
"title": detail.title,
|
||||
"package_id": None,
|
||||
"steam_app_id": None,
|
||||
"display_name": None,
|
||||
"time_updated": detail.time_updated,
|
||||
"metadata_file_size": detail.file_size,
|
||||
"disk_size": None,
|
||||
"local_path": None,
|
||||
"status": "failed"
|
||||
if invalid_reason
|
||||
else ("pending" if stale else (previous or {}).get("status", "pending")),
|
||||
"error": invalid_reason,
|
||||
"last_checked_at": utc_now(),
|
||||
"last_downloaded_at": None,
|
||||
}
|
||||
|
||||
def _inspection_values(
|
||||
self,
|
||||
workshop_id: str,
|
||||
downloaded: bool,
|
||||
forced_error: str | None = None,
|
||||
) -> tuple[bool, dict[str, Any]]:
|
||||
mod_path = self.settings.workshop_content_dir / workshop_id
|
||||
if forced_error:
|
||||
return False, {
|
||||
"workshop_id": workshop_id,
|
||||
"status": "failed",
|
||||
"error": forced_error,
|
||||
"disk_size": directory_size(mod_path),
|
||||
"local_path": str(mod_path) if mod_path.exists() else None,
|
||||
"last_checked_at": utc_now(),
|
||||
"last_downloaded_at": utc_now() if downloaded else None,
|
||||
}
|
||||
try:
|
||||
parsed = parse_about_xml(mod_path)
|
||||
return True, {
|
||||
"workshop_id": workshop_id,
|
||||
"package_id": parsed.package_id,
|
||||
"steam_app_id": parsed.declared_steam_app_id,
|
||||
"display_name": parsed.display_name,
|
||||
"disk_size": directory_size(mod_path),
|
||||
"local_path": str(mod_path),
|
||||
"status": "ready",
|
||||
"error": "",
|
||||
"last_checked_at": utc_now(),
|
||||
"last_downloaded_at": utc_now() if downloaded else None,
|
||||
}
|
||||
except (OSError, ValueError) as error:
|
||||
return False, {
|
||||
"workshop_id": workshop_id,
|
||||
"status": "failed",
|
||||
"error": str(error),
|
||||
"disk_size": directory_size(mod_path),
|
||||
"local_path": str(mod_path) if mod_path.exists() else None,
|
||||
"last_checked_at": utc_now(),
|
||||
"last_downloaded_at": utc_now() if downloaded else None,
|
||||
}
|
||||
|
||||
def refresh(self, run_id: str, trigger: str) -> None:
|
||||
started_at = utc_now()
|
||||
started = time.monotonic()
|
||||
self.database.create_run(run_id, trigger, started_at)
|
||||
metrics: dict[str, Any] = {
|
||||
"collection_items": 0,
|
||||
"unique_items": 0,
|
||||
"ready_items": 0,
|
||||
"downloads_requested": 0,
|
||||
"downloads_succeeded": 0,
|
||||
"downloads_failed": 0,
|
||||
"bytes_before": directory_size(self.settings.workshop_content_dir),
|
||||
"bytes_after": 0,
|
||||
"bytes_downloaded": 0,
|
||||
"cache_bytes": 0,
|
||||
"status": "failed",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
collection_ids = list(self.settings.collections.values())
|
||||
response = self.steam.get_collections(collection_ids)
|
||||
named_collections: dict[str, tuple[str, list[str]]] = {}
|
||||
all_ids: list[str] = []
|
||||
for name, collection_id in self.settings.collections.items():
|
||||
workshop_ids = response.get(collection_id)
|
||||
if workshop_ids is None:
|
||||
raise RuntimeError(
|
||||
f"Steam did not return collection {collection_id}"
|
||||
)
|
||||
named_collections[name] = (collection_id, workshop_ids)
|
||||
all_ids.extend(workshop_ids)
|
||||
self.database.replace_collection_members(named_collections)
|
||||
|
||||
unique_ids = sorted(set(all_ids), key=int)
|
||||
metrics["collection_items"] = len(all_ids)
|
||||
metrics["unique_items"] = len(unique_ids)
|
||||
details = self.steam.get_item_details(unique_ids)
|
||||
previous = self.database.mod_map(unique_ids)
|
||||
|
||||
targets: list[str] = []
|
||||
metadata_rows: list[dict[str, Any]] = []
|
||||
for workshop_id in unique_ids:
|
||||
detail = details.get(workshop_id)
|
||||
if not detail:
|
||||
self.database.upsert_mod(
|
||||
{
|
||||
"workshop_id": workshop_id,
|
||||
"status": "failed",
|
||||
"error": "Steam returned no item metadata",
|
||||
"last_checked_at": utc_now(),
|
||||
}
|
||||
)
|
||||
continue
|
||||
old = previous.get(workshop_id)
|
||||
local_path = self.settings.workshop_content_dir / workshop_id
|
||||
stale = (
|
||||
old is None
|
||||
or int(old.get("time_updated") or 0) != detail.time_updated
|
||||
or old.get("status") != "ready"
|
||||
or not local_path.is_dir()
|
||||
)
|
||||
metadata_rows.append(self._store_metadata(detail, old, stale))
|
||||
if (
|
||||
detail.result == 1
|
||||
and detail.consumer_app_id in (0, 294100)
|
||||
and stale
|
||||
):
|
||||
targets.append(workshop_id)
|
||||
|
||||
self.database.upsert_mods(metadata_rows)
|
||||
|
||||
metrics["downloads_requested"] = len(targets)
|
||||
if targets and not self.runner.available():
|
||||
reason = (
|
||||
f"SteamCMD was not found at '{self.settings.steamcmd_path}'. "
|
||||
"Set STEAMCMD_PATH to enable anonymous downloads."
|
||||
)
|
||||
inspection_rows: list[dict[str, Any]] = []
|
||||
for workshop_id in targets:
|
||||
mod_path = self.settings.workshop_content_dir / workshop_id
|
||||
success, values = self._inspection_values(
|
||||
workshop_id,
|
||||
downloaded=False,
|
||||
forced_error=None if mod_path.is_dir() else reason,
|
||||
)
|
||||
inspection_rows.append(values)
|
||||
if not success:
|
||||
metrics["downloads_failed"] += 1
|
||||
self.database.upsert_mods(inspection_rows)
|
||||
else:
|
||||
for batch_number, batch in enumerate(
|
||||
_batches(targets, self.settings.steamcmd_batch_size), start=1
|
||||
):
|
||||
result = self.runner.run_batch(batch)
|
||||
self.database.add_download_attempt(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"batch_number": batch_number,
|
||||
"item_count": len(batch),
|
||||
"workshop_ids": batch,
|
||||
"started_at": result.started_at,
|
||||
"finished_at": result.finished_at,
|
||||
"elapsed_seconds": result.elapsed_seconds,
|
||||
"exit_code": result.exit_code,
|
||||
"status": result.status,
|
||||
"output_tail": result.output_tail,
|
||||
}
|
||||
)
|
||||
inspection_rows = []
|
||||
for workshop_id in batch:
|
||||
failure = (
|
||||
"SteamCMD reported a download failure"
|
||||
if workshop_id in result.failed_ids
|
||||
else None
|
||||
)
|
||||
success, values = self._inspection_values(
|
||||
workshop_id, downloaded=True, forced_error=failure
|
||||
)
|
||||
inspection_rows.append(values)
|
||||
if success:
|
||||
metrics["downloads_succeeded"] += 1
|
||||
else:
|
||||
metrics["downloads_failed"] += 1
|
||||
self.database.upsert_mods(inspection_rows)
|
||||
|
||||
# Re-inspect ready cached items whose metadata has never been parsed by this backend.
|
||||
current = self.database.mod_map(unique_ids)
|
||||
pending_rows = []
|
||||
for workshop_id in unique_ids:
|
||||
row = current.get(workshop_id)
|
||||
if row and row.get("status") == "pending":
|
||||
_, values = self._inspection_values(workshop_id, downloaded=False)
|
||||
pending_rows.append(values)
|
||||
if pending_rows:
|
||||
self.database.upsert_mods(pending_rows)
|
||||
|
||||
metrics["bytes_after"] = directory_size(self.settings.workshop_content_dir)
|
||||
metrics["cache_bytes"] = metrics["bytes_after"]
|
||||
metrics["bytes_downloaded"] = max(
|
||||
metrics["bytes_after"] - metrics["bytes_before"], 0
|
||||
)
|
||||
final_rows = self.database.mod_map(unique_ids)
|
||||
metrics["ready_items"] = sum(
|
||||
1 for row in final_rows.values() if row.get("status") == "ready"
|
||||
)
|
||||
metrics["status"] = (
|
||||
"completed"
|
||||
if metrics["ready_items"] == metrics["unique_items"]
|
||||
else "partial"
|
||||
)
|
||||
if metrics["status"] == "partial" and not metrics["error"]:
|
||||
metrics["error"] = (
|
||||
f"{metrics['unique_items'] - metrics['ready_items']} items are not ready"
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - the run must always persist a terminal state
|
||||
metrics["status"] = "failed"
|
||||
metrics["error"] = str(error)
|
||||
metrics["bytes_after"] = directory_size(self.settings.workshop_content_dir)
|
||||
metrics["cache_bytes"] = metrics["bytes_after"]
|
||||
finally:
|
||||
elapsed = max(time.monotonic() - started, 0.001)
|
||||
metrics["elapsed_seconds"] = round(elapsed, 3)
|
||||
metrics["average_mbps"] = round(
|
||||
(metrics["bytes_downloaded"] * 8 / 1_000_000) / elapsed, 3
|
||||
)
|
||||
metrics["finished_at"] = utc_now()
|
||||
self.database.finish_run(run_id, metrics)
|
||||
|
||||
|
||||
class RefreshCoordinator:
|
||||
def __init__(
|
||||
self, service: RefreshService, interval_seconds: int, refresh_on_startup: bool
|
||||
):
|
||||
self.service = service
|
||||
self.interval_seconds = interval_seconds
|
||||
self.refresh_on_startup = refresh_on_startup
|
||||
self.current_run_id: str | None = None
|
||||
self.next_refresh_at: str | None = None
|
||||
self._refresh_task: asyncio.Task[None] | None = None
|
||||
self._scheduler_task: asyncio.Task[None] | None = None
|
||||
self._stop = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._scheduler_task:
|
||||
return
|
||||
self._scheduler_task = asyncio.create_task(self._scheduler_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._scheduler_task:
|
||||
self._scheduler_task.cancel()
|
||||
await asyncio.gather(self._scheduler_task, return_exceptions=True)
|
||||
|
||||
async def trigger(self, trigger: str) -> tuple[str, bool]:
|
||||
if self._refresh_task and not self._refresh_task.done():
|
||||
return self.current_run_id or "", False
|
||||
run_id = uuid.uuid4().hex
|
||||
self.current_run_id = run_id
|
||||
|
||||
async def run() -> None:
|
||||
try:
|
||||
await asyncio.to_thread(self.service.refresh, run_id, trigger)
|
||||
finally:
|
||||
self.current_run_id = None
|
||||
|
||||
self._refresh_task = asyncio.create_task(run())
|
||||
return run_id, True
|
||||
|
||||
async def _scheduler_loop(self) -> None:
|
||||
if self.refresh_on_startup:
|
||||
await self.trigger("startup")
|
||||
while not self._stop.is_set():
|
||||
next_time = datetime.now(timezone.utc) + timedelta(
|
||||
seconds=self.interval_seconds
|
||||
)
|
||||
self.next_refresh_at = next_time.isoformat()
|
||||
try:
|
||||
await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds)
|
||||
except asyncio.TimeoutError:
|
||||
await self.trigger("scheduled")
|
||||
|
||||
def state(self) -> dict[str, Any]:
|
||||
return {
|
||||
"running": bool(self._refresh_task and not self._refresh_task.done()),
|
||||
"current_run_id": self.current_run_id,
|
||||
"next_refresh_at": self.next_refresh_at,
|
||||
"interval_seconds": self.interval_seconds,
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const $$ = (selector) => [...document.querySelectorAll(selector)];
|
||||
|
||||
const INTRO_MINIMUM_MS = 900;
|
||||
const REQUIRED_EXPANSIONS = {
|
||||
royalty: { name: "Royalty", art: "/art/NotOwned_Royalty.png" },
|
||||
ideology: { name: "Ideology", art: "/art/NotOwned_Ideology.png" },
|
||||
biotech: { name: "Biotech", art: "/art/NotOwned_Biotech.png" },
|
||||
anomaly: { name: "Anomaly", art: "/art/NotOwned_Anomaly.png" },
|
||||
odyssey: { name: "Odyssey", art: null }
|
||||
};
|
||||
const INSTALL_PATHS = {
|
||||
windows: {
|
||||
label: "Windows",
|
||||
path: "%USERPROFILE%\\AppData\\LocalLow\\Ludeon Studios\\RimWorld by Ludeon Studios\\ModLists"
|
||||
},
|
||||
macos: {
|
||||
label: "macOS",
|
||||
path: "~/Library/Application Support/RimWorld/ModLists"
|
||||
},
|
||||
linux: {
|
||||
label: "Linux",
|
||||
path: "~/.config/unity3d/Ludeon Studios/RimWorld by Ludeon Studios/ModLists"
|
||||
}
|
||||
};
|
||||
const state = {
|
||||
currentStep: 0,
|
||||
listType: "standard",
|
||||
sortAcknowledged: false,
|
||||
polling: null,
|
||||
rmlReady: false
|
||||
};
|
||||
|
||||
function detectedOperatingSystem() {
|
||||
const platform = String(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || "").toLowerCase();
|
||||
if (platform.includes("mac")) return "macos";
|
||||
if (platform.includes("linux") || platform.includes("x11")) return "linux";
|
||||
return "windows";
|
||||
}
|
||||
|
||||
function renderInstallLocation() {
|
||||
const install = INSTALL_PATHS[detectedOperatingSystem()];
|
||||
$("#detected-os-name").textContent = install.label;
|
||||
$("#detected-os-path").textContent = install.path;
|
||||
}
|
||||
|
||||
function resetListType() {
|
||||
const standard = $('input[name="list-type"][value="standard"]');
|
||||
const acknowledgement = $("#acknowledge-autosort");
|
||||
standard.checked = true;
|
||||
state.listType = "standard";
|
||||
state.sortAcknowledged = false;
|
||||
$("#homebrew-settings").hidden = true;
|
||||
$("#autosort-confirmation").classList.remove("acknowledged");
|
||||
acknowledgement.setAttribute("aria-pressed", "false");
|
||||
acknowledgement.textContent = "Yes, I understand";
|
||||
setMessage("", "", "#wizard-message");
|
||||
updateDownloadAvailability();
|
||||
}
|
||||
|
||||
function formatDate(value, fallback = "Not available") {
|
||||
if (!value) return fallback;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatRelativeDate(value) {
|
||||
if (!value) return "Waiting for the first refresh";
|
||||
const elapsedSeconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000));
|
||||
if (elapsedSeconds < 60) return "Updated just now";
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
if (minutes < 60) return `Updated ${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `Updated ${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `Updated ${days} day${days === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
function setMessage(text, kind = "", selector = "#action-message") {
|
||||
const element = $(selector);
|
||||
element.textContent = text;
|
||||
element.className = `${element.dataset.baseClass || element.className.split(" ")[0]} ${kind}`.trim();
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, options);
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
if (contentType.includes("json")) {
|
||||
const body = await response.json();
|
||||
detail = typeof body.detail === "string" ? body.detail : body.detail?.message || detail;
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return contentType.includes("json") ? response.json() : response;
|
||||
}
|
||||
|
||||
function renderServiceState(data) {
|
||||
const element = $("#service-state");
|
||||
const label = element.querySelector("span:last-child");
|
||||
const total = Number(data.totals?.total_mods || 0);
|
||||
const ready = Number(data.totals?.ready_mods || 0);
|
||||
element.className = "service-state";
|
||||
|
||||
if (data.scheduler?.running) {
|
||||
element.classList.add("working");
|
||||
label.textContent = "Updating now";
|
||||
} else if (data.latest_run?.status === "failed") {
|
||||
element.classList.add("failed");
|
||||
label.textContent = "Update needs attention";
|
||||
} else if (!data.steamcmd_available) {
|
||||
element.classList.add("failed");
|
||||
label.textContent = "Downloads unavailable";
|
||||
} else if (ready < total) {
|
||||
element.classList.add("working");
|
||||
label.textContent = "Preparing RML";
|
||||
} else {
|
||||
element.classList.add("ready");
|
||||
label.textContent = "Ready";
|
||||
}
|
||||
}
|
||||
|
||||
function updateDownloadAvailability() {
|
||||
const downloadButton = $("#download-list");
|
||||
const availability = $("#rml-availability");
|
||||
const needsFile = state.listType === "homebrew" && !$("#mods-config").files[0];
|
||||
|
||||
downloadButton.disabled = !state.rmlReady || needsFile || !state.sortAcknowledged;
|
||||
if (!state.rmlReady) {
|
||||
availability.textContent = "The current RML is still being prepared. You can review every step now and download when it is ready.";
|
||||
availability.className = "availability waiting";
|
||||
} else if (needsFile) {
|
||||
availability.textContent = "Choose ModsConfig.xml to finish your homebrew list.";
|
||||
availability.className = "availability waiting";
|
||||
} else if (!state.sortAcknowledged) {
|
||||
availability.textContent = "Confirm the auto-sort step above to unlock your download.";
|
||||
availability.className = "availability waiting";
|
||||
} else {
|
||||
availability.textContent = state.listType === "homebrew"
|
||||
? "Your homebrew list is ready to build."
|
||||
: "The current Progression list is ready to download.";
|
||||
availability.className = "availability ready";
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboard(data) {
|
||||
renderServiceState(data);
|
||||
const totals = data.totals || {};
|
||||
const total = Number(totals.total_mods || 0);
|
||||
const ready = Number(totals.ready_mods || 0);
|
||||
const counts = Object.fromEntries(
|
||||
(data.collections || []).map(collection => [String(collection.name).toLowerCase(), Number(collection.item_count || 0)])
|
||||
);
|
||||
|
||||
$("#count-total").textContent = total ? total.toLocaleString() : "—";
|
||||
for (const category of ["core", "content", "cosmetics"]) {
|
||||
const formatted = counts[category]?.toLocaleString() || "—";
|
||||
$(`#count-${category}`).textContent = formatted;
|
||||
$(`[data-collection-count="${category}"]`).textContent = formatted;
|
||||
}
|
||||
|
||||
const updatedAt = data.rml_updated_at;
|
||||
$("#last-updated").textContent = data.scheduler?.running ? "Updating now" : formatRelativeDate(updatedAt);
|
||||
$("#last-updated").title = formatDate(updatedAt);
|
||||
|
||||
state.rmlReady = total > 0 && ready === total && !data.scheduler?.running;
|
||||
const nextRefresh = data.scheduler?.next_refresh_at;
|
||||
if (data.scheduler?.running) {
|
||||
$("#update-detail").textContent = "The hourly Workshop refresh is currently running.";
|
||||
} else if (!state.rmlReady) {
|
||||
$("#update-detail").textContent = "The current RML is being prepared from the latest Workshop data.";
|
||||
} else {
|
||||
$("#update-detail").textContent = nextRefresh
|
||||
? `Next automatic check ${formatDate(nextRefresh)}.`
|
||||
: "Workshop data refreshes every hour.";
|
||||
}
|
||||
updateDownloadAvailability();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
renderDashboard(await api("/api/status"));
|
||||
} catch (error) {
|
||||
const element = $("#service-state");
|
||||
element.className = "service-state failed";
|
||||
element.querySelector("span:last-child").textContent = "Backend unavailable";
|
||||
$("#last-updated").textContent = "Unavailable";
|
||||
$("#update-detail").textContent = "The service could not be reached.";
|
||||
state.rmlReady = false;
|
||||
updateDownloadAvailability();
|
||||
setMessage(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function showStep(step) {
|
||||
const panels = $$(".wizard-step");
|
||||
const progressItems = $$(".wizard-progress li");
|
||||
state.currentStep = Math.max(0, Math.min(step, panels.length - 1));
|
||||
|
||||
panels.forEach((panel, index) => {
|
||||
const active = index === state.currentStep;
|
||||
panel.hidden = !active;
|
||||
panel.classList.toggle("active", active);
|
||||
});
|
||||
|
||||
progressItems.forEach((item, index) => {
|
||||
item.classList.toggle("active", index === state.currentStep);
|
||||
item.classList.toggle("complete", index < state.currentStep);
|
||||
if (index === state.currentStep) item.setAttribute("aria-current", "step");
|
||||
else item.removeAttribute("aria-current");
|
||||
});
|
||||
|
||||
if (state.currentStep === 2) updateExpansionRequirements();
|
||||
}
|
||||
|
||||
function updateExpansionRequirements() {
|
||||
const inputs = $$("#dlc-options input");
|
||||
const missing = inputs
|
||||
.filter(input => !input.checked)
|
||||
.map(input => REQUIRED_EXPANSIONS[input.value]);
|
||||
const warning = $("#expansion-warning");
|
||||
const iconList = $("#missing-expansion-icons");
|
||||
|
||||
inputs.forEach(input => {
|
||||
const isMissing = !input.checked;
|
||||
input.closest("label").classList.toggle("missing", isMissing);
|
||||
input.setAttribute("aria-invalid", String(isMissing));
|
||||
});
|
||||
|
||||
warning.hidden = missing.length === 0;
|
||||
$("#expansions-next").disabled = missing.length > 0;
|
||||
if (!missing.length) {
|
||||
iconList.replaceChildren();
|
||||
$("#expansion-warning-text").textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const names = missing.map(expansion => expansion.name);
|
||||
$("#expansion-warning-text").textContent = `${names.join(", ")} ${missing.length === 1 ? "is" : "are"} required before you can continue.`;
|
||||
iconList.innerHTML = missing.map(expansion => expansion.art
|
||||
? `<img src="${expansion.art}" alt="">`
|
||||
: `<span class="missing-expansion-fallback"><b>!</b><small>ODYSSEY</small><em>REQUIRED</em></span>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
function selectedDlcQuery() {
|
||||
return $$("#dlc-options input:checked")
|
||||
.map(input => `dlc=${encodeURIComponent(input.value)}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
async function downloadResponse(responsePromise, filename) {
|
||||
const response = await responsePromise;
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
$("#start-wizard").addEventListener("click", () => {
|
||||
resetListType();
|
||||
showStep(0);
|
||||
$("#download-wizard").showModal();
|
||||
});
|
||||
|
||||
$("#close-wizard").addEventListener("click", () => $("#download-wizard").close());
|
||||
|
||||
$("#download-wizard").addEventListener("click", event => {
|
||||
if (event.target === event.currentTarget) event.currentTarget.close();
|
||||
});
|
||||
|
||||
$$('[data-next]').forEach(button => button.addEventListener("click", () => showStep(state.currentStep + 1)));
|
||||
$$('[data-back]').forEach(button => button.addEventListener("click", () => showStep(state.currentStep - 1)));
|
||||
|
||||
$("#setup-confirm").addEventListener("change", event => {
|
||||
$("#setup-next").disabled = !event.target.checked;
|
||||
});
|
||||
|
||||
$$("#dlc-options input").forEach(input => input.addEventListener("change", updateExpansionRequirements));
|
||||
|
||||
$("#show-all-paths").addEventListener("click", event => {
|
||||
const paths = $("#all-install-paths");
|
||||
const show = paths.hidden;
|
||||
paths.hidden = !show;
|
||||
event.currentTarget.setAttribute("aria-expanded", String(show));
|
||||
event.currentTarget.textContent = show ? "Hide other OS" : "Show other OS";
|
||||
});
|
||||
|
||||
$("#acknowledge-autosort").addEventListener("click", event => {
|
||||
state.sortAcknowledged = true;
|
||||
$("#autosort-confirmation").classList.add("acknowledged");
|
||||
event.currentTarget.setAttribute("aria-pressed", "true");
|
||||
event.currentTarget.textContent = "Understood ✓";
|
||||
updateDownloadAvailability();
|
||||
});
|
||||
|
||||
$$('input[name="list-type"]').forEach(input => input.addEventListener("change", event => {
|
||||
state.listType = event.target.value;
|
||||
$("#homebrew-settings").hidden = state.listType !== "homebrew";
|
||||
setMessage("", "", "#wizard-message");
|
||||
updateDownloadAvailability();
|
||||
}));
|
||||
|
||||
$("#mods-config").addEventListener("change", event => {
|
||||
const file = event.target.files[0];
|
||||
$("#file-label").textContent = file?.name || "Choose ModsConfig.xml";
|
||||
$(".file-picker").classList.toggle("has-file", Boolean(file));
|
||||
updateDownloadAvailability();
|
||||
});
|
||||
|
||||
$("#download-list").addEventListener("click", async () => {
|
||||
const button = $("#download-list");
|
||||
button.disabled = true;
|
||||
setMessage("Building your mod list…", "", "#wizard-message");
|
||||
|
||||
try {
|
||||
if (state.listType === "homebrew") {
|
||||
const file = $("#mods-config").files[0];
|
||||
if (!file) throw new Error("Choose ModsConfig.xml before building a homebrew list.");
|
||||
const form = new FormData();
|
||||
form.append("mods_config", file);
|
||||
await downloadResponse(api("/api/modlists/homebrew", { method: "POST", body: form }), "ProgressionHomebrew.rml");
|
||||
setMessage("ProgressionHomebrew.rml is ready. Auto-sort it after loading.", "success", "#wizard-message");
|
||||
} else {
|
||||
const query = selectedDlcQuery();
|
||||
await downloadResponse(api(`/api/modlists/vanilla${query ? `?${query}` : ""}`), "ProgressionVanilla.rml");
|
||||
setMessage("ProgressionVanilla.rml is ready.", "success", "#wizard-message");
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage(error.message, "error", "#wizard-message");
|
||||
} finally {
|
||||
updateDownloadAvailability();
|
||||
}
|
||||
});
|
||||
|
||||
async function start() {
|
||||
$("#action-message").dataset.baseClass = "action-message";
|
||||
$("#wizard-message").dataset.baseClass = "wizard-message";
|
||||
renderInstallLocation();
|
||||
const minimumIntro = new Promise(resolve => window.setTimeout(resolve, INTRO_MINIMUM_MS));
|
||||
await Promise.all([loadDashboard(), minimumIntro]);
|
||||
document.body.classList.add("is-ready");
|
||||
window.setTimeout(() => {
|
||||
$("#intro").hidden = true;
|
||||
}, 850);
|
||||
}
|
||||
|
||||
start();
|
||||
state.polling = window.setInterval(loadDashboard, 60_000);
|
||||
@@ -0,0 +1,254 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Build the latest Progression mod list for RimWorld.">
|
||||
<meta name="theme-color" content="#151515">
|
||||
<title>Progression — RimWorld Modpack</title>
|
||||
<link rel="icon" type="image/png" href="/art/ProgressionICO.png">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="intro" id="intro" aria-hidden="true">
|
||||
<div class="intro-shade"></div>
|
||||
<div class="intro-content">
|
||||
<img src="/art/GameTitle.png" alt="" class="intro-logo">
|
||||
<p>Preparing the latest mod list</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-shell" id="app">
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/" aria-label="Progression home">
|
||||
<img src="/art/GameTitle.png" alt="RimWorld Progression">
|
||||
</a>
|
||||
<div class="service-state" id="service-state" aria-live="polite">
|
||||
<span class="state-dot"></span>
|
||||
<span>Connecting</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero panel" aria-labelledby="page-title">
|
||||
<div class="hero-copy">
|
||||
<h1 id="page-title"><em>Built for beginners.</em><br>A current mod list in one click.</h1>
|
||||
<p class="hero-summary">Choose the expansions you own and download a ready-to-load RimWorld mod list.</p>
|
||||
|
||||
<button class="start-button" id="start-wizard" type="button">
|
||||
<span>
|
||||
<strong>Download RimWorld Mod List</strong>
|
||||
<small>Start here</small>
|
||||
</span>
|
||||
<span class="start-arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
|
||||
<a class="windows-download" href="/api/downloads/windows">
|
||||
<span class="windows-mark" aria-hidden="true">⊞</span>
|
||||
<span>
|
||||
<strong>Download the Windows app</strong>
|
||||
<small>Latest ProgressionLoader.exe</small>
|
||||
</span>
|
||||
<span class="windows-download-arrow" aria-hidden="true">↓</span>
|
||||
</a>
|
||||
|
||||
<div class="freshness">
|
||||
<span>RML last updated</span>
|
||||
<strong id="last-updated">Checking…</strong>
|
||||
<small id="update-detail">Workshop data refreshes every hour.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-art" aria-hidden="true">
|
||||
<img src="/art/ProgressionLogo.png" alt="">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="count-grid" aria-label="Current mod counts">
|
||||
<article class="count-card count-total">
|
||||
<span class="count-label">Total</span>
|
||||
<strong id="count-total">—</strong>
|
||||
<small>unique mods</small>
|
||||
</article>
|
||||
<article class="count-card count-core">
|
||||
<span class="count-label">Core</span>
|
||||
<strong id="count-core">—</strong>
|
||||
<small>foundation</small>
|
||||
</article>
|
||||
<article class="count-card count-content">
|
||||
<span class="count-label">Content</span>
|
||||
<strong id="count-content">—</strong>
|
||||
<small>gameplay</small>
|
||||
</article>
|
||||
<article class="count-card count-cosmetics">
|
||||
<span class="count-label">Cosmetics</span>
|
||||
<strong id="count-cosmetics">—</strong>
|
||||
<small>visuals</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<p class="action-message" id="action-message" aria-live="polite"></p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-meta">
|
||||
<span id="footer-status">Workshop data refreshed every hour</span>
|
||||
<span class="art-credit">Art by Ferny</span>
|
||||
</div>
|
||||
<a class="hr-link" href="https://hudsonriggs.systems" target="_blank" rel="noopener noreferrer" aria-label="Visit Hudson Riggs Systems">
|
||||
<span>Hudson Riggs Systems</span>
|
||||
<img src="/art/hudsonriggssystems.png" alt="">
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<dialog class="wizard" id="download-wizard" aria-labelledby="wizard-title">
|
||||
<div class="wizard-shell">
|
||||
<header class="wizard-header">
|
||||
<div>
|
||||
<span class="wizard-eyebrow">Progression Loader</span>
|
||||
<strong id="wizard-title">Build your mod list</strong>
|
||||
</div>
|
||||
<button class="icon-button" id="close-wizard" type="button" aria-label="Close">×</button>
|
||||
</header>
|
||||
|
||||
<ol class="wizard-progress" aria-label="Download steps">
|
||||
<li class="active" aria-current="step"><span>1</span><small>Collections</small></li>
|
||||
<li><span>2</span><small>Setup</small></li>
|
||||
<li><span>3</span><small>Expansions</small></li>
|
||||
<li><span>4</span><small>Download</small></li>
|
||||
</ol>
|
||||
|
||||
<div class="wizard-viewport">
|
||||
<section class="wizard-step active" data-step="0">
|
||||
<p class="step-kicker">Step one</p>
|
||||
<h2>Steam Workshop collections</h2>
|
||||
<p class="step-lede">The service keeps Progression’s three source collections current for you.</p>
|
||||
<div class="collection-checks">
|
||||
<div><span class="checkmark">✓</span><span><strong>Core</strong><small><b data-collection-count="core">—</b> mods</small></span></div>
|
||||
<div><span class="checkmark">✓</span><span><strong>Content</strong><small><b data-collection-count="content">—</b> mods</small></span></div>
|
||||
<div><span class="checkmark">✓</span><span><strong>Cosmetics</strong><small><b data-collection-count="cosmetics">—</b> mods</small></span></div>
|
||||
</div>
|
||||
<div class="wizard-actions wizard-actions-end">
|
||||
<button class="button button-primary" type="button" data-next>Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wizard-step" data-step="1" hidden>
|
||||
<p class="step-kicker">Step two</p>
|
||||
<h2>RimWorld setup verification</h2>
|
||||
<p class="step-lede">Before creating the list, make sure the game has created its configuration folders.</p>
|
||||
<ul class="setup-list">
|
||||
<li><span>✓</span>RimWorld has finished downloading</li>
|
||||
<li><span>✓</span>You have launched RimWorld at least once</li>
|
||||
<li><span>✓</span>The game reaches the main menu without errors</li>
|
||||
<li><span>✓</span>Your owned expansions are installed and active</li>
|
||||
</ul>
|
||||
<label class="confirm-row">
|
||||
<input type="checkbox" id="setup-confirm">
|
||||
<span>I’ve completed these steps</span>
|
||||
</label>
|
||||
<div class="wizard-actions">
|
||||
<button class="button button-back" type="button" data-back>Back</button>
|
||||
<button class="button button-primary" id="setup-next" type="button" data-next disabled>Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wizard-step" data-step="2" hidden>
|
||||
<p class="step-kicker">Step three</p>
|
||||
<h2>Choose your expansions</h2>
|
||||
<p class="step-lede">The complete Progression experience requires every RimWorld expansion listed below.</p>
|
||||
<fieldset class="dlc-grid" id="dlc-options">
|
||||
<legend class="sr-only">Required RimWorld expansions</legend>
|
||||
<label><input type="checkbox" value="royalty" checked><span>Royalty</span></label>
|
||||
<label><input type="checkbox" value="ideology" checked><span>Ideology</span></label>
|
||||
<label><input type="checkbox" value="biotech" checked><span>Biotech</span></label>
|
||||
<label><input type="checkbox" value="anomaly" checked><span>Anomaly</span></label>
|
||||
<label><input type="checkbox" value="odyssey" checked><span>Odyssey</span></label>
|
||||
</fieldset>
|
||||
<div class="expansion-warning" id="expansion-warning" role="status" aria-live="polite" hidden>
|
||||
<div class="expansion-warning-copy">
|
||||
<span>Required</span>
|
||||
<strong>Missing required expansions</strong>
|
||||
<p id="expansion-warning-text"></p>
|
||||
</div>
|
||||
<div class="missing-expansion-icons" id="missing-expansion-icons" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="wizard-actions">
|
||||
<button class="button button-back" type="button" data-back>Back</button>
|
||||
<button class="button button-primary" id="expansions-next" type="button" data-next>Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="wizard-step" data-step="3" hidden>
|
||||
<p class="step-kicker">Final step</p>
|
||||
<h2>Choose your mod list</h2>
|
||||
<p class="step-lede">The standard mod list is ready by default. Choose Homebrew only if you want to keep mods already enabled in your game.</p>
|
||||
|
||||
<div class="list-type-grid" role="radiogroup" aria-label="Mod list type">
|
||||
<label>
|
||||
<input type="radio" name="list-type" value="standard" checked>
|
||||
<span><strong>Standard mod list</strong><small>The complete Progression list</small></span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="list-type" value="homebrew">
|
||||
<span><strong>Homebrew</strong><small>Keep your enabled mods</small></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="homebrew-upload" id="homebrew-settings" hidden>
|
||||
<label class="file-picker" for="mods-config">
|
||||
<input type="file" id="mods-config" accept=".xml,text/xml">
|
||||
<span class="file-picker-copy">
|
||||
<strong id="file-label">Choose ModsConfig.xml</strong>
|
||||
<small>Processed in memory and never stored</small>
|
||||
</span>
|
||||
<span class="file-action">Browse</span>
|
||||
</label>
|
||||
<p><strong>After loading:</strong> auto-sort and check for mod conflicts.</p>
|
||||
</div>
|
||||
|
||||
<section class="install-location" aria-labelledby="install-location-title">
|
||||
<div class="install-location-heading">
|
||||
<div>
|
||||
<span>After downloading</span>
|
||||
<strong id="install-location-title">Move the RML into your <span id="detected-os-name">computer’s</span> ModLists folder</strong>
|
||||
</div>
|
||||
<button class="path-toggle" id="show-all-paths" type="button" aria-expanded="false" aria-controls="all-install-paths">Show other OS</button>
|
||||
</div>
|
||||
<code class="detected-path" id="detected-os-path"></code>
|
||||
<div class="all-install-paths" id="all-install-paths" hidden>
|
||||
<div><strong>Windows</strong><code>%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\ModLists</code></div>
|
||||
<div><strong>macOS</strong><code>~/Library/Application Support/RimWorld/ModLists</code></div>
|
||||
<div><strong>Linux</strong><code>~/.config/unity3d/Ludeon Studios/RimWorld by Ludeon Studios/ModLists</code></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="autosort-confirmation" id="autosort-confirmation">
|
||||
<section class="autosort-card" aria-labelledby="autosort-title">
|
||||
<span class="autosort-mark" aria-hidden="true">↕</span>
|
||||
<div>
|
||||
<span class="autosort-eyebrow">Required after loading</span>
|
||||
<h3 id="autosort-title">Auto-sort before you play</h3>
|
||||
<p>Open RimWorld’s mod manager, load the RML, then select <strong>Auto-sort</strong> and review any conflicts before starting a game.</p>
|
||||
</div>
|
||||
</section>
|
||||
<button class="autosort-acknowledge" id="acknowledge-autosort" type="button" aria-pressed="false">Yes, I understand</button>
|
||||
</div>
|
||||
|
||||
<div class="availability" id="rml-availability" aria-live="polite">Checking RML availability…</div>
|
||||
<p class="wizard-message" id="wizard-message" aria-live="polite"></p>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="button button-back" type="button" data-back>Back</button>
|
||||
<button class="button button-download" id="download-list" type="button" disabled>
|
||||
<span>Download mod list</span><span aria-hidden="true">↓</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,623 @@
|
||||
@font-face {
|
||||
font-family: "Progression Georgia";
|
||||
src: url("/art/georgia.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Progression Georgia";
|
||||
src: url("/art/georgiab.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Progression Georgia";
|
||||
src: url("/art/georgiaz.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #151515;
|
||||
--surface: #212121;
|
||||
--surface-raised: #2b2b2b;
|
||||
--surface-deep: #171a1c;
|
||||
--line: #4d4d4d;
|
||||
--line-soft: #373737;
|
||||
--text: #f5f2ec;
|
||||
--body: #c8c8c8;
|
||||
--muted: #979797;
|
||||
--yellow: #f0a621;
|
||||
--green: #79a557;
|
||||
--green-bright: #8fbd30;
|
||||
--purple: #cf83ce;
|
||||
--red: #ff6863;
|
||||
--pink: #ef718f;
|
||||
--radius: 22px;
|
||||
--font-serif: "Progression Georgia", Georgia, "Times New Roman", serif;
|
||||
--font-ui: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; background: var(--bg); }
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 85% 8%, rgba(84, 114, 79, .12), transparent 27rem),
|
||||
linear-gradient(180deg, #171717 0, #101214 100%);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button, input { font: inherit; }
|
||||
button { color: inherit; }
|
||||
img { display: block; max-width: 100%; }
|
||||
h1, h2, p { margin: 0; }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.intro {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #0b0b0c url("/art/Desolation.png") center / cover no-repeat;
|
||||
transition: opacity .7s ease, visibility .7s ease;
|
||||
}
|
||||
|
||||
.intro-shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(21, 21, 21, .08), rgba(21, 21, 21, .76) 72%),
|
||||
linear-gradient(rgba(21, 21, 21, .36), rgba(21, 21, 21, .66));
|
||||
}
|
||||
|
||||
.intro-content {
|
||||
position: relative;
|
||||
width: min(72vw, 760px);
|
||||
text-align: center;
|
||||
animation: intro-rise .85s cubic-bezier(.2, .72, .24, 1) both;
|
||||
}
|
||||
|
||||
.intro-logo { width: 100%; filter: drop-shadow(0 10px 32px rgba(0, 0, 0, .7)); }
|
||||
.intro-content p {
|
||||
margin-top: 20px;
|
||||
color: rgba(245, 242, 236, .65);
|
||||
font-family: var(--font-serif);
|
||||
font-size: clamp(.85rem, 1.3vw, 1.05rem);
|
||||
font-style: italic;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.is-ready .intro { opacity: 0; visibility: hidden; }
|
||||
|
||||
.page-shell {
|
||||
width: min(1220px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding-bottom: 26px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: opacity .55s ease .18s, transform .55s ease .18s;
|
||||
}
|
||||
|
||||
.is-ready .page-shell { opacity: 1; transform: none; }
|
||||
|
||||
.site-header {
|
||||
min-height: 98px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.brand { width: clamp(260px, 35vw, 420px); }
|
||||
.brand img { width: 100%; }
|
||||
|
||||
.service-state {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: var(--muted);
|
||||
font-size: .78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.state-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--yellow);
|
||||
box-shadow: 0 0 14px rgba(240, 166, 33, .5);
|
||||
}
|
||||
|
||||
.service-state.ready .state-dot { background: #75bf77; box-shadow: 0 0 14px rgba(117, 191, 119, .5); }
|
||||
.service-state.failed .state-dot { background: var(--red); box-shadow: none; }
|
||||
.service-state.working .state-dot { animation: pulse 1.2s ease-in-out infinite; }
|
||||
|
||||
main { display: grid; gap: 18px; padding-top: 2px; }
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, .18);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 430px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(280px, .72fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(110deg, rgba(20, 25, 29, .97) 0 60%, rgba(28, 43, 38, .82) 100%);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: center;
|
||||
padding: clamp(34px, 5vw, 60px);
|
||||
}
|
||||
|
||||
h1, h2 { font-family: var(--font-serif); }
|
||||
|
||||
h1 {
|
||||
max-width: 760px;
|
||||
font-size: clamp(2.35rem, 5.4vw, 4.7rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -.035em;
|
||||
line-height: .97;
|
||||
}
|
||||
|
||||
h1 em { color: #e7ffe0; font-size: .68em; }
|
||||
|
||||
.hero-summary {
|
||||
max-width: 58ch;
|
||||
margin-top: 22px;
|
||||
color: var(--body);
|
||||
font-family: var(--font-serif);
|
||||
font-size: clamp(1rem, 1.5vw, 1.16rem);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.start-button {
|
||||
width: min(100%, 620px);
|
||||
min-height: 86px;
|
||||
margin-top: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 15px 18px 15px 22px;
|
||||
border: 1px solid #8bad3f;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(120deg, #6f981f, #84ad28);
|
||||
box-shadow: inset 0 1px rgba(255,255,255,.15), 0 12px 26px rgba(50, 80, 15, .19);
|
||||
color: #f8ffe9;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: transform .18s ease, filter .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
|
||||
.start-button:hover { transform: translateY(-2px); filter: brightness(1.06); box-shadow: inset 0 1px rgba(255,255,255,.15), 0 16px 32px rgba(50, 80, 15, .25); }
|
||||
.start-button:focus-visible { outline: 3px solid rgba(183, 226, 83, .35); outline-offset: 3px; }
|
||||
.start-button > span:first-child { display: grid; }
|
||||
.start-button strong { font-family: var(--font-serif); font-size: clamp(1.15rem, 2.3vw, 1.55rem); line-height: 1.15; }
|
||||
.start-button small { margin-top: 3px; color: rgba(248,255,233,.76); font-size: .76rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.start-arrow { width: 44px; height: 44px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: rgba(18, 38, 8, .23); font-size: 1.35rem; transition: transform .18s ease; }
|
||||
.start-button:hover .start-arrow { transform: translateX(3px); }
|
||||
|
||||
.windows-download {
|
||||
width: min(100%, 620px);
|
||||
min-height: 62px;
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) 28px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #4c565e;
|
||||
border-radius: 10px;
|
||||
background: rgba(19, 24, 27, .82);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transition: border-color .18s ease, background .18s ease, transform .18s ease;
|
||||
}
|
||||
.windows-download:hover { transform: translateY(-1px); border-color: #7a8994; background: rgba(25, 31, 35, .94); }
|
||||
.windows-download:focus-visible { outline: 3px solid rgba(151, 179, 198, .25); outline-offset: 3px; }
|
||||
.windows-mark { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 7px; background: #27343c; color: #b9d9eb; font-size: 1.35rem; line-height: 1; }
|
||||
.windows-download > span:nth-child(2) { min-width: 0; display: grid; }
|
||||
.windows-download strong { font-family: var(--font-serif); font-size: .98rem; }
|
||||
.windows-download small { color: var(--muted); font-size: .72rem; }
|
||||
.windows-download-arrow { color: #afc6d4; font-size: 1.1rem; text-align: center; }
|
||||
|
||||
.freshness {
|
||||
width: fit-content;
|
||||
margin-top: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
column-gap: 13px;
|
||||
align-items: baseline;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.freshness > span {
|
||||
color: var(--muted);
|
||||
font-size: .72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.freshness strong { color: var(--text); font-family: var(--font-serif); font-size: 1.04rem; }
|
||||
.freshness small { grid-column: 1 / -1; margin-top: 2px; color: var(--muted); }
|
||||
|
||||
.hero-art {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 290px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 30px;
|
||||
background:
|
||||
repeating-linear-gradient(135deg, rgba(255,255,255,.026) 0 18px, transparent 18px 36px),
|
||||
linear-gradient(145deg, #2b3033, #1e2d28);
|
||||
border-left: 1px solid rgba(255,255,255,.09);
|
||||
}
|
||||
|
||||
.hero-art img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(62%, 220px);
|
||||
filter: drop-shadow(0 18px 16px rgba(0, 0, 0, .45));
|
||||
}
|
||||
|
||||
.count-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
|
||||
.count-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 132px;
|
||||
padding: 20px 22px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 16px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.count-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 72px;
|
||||
height: 5px;
|
||||
background: var(--card-accent, var(--yellow));
|
||||
}
|
||||
|
||||
.count-total { --card-accent: var(--yellow); }
|
||||
.count-core { --card-accent: #d7d7d7; }
|
||||
.count-content { --card-accent: var(--green); }
|
||||
.count-cosmetics { --card-accent: var(--purple); }
|
||||
.count-label { color: var(--body); font-family: var(--font-serif); font-size: 1rem; font-weight: 700; }
|
||||
.count-card strong { display: block; margin-top: 4px; font-family: var(--font-serif); font-size: clamp(2rem, 3vw, 2.75rem); font-weight: 700; line-height: 1; }
|
||||
.count-card small { color: var(--muted); }
|
||||
|
||||
.action-message { min-height: 18px; color: var(--muted); font-size: .86rem; text-align: center; }
|
||||
.action-message.error, .wizard-message.error { color: #ff918d; }
|
||||
.action-message.success, .wizard-message.success { color: #a2d596; }
|
||||
|
||||
footer {
|
||||
min-height: 88px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
color: #747474;
|
||||
font-size: .76rem;
|
||||
}
|
||||
|
||||
.footer-meta { display: flex; align-items: center; gap: 10px; }
|
||||
.art-credit { padding: 4px 8px; border: 1px solid #484848; border-radius: 999px; color: #999; font-family: var(--font-serif); font-style: italic; }
|
||||
|
||||
.hr-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #f16365;
|
||||
text-decoration: none;
|
||||
transition: color .16s ease, transform .16s ease;
|
||||
}
|
||||
|
||||
.hr-link:hover { color: #ff7b7d; transform: translateY(-1px); }
|
||||
.hr-link img { width: 42px; height: 42px; object-fit: contain; }
|
||||
|
||||
.wizard {
|
||||
width: min(820px, calc(100% - 30px));
|
||||
max-width: none;
|
||||
max-height: min(94vh, 820px);
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 0;
|
||||
border-radius: 22px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(.988);
|
||||
transition: opacity .22s ease, transform .22s ease, overlay .22s allow-discrete, display .22s allow-discrete;
|
||||
}
|
||||
|
||||
.wizard[open] { opacity: 1; transform: none; }
|
||||
|
||||
@starting-style {
|
||||
.wizard[open] { opacity: 0; transform: translateY(12px) scale(.988); }
|
||||
}
|
||||
|
||||
.wizard::backdrop {
|
||||
background: rgba(6, 8, 9, .82);
|
||||
backdrop-filter: blur(7px);
|
||||
animation: backdrop-in .22s ease both;
|
||||
}
|
||||
|
||||
.wizard-shell {
|
||||
overflow: hidden;
|
||||
border: 1px solid #555;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
repeating-linear-gradient(135deg, rgba(255,255,255,.017) 0 18px, transparent 18px 36px),
|
||||
#202224;
|
||||
box-shadow: 0 28px 90px rgba(0,0,0,.58);
|
||||
}
|
||||
|
||||
.wizard-header {
|
||||
min-height: 76px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 15px 20px 14px 24px;
|
||||
border-bottom: 1px solid #393b3c;
|
||||
background: rgba(17, 20, 22, .78);
|
||||
}
|
||||
|
||||
.wizard-header > div { display: grid; }
|
||||
.wizard-eyebrow, .step-kicker { color: var(--yellow); font-size: .67rem; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.wizard-header strong { font-family: var(--font-serif); font-size: 1.18rem; }
|
||||
.icon-button { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid #454849; border-radius: 50%; background: #292c2e; color: var(--muted); cursor: pointer; font-size: 1.4rem; line-height: 1; }
|
||||
.icon-button:hover { border-color: #727576; color: var(--text); }
|
||||
|
||||
.wizard-progress {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
margin: 0;
|
||||
padding: 20px 46px 18px;
|
||||
list-style: none;
|
||||
border-bottom: 1px solid #37393a;
|
||||
background: rgba(29, 31, 32, .9);
|
||||
}
|
||||
|
||||
.wizard-progress::before { content: ""; position: absolute; top: 35px; left: 14%; right: 14%; height: 1px; background: #4a4c4d; }
|
||||
.wizard-progress li { position: relative; z-index: 1; display: grid; justify-items: center; gap: 5px; color: #777; }
|
||||
.wizard-progress li span { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid #505253; border-radius: 50%; background: #242627; font-family: var(--font-serif); font-size: .84rem; transition: background .22s ease, border-color .22s ease, color .22s ease; }
|
||||
.wizard-progress li small { font-size: .68rem; font-weight: 750; letter-spacing: .04em; }
|
||||
.wizard-progress li.active { color: var(--text); }
|
||||
.wizard-progress li.active span { border-color: #a78340; background: #524326; color: #ffe6a4; }
|
||||
.wizard-progress li.complete { color: #91b989; }
|
||||
.wizard-progress li.complete span { border-color: #5d7d57; background: #33452f; color: #c9efc0; }
|
||||
|
||||
.wizard-viewport { min-height: 405px; }
|
||||
.wizard-step { padding: 28px 42px 26px; }
|
||||
.wizard-step.active { animation: step-enter .3s cubic-bezier(.2,.72,.24,1) both; }
|
||||
.wizard-step h2 { margin-top: 4px; font-size: clamp(1.8rem, 4vw, 2.65rem); line-height: 1.08; }
|
||||
.step-lede { max-width: 60ch; margin-top: 10px; color: var(--muted); }
|
||||
|
||||
.collection-checks { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 28px; }
|
||||
.collection-checks > div { min-width: 0; display: flex; align-items: center; gap: 11px; padding: 15px; border: 1px solid #414443; border-radius: 10px; background: rgba(17,20,19,.72); }
|
||||
.collection-checks > div > span:last-child { min-width: 0; display: grid; }
|
||||
.collection-checks strong { font-family: var(--font-serif); }
|
||||
.collection-checks small { color: var(--muted); }
|
||||
.collection-checks b { color: #c8e9bd; font-weight: 800; }
|
||||
.checkmark { width: 30px; height: 30px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: #33452f; color: #bfe6b5; font-weight: 900; }
|
||||
|
||||
.setup-list { display: grid; gap: 9px; margin: 24px 0 20px; padding: 0; list-style: none; }
|
||||
.setup-list li { display: flex; align-items: center; gap: 11px; padding: 10px 13px; border-radius: 8px; background: rgba(15,18,17,.58); color: var(--body); }
|
||||
.setup-list li span { color: #95ca89; font-weight: 900; }
|
||||
.confirm-row { width: fit-content; display: flex; align-items: center; gap: 10px; color: var(--text); cursor: pointer; font-weight: 750; }
|
||||
.confirm-row input { width: 18px; height: 18px; accent-color: #759f23; }
|
||||
|
||||
.dlc-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; min-width: 0; margin: 27px 0 16px; padding: 0; border: 0; }
|
||||
.dlc-grid label { min-width: 0; cursor: pointer; }
|
||||
.dlc-grid input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.dlc-grid span { min-height: 58px; display: grid; place-items: center; padding: 8px 5px; border: 1px solid var(--line); border-radius: 9px; background: #1b1b1b; color: var(--muted); font-size: .78rem; font-weight: 800; transition: border-color .16s ease, background .16s ease, color .16s ease, transform .16s ease; }
|
||||
.dlc-grid input:checked + span { border-color: #807551; background: #3d392c; color: #fff7c8; }
|
||||
.dlc-grid label.missing span { border-color: #9b4446; background: #392426; color: #ffaaa7; }
|
||||
.dlc-grid label:hover span { transform: translateY(-1px); }
|
||||
.dlc-grid input:focus-visible + span { outline: 3px solid rgba(240,166,33,.32); outline-offset: 2px; }
|
||||
.expansion-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin: 13px 0;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid #814347;
|
||||
border-radius: 11px;
|
||||
background:
|
||||
repeating-linear-gradient(135deg, rgba(255,95,91,.045) 0 12px, transparent 12px 24px),
|
||||
#2d2022;
|
||||
animation: warning-enter .24s cubic-bezier(.2,.72,.24,1) both;
|
||||
}
|
||||
|
||||
.expansion-warning[hidden] { display: none; }
|
||||
.expansion-warning-copy { min-width: 0; display: grid; }
|
||||
.expansion-warning-copy > span { color: #ff7773; font-size: .64rem; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; }
|
||||
.expansion-warning-copy strong { margin-top: 2px; font-family: var(--font-serif); font-size: 1.05rem; }
|
||||
.expansion-warning-copy p { margin-top: 2px; color: #cba9a8; font-size: .78rem; }
|
||||
.missing-expansion-icons { flex: 0 0 auto; display: flex; align-items: center; gap: 7px; }
|
||||
.missing-expansion-icons img { width: 58px; height: 58px; object-fit: contain; }
|
||||
.missing-expansion-fallback { width: 58px; height: 58px; display: grid; place-items: center; align-content: center; border: 2px solid #a8b2bd; clip-path: polygon(50% 0, 92% 24%, 92% 76%, 50% 100%, 8% 76%, 8% 24%); background: #31363a; text-align: center; }
|
||||
.missing-expansion-fallback b { color: #ff3f3a; font-family: var(--font-serif); font-size: 1.7rem; line-height: .75; }
|
||||
.missing-expansion-fallback small { margin-top: 3px; color: #d9dde0; font-size: .44rem; font-style: normal; font-weight: 900; letter-spacing: .03em; }
|
||||
.missing-expansion-fallback em { color: #fff; font-size: .46rem; font-style: normal; font-weight: 900; }
|
||||
|
||||
.list-type-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 24px; }
|
||||
.list-type-grid label { cursor: pointer; }
|
||||
.list-type-grid input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.list-type-grid label > span { min-height: 84px; display: grid; align-content: center; padding: 15px 17px; border: 1px solid #484a4b; border-radius: 10px; background: #191b1c; transition: border-color .16s ease, background .16s ease, transform .16s ease; }
|
||||
.list-type-grid label:hover > span { transform: translateY(-1px); }
|
||||
.list-type-grid strong { font-family: var(--font-serif); font-size: 1.05rem; }
|
||||
.list-type-grid small { color: var(--muted); }
|
||||
.list-type-grid input:checked + span { border-color: #987c47; background: #332f25; }
|
||||
.list-type-grid label:last-child input:checked + span { border-color: #8a608d; background: #332735; }
|
||||
.list-type-grid input:focus-visible + span { outline: 3px solid rgba(240,166,33,.25); outline-offset: 2px; }
|
||||
|
||||
.homebrew-upload { margin-top: 12px; animation: step-enter .25s ease both; }
|
||||
.homebrew-upload[hidden] { display: none; }
|
||||
.file-picker { min-height: 61px; min-width: 0; display: flex; align-items: center; gap: 14px; padding: 9px 10px 9px 14px; border: 1px dashed #665669; border-radius: 9px; background: #1b191c; cursor: pointer; }
|
||||
.file-picker:hover, .file-picker.has-file { border-color: var(--purple); }
|
||||
.file-picker input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.file-picker input:focus-visible + .file-picker-copy { outline: 3px solid rgba(207,131,206,.28); outline-offset: 3px; }
|
||||
.file-picker-copy { min-width: 0; display: grid; }
|
||||
.file-picker-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-picker-copy small { color: var(--muted); }
|
||||
.file-action { margin-left: auto; padding: 7px 10px; border-radius: 6px; background: #302a31; color: #edc9ed; font-size: .78rem; font-weight: 800; }
|
||||
.homebrew-upload p { margin-top: 8px; color: var(--muted); font-size: .78rem; }
|
||||
.homebrew-upload p strong { color: #d6b7d6; }
|
||||
|
||||
.install-location { margin-top: 14px; padding: 12px 13px; border: 1px solid #454849; border-radius: 10px; background: rgba(17, 19, 20, .72); }
|
||||
.install-location-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.install-location-heading > div { min-width: 0; display: grid; }
|
||||
.install-location-heading > div > span { color: var(--yellow); font-size: .62rem; font-weight: 850; letter-spacing: .11em; text-transform: uppercase; }
|
||||
.install-location-heading strong { margin-top: 2px; font-family: var(--font-serif); font-size: .91rem; }
|
||||
.path-toggle { flex: 0 0 auto; border: 0; padding: 3px 0; background: transparent; color: #c6b77f; cursor: pointer; font-size: .72rem; font-weight: 800; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.path-toggle:hover { color: #ffe5a0; }
|
||||
.detected-path, .all-install-paths code { display: block; overflow-wrap: anywhere; color: #bad1b4; font-family: Consolas, "Cascadia Mono", monospace; font-size: .7rem; line-height: 1.45; }
|
||||
.detected-path { margin-top: 8px; padding: 7px 9px; border-radius: 6px; background: #111413; }
|
||||
.all-install-paths { display: grid; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid #393c3d; }
|
||||
.all-install-paths[hidden] { display: none; }
|
||||
.all-install-paths > div { display: grid; grid-template-columns: 62px minmax(0, 1fr); gap: 10px; }
|
||||
.all-install-paths strong { color: var(--body); font-size: .7rem; }
|
||||
|
||||
.autosort-confirmation { margin-top: 14px; }
|
||||
.autosort-card { min-height: 118px; display: grid; grid-template-columns: 72px minmax(0, 1fr); align-items: center; gap: 18px; padding: 17px 19px; border: 1px solid #8b6728; border-radius: 12px 12px 0 0; background: linear-gradient(125deg, rgba(73, 51, 17, .84), rgba(38, 32, 22, .9)); box-shadow: inset 0 1px rgba(255, 227, 157, .08); }
|
||||
.autosort-mark { width: 64px; height: 64px; display: grid; place-items: center; border: 1px solid #aa7c2d; border-radius: 10px; background: #322615; color: #ffc75d; font-family: var(--font-serif); font-size: 2.35rem; font-weight: 900; line-height: 1; }
|
||||
.autosort-card > div { min-width: 0; }
|
||||
.autosort-eyebrow { color: #f4b947; font-size: .63rem; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.autosort-card h3 { margin-top: 2px; font-family: var(--font-serif); font-size: 1.35rem; line-height: 1.1; }
|
||||
.autosort-card p { margin-top: 6px; color: #c8bda7; font-size: .79rem; line-height: 1.45; }
|
||||
.autosort-card p strong { color: #ffe0a0; }
|
||||
.autosort-acknowledge { width: 100%; min-height: 44px; border: 1px solid #8b6728; border-top: 0; border-radius: 0 0 12px 12px; background: #211d16; color: #f4d28e; cursor: pointer; font-weight: 850; transition: background .16s ease, color .16s ease, border-color .16s ease; }
|
||||
.autosort-acknowledge:hover { background: #2c2519; color: #ffe4a8; }
|
||||
.autosort-acknowledge:focus-visible { outline: 3px solid rgba(240, 166, 33, .3); outline-offset: 2px; }
|
||||
.autosort-confirmation.acknowledged .autosort-card { border-color: #56734f; background: linear-gradient(125deg, rgba(42, 64, 36, .75), rgba(29, 38, 27, .9)); }
|
||||
.autosort-confirmation.acknowledged .autosort-mark { border-color: #688a60; background: #293a25; color: #bde5b4; }
|
||||
.autosort-confirmation.acknowledged .autosort-acknowledge { border-color: #56734f; background: #31472d; color: #ccefc5; }
|
||||
|
||||
.availability { margin-top: 14px; padding: 9px 11px; border-radius: 7px; font-size: .79rem; }
|
||||
.availability.waiting { background: #302b20; color: #cdbd91; }
|
||||
.availability.ready { background: #273424; color: #a9d49e; }
|
||||
.wizard-message { min-height: 22px; margin-top: 6px; color: var(--muted); font-size: .8rem; }
|
||||
|
||||
.wizard-actions { display: flex; justify-content: space-between; gap: 10px; margin-top: 22px; }
|
||||
.wizard-actions-end { justify-content: flex-end; margin-top: 72px; }
|
||||
.button { min-height: 46px; display: inline-flex; align-items: center; justify-content: center; gap: 28px; border: 1px solid transparent; border-radius: 8px; padding: 10px 20px; cursor: pointer; font-weight: 850; transition: transform .15s ease, filter .15s ease, border-color .15s ease; }
|
||||
.button:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
||||
.button:disabled { cursor: not-allowed; opacity: .42; transform: none; filter: none; }
|
||||
.button-primary { min-width: 150px; background: #729f20; color: #f7ffe9; }
|
||||
.button-back { border-color: #4b4d4e; background: transparent; color: var(--muted); }
|
||||
.button-download { min-width: 230px; justify-content: space-between; background: #729f20; color: #f7ffe9; }
|
||||
|
||||
@keyframes intro-rise { from { opacity: 0; transform: translateY(16px) scale(.985); } to { opacity: 1; transform: none; } }
|
||||
@keyframes pulse { 50% { opacity: .45; transform: scale(.78); } }
|
||||
@keyframes backdrop-in { from { background: rgba(6,8,9,0); backdrop-filter: blur(0); } }
|
||||
@keyframes step-enter { from { opacity: 0; transform: translateX(9px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes warning-enter { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero { grid-template-columns: 1fr minmax(230px, .52fr); }
|
||||
.hero-copy { padding: 38px; }
|
||||
.count-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.page-shell { width: min(calc(100% - 24px), 1220px); }
|
||||
.site-header { min-height: 78px; }
|
||||
.brand { width: min(67vw, 330px); }
|
||||
.service-state { font-size: 0; }
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.hero-copy { padding: 30px 24px; }
|
||||
.hero-art { min-height: 180px; border-top: 1px solid rgba(255,255,255,.09); border-left: 0; }
|
||||
.hero-art img { width: 122px; }
|
||||
.freshness { grid-template-columns: 1fr; }
|
||||
.freshness small { grid-column: auto; }
|
||||
footer { align-items: flex-start; gap: 10px; }
|
||||
.hr-link span { display: none; }
|
||||
.wizard { width: min(100% - 18px, 820px); max-height: 94vh; }
|
||||
.wizard-header { min-height: 66px; padding-left: 18px; }
|
||||
.wizard-progress { padding: 16px 16px 14px; }
|
||||
.wizard-progress::before { top: 31px; }
|
||||
.wizard-progress li small { display: none; }
|
||||
.wizard-step { padding: 27px 20px 24px; }
|
||||
.wizard-viewport { min-height: 470px; }
|
||||
.collection-checks { grid-template-columns: 1fr; }
|
||||
.wizard-actions-end { margin-top: 30px; }
|
||||
.dlc-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.expansion-warning { align-items: flex-start; flex-direction: column; }
|
||||
.missing-expansion-icons { max-width: 100%; flex-wrap: wrap; }
|
||||
.list-type-grid { grid-template-columns: 1fr; }
|
||||
.install-location-heading { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
.autosort-card { grid-template-columns: 54px minmax(0, 1fr); gap: 13px; padding: 15px; }
|
||||
.autosort-mark { width: 50px; height: 50px; font-size: 1.9rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.count-grid { grid-template-columns: 1fr; }
|
||||
.count-card { min-height: 100px; }
|
||||
.start-button { min-height: 78px; padding-left: 16px; }
|
||||
.footer-meta { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.wizard-actions { display: grid; grid-template-columns: 1fr 1.5fr; }
|
||||
.button, .button-download { min-width: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; scroll-behavior: auto !important; transition: none !important; }
|
||||
.intro { display: none; }
|
||||
.page-shell { opacity: 1; transform: none; }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
COLLECTION_DETAILS_URL = (
|
||||
"https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/"
|
||||
)
|
||||
PUBLISHED_FILE_DETAILS_URL = (
|
||||
"https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkshopDetails:
|
||||
workshop_id: str
|
||||
title: str
|
||||
time_updated: int
|
||||
file_size: int
|
||||
consumer_app_id: int
|
||||
result: int
|
||||
|
||||
|
||||
def _chunks(values: list[str], size: int) -> Iterable[list[str]]:
|
||||
for index in range(0, len(values), size):
|
||||
yield values[index : index + size]
|
||||
|
||||
|
||||
class SteamWorkshopClient:
|
||||
def __init__(self, timeout: int = 30):
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
retries = Retry(
|
||||
total=4,
|
||||
backoff_factor=0.75,
|
||||
status_forcelist=(429, 500, 502, 503, 504),
|
||||
allowed_methods=frozenset({"POST"}),
|
||||
)
|
||||
self.session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
self.session.headers.update({"User-Agent": "ProgressionMods/1.0"})
|
||||
|
||||
def get_collections(self, collection_ids: list[str]) -> dict[str, list[str]]:
|
||||
payload: dict[str, str | int] = {"collectioncount": len(collection_ids)}
|
||||
for index, collection_id in enumerate(collection_ids):
|
||||
payload[f"publishedfileids[{index}]"] = collection_id
|
||||
|
||||
response = self.session.post(
|
||||
COLLECTION_DETAILS_URL, data=payload, timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
details = response.json().get("response", {}).get("collectiondetails", [])
|
||||
|
||||
result: dict[str, list[str]] = {}
|
||||
for detail in details:
|
||||
collection_id = str(detail.get("publishedfileid", ""))
|
||||
if int(detail.get("result", 0)) != 1:
|
||||
raise RuntimeError(f"Steam rejected collection {collection_id}")
|
||||
children = sorted(
|
||||
detail.get("children", []),
|
||||
key=lambda child: int(child.get("sortorder", 0)),
|
||||
)
|
||||
result[collection_id] = [
|
||||
str(child["publishedfileid"]) for child in children
|
||||
]
|
||||
return result
|
||||
|
||||
def get_item_details(self, workshop_ids: list[str]) -> dict[str, WorkshopDetails]:
|
||||
result: dict[str, WorkshopDetails] = {}
|
||||
for batch in _chunks(workshop_ids, 100):
|
||||
payload: dict[str, str | int] = {"itemcount": len(batch)}
|
||||
for index, workshop_id in enumerate(batch):
|
||||
payload[f"publishedfileids[{index}]"] = workshop_id
|
||||
response = self.session.post(
|
||||
PUBLISHED_FILE_DETAILS_URL, data=payload, timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
details = (
|
||||
response.json().get("response", {}).get("publishedfiledetails", [])
|
||||
)
|
||||
for detail in details:
|
||||
workshop_id = str(detail.get("publishedfileid", ""))
|
||||
result[workshop_id] = WorkshopDetails(
|
||||
workshop_id=workshop_id,
|
||||
title=str(detail.get("title", "")),
|
||||
time_updated=int(detail.get("time_updated", 0) or 0),
|
||||
file_size=int(detail.get("file_size", 0) or 0),
|
||||
consumer_app_id=int(detail.get("consumer_app_id", 0) or 0),
|
||||
result=int(detail.get("result", 0) or 0),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
RIMWORLD_APP_ID = "294100"
|
||||
FAILED_ITEM_PATTERN = re.compile(
|
||||
r"(?:ERROR!\s*)?Downloading item\s+(\d+)\s+failed", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def directory_size(path: Path) -> int:
|
||||
if not path.exists():
|
||||
return 0
|
||||
total = 0
|
||||
for entry in path.rglob("*"):
|
||||
try:
|
||||
if entry.is_file():
|
||||
total += entry.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def find_about_xml(mod_path: Path) -> Path | None:
|
||||
direct = mod_path / "About" / "About.xml"
|
||||
if direct.is_file():
|
||||
return direct
|
||||
try:
|
||||
for candidate in mod_path.rglob("*.xml"):
|
||||
if (
|
||||
candidate.name.lower() == "about.xml"
|
||||
and candidate.parent.name.lower() == "about"
|
||||
):
|
||||
return candidate
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _child_text(root: ET.Element, name: str) -> str | None:
|
||||
wanted = name.lower()
|
||||
for child in root:
|
||||
if child.tag.lower() == wanted and child.text:
|
||||
return child.text.strip()
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedMod:
|
||||
package_id: str
|
||||
display_name: str
|
||||
declared_steam_app_id: str | None
|
||||
|
||||
|
||||
def parse_about_xml(mod_path: Path) -> ParsedMod:
|
||||
about_xml = find_about_xml(mod_path)
|
||||
if not about_xml:
|
||||
raise ValueError("About/About.xml not found")
|
||||
try:
|
||||
root = ET.parse(about_xml).getroot()
|
||||
except ET.ParseError as error:
|
||||
raise ValueError(f"About.xml is invalid XML: {error}") from error
|
||||
|
||||
package_id = _child_text(root, "packageId")
|
||||
if not package_id:
|
||||
raise ValueError("packageId not found in About.xml")
|
||||
display_name = _child_text(root, "name") or package_id
|
||||
return ParsedMod(
|
||||
package_id=package_id.lower(),
|
||||
display_name=display_name,
|
||||
declared_steam_app_id=_child_text(root, "steamAppId"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SteamCmdBatchResult:
|
||||
workshop_ids: list[str]
|
||||
started_at: str
|
||||
finished_at: str
|
||||
elapsed_seconds: float
|
||||
exit_code: int | None
|
||||
status: str
|
||||
failed_ids: set[str]
|
||||
output_tail: str
|
||||
|
||||
|
||||
class SteamCmdRunner:
|
||||
def __init__(
|
||||
self,
|
||||
executable: str,
|
||||
content_dir: Path,
|
||||
timeout_seconds: int,
|
||||
):
|
||||
self.executable = executable
|
||||
self.content_dir = content_dir
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
def executable_path(self) -> str | None:
|
||||
configured = Path(self.executable)
|
||||
if configured.is_file():
|
||||
return str(configured.resolve())
|
||||
return shutil.which(self.executable)
|
||||
|
||||
def available(self) -> bool:
|
||||
return self.executable_path() is not None
|
||||
|
||||
def run_batch(self, workshop_ids: list[str]) -> SteamCmdBatchResult:
|
||||
executable = self.executable_path()
|
||||
if not executable:
|
||||
raise FileNotFoundError(
|
||||
f"SteamCMD was not found at '{self.executable}'. Set STEAMCMD_PATH."
|
||||
)
|
||||
invalid = [item for item in workshop_ids if not item.isdigit()]
|
||||
if invalid:
|
||||
raise ValueError(f"Invalid Workshop ids: {', '.join(invalid)}")
|
||||
|
||||
command = [executable, "+login", "anonymous"]
|
||||
for workshop_id in workshop_ids:
|
||||
command.extend(
|
||||
[
|
||||
"+workshop_download_item",
|
||||
RIMWORLD_APP_ID,
|
||||
workshop_id,
|
||||
"validate",
|
||||
]
|
||||
)
|
||||
command.append("+quit")
|
||||
|
||||
started_at = utc_now()
|
||||
started = time.monotonic()
|
||||
exit_code: int | None = None
|
||||
status = "completed"
|
||||
output = ""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=str(Path(executable).resolve().parent),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.timeout_seconds,
|
||||
check=False,
|
||||
shell=False,
|
||||
)
|
||||
exit_code = completed.returncode
|
||||
output = f"{completed.stdout}\n{completed.stderr}".strip()
|
||||
if completed.returncode != 0:
|
||||
status = "failed"
|
||||
except subprocess.TimeoutExpired as error:
|
||||
status = "timeout"
|
||||
stdout = (
|
||||
error.stdout.decode(errors="replace")
|
||||
if isinstance(error.stdout, bytes)
|
||||
else error.stdout
|
||||
)
|
||||
stderr = (
|
||||
error.stderr.decode(errors="replace")
|
||||
if isinstance(error.stderr, bytes)
|
||||
else error.stderr
|
||||
)
|
||||
output = f"{stdout or ''}\n{stderr or ''}".strip()
|
||||
|
||||
failed_ids = set(FAILED_ITEM_PATTERN.findall(output))
|
||||
for workshop_id in workshop_ids:
|
||||
if not (self.content_dir / workshop_id).is_dir():
|
||||
failed_ids.add(workshop_id)
|
||||
if not failed_ids:
|
||||
# SteamCMD's bootstrapper can return 1 after updating and relaunching even
|
||||
# though every requested item was installed successfully.
|
||||
status = "completed"
|
||||
elif len(failed_ids) < len(workshop_ids):
|
||||
status = "partial"
|
||||
else:
|
||||
status = "failed"
|
||||
|
||||
return SteamCmdBatchResult(
|
||||
workshop_ids=workshop_ids,
|
||||
started_at=started_at,
|
||||
finished_at=utc_now(),
|
||||
elapsed_seconds=round(time.monotonic() - started, 3),
|
||||
exit_code=exit_code,
|
||||
status=status,
|
||||
failed_ids=failed_ids,
|
||||
output_tail=output[-8000:],
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements-web.txt
|
||||
httpx2>=0.1.0
|
||||
@@ -0,0 +1,5 @@
|
||||
requests>=2.25.1
|
||||
python-dotenv>=0.19.0
|
||||
fastapi>=0.115.0,<1.0
|
||||
uvicorn[standard]>=0.30.0,<1.0
|
||||
python-multipart>=0.0.12,<1.0
|
||||
+4
-1
@@ -4,4 +4,7 @@ pyglet>=1.5.0
|
||||
Pillow>=8.0.0
|
||||
packaging>=21.0
|
||||
customtkinter>=5.0.0
|
||||
rich>=13.0.0
|
||||
rich>=13.0.0
|
||||
fastapi>=0.115.0,<1.0
|
||||
uvicorn[standard]>=0.30.0,<1.0
|
||||
python-multipart>=0.0.12,<1.0
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from progression_backend.app import create_app
|
||||
from progression_backend.app import latest_windows_release_url
|
||||
from progression_backend.config import Settings
|
||||
from progression_backend.database import Database
|
||||
from progression_backend.modlist import (
|
||||
ModListItem,
|
||||
core_items,
|
||||
generate_rml,
|
||||
parse_active_mods,
|
||||
workshop_items,
|
||||
)
|
||||
from progression_backend.refresh import RefreshService
|
||||
from progression_backend.steam_api import WorkshopDetails
|
||||
from progression_backend.workshop import SteamCmdBatchResult, parse_about_xml, utc_now
|
||||
|
||||
|
||||
class FakeSteam:
|
||||
def get_collections(self, collection_ids: list[str]) -> dict[str, list[str]]:
|
||||
return {collection_id: ["123456"] for collection_id in collection_ids}
|
||||
|
||||
def get_item_details(self, workshop_ids: list[str]) -> dict[str, WorkshopDetails]:
|
||||
return {
|
||||
workshop_id: WorkshopDetails(
|
||||
workshop_id=workshop_id,
|
||||
title="Test & Mod",
|
||||
time_updated=123,
|
||||
file_size=456,
|
||||
consumer_app_id=294100,
|
||||
result=1,
|
||||
)
|
||||
for workshop_id in workshop_ids
|
||||
}
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self, content_dir: Path):
|
||||
self.content_dir = content_dir
|
||||
|
||||
def available(self) -> bool:
|
||||
return True
|
||||
|
||||
def run_batch(self, workshop_ids: list[str]) -> SteamCmdBatchResult:
|
||||
for workshop_id in workshop_ids:
|
||||
about = self.content_dir / workshop_id / "About"
|
||||
about.mkdir(parents=True)
|
||||
(about / "About.xml").write_text(
|
||||
"<ModMetaData><name>Test & Mod</name>"
|
||||
"<packageId>Author.Test</packageId>"
|
||||
"<steamAppId>294100</steamAppId></ModMetaData>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
timestamp = utc_now()
|
||||
return SteamCmdBatchResult(
|
||||
workshop_ids=workshop_ids,
|
||||
started_at=timestamp,
|
||||
finished_at=timestamp,
|
||||
elapsed_seconds=0.1,
|
||||
exit_code=0,
|
||||
status="completed",
|
||||
failed_ids=set(),
|
||||
output_tail="Success",
|
||||
)
|
||||
|
||||
|
||||
class BackendTests(unittest.TestCase):
|
||||
@patch("progression_backend.app.requests.get")
|
||||
def test_latest_windows_release_includes_prereleases(self, get: Mock) -> None:
|
||||
response = Mock()
|
||||
response.json.return_value = [
|
||||
{
|
||||
"tag_name": "0.2.2",
|
||||
"published_at": "2026-06-13T02:37:15Z",
|
||||
"draft": False,
|
||||
"prerelease": True,
|
||||
"assets": [
|
||||
{
|
||||
"name": "ProgressionLoader.exe",
|
||||
"browser_download_url": "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.2/ProgressionLoader.exe",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
get.return_value = response
|
||||
|
||||
self.assertEqual(
|
||||
latest_windows_release_url(),
|
||||
"https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.2/ProgressionLoader.exe",
|
||||
)
|
||||
response.raise_for_status.assert_called_once_with()
|
||||
|
||||
@patch("progression_backend.app.requests.get")
|
||||
def test_latest_windows_release_prefers_stable_build(self, get: Mock) -> None:
|
||||
response = Mock()
|
||||
response.json.return_value = [
|
||||
{
|
||||
"published_at": "2026-07-01T00:00:00Z",
|
||||
"draft": False,
|
||||
"prerelease": True,
|
||||
"assets": [
|
||||
{
|
||||
"name": "ProgressionLoader.exe",
|
||||
"browser_download_url": "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.3.0-rc1/ProgressionLoader.exe",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"published_at": "2026-06-30T00:00:00Z",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
"assets": [
|
||||
{
|
||||
"name": "ProgressionLoader.exe",
|
||||
"browser_download_url": "https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.3/ProgressionLoader.exe",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
get.return_value = response
|
||||
|
||||
self.assertEqual(
|
||||
latest_windows_release_url(),
|
||||
"https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases/download/0.2.3/ProgressionLoader.exe",
|
||||
)
|
||||
|
||||
def test_about_parser_normalizes_package_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
about = Path(directory) / "About"
|
||||
about.mkdir()
|
||||
(about / "About.xml").write_text(
|
||||
"<ModMetaData><name>A & B</name><packageId>Author.Mod</packageId></ModMetaData>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
parsed = parse_about_xml(Path(directory))
|
||||
self.assertEqual(parsed.package_id, "author.mod")
|
||||
self.assertEqual(parsed.display_name, "A & B")
|
||||
|
||||
def test_rml_uses_workshop_id_and_escapes_names(self) -> None:
|
||||
payload = generate_rml(
|
||||
[
|
||||
*core_items(["royalty"]),
|
||||
ModListItem("author.mod", "123456", "A & B"),
|
||||
],
|
||||
"1.6",
|
||||
)
|
||||
root = ET.fromstring(payload)
|
||||
self.assertEqual(root.findtext("meta/gameVersion"), "1.6")
|
||||
self.assertEqual(
|
||||
[node.text for node in root.findall("meta/modSteamIds/li")],
|
||||
["0", "0", "123456"],
|
||||
)
|
||||
self.assertIn(b"A & B", payload)
|
||||
|
||||
def test_homebrew_parser_reads_active_mods(self) -> None:
|
||||
items = parse_active_mods(
|
||||
b"<ModsConfigData><activeMods><li>ludeon.rimworld</li>"
|
||||
b"<li>author.mod</li></activeMods></ModsConfigData>"
|
||||
)
|
||||
self.assertEqual(
|
||||
[item.package_id for item in items], ["ludeon.rimworld", "author.mod"]
|
||||
)
|
||||
|
||||
def test_refresh_persists_download_analytics(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
settings = Settings(
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
database_path=root / "progression.db",
|
||||
steamcmd_path="steamcmd",
|
||||
workshop_content_dir=root / "workshop" / "294100",
|
||||
steamcmd_batch_size=25,
|
||||
steamcmd_timeout_seconds=60,
|
||||
refresh_interval_seconds=3600,
|
||||
refresh_on_startup=False,
|
||||
game_version="1.6",
|
||||
admin_token=None,
|
||||
collections={"Core": "1", "Content": "2", "Cosmetics": "3"},
|
||||
static_dir=root,
|
||||
)
|
||||
database = Database(settings.database_path)
|
||||
database.initialize()
|
||||
service = RefreshService(
|
||||
settings,
|
||||
database,
|
||||
steam=FakeSteam(),
|
||||
runner=FakeRunner(settings.workshop_content_dir),
|
||||
)
|
||||
service.refresh("test-run", "test")
|
||||
|
||||
run = database.fetch_one(
|
||||
"SELECT * FROM refresh_runs WHERE id = ?", ("test-run",)
|
||||
)
|
||||
self.assertIsNotNone(run)
|
||||
self.assertEqual(run["status"], "completed", run)
|
||||
self.assertEqual(run["unique_items"], 1)
|
||||
self.assertEqual(run["downloads_succeeded"], 1)
|
||||
mod = database.fetch_one("SELECT * FROM mods WHERE workshop_id = '123456'")
|
||||
self.assertEqual(mod["package_id"], "author.test")
|
||||
self.assertEqual(mod["steam_app_id"], "294100")
|
||||
generated = workshop_items([mod])
|
||||
self.assertEqual(generated[0].workshop_id, "123456")
|
||||
|
||||
def test_api_serves_dashboard_and_reports_empty_modlist(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
settings = Settings(
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
database_path=root / "progression.db",
|
||||
steamcmd_path="missing-steamcmd",
|
||||
workshop_content_dir=root / "workshop" / "294100",
|
||||
steamcmd_batch_size=25,
|
||||
steamcmd_timeout_seconds=60,
|
||||
refresh_interval_seconds=3600,
|
||||
refresh_on_startup=False,
|
||||
game_version="1.6",
|
||||
admin_token=None,
|
||||
collections={"Core": "1", "Content": "2", "Cosmetics": "3"},
|
||||
static_dir=project_root / "progression_backend" / "static",
|
||||
)
|
||||
with TestClient(create_app(settings)) as client:
|
||||
self.assertEqual(client.get("/").status_code, 200)
|
||||
self.assertEqual(client.get("/art/GameTitle.png").status_code, 200)
|
||||
self.assertEqual(client.get("/api/health").json()["status"], "ok")
|
||||
self.assertIn("rml_updated_at", client.get("/api/status").json())
|
||||
response = client.get("/api/modlists/vanilla")
|
||||
self.assertEqual(response.status_code, 409)
|
||||
|
||||
release_url = (
|
||||
"https://git.hudsonriggs.systems/HRiggs/ProgressionMods/"
|
||||
"releases/download/0.2.2/ProgressionLoader.exe"
|
||||
)
|
||||
with patch(
|
||||
"progression_backend.app.latest_windows_release_url",
|
||||
return_value=release_url,
|
||||
):
|
||||
response = client.get(
|
||||
"/api/downloads/windows", follow_redirects=False
|
||||
)
|
||||
self.assertEqual(response.status_code, 307)
|
||||
self.assertEqual(response.headers["location"], release_url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user