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
|
COSMETICS_COLLECTION_URL=steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646
|
||||||
|
|
||||||
# ModsConfig.xml Path Template
|
# 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)
|
# Steam Workshop related (if any cache files are generated)
|
||||||
workshop_cache/
|
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
|
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
|
Pillow>=8.0.0
|
||||||
packaging>=21.0
|
packaging>=21.0
|
||||||
customtkinter>=5.0.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