Add full sortof codebase: API, drain workers, frontend, schema, specs

This commit is contained in:
2026-05-04 03:27:54 +00:00
parent acda2c90f8
commit 55d3794bfb
43 changed files with 13375 additions and 53 deletions

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Secrets / runtime config
.env
.env.local
.env.*.local
# Python virtualenvs (api/.venv, worker/.venv, etc.)
.venv/
venv/
env/
# Python bytecode caches
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Local ad-hoc backups (.bak / .bak-YYYYMMDD-HHMM / .bak-<label>)
*.bak
*.bak-*
# Editor / IDE / OS artifacts
*.swp
*.swo
*~
.DS_Store
Thumbs.db
.idea/
.vscode/
# Logs
*.log
*.log.*
logs/
# DepotDownloader scratch dirs
depots/
.DepotDownloader/
# Postgres bind-mount data dir (if anyone ever switches off the named volume)
postgres_data/
data/postgres/
# Node (defensive — not used today, but headoff if a build step shows up)
node_modules/
npm-debug.log*
yarn-error.log*

View File

@@ -1,68 +1,52 @@
# sortof # sortof
A Project Zomboid mod load-order sorter. Paste Steam Workshop IDs or a Project Zomboid mod load-order sorter for dedicated servers. Paste workshop
collection URL, get back the three lines (`WorkshopItems=`, `Mods=`, IDs or a collection URL; get back the `WorkshopItems=`, `Mods=`, and `Map=`
`Map=`) you paste into your dedicated server's `.ini` file. lines for `servertest.ini`.
## Stack ## Stack
- **API** — FastAPI + asyncpg, Python 3.12. - FastAPI + asyncpg, Python 3.12.
- **Cache** — PostgreSQL 16. One row per `(workshop_id, mod_id)` parsed - Postgres 16 (Docker).
from each mod's `mod.info`. Cache invalidates on Steam's - DepotDownloader for fetching `mod.info` / `map.info` from each cached
`time_updated`; no manual TTLs. workshop item.
- **Drain worker** — pulls cache misses via DepotDownloader, parses - Vanilla JSX + index.html, no build step (Babel-standalone in-browser).
`mod.info` and `media/maps/*/map.info`, writes back to Postgres.
- **Frontend** — vanilla JSX in `frontend/`, served by FastAPI
`StaticFiles`, transpiled in-browser by Babel-standalone. No build
step, no npm.
- **Deployment** — Docker for Postgres only; the API and drain workers
run as systemd units on the host.
## What it actually does ## Setup
- Resolves bare workshop IDs **and** collection URLs (anonymous Steam API ```bash
expansion). docker compose up -d sortof_db
- Topologically sorts mods by `requirements`, `loadAfter`, `loadBefore`
with explicit support for `loadFirst` / `loadLast` and the "patch"
tier.
- Detects multi-branch wsids (e.g. AuthenticZ, Project RV Interior),
auto-picks a sensible default by build flavor or input cross-reference,
and surfaces the alternates in a picker.
- Emits actionable warnings: missing dependencies (with one-click "add"
buttons when the dep is in our cache), build-mismatch flags with
swap-or-remove actions, duplicate-mod_id conflicts, addon detection,
and required-item suggestions scraped from the Workshop page.
- Tracks community-reported broken mods per PZ build, with thumbs-up /
thumbs-down voting and a search panel.
## Bootstrapping (rough) python3.12 -m venv api/.venv
api/.venv/bin/pip install -r api/requirements.txt
1. `docker compose up -d sortof_db` — runs Postgres on `127.0.0.1:5439`, python3.12 -m venv worker/.venv
applies `init/*.sql` migrations on first boot. worker/.venv/bin/pip install -r worker/requirements.txt
2. Create the two virtualenvs: ```
```bash
python3.12 -m venv api/.venv && api/.venv/bin/pip install -r api/requirements.txt
python3.12 -m venv worker/.venv && worker/.venv/bin/pip install -r worker/requirements.txt
```
3. Drop a `.env` next to `docker-compose.yml` with `POSTGRES_USER`,
`POSTGRES_PASSWORD`, `POSTGRES_DB`, and `SORTOF_CORS_ORIGINS`.
4. Set `DD_PATH` to wherever DepotDownloader lives, then start the API
(`api/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8801`) and a
drain worker (`worker/.venv/bin/python drain.py`).
For the systemd units, see `docs/` (and the running host). `.env` next to `docker-compose.yml`:
```
POSTGRES_USER=sortof
POSTGRES_PASSWORD=<random>
POSTGRES_DB=sortof
SORTOF_CORS_ORIGINS=http://127.0.0.1:8801
```
Set `DD_PATH` to the DepotDownloader binary, then:
```bash
api/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8801
worker/.venv/bin/python drain.py
```
## Layout ## Layout
``` ```
api/ FastAPI service + adapters + sort engine api/ FastAPI service, sort engine, response adapters
worker/ drain worker DepotDownloader + mod.info parser worker/ drain worker (DepotDownloader + mod.info parser)
frontend/ vanilla JSX + index.html (no build step) frontend/ index.html + JSX
init/ Postgres bootstrap migrations (runs in order) init/ Postgres bootstrap migrations (run on first boot)
data/ checked-in JSON like pz_versions.json data/ checked-in JSON config (e.g., pz_versions.json)
docs/ specs, plans, audit notes docs/ specs, plans
``` ```
## License
TBD.

683
api/adapters.py Normal file
View File

@@ -0,0 +1,683 @@
"""Translate mlos_sort + mod_parsed rows into the SORTOF_DATA shape the frontend expects."""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional, Set, Tuple
from mlos_sort import ModInfo
CAT_MAP: Dict[str, str] = {
"coreRequirement": "lib",
"tweaks": "lib",
"tile": "tile",
"debug": "debug",
"resource": "resource",
"map": "map",
"qol": "qol",
"moodle": "moodle",
"tweak_minor": "tweak",
"music": "music",
"wearable": "wearable",
"profession": "profession",
"movement": "movement",
"building": "building",
"farming": "farming",
"zombie": "zombie",
"zone": "zone",
"armor": "armor",
"food": "food",
"health": "health",
"weapon": "weapon",
"crafting": "crafting",
"container": "container",
"vehicle": "vehicle",
"vehicle_spawn": "v.spawn",
"loot": "loot",
"code": "gameplay",
"ui": "ui",
"sound": "sound",
"texture": "texture",
"translation": "i18n",
"multiplayer": "mp",
"server_only": "server",
"fix": "fix",
"other": "gameplay",
"undefined": "gameplay",
# Spec G-patch §6: dedicated frontend pill so patches are visible at a
# glance even though their position (last) already telegraphs the role.
"patch": "patch",
}
def to_frontend_cat(mlos_cat: str) -> str:
return CAT_MAP.get(mlos_cat, "gameplay")
def position(load_first: str, load_last: str) -> str:
if load_first == "on":
return "first"
if load_last == "on":
return "last"
return ""
# Hand-curated mod_id alias map: when the user has any of the value mod_ids,
# the key mod_id is considered "satisfied" for missing-dep purposes. This
# stops a B42 mod whose mod_id was renamed across builds (e.g. TrueMoozic)
# from showing up as a missing dep for downstream B41 mods that still
# require the old mod_id (`truemusic`).
MOD_ID_ALIASES: Dict[str, List[str]] = {
"truemusic": ["TrueMoozic"],
}
def _resolve_satisfied_via_alias(input_modids: Set[str]) -> Set[str]:
"""Return the input mod_id set expanded with any keys whose alias values
are present in the input."""
out = set(input_modids)
for key, values in MOD_ID_ALIASES.items():
if any(v in input_modids for v in values):
out.add(key)
return out
def build_warnings(
mlos_warnings: Dict[str, Any],
wsid_lookup: Dict[str, str] | None = None,
source_wsids: Dict[str, str] | None = None,
input_modids: Set[str] | None = None,
) -> List[Dict[str, Any]]:
"""Translate mlos_sort warnings to the SORTOF_DATA WARNINGS shape.
wsid_lookup: optional {mod_id -> workshop_id} map of *deps* we already
have cached. When provided, each 'missing' warning is augmented with
`actions: [{type: "add-wsid", wsid, label}]` so the frontend can render
a click-to-add button. Unknown deps fall back to a Steam Workshop search
link the user follows manually.
source_wsids: optional {mod_id -> workshop_id} map of *every cached mod
in the current sort*. Used to attach the source mod's wsid onto
'missing' / 'conflict' warnings so the frontend can deep-link to the
Workshop page of the mod that's complaining.
"""
lookup = wsid_lookup or {}
sources = source_wsids or {}
out: List[Dict[str, Any]] = []
for path in mlos_warnings.get("cycles", []) or []:
out.append({
"tag": "cycle",
"level": "red",
"msg": "cycle: " + "".join(path),
})
satisfied_via_alias = _resolve_satisfied_via_alias(input_modids or set())
for mod_id, deps in (mlos_warnings.get("missing_requirements") or {}).items():
# Drop deps that the user already has via an alias (e.g.
# `truemusic` is satisfied if the user has `TrueMoozic`). If every
# listed dep is satisfied via alias, skip the warning entirely.
deps = [d for d in deps if d not in satisfied_via_alias]
if not deps:
continue
actions: List[Dict[str, str]] = []
for dep in deps:
wsid = lookup.get(dep)
if wsid:
actions.append({
"type": "add-wsid",
"wsid": wsid,
"modId": dep,
"label": f"add {dep}",
})
else:
# No cache hit -> link to Steam Workshop search so the user
# can find the wsid manually. PZ appid is 108600.
from urllib.parse import quote_plus
actions.append({
"type": "search-workshop",
"modId": dep,
"url": (
"https://steamcommunity.com/workshop/browse/"
f"?appid=108600&searchtext={quote_plus(dep)}"
),
"label": f"find {dep}",
})
src_wsid = sources.get(mod_id)
# When we know the source mod's wsid, the warning's leading mod name
# is hyperlinked to the source's Workshop page (which lists every
# required item natively). The 'find …' Steam Workshop search action
# is redundant in that case (returns noisy generic results), so drop
# it; keep concrete 'add <wsid>' actions for cache-resolved deps.
if src_wsid:
actions = [a for a in actions if a.get("type") != "search-workshop"]
warn: Dict[str, Any] = {
"tag": "missing",
"level": "red",
"msg": f"{mod_id} requires {', '.join(deps)} - not in your list.",
}
if src_wsid:
warn["wsid"] = src_wsid
if actions:
warn["actions"] = actions
out.append(warn)
for mod_id, ene in (mlos_warnings.get("incompatible_enabled") or {}).items():
warn = {
"tag": "conflict",
"level": "amber",
"msg": f"{mod_id} marked incompatible with {', '.join(ene)}.",
}
src_wsid = sources.get(mod_id)
if src_wsid:
warn["wsid"] = src_wsid
out.append(warn)
return out
# ── Spec C §4: branch flavoring + prefix-base + suffix-token utilities ──
# Build flavor detection for Rule A. Matches:
# - "B42" / "B41" at end-of-string when preceded by a separator (`_`, `-`,
# space, start-of-string) OR a lowercase letter (CamelCase boundary like
# "ArmorB42").
# - "_b42" / "_b41" at end (snake_case).
# - "_b42_" / "_b41_" mid-string token.
# - "_legacy_" anywhere -> B41 (legacy is always old-build behavior).
_RE_B41 = re.compile(
r"_legacy_"
r"|(?:^|[_\- ]|(?<=[a-z]))[Bb]41$"
r"|_[Bb]41(?:[_\- ]|$)"
)
_RE_B42 = re.compile(
r"(?:^|[_\- ]|(?<=[a-z]))[Bb]42$"
r"|_[Bb]42(?:[_\- ]|$)"
)
def _build_flavor(mod_id: str) -> Optional[str]:
"""Return 'B41' / 'B42' / None per Spec C §4.3 suffix rule."""
if not mod_id:
return None
if _RE_B41.search(mod_id):
return "B41"
if _RE_B42.search(mod_id):
return "B42"
return None
def _strict_prefix_of(a: str, b: str) -> bool:
"""`a` is a strict prefix of `b` per Spec C §4.4: a != b, b starts with a,
boundary char is non-lowercase-letter (separator/digit/uppercase)."""
if not a or not b or a == b or not b.startswith(a):
return False
boundary = b[len(a)]
return boundary in "_- " or boundary.isdigit() or boundary.isupper()
def _prefix_base(group: List[ModInfo]) -> ModInfo:
"""Return the branch whose mod_id is a strict prefix of every other branch
in `group` (the 'core'/'main'). Falls back to alphabetical-first if no
such universal prefix exists. Spec C §4.4."""
sorted_grp = sorted(group, key=lambda m: m.id)
for cand in sorted_grp:
if all(_strict_prefix_of(cand.id, m.id) for m in sorted_grp if m.id != cand.id):
return cand
return sorted_grp[0]
# Spec C §4.6 hint patterns. Order matters: first match wins.
_HINT_PATTERNS: List[Tuple[re.Pattern, str]] = [
(re.compile(r"_legacy_", re.IGNORECASE), "legacy build - usually not what you want"),
(re.compile(r"_v\d+(?:_\d+)*$", re.IGNORECASE), "legacy build - usually not what you want"),
(re.compile(r"(?:_lite|_light)$", re.IGNORECASE), "lighter alternate variant - pick one"),
(re.compile(r"(?:_hd|_detailshd)$", re.IGNORECASE), "high-resolution variant"),
(re.compile(r"_(?:noce|novanilla|farmdisable|disable[a-z]*)$", re.IGNORECASE),
"opt-out variant"),
(re.compile(r"_(?:usdm|imports|exotics|realnames)$", re.IGNORECASE),
"alternate variant - pick one"),
]
def _branch_hint(mod_id: str) -> Optional[str]:
"""Return the hint text per Spec C §4.6 D/G patterns, or None."""
if not mod_id:
return None
hits: List[str] = []
for pat, text in _HINT_PATTERNS:
if pat.search(mod_id):
hits.append(text)
return " · ".join(dict.fromkeys(hits)) if hits else None
def _suffix_token(mod_id: str, base_id: str) -> Optional[str]:
"""For Rule C: extract the trailing token after the last `_` if mod_id is
a `<base>_<TOKEN>` form. Returns None for one-letter tokens (`_a`, `_x`).
Two-letter tokens like `_AZ` (AuthenticZ patch) and `_GG` are kept - the
user's spec parenthetical excludes only one-letter false positives."""
if not mod_id or not base_id or mod_id == base_id:
return None
if "_" not in mod_id:
return None
suffix = mod_id.rsplit("_", 1)[-1]
if len(suffix) < 2:
return None
return suffix
def _input_match_terms(mod_id: str) -> Set[str]:
"""Build the set of search terms for one input mod_id, used by Rule C.
Produces both:
- normalized form (lowercase + alphanumeric only) -> substring match
- acronym (first letters of CamelCase / whitespace-separated tokens,
lowercased) -> covers cases like "Authentic Z - Current" where the
token `_AZ` is the initials, not a literal substring.
"""
out: Set[str] = set()
if not mod_id:
return out
out.add(re.sub(r"[^a-z0-9]+", "", mod_id.lower()))
pieces: List[str] = []
for chunk in re.split(r"[^A-Za-z0-9]+", mod_id):
if not chunk:
continue
# CamelCase split: each capital-led run is a piece, plus trailing lowercase runs.
parts = re.findall(r"[A-Z]+[a-z0-9]*|[a-z0-9]+", chunk)
pieces.extend(parts)
if pieces:
acro = "".join(p[0].lower() for p in pieces if p and p[0].isalnum())
if len(acro) >= 2:
out.add(acro)
return out
def _apply_branch_rules(
mods: List[ModInfo],
*,
pz_build: str,
input_modids: Set[str],
is_resort: bool = False,
selected_modids: Optional[Set[str]] = None,
) -> Tuple[Set[str], List[Dict[str, Any]], Dict[str, str]]:
"""Spec C §4 auto-disambiguation pipeline.
Returns (drop_ids, warnings, hints). drop_ids = mod_ids to filter out of
SORTED_ORDER/MODS_LINE. hints = {mod_id -> hint_text} for picker rows.
Order per wsid: detect coordinated/radio (exempt), then A → C → B.
A and C may both fire (orthogonal axes). B is the auto-pick fallback.
`input_modids` is the full set across the user's input. Rule C excludes
same-wsid mod_ids when matching to avoid self-references (a wsid's
branches would otherwise always match each other's tokens).
"""
by_wsid: Dict[str, List[ModInfo]] = {}
for m in mods:
wsid = m.workshop_id or ""
if not wsid:
continue
by_wsid.setdefault(wsid, []).append(m)
# Per-wsid mod_id sets so Rule C can exclude same-wsid siblings.
modids_by_wsid: Dict[str, Set[str]] = {
w: {m.id for m in g} for w, g in by_wsid.items()
}
drop_ids: Set[str] = set()
warnings: List[Dict[str, Any]] = []
hints: Dict[str, str] = {}
pz_build = (pz_build or "").upper() if pz_build else "B42"
if pz_build not in ("B41", "B42"):
pz_build = "B42"
for wsid, group in by_wsid.items():
if len(group) < 2:
continue
ids_in_group = {m.id for m in group}
# Exemption 0: ADDON wsid — at least one mod self-identifies as an
# "Optional add-on" (mod.info description) and at least one mod
# doesn't. Treat as additive: primaries stay in MODS_LINE by default,
# addons land in drop_ids so the user has to explicitly tick them in
# the picker. Picker is naturally checkbox-mode (no incompatibles
# between siblings), so toggling lights addons up.
#
# Resort path: user's explicit selection wins. Drop addons NOT in
# the user's selection; keep those they ticked.
addon_mods = [m for m in group if m.is_addon]
primary_mods = [m for m in group if not m.is_addon]
if addon_mods and primary_mods:
for m in addon_mods:
if not is_resort:
drop_ids.add(m.id)
elif selected_modids is not None and m.id not in selected_modids:
drop_ids.add(m.id)
hints[m.id] = "addon"
for m in primary_mods:
hints[m.id] = "main"
continue
# Exemption 1: radio-mode (incompatibleMods sibling). Picker handles.
radio = any(
any(other in ids_in_group for other in m.incompatibleMods)
for m in group
)
if radio:
continue
# Exemption 2: coordinated (cross-refs in requirements/loadAfter/loadBefore).
coordinated = any(
any(other in ids_in_group for other in m.requirements)
or any(other in ids_in_group for other in m.loadAfter)
or any(other in ids_in_group for other in m.loadBefore)
for m in group
)
if coordinated:
# Coordinated wsids stay whole BY DEFAULT, but if a prefix-base
# exists and a non-base addon branch declares an EXTERNAL require
# (a mod_id not in the wsid's siblings) that isn't in input_modids,
# drop the addon. The author's `require=` here means "this addon
# only makes sense if you have the external mod"; if you don't,
# the addon shouldn't be in the load order. Suppresses the
# would-be missing-dep warning at the same time (post-build_warnings
# filter strips warnings whose source mod_id is in drop_ids).
base_candidates = [
m for m in group
if any(_strict_prefix_of(m.id, o.id) for o in group if o.id != m.id)
]
if base_candidates:
base = max(base_candidates, key=lambda m: len(m.id))
for m in group:
if m.id == base.id:
continue
externals = [r for r in m.requirements if r not in ids_in_group]
if externals and not all(r in input_modids for r in externals):
drop_ids.add(m.id)
for m in group:
h = _branch_hint(m.id)
if h:
hints[m.id] = h
continue
# Truly ambiguous: apply A → C → B.
title = (group[0].name or wsid)
kept_by_a: Optional[ModInfo] = None
kept_by_c: List[ModInfo] = []
# ── Rule A: build-aware default ─────────────────────────────────
flavored = [(m, _build_flavor(m.id)) for m in group]
match_active = [m for m, fl in flavored if fl == pz_build]
opposite_build = "B41" if pz_build == "B42" else "B42"
match_opposite = [m for m, fl in flavored if fl == opposite_build]
unflavored = [m for m, fl in flavored if fl is None]
if len(match_active) == 1 and len(group) - 1 == len(match_opposite) + len(unflavored):
# Exactly one branch matches active build; rest are opposite/unflavored.
kept_by_a = match_active[0]
elif not match_active and match_opposite:
# No active-build variant exists -> author-default fallback (un-flavored
# treated as B42), emit build-mismatch.
warnings.append({
"tag": "build-mismatch",
"level": "amber",
"msg": (
f"no {pz_build} variant for {title} ({wsid}); using author default"
),
"wsid": wsid,
})
# ── Rule C: input cross-reference ───────────────────────────────
# Identify the base branch (longest mod_id that is a strict prefix
# of any other) -- if multiple branches share an _<TOKEN>_ form.
base_candidates = [
m for m in group
if any(_strict_prefix_of(m.id, other.id) for other in group if other.id != m.id)
]
rule_c_base: Optional[ModInfo] = None
if base_candidates:
# Pick the longest base that's a prefix of any sibling (i.e., the
# most-specific shared root). For Jeeve's: `JeevesPatches` qualifies.
rule_c_base = max(base_candidates, key=lambda m: len(m.id))
# Cross-reference set: every input mod_id EXCEPT the ones from
# this wsid (otherwise sibling branches always match each other).
same_wsid_ids = modids_by_wsid.get(wsid, set())
other_modids = input_modids - same_wsid_ids
# Two flavors of search term per input mod_id: the normalized
# form (substring match) and the acronym (first letters - so
# token "AZ" matches "Authentic Z - Current"). See _input_match_terms.
norm_input: Set[str] = set()
for mid in other_modids:
norm_input.update(_input_match_terms(mid))
unticked_addons: List[str] = []
for m in group:
if m is rule_c_base:
continue
tok = _suffix_token(m.id, rule_c_base.id)
if not tok:
continue
tok_l = re.sub(r"[^a-z0-9]+", "", tok.lower())
if not tok_l:
continue
hit = any(tok_l in mid_norm for mid_norm in norm_input)
if hit:
kept_by_c.append(m)
else:
unticked_addons.append(m.id)
if kept_by_c:
# Always include the base alongside matched addons.
if rule_c_base not in kept_by_c:
kept_by_c = [rule_c_base] + kept_by_c
elif any(_suffix_token(m.id, rule_c_base.id) for m in group if m is not rule_c_base):
# Suffix-tokened branches exist but none matched - emit warning.
# `picked`/`alternatives` reuse the auto-picked-branch shape
# so the WarnRow renders an inline picker (the warning's
# "tick any you want" is otherwise unreachable from the
# warnings panel).
if unticked_addons:
warnings.append({
"tag": "unmatched-addons",
"level": "amber",
"msg": (
f"{title} ({wsid}) ships addons for other mods you "
f"don't have: {', '.join(unticked_addons[:8])}"
+ ("" if len(unticked_addons) > 8 else "")
+ ". Tick any you want:"
),
"wsid": wsid,
"picked": rule_c_base.id,
"alternatives": [m.id for m in group],
})
# ── Resolve which set of branches is kept ───────────────────────
# A and C are orthogonal: build-flavor pick + addon picks both apply.
kept_set: Set[str] = set()
if kept_by_a:
kept_set.add(kept_by_a.id)
if kept_by_c:
for m in kept_by_c:
kept_set.add(m.id)
if not kept_set:
# Rule B fallback: prefix-base if available, else alphabetical first.
primary = _prefix_base(group)
kept_set.add(primary.id)
skipped = [m.id for m in group if m.id != primary.id]
warnings.append({
"tag": "auto-picked-branch",
"level": "amber",
"msg": (
f"{title} ({wsid}) ships {len(group)} branches; auto-picked "
f"{primary.id}. Pick another:"
),
"wsid": wsid,
"picked": primary.id,
"alternatives": [m.id for m in group],
})
# Resort-mode override: the user has explicitly picked branches via
# the picker. Replace the rules' kept_set with the user's selection
# restricted to this group, and update the matching auto-picked-
# branch warning's `picked` field so the picker UI shows their
# current choice ticked. Without this, narrowing a multi-branch
# wsid to one branch erases the picker section because the warning
# was tied to the rule's auto-pick, not the user's pick.
if is_resort and selected_modids is not None:
user_picks = [m.id for m in group if m.id in selected_modids]
if user_picks:
kept_set = set(user_picks)
for w in warnings:
if (w.get("tag") == "auto-picked-branch"
and w.get("wsid") == wsid):
w["picked"] = user_picks[0]
w["msg"] = (
f"{title} ({wsid}) ships {len(group)} branches; "
f"selected {user_picks[0]}. Pick another:"
)
for m in group:
if m.id not in kept_set:
drop_ids.add(m.id)
h = _branch_hint(m.id)
if h:
hints[m.id] = h
return (drop_ids, warnings, hints)
# Backwards-compat shim used by existing call sites that don't yet pass
# pz_build/input_modids. Removed once all callers migrate.
def _autopick_ambiguous(mods: List[ModInfo]) -> Tuple[Set[str], List[Dict[str, Any]]]:
drop_ids, warns, _hints = _apply_branch_rules(
mods, pz_build="B42", input_modids={m.id for m in mods},
)
return (drop_ids, warns)
def build_response(
input_ids: List[str],
hit_ids: List[str],
mods: List[ModInfo],
sort_result: Dict[str, Any],
status: str,
wsid_lookup: Dict[str, str] | None = None,
pz_build: str = "B42",
is_resort: bool = False,
selected_modids: Optional[Set[str]] = None,
) -> Dict[str, Any]:
by_id = {m.id: m for m in mods}
sorted_order: List[str] = list(sort_result.get("Mods", []))
workshop_line_parts: List[str] = list(sort_result.get("WorkshopItems", []))
map_folders: List[str] = list(sort_result.get("Map", []))
# MAP_LINE composition rules:
# 1. Hoisted bases (MAP_FIRST) at the FRONT, in the listed order.
# These are library/parent map cells that many other map mods
# overlay onto; they must load before everything else for the
# dependents to attach correctly.
# 2. Remaining modded map folders, in mod-load order.
# 3. Vanilla Muldraugh, KY at the end ALWAYS.
BASE_MAP = "Muldraugh, KY"
MAP_FIRST = ("map_distanciado",)
map_line_parts: List[str] = []
seen_maps: set = set()
map_folder_set = set(map_folders)
for m in MAP_FIRST:
if m in map_folder_set and m not in seen_maps:
seen_maps.add(m)
map_line_parts.append(m)
for m in map_folders:
if m and m not in seen_maps:
seen_maps.add(m)
map_line_parts.append(m)
if BASE_MAP not in seen_maps:
map_line_parts.append(BASE_MAP)
# Spec C §4: build-aware + input-cross-reference + prefix-base auto-pick.
# MOD_DB still contains every cached branch so the picker can offer
# alternates; SORTED_ORDER and MODS_LINE reflect the rule output only.
input_modids = {m.id for m in mods}
drop_ids, autopick_warnings, hints = _apply_branch_rules(
mods, pz_build=pz_build or "B42", input_modids=input_modids,
is_resort=is_resort, selected_modids=selected_modids,
)
# Resort path: `sorted_order` already reflects the user's explicit
# selection (sort_mods ran on the selected subset); the drop_ids from
# _apply_branch_rules are for MOD_DB picker visibility, not for filtering
# out user-selected mods.
if not is_resort:
sorted_order = [mid for mid in sorted_order if mid not in drop_ids]
def _modentry(m: ModInfo) -> Dict[str, Any]:
entry: Dict[str, Any] = {
"wsid": m.workshop_id or "",
"modId": m.id,
"name": m.name or m.id,
"cat": to_frontend_cat(m.category),
"deps": list(m.requirements),
"conflicts": list(m.incompatibleMods),
"pos": position(m.loadFirst, m.loadLast),
"isMap": bool(m.maps),
"mapName": m.maps[0] if m.maps else None,
}
h = hints.get(m.id)
if h:
entry["hint"] = h
return entry
mod_db: List[Dict[str, Any]] = []
seen_in_db: set = set()
for mod_id in sorted_order:
m = by_id.get(mod_id)
if m is None:
continue
seen_in_db.add(mod_id)
mod_db.append(_modentry(m))
# Append auto-skipped branches so the picker can show them as alternatives.
# They aren't in SORTED_ORDER, so the table doesn't render rows for them
# directly - they only surface inside their parent wsid's BranchPicker.
for mod_id in sorted(drop_ids):
m = by_id.get(mod_id)
if m is None or mod_id in seen_in_db:
continue
mod_db.append(_modentry(m))
hit_set = set(hit_ids)
pending = [w for w in input_ids if w not in hit_set]
# Filter out missing-dep warnings whose SOURCE mod_id was dropped by
# _apply_branch_rules. Rationale: if we silently dropped, e.g.,
# fhqExpVehSpawnRedRace because its external require RedRacer wasn't
# in input, the would-be "fhqExpVehSpawnRedRace requires RedRacer" warning
# is just noise — that mod isn't in the user's effective load order
# anymore. Pattern-match the leading source mod_id on each warning.
# Map each cached mod_id to its source wsid so build_warnings can attach
# `wsid` onto missing/conflict warnings (lets the UI deep-link to the
# Workshop page of the mod that's actually complaining).
source_wsids = {m.id: m.workshop_id for m in mods if m.workshop_id}
raw_warnings = build_warnings(
sort_result.get("warnings", {}) or {},
wsid_lookup,
source_wsids=source_wsids,
input_modids=input_modids,
)
if drop_ids:
kept_warnings: List[Dict[str, Any]] = []
for w in raw_warnings:
if w.get("tag") not in ("missing", "conflict"):
kept_warnings.append(w)
continue
m = re.match(r"^([A-Za-z0-9_+\-]{2,})\s+", w.get("msg", ""))
if m and m.group(1) in drop_ids:
continue # source mod_id was dropped → suppress its warning
kept_warnings.append(w)
raw_warnings = kept_warnings
return {
"status": status,
"MOD_DB": mod_db,
"SORTED_ORDER": sorted_order,
"WORKSHOP_ITEMS_LINE": ";".join(workshop_line_parts),
"MODS_LINE": ";".join(sorted_order),
"MAP_LINE": ";".join(map_line_parts),
"WARNINGS": raw_warnings + autopick_warnings,
"pending": pending,
}

1480
api/app.py Normal file

File diff suppressed because it is too large Load Diff

34
api/db.py Normal file
View File

@@ -0,0 +1,34 @@
"""asyncpg pool factory. DSN is built from /opt/sortof/.env at startup."""
from __future__ import annotations
import os
import urllib.parse
from pathlib import Path
import asyncpg
from dotenv import load_dotenv
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
def _build_dsn() -> str:
load_dotenv(ENV_PATH)
explicit = os.environ.get("DATABASE_URL")
if explicit:
return explicit
user = os.environ["POSTGRES_USER"]
pw = urllib.parse.quote(os.environ["POSTGRES_PASSWORD"], safe="")
name = os.environ["POSTGRES_DB"]
host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
port = os.environ.get("POSTGRES_PORT", "5439")
return f"postgresql://{user}:{pw}@{host}:{port}/{name}"
async def create_pool() -> asyncpg.Pool:
return await asyncpg.create_pool(
dsn=_build_dsn(),
min_size=1,
max_size=8,
command_timeout=15,
)

140
api/expansion.py Normal file
View File

@@ -0,0 +1,140 @@
"""Background async task: take a freshly-created sort_jobs row in 'expanding'
phase, resolve its collection_ids via Steam, populate wsids[], advance phase
to 'queued' (and drop wsids into download_jobs as needed)."""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict, List, Tuple
import asyncpg
import httpx
from jobs import update_phase
from steam import fetch_collection_details
log = logging.getLogger("sortof.expansion")
async def _resolve_collections(
conn: asyncpg.Connection,
http: httpx.AsyncClient,
collection_ids: List[str],
) -> Tuple[Dict[str, List[str]], List[str]]:
"""Returns (resolved, unresolvable). resolved maps collection_id ->
[child_wsids]. unresolvable lists collection_ids that GetCollectionDetails
couldn't fetch (after one retry)."""
if not collection_ids:
return ({}, [])
# Cache lookup (TTL = 6h via last_fetched_at).
cache_rows = await conn.fetch(
"""
SELECT collection_id, child_workshop_ids
FROM collections
WHERE collection_id = ANY($1::text[])
AND last_fetched_at > now() - interval '6 hours'
""",
collection_ids,
)
resolved: Dict[str, List[str]] = {
r["collection_id"]: list(r["child_workshop_ids"])
for r in cache_rows
}
miss = [cid for cid in collection_ids if cid not in resolved]
unresolvable: List[str] = []
if miss:
# Spec §5.4: 1 retry with 2s backoff on HTTPError. If both attempts
# raise, api_out stays {} and the per-cid pass below uniformly marks
# every miss as unresolvable (rec is None branch).
api_out: Dict[str, Any] = {}
for attempt in (1, 2):
try:
api_out = await fetch_collection_details(http, miss)
break
except httpx.HTTPError as e:
log.warning("GetCollectionDetails attempt %d failed: %s", attempt, e)
if attempt == 1:
await asyncio.sleep(2.0)
for cid in miss:
rec = api_out.get(cid)
if rec is None or rec.get("result") != 1:
unresolvable.append(cid)
continue
children = rec.get("children") or []
resolved[cid] = list(children)
await conn.execute(
"""
INSERT INTO collections (collection_id, child_workshop_ids, last_fetched_at)
VALUES ($1, $2, now())
ON CONFLICT (collection_id) DO UPDATE
SET child_workshop_ids = EXCLUDED.child_workshop_ids,
last_fetched_at = now()
""",
cid, children,
)
return (resolved, unresolvable)
async def run_expansion(
pool: asyncpg.Pool,
http: httpx.AsyncClient,
job_id: str,
bare_wsids: List[str],
collection_ids: List[str],
) -> None:
"""Top-level expansion task. Logs and persists; never raises out."""
try:
async with pool.acquire() as conn:
resolved, unresolvable = await _resolve_collections(conn, http, collection_ids)
# Compose wsids: collections (in input order) + bare wsids, deduped.
seen: set = set()
wsids: List[str] = []
for cid in collection_ids:
for w in resolved.get(cid, []):
if w and w not in seen:
seen.add(w)
wsids.append(w)
for w in bare_wsids:
if w not in seen:
seen.add(w)
wsids.append(w)
if not wsids:
# All collections unresolvable AND no bare wsids. Job dies.
await update_phase(
conn, job_id, "failed",
failure_reason="all input collections unresolvable",
)
log.info("expansion %s: failed - all collections unresolvable", job_id)
return
partial_warnings = [
{
"tag": "collection-partial",
"level": "warning",
"msg": f"collection {cid} could not be fetched",
}
for cid in unresolvable
]
seed_result = {"WARNINGS": partial_warnings} if partial_warnings else None
await update_phase(
conn, job_id, "queued",
wsids=wsids,
result_json=seed_result,
)
log.info(
"expansion %s: queued (wsids=%d unresolvable=%d)",
job_id, len(wsids), len(unresolvable),
)
except Exception:
log.exception("expansion %s: crashed", job_id)
try:
async with pool.acquire() as conn:
await update_phase(conn, job_id, "failed", failure_reason="expansion crashed")
except Exception:
log.exception("expansion %s: cleanup failed", job_id)

199
api/jobs.py Normal file
View File

@@ -0,0 +1,199 @@
"""sort_jobs persistence + phase derivation.
Phase is *derived* on every GET (Spec B+F §4): never stored as the source
of truth except for terminal states. The function `derive_phase` reads
live counts from download_jobs and decides expanding/queued/draining/done.
This makes the system restart-resilient by construction - there is no
event log to replay.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from uuid import UUID
import asyncpg
# ── CRUD ────────────────────────────────────────────────────────────────────
async def create_job(
conn: asyncpg.Connection,
*,
input_raw: str,
collection_ids: List[str],
wsids: Optional[List[str]],
rules_raw: Optional[str],
initial_phase: str,
pz_build: Optional[str] = None,
) -> str:
"""Insert a sort_jobs row and return the job_id (UUID as string).
initial_phase: 'expanding' if collections still need resolving,
'queued' if wsids are already resolved at submit time.
pz_build: 'B41' / 'B42' captured at submit so the polling-path
result regen can emit build-mismatch warnings against the
user's chosen build.
"""
row = await conn.fetchrow(
"""
INSERT INTO sort_jobs (phase, input_raw, collection_ids, wsids, rules_raw, pz_build)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING job_id
""",
initial_phase, input_raw, collection_ids, wsids, rules_raw, pz_build,
)
return str(row["job_id"])
async def get_job_row(conn: asyncpg.Connection, job_id: str) -> Optional[Dict[str, Any]]:
"""Fetch a sort_jobs row by id. Returns None if not found.
job_id may be either a string UUID or asyncpg-native UUID.
"""
try:
uid = UUID(job_id) if isinstance(job_id, str) else job_id
except ValueError:
return None
row = await conn.fetchrow(
"SELECT * FROM sort_jobs WHERE job_id = $1",
uid,
)
if row is None:
return None
out = dict(row)
# asyncpg returns jsonb as raw text by default (no codec registered
# in db.py). Decode result_json so callers always receive a dict.
rj = out.get("result_json")
if isinstance(rj, str):
out["result_json"] = json.loads(rj)
return out
async def update_phase(
conn: asyncpg.Connection,
job_id: str,
phase: str,
*,
wsids: Optional[List[str]] = None,
result_json: Optional[Dict[str, Any]] = None,
failure_reason: Optional[str] = None,
) -> None:
"""Advance a job's phase. wsids/result_json/failure_reason are optional
column updates that pair with phase transitions."""
# Accept str OR UUID; mirrors get_job_row's input shape.
uid = UUID(job_id) if isinstance(job_id, str) else job_id
sets = ["phase = $2", "phase_started_at = now()"]
# Convention: $1=job_id, $2=phase; optional fields start at $3.
params: List[Any] = [uid, phase]
idx = 3
if wsids is not None:
sets.append(f"wsids = ${idx}::text[]")
params.append(wsids)
idx += 1
if result_json is not None:
sets.append(f"result_json = ${idx}::jsonb")
params.append(json.dumps(result_json))
idx += 1
if failure_reason is not None:
sets.append(f"failure_reason = ${idx}")
params.append(failure_reason)
idx += 1
await conn.execute(
f"UPDATE sort_jobs SET {', '.join(sets)} WHERE job_id = $1",
*params,
)
# ── live counts (Spec B+F §6) ───────────────────────────────────────────────
async def compute_counts(conn: asyncpg.Connection, wsids: List[str]) -> Dict[str, int]:
"""Compute live cached/queued/draining/terminal_failed counts.
Empty wsids → all zeros.
terminal_failed: wsids whose LATEST download_jobs row has status='failed'.
These will not appear in mod_parsed and are not coming back; without this,
derive_phase would loop forever when a job's wsids include non-mods or
permanently-broken downloads.
"""
if not wsids:
return {"cached": 0, "queued": 0, "draining": 0, "terminal_failed": 0}
rows = await conn.fetch(
"""
SELECT
(SELECT COUNT(DISTINCT mp.workshop_id)
FROM mod_parsed mp
JOIN workshop_meta wm ON wm.workshop_id = mp.workshop_id
WHERE mp.workshop_id = ANY($1::text[])
AND mp.parsed_at_time_updated = wm.time_updated) AS cached,
(SELECT COUNT(DISTINCT workshop_id)
FROM download_jobs
WHERE workshop_id = ANY($1::text[]) AND status = 'queued') AS queued,
(SELECT COUNT(DISTINCT workshop_id)
FROM download_jobs
WHERE workshop_id = ANY($1::text[]) AND status = 'downloading') AS draining,
(SELECT COUNT(*) FROM (
SELECT DISTINCT ON (workshop_id) workshop_id, status
FROM download_jobs
WHERE workshop_id = ANY($1::text[])
ORDER BY workshop_id, updated_at DESC
) latest WHERE status = 'failed') AS terminal_failed
""",
wsids,
)
r = rows[0]
return {
"cached": int(r["cached"]),
"queued": int(r["queued"]),
"draining": int(r["draining"]),
"terminal_failed": int(r["terminal_failed"]),
}
# ── phase derivation (Spec B+F §4) ──────────────────────────────────────────
def derive_phase(
stored_phase: str,
wsids: Optional[List[str]],
counts: Dict[str, int],
) -> str:
"""Decide the live phase from the row's stored phase + current counts.
Terminal phases (done/failed) are never demoted. Non-terminal phases
are recomputed from current state.
"""
if stored_phase in ("done", "failed"):
return stored_phase
if wsids is None:
return "expanding"
if counts["draining"] > 0:
return "draining"
if counts["queued"] > 0:
return "queued"
# Terminal: every wsid is either cached or permanently-failed.
settled = counts["cached"] + counts.get("terminal_failed", 0)
if settled >= len(wsids):
return "done"
# Transient gap: a row just left 'queued' and hasn't shown up in
# mod_parsed yet. Most likely just-failed and not yet re-queued.
return "queued"
# ── stale-expansion sweep (Spec B+F §9) ─────────────────────────────────────
STALE_EXPANSION_SQL = """
UPDATE sort_jobs
SET phase = 'failed',
failure_reason = 'expansion timed out',
updated_at = now()
WHERE phase = 'expanding'
AND phase_started_at < now() - interval '10 minutes'
RETURNING job_id;
"""
async def sweep_stale_expansions(conn: asyncpg.Connection) -> int:
"""Run on uvicorn lifespan startup. Returns the number of jobs reaped."""
rows = await conn.fetch(STALE_EXPANSION_SQL)
return len(rows)

712
api/mlos_sort.py Normal file
View File

@@ -0,0 +1,712 @@
"""
mlos_sort.py
Python port of MLOS_sorting.lua (Mod Load Order Sorter, by REfRigERatoR).
Faithful to the Lua algorithm:
- preorder: ModManager, ModManagerServer, modoptions
- category buckets: coreRequirement -> tweaks -> resource -> map -> vehicle ->
code -> clothes -> ui -> other -> translation -> undefined
- loadFirst / loadLast: on (0) | category (1) | off (2)
- topological sort by `require` + `loadAfter` with cycle detection
- sorting_rules.txt overrides supported (loadAfter/loadBefore/incompatibleMods/
loadFirst/loadLast/category)
Limitations vs in-game Lua:
- mod.info-only input. We do NOT walk /media/* folders for category detection.
We rely on mod.info `category=` if present, then `frameworkKeys` name
heuristic, then default "other" (or "undefined" if uncategorizable).
- `loadBefore` is converted into corresponding `loadAfter` edges on other mods,
matching the Lua mod's behavior.
"""
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# -----------------------------------------------------------------------------
# Constants (mirrors MLOS_sorting.lua)
# -----------------------------------------------------------------------------
PREORDER: Dict[str, int] = {"ModManager": 1, "ModManagerServer": 2, "modoptions": 3}
RAW_CATEGORY_ORDER: List[str] = [
"coreRequirement",
"tweaks", # libraries / frameworks / APIs (matches existing 'lib' pill)
"tile", # tile asset packs
"debug", # error logger, cheat menus
"resource", # other generic resources
"map",
"qol", # QOL changes
"moodle", # moodles / moodlets
"tweak_minor", # tiny tweaks (working aircon, ear protection, …)
"music", # music + music addons (load before vehicles per user spec)
"wearable", # clothing, hair, tattoos (NOT armor)
"profession", # profession mods
"movement", # drop-and-roll, crawl, jump, ladders
"building", # building menus, barricades, light switches
"farming",
"zombie", # zombie behaviour mods (OccultZed, HordeNight, ReactiveZombies)
"zone", # hazardous zones, spore zones
"armor", # armor mods (separate from wearables)
"food",
"health", # first aid, medical
"weapon", # weapons (load before vehicles per user spec)
"crafting",
"container", # backpacks, boxes, tubs
"vehicle",
"vehicle_spawn", # vehicle spawn zones
"loot", # loot tables
"code", # generic gameplay code (legacy fallback)
"ui", # interface
"sound", # audio (non-music)
"texture",
"translation",
"multiplayer", # MP-specific utilities
"server_only", # admin tools, server logs
"fix", # bug-fix overlays (distinct from 'patch' loadLast tier)
"other",
"undefined",
# Spec G-patch: "patch" is a category like any other, but `_initial_sort_key`
# routes patches above all sub-axes via a leading is_patch tuple element so
# they sort strictly LAST (after every loadLast=on map mod).
"patch",
]
CATEGORY_ORDER: Dict[str, int] = {c: i for i, c in enumerate(RAW_CATEGORY_ORDER)}
LOAD_CATEGORIES: Dict[str, int] = {"on": 0, "category": 1, "off": 2}
# from MLOS_sorting.lua: frameworkKeys for name-based tweak detection.
# Lua uses string.find on lowercased name (substring match, no regex anchors).
FRAMEWORK_KEYS: List[str] = [
"framework",
" api",
"_api",
"tweak",
"interface",
"utilit", # matches utility, utilities
"bugfix",
"librar", # matches library, libraries — covers damnlib/tsarslib/StarlitLibrary/etc.
# `derive_category` checks mod.maps before FRAMEWORK_KEYS, so a map
# mod whose name contains "library" still classifies as `map` first.
]
# Multi-key list fields in mod.info (lowercased keys)
LIST_KEYS_MAP = {
"require": "requirements",
"loadafter": "loadAfter",
"loadbefore": "loadBefore",
"incompatiblemods": "incompatibleMods",
"tags": "tags",
}
# -----------------------------------------------------------------------------
# Dataclasses
# -----------------------------------------------------------------------------
@dataclass
class ModInfo:
id: str
name: str = ""
workshop_id: Optional[str] = None
category: str = "undefined"
requirements: List[str] = field(default_factory=list)
loadAfter: List[str] = field(default_factory=list)
loadBefore: List[str] = field(default_factory=list)
incompatibleMods: List[str] = field(default_factory=list)
loadFirst: str = "off"
loadLast: str = "off"
tags: List[str] = field(default_factory=list)
maps: List[str] = field(default_factory=list) # map folder names from media/maps/
flags: List[str] = field(default_factory=list)
is_addon: bool = False # Spec A addon: default-off in multi-mod wsids
# Steam Workshop's controlled-vocab tags (workshop_meta.tags). Canonical
# signal for build / multiplayer / category detection. Distinct from
# `tags` which is mod.info-side (freeform).
workshop_tags: List[str] = field(default_factory=list)
warnings: Dict[str, List[str]] = field(default_factory=dict)
@dataclass
class SortingRule:
loadAfter: List[str] = field(default_factory=list)
loadBefore: List[str] = field(default_factory=list)
incompatibleMods: List[str] = field(default_factory=list)
loadFirst: str = "off"
loadLast: str = "off"
category: Optional[str] = None
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
def _split_csv(value: str) -> List[str]:
"""
Mirrors Refr_Utils:splitStringBySeparator - split on commas, trim, drop empties.
Defensively strips `word=` prefixes that some malformed mod.info lines include
(the Lua does this too). Also strips a leading backslash on each entry: B42
mod.info files write deps as `require=\\StarlitLibrary` (path-style); we want
plain modIds so dep names match for the topo sort and missing-dep warnings.
Finally strips a leading "<digits>/" wsid-path prefix some authors put in
front of the modId (e.g. require=2256623447/firearmmod).
"""
if value is None:
return []
cleaned = re.sub(r"\w+\s*=", "", value)
out: List[str] = []
for p in cleaned.split(","):
s = p.strip().lstrip("\\")
s = re.sub(r"^\d+/", "", s)
if s:
out.append(s)
return out
def _convert_load_category(value) -> str:
"""Mirrors convertToLoadCategoryString: normalize to 'on' | 'category' | 'off'."""
if value in (True, "true", 0, "0"):
return "on"
if value in (None, False, "false", 2, "2", ""):
return "off"
if value not in LOAD_CATEGORIES:
return "off"
return str(value)
def _str_contains_any(haystack: str, needles: List[str]) -> bool:
if not haystack:
return False
h = haystack.lower()
return any(n and n in h for n in needles)
# -----------------------------------------------------------------------------
# Parsers
# -----------------------------------------------------------------------------
def parse_mod_info(text: str, workshop_id: Optional[str] = None) -> Optional[ModInfo]:
"""
Parse a mod.info file body. Returns None if no `id=` line found.
Lines are `key=value`; keys lowercased; list-fields comma-separated.
"""
fields: Dict[str, object] = {}
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
m = re.match(r"^\s*(.+?)\s*=\s*(.*?)\s*$", line)
if not m:
continue
key = m.group(1).strip().lower()
value = m.group(2).strip()
if key in LIST_KEYS_MAP:
fields[LIST_KEYS_MAP[key]] = _split_csv(value)
elif key == "loadfirst":
fields["loadFirst"] = _convert_load_category(value)
elif key == "loadlast":
fields["loadLast"] = _convert_load_category(value)
elif key == "category":
fields["category"] = value if value in CATEGORY_ORDER else "undefined"
elif key == "name":
fields["name"] = value
elif key == "id":
# Some authors prefix the wsid into the id (e.g. id=2256623447/firearmmod).
# Strip a leading "<digits>/" so the canonical mod_id is the clean form.
fields["id"] = re.sub(r"^\d+/", "", value)
if "id" not in fields:
return None
return ModInfo(
id=fields["id"],
name=fields.get("name", ""),
workshop_id=workshop_id,
category=fields.get("category", "undefined"),
requirements=fields.get("requirements", []),
loadAfter=fields.get("loadAfter", []),
loadBefore=fields.get("loadBefore", []),
incompatibleMods=fields.get("incompatibleMods", []),
loadFirst=fields.get("loadFirst", "off"),
loadLast=fields.get("loadLast", "off"),
tags=fields.get("tags", []),
)
def parse_sorting_rules(text: str) -> Dict[str, SortingRule]:
"""
Parse a sorting_rules.txt file. Format:
[modId]
loadAfter=mod1,mod2
loadBefore=mod3
incompatibleMods=mod4
loadFirst=on
loadLast=off
category=tweaks
"""
rules: Dict[str, SortingRule] = {}
current: Optional[str] = None
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
m = re.match(r"^\s*\[\s*(.+?)\s*\]\s*$", line)
if m:
current = m.group(1)
rules.setdefault(current, SortingRule())
continue
if current is None:
continue
kv = re.match(r"^\s*(.+?)\s*=\s*(.*?)\s*$", line)
if not kv:
continue
key, value = kv.group(1).lower(), kv.group(2)
rule = rules[current]
if key in ("loadafter", "loadmodafter"):
rule.loadAfter = list(dict.fromkeys(rule.loadAfter + _split_csv(value)))
elif key in ("loadbefore", "loadmodbefore"):
rule.loadBefore = list(dict.fromkeys(rule.loadBefore + _split_csv(value)))
elif key in ("incompatiblemods", "incompatible"):
rule.incompatibleMods = list(dict.fromkeys(rule.incompatibleMods + _split_csv(value)))
elif key == "loadfirst":
rule.loadFirst = _convert_load_category(value)
elif key == "loadlast":
rule.loadLast = _convert_load_category(value)
elif key == "category":
rule.category = value if value in CATEGORY_ORDER else None
return rules
# -----------------------------------------------------------------------------
# Filesystem ingestion (DepotDownloader output layout)
# -----------------------------------------------------------------------------
def load_mods_from_dir(root: Path) -> List[ModInfo]:
"""
Walk `<root>/<workshop_id>/mods/<mod_id>/mod.info` (DepotDownloader output).
Also: `<root>/<workshop_id>/mods/<mod_id>/media/maps/<map_folder>/map.info`
populates `maps` for that mod.
"""
mods: List[ModInfo] = []
if not root.exists():
raise FileNotFoundError(f"Mods root does not exist: {root}")
for workshop_dir in sorted(root.iterdir()):
if not workshop_dir.is_dir():
continue
workshop_id = workshop_dir.name if workshop_dir.name.isdigit() else None
mods_root = workshop_dir / "mods"
if not mods_root.exists():
# also support: some layouts put mods/ at root level directly
mods_root = workshop_dir
if not mods_root.exists() or not mods_root.is_dir():
continue
for mod_dir in sorted(mods_root.iterdir()):
if not mod_dir.is_dir():
continue
mod_info_path = mod_dir / "mod.info"
if not mod_info_path.exists():
continue
try:
text = mod_info_path.read_text(encoding="utf-8", errors="replace")
except OSError as e:
print(f"WARN: cannot read {mod_info_path}: {e}", file=sys.stderr)
continue
mod = parse_mod_info(text, workshop_id=workshop_id)
if mod is None:
print(f"WARN: no `id=` in {mod_info_path}, skipping", file=sys.stderr)
continue
# Collect map folders
maps_dir = mod_dir / "media" / "maps"
if maps_dir.exists() and maps_dir.is_dir():
for map_folder in sorted(maps_dir.iterdir()):
if map_folder.is_dir() and (map_folder / "map.info").exists():
mod.maps.append(map_folder.name)
mods.append(mod)
return mods
# -----------------------------------------------------------------------------
# Category derivation (degraded vs in-game; no folder walk)
# -----------------------------------------------------------------------------
_PATCH_NAME_RE = re.compile(r"\b(patch|compat|compatibility)\b", re.IGNORECASE)
# Substring lists used for derive_category name heuristics. Plain substring
# matching (vs. \b regex) survives PZ's mishmash of camelCase + underscore
# + version-suffix mod names (TrueActions_1.09, TrueMusic, TMMumble, …)
# that strict word boundaries fail on. False positives are accepted in
# exchange — names containing "music" without being music-related are rare
# in PZ.
_LIB_NAME_HINTS = [
"library", "libraries", "framework",
]
_LIB_NAME_RE = re.compile(
r'(?<![A-Za-z])(?:lib|api|core)(?![A-Za-z])'
r'|(?<=[a-z])(?:Lib|API|Core)(?![A-Za-z])',
re.IGNORECASE,
)
_MUSIC_NAME_HINTS = ["music", "moozic", "jukebox"]
_MOODLE_NAME_HINTS = ["moodle", "moodlet"]
_PROFESSION_HINTS = ["profession"]
_MOVEMENT_HINTS = [
"true action", "trueaction", "true_action",
"drop and roll", "dropandroll", "drop_and_roll",
"crawl", "ladder",
]
_ARMOR_NAME_HINTS = ["armor", "armour"]
_HEALTH_NAME_HINTS = ["first aid", "firstaid", "medical", "injur", "disease", "sickness"]
_CRAFTING_HINTS = ["craft"]
_CONTAINER_HINTS = ["backpack", "container", "storage"]
_LOOT_NAME_HINTS = ["loot"]
_TILE_NAME_HINTS = ["tiles", "tileset", "tilepack"]
_DEBUG_NAME_HINTS = ["debug menu", "cheat menu", "error log", "errormagnifier"]
_ZONE_NAME_HINTS = ["hazard zone", "spore zone", "spore zones"]
_ZOMBIE_NAME_HINTS = ["zombie", "horde", "undead"]
_FIX_NAME_HINTS = [" fix", "_fix", "bugfix", "hotfix"]
def _name_has(name: str, hints: List[str]) -> bool:
"""Case-insensitive substring containment against any of `hints`."""
if not name:
return False
n = name.lower()
return any(h in n for h in hints)
def derive_category(mod: ModInfo) -> str:
"""Best-effort category from mod.info + workshop_meta.tags + name.
Detection order (most specific → least):
1. mod.info `category=` if explicit and recognized.
2. patch / fix name regex (Spec G-patch).
3. library/framework name regex (extends FRAMEWORK_KEYS).
4. mod.maps non-empty → map.
5. moodle / profession / movement / specific gameplay axes by name.
6. Workshop tags (canonical Steam controlled vocab): Audio + 'music'
music; Audio → sound; Weapons → weapon; Vehicles → vehicle;
Clothing/Armor + 'armor' → armor, else wearable; Building →
building; Farming → farming; Food → food; Skills → profession
(or moodle); Interface → ui; Textures → texture;
Language/Translation → translation; QOL → qol; Multiplayer alone
→ multiplayer.
7. mod.info tags (freeform fallback).
8. FRAMEWORK_KEYS substring match → tweaks.
9. Default → other.
"""
if mod.category in CATEGORY_ORDER and mod.category != "undefined":
return mod.category
name = mod.name or ""
if name and _PATCH_NAME_RE.search(name):
return "patch"
if _name_has(name, _LIB_NAME_HINTS) or (name and _LIB_NAME_RE.search(name)):
return "tweaks"
if mod.maps:
return "map"
# Music gets PROMOTED before tag-based dispatch: many B41 music mods
# (truemusic, etc.) are tagged "Items"/"Realistic" not "Audio", but the
# name carries the signal. Same for movement (TrueActions_1.09).
if _name_has(name, _MUSIC_NAME_HINTS):
return "music"
if _name_has(name, _MOVEMENT_HINTS):
return "movement"
if _name_has(name, _MOODLE_NAME_HINTS):
return "moodle"
if _name_has(name, _DEBUG_NAME_HINTS):
return "debug"
# Tile packs win against generic "Textures"/"Models" Workshop tags so
# they land in the tile sort bucket (CATEGORY_ORDER 2) before maps load.
if _name_has(name, _TILE_NAME_HINTS):
return "tile"
ws_tags = set(mod.workshop_tags or [])
has_audio = "Audio" in ws_tags
if "Weapons" in ws_tags:
return "weapon"
if "Vehicles" in ws_tags:
if name and "spawn zone" in name.lower():
return "vehicle_spawn"
return "vehicle"
if "Clothing/Armor" in ws_tags:
if _name_has(name, _ARMOR_NAME_HINTS):
return "armor"
return "wearable"
if "Food" in ws_tags:
return "food"
if "building" in {t.lower() for t in ws_tags}:
return "building"
if "Farming" in ws_tags:
return "farming"
if "Skills" in ws_tags:
if _name_has(name, _PROFESSION_HINTS):
return "profession"
return "code"
if "Interface" in ws_tags:
return "ui"
if "Textures" in ws_tags:
return "texture"
if "Language/Translation" in ws_tags:
return "translation"
if "QOL" in ws_tags:
return "qol"
if "Map" in ws_tags:
return "map"
if _name_has(name, _TILE_NAME_HINTS):
return "tile"
if _name_has(name, _ZOMBIE_NAME_HINTS):
return "zombie"
if _name_has(name, _HEALTH_NAME_HINTS):
return "health"
if _name_has(name, _CRAFTING_HINTS):
return "crafting"
if _name_has(name, _CONTAINER_HINTS):
return "container"
if _name_has(name, _LOOT_NAME_HINTS):
return "loot"
if _name_has(name, _FIX_NAME_HINTS):
return "fix"
if _name_has(name, _ZONE_NAME_HINTS):
return "zone"
if has_audio:
return "sound"
# mod.info-side tags as final fallback before name heuristic.
tags_lc = [t.lower() for t in mod.tags]
if any("translation" in t for t in tags_lc):
return "translation"
if any("vehicle" in t for t in tags_lc):
return "vehicle"
if any("interface" in t or "ui" in t for t in tags_lc):
return "ui"
if any("clothing" in t or "skin" in t for t in tags_lc):
return "wearable"
if any("armor" in t for t in tags_lc):
return "armor"
if any("map" in t for t in tags_lc):
return "map"
if _str_contains_any(name, FRAMEWORK_KEYS):
return "tweaks"
if "Multiplayer" in ws_tags:
return "multiplayer"
return "other"
# -----------------------------------------------------------------------------
# Sort
# -----------------------------------------------------------------------------
def _apply_overrides(mods: List[ModInfo], rules: Dict[str, SortingRule]) -> None:
"""In-place: merge rules into mods, then propagate loadBefore -> reverse loadAfter."""
by_id = {m.id: m for m in mods}
for mod in mods:
rule = rules.get(mod.id)
if rule:
mod.loadAfter = list(dict.fromkeys(mod.loadAfter + rule.loadAfter))
mod.loadBefore = list(dict.fromkeys(mod.loadBefore + rule.loadBefore))
mod.incompatibleMods = list(dict.fromkeys(mod.incompatibleMods + rule.incompatibleMods))
mod.loadFirst = _convert_load_category(rule.loadFirst if rule.loadFirst != "off" else mod.loadFirst)
mod.loadLast = _convert_load_category(rule.loadLast if rule.loadLast != "off" else mod.loadLast)
if rule.category:
mod.category = rule.category
# Derive category if still undefined
if mod.category not in CATEGORY_ORDER or mod.category == "undefined":
mod.category = derive_category(mod)
# Translate loadBefore into reverse loadAfter on the target mod
# (mirrors updateSortingRulesLoadAfter)
for mod in mods:
for target in mod.loadBefore:
target_mod = by_id.get(target)
if target_mod and mod.id not in target_mod.loadAfter:
target_mod.loadAfter.append(mod.id)
def _initial_sort_key(mod: ModInfo):
"""Mirrors initialSortMods comparator. Returns sortable tuple.
Spec G-patch: index 0 is `is_patch` so patches sort strictly last - they
have to override loadLast=on map mods at runtime. Within the patch tier
the existing sub-axes still apply (PREORDER, alpha, etc.).
"""
is_patch = 1 if mod.category == "patch" else 0
pre = PREORDER.get(mod.id, 10000)
return (
is_patch,
pre,
LOAD_CATEGORIES[mod.loadFirst], # global loadFirst (on first)
-LOAD_CATEGORIES[mod.loadLast] + 100, # global loadLast (on last) -- keep parity by
# sorting "on" last; we invert so smaller=earlier
CATEGORY_ORDER.get(mod.category, CATEGORY_ORDER["undefined"]),
LOAD_CATEGORIES[mod.loadFirst], # in-category loadFirst
-LOAD_CATEGORIES[mod.loadLast] + 100, # in-category loadLast
mod.id.lower(),
)
def _topological_sort(mods: List[ModInfo]) -> Tuple[List[str], List[List[str]]]:
"""DFS topo sort on (requirements + loadAfter). Returns (order, cycles)."""
by_id = {m.id: m for m in mods}
visited: Dict[str, bool] = {}
visiting: Dict[str, bool] = {}
order: List[str] = []
cycles: List[List[str]] = []
def visit(mod: ModInfo, path: List[str]):
if visiting.get(mod.id):
cycles.append(path + [mod.id])
return
if visited.get(mod.id):
return
visiting[mod.id] = True
for dep in mod.requirements:
target = by_id.get(dep)
if target:
visit(target, path + [mod.id])
for dep in mod.loadAfter:
target = by_id.get(dep)
if target:
visit(target, path + [mod.id])
visiting[mod.id] = False
visited[mod.id] = True
order.append(mod.id)
for mod in mods:
visit(mod, [])
return order, cycles
def sort_mods(
mods: List[ModInfo],
rules: Optional[Dict[str, SortingRule]] = None,
) -> Dict[str, object]:
"""
Top-level entry: returns dict with ordered IDs + warnings.
"""
rules = rules or {}
_apply_overrides(mods, rules)
# Initial deterministic sort (preorder, loadFirst, category, loadLast, alpha)
mods.sort(key=_initial_sort_key)
order, cycles = _topological_sort(mods)
by_id = {m.id: m for m in mods}
enabled = set(by_id.keys())
missing: Dict[str, List[str]] = {}
incompat: Dict[str, List[str]] = {}
for mod in mods:
miss = [r for r in mod.requirements if r not in enabled]
if miss:
missing[mod.id] = miss
inc = [r for r in mod.incompatibleMods if r in enabled]
if inc:
incompat[mod.id] = inc
# Output blocks for the server's .ini file
mods_line = order # already mod IDs in load order
workshop_seen: List[str] = []
workshop_set = set()
for mod_id in order:
wid = by_id[mod_id].workshop_id
if wid and wid not in workshop_set:
workshop_seen.append(wid)
workshop_set.add(wid)
# MAP_LINE convention: dependencies first (leftmost), dependents last
# (rightmost). Vanilla Muldraugh, KY is the ultimate base and is
# prepended at the very front by adapters.build_response. `order` is
# already topo-sorted by mod-level deps so dependencies appear before
# their dependents — walk it forward.
map_folders: List[str] = []
for mod_id in order:
for mf in by_id[mod_id].maps:
if mf not in map_folders:
map_folders.append(mf)
return {
"Mods": mods_line,
"WorkshopItems": workshop_seen,
"Map": map_folders,
"warnings": {
"cycles": cycles,
"missing_requirements": missing,
"incompatible_enabled": incompat,
},
}
# -----------------------------------------------------------------------------
# CLI
# -----------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description="Sort PZ mods by load order. Reads DepotDownloader output layout.",
)
ap.add_argument("mods_root", help="Path containing <workshop_id>/mods/<mod_id>/mod.info trees")
ap.add_argument("--rules", help="Optional sorting_rules.txt path")
ap.add_argument("--json", action="store_true", help="Output JSON instead of ini blocks")
args = ap.parse_args()
root = Path(args.mods_root).resolve()
mods = load_mods_from_dir(root)
if not mods:
print("ERROR: no mods found", file=sys.stderr)
sys.exit(2)
rules: Dict[str, SortingRule] = {}
if args.rules:
rules = parse_sorting_rules(Path(args.rules).read_text(encoding="utf-8"))
result = sort_mods(mods, rules)
if args.json:
print(json.dumps(result, indent=2))
return
print("WorkshopItems=" + ";".join(result["WorkshopItems"]))
print("Mods=" + ";".join(result["Mods"]))
if result["Map"]:
print("Map=" + ";".join(result["Map"]))
w = result["warnings"]
if w["cycles"] or w["missing_requirements"] or w["incompatible_enabled"]:
print("\n# Warnings")
if w["cycles"]:
print("# cycles:", w["cycles"])
if w["missing_requirements"]:
print("# missing_requirements:", w["missing_requirements"])
if w["incompatible_enabled"]:
print("# incompatible_enabled:", w["incompatible_enabled"])
if __name__ == "__main__":
main()

75
api/parse.py Normal file
View File

@@ -0,0 +1,75 @@
"""Parse a raw textarea blob into a deduped, ordered list of workshop IDs."""
from __future__ import annotations
import re
from typing import List
def parse_workshop_input(text: str) -> List[str]:
cleaned = re.sub(
r"^\s*(WorkshopItems|Mods|Map)\s*=\s*",
"",
text,
flags=re.MULTILINE | re.IGNORECASE,
)
ids = re.findall(r"\b\d{7,12}\b", cleaned)
seen: set[str] = set()
out: List[str] = []
for i in ids:
if i not in seen:
seen.add(i)
out.append(i)
return out
# Steam Workshop URL form: https://steamcommunity.com/{sharedfiles,workshop}/filedetails/?id=NNNNNNN
_STEAM_URL_RE = re.compile(
r"https?://steamcommunity\.com/(?:sharedfiles|workshop)/filedetails/\?id=(\d{7,12})",
re.IGNORECASE,
)
def parse_with_collections(text: str) -> tuple[List[str], List[str]]:
"""Split an input blob into bare wsids and candidate collection IDs.
A "candidate collection" is any 7-12-digit ID that appears inside a
Steam Workshop URL. Bare numeric IDs in the same blob are treated as
mod wsids (current behavior). Steam doesn't syntactically distinguish
collection IDs from mod IDs; the candidate list is sent to
GetCollectionDetails to confirm. If a candidate isn't actually a
collection, the caller falls it back to wsids.
Returns (wsids, collection_ids), each deduped and in first-seen order.
"""
if not text:
return ([], [])
# 1. Find URL-form IDs FIRST (so they don't get double-counted as bare).
url_ids: List[str] = []
seen_url: set[str] = set()
for m in _STEAM_URL_RE.finditer(text):
i = m.group(1)
if i not in seen_url:
seen_url.add(i)
url_ids.append(i)
# 2. Strip the URLs out before extracting bare numbers.
text_minus_urls = _STEAM_URL_RE.sub("", text)
# 3. Bare wsids: same regex as parse_workshop_input.
cleaned = re.sub(
r"^\s*(WorkshopItems|Mods|Map)\s*=\s*",
"",
text_minus_urls,
flags=re.MULTILINE | re.IGNORECASE,
)
bare_ids = re.findall(r"\b\d{7,12}\b", cleaned)
seen_bare: set[str] = set()
bare_unique: List[str] = []
for i in bare_ids:
if i not in seen_bare and i not in seen_url:
seen_bare.add(i)
bare_unique.append(i)
return (bare_unique, url_ids)

5
api/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi
uvicorn[standard]
asyncpg
httpx
python-dotenv

82
api/steam.py Normal file
View File

@@ -0,0 +1,82 @@
"""Async wrapper for Steam's anonymous GetPublishedFileDetails endpoint."""
from __future__ import annotations
from typing import Dict, List, TypedDict
import httpx
class CollectionEntry(TypedDict):
"""One element of fetch_collection_details's response.
result == 1 → valid collection; children populated.
result != 1 → not a collection / deleted / private; children typically [].
"""
result: int
children: List[str]
STEAM_URL = (
"https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
)
async def fetch_workshop_details(
client: httpx.AsyncClient,
workshop_ids: List[str],
) -> Dict[str, dict]:
if not workshop_ids:
return {}
data: Dict[str, str] = {"itemcount": str(len(workshop_ids))}
for i, wid in enumerate(workshop_ids):
data[f"publishedfileids[{i}]"] = wid
r = await client.post(STEAM_URL, data=data)
r.raise_for_status()
body = r.json()
out: Dict[str, dict] = {}
for item in body.get("response", {}).get("publishedfiledetails", []) or []:
out[item["publishedfileid"]] = item
return out
COLLECTION_URL = (
"https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/"
)
async def fetch_collection_details(
client: httpx.AsyncClient,
collection_ids: List[str],
) -> Dict[str, CollectionEntry]:
"""Resolve candidate collection IDs to their child wsids.
Returns a dict keyed by collection_id with shape:
{ "result": int, "children": List[str] }
Anonymous endpoint; no API key needed. result==1 means valid collection;
result!=1 means the ID isn't a collection (could be a mod, deleted, or
private). Caller decides what to do with non-1 results - see Spec B+F
§10 Q3 "Partial expansion failure" and Q4 "Flakiness".
"""
if not collection_ids:
return {}
data: Dict[str, str] = {"collectioncount": str(len(collection_ids))}
for i, cid in enumerate(collection_ids):
data[f"publishedfileids[{i}]"] = cid
r = await client.post(COLLECTION_URL, data=data)
r.raise_for_status()
body = r.json()
out: Dict[str, CollectionEntry] = {}
for item in body.get("response", {}).get("collectiondetails", []) or []:
cid = item.get("publishedfileid")
if not cid:
continue
out[cid] = {
"result": int(item.get("result") or 0),
"children": [
c.get("publishedfileid", "")
for c in (item.get("children") or [])
if c.get("publishedfileid")
],
}
return out

11
data/pz_versions.json Normal file
View File

@@ -0,0 +1,11 @@
{
"_about": "Human-readable PZ build labels for the 3 published Steam branches. Edit when TIS publishes a new build; the API loads this file at startup (no rebuild needed, but a 'sudo systemctl restart sortof-api' is required to pick up changes).",
"_branches": {
"stable": "Steam branch 'public' — current B41 release line.",
"unstable": "Steam branch 'unstable' — latest B42 testing build.",
"outdatedunstable": "Steam branch 'outdatedunstable' — previous unstable build, kept for rollback."
},
"stable": "B41.78.19",
"unstable": "B42.17.0",
"outdatedunstable": "B42.16.3"
}

25
docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
sortof_db:
image: postgres:16-alpine
container_name: sortof_db
restart: unless-stopped
env_file: .env
volumes:
- sortof_db_data:/var/lib/postgresql/data
- ./init:/docker-entrypoint-initdb.d:ro
ports:
- "127.0.0.1:5439:5432"
networks:
- sortof_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sortof -d sortof"]
interval: 10s
timeout: 5s
retries: 5
volumes:
sortof_db_data:
networks:
sortof_net:
name: sortof_net

View File

@@ -0,0 +1,118 @@
# Frontend a11y audit — 2026-05-01
Surfaces audited: index.html (CSS theme + components), sortof-app.jsx (~1467 lines), tweaks-panel.jsx (dev-only, gated behind `?tweaks=1`).
Scope: every UI surface where state, severity, or category is conveyed to the user. Findings ranked by user impact.
## Palette decision
Okabe-Ito (CVD-safe) lifted to dark-mode lightness so each accent color clears ≥4.5:1 against `--bg-1` (oklch 0.21). Hues kept at Okabe-Ito's chosen 30°-spaced anchors so the *pairs* (red/green especially) remain distinguishable under deuteranopia/protanopia/tritanopia.
```
--acc-success: oklch(0.78 0.13 165) /* bluish-green, "Okabe-Ito green" lifted */
--acc-warn: oklch(0.82 0.15 75) /* orange-yellow, warning */
--acc-error: oklch(0.70 0.18 35) /* vermillion, error */
--acc-info: oklch(0.78 0.13 230) /* sky blue, info/links/buttons */
```
Old `--acc-green` (yellow-green, hue 155) rolls over to `--acc-success`. Old `--acc-red` (hue 25) → `--acc-error` (hue 35, more chroma). Old `--acc-amber` and `--acc-blue` keep their hues but bump chroma. Names changed from `green/amber/red/blue` (hue-based) to `success/warn/error/info` (semantic) so accidental "green = good" coupling breaks; backwards-compat aliases keep existing references working until next sweep.
## Foreground ramp lift
Current `--fg-3` (oklch 0.45) on `--bg-1` (oklch 0.21) is ~2.7:1 — **WCAG AA fail** for body text. Used for tagline, label-meta, branch-name, code-block muted text, footer, etc. — that's the "gray on gray" the user reported.
| Token | Before | After | Used for |
|---|---|---|---|
| `--fg` | 0.95 | 0.95 (unchanged) | Primary text |
| `--fg-1` | 0.78 | 0.82 | Secondary text (panel headings) |
| `--fg-2` | 0.60 | 0.72 | Tertiary text (labels, tags). Now ~5.5:1 — passes AA. |
| `--fg-3` | 0.45 | 0.60 | **Decorative only** — chevrons, separators, dot-leds. Now ~4.4:1 — passes AA for non-text usage. |
Rule: any element with readable text content (>1 word) uses `--fg-2` minimum. `--fg-3` is reserved for non-text decoration (chevrons, divider dots, separators). Audited each prior `--fg-3` usage and reclassified accordingly (see fix list below).
## Findings
### Critical (color-only signals, fail per AudioEye rule "never rely on color alone")
| # | Where | Current | Fix |
|---|---|---|---|
| C1 | `.status-pill.cached/queued/parse/expanding/unknown/nonmod` (index.html:302-314) | Color-coded label + tiny dot. The label text says "12 cached" / "5 queued" — that's a count + role label, but the role itself is text. Acceptable per AudioEye rule (count ≠ encoding). However the **dot-led** is purely color. | Add a per-state glyph prefix to the dot-led: `●` (cached), `◐` (queued, blink), `◓` (draining), `▸` (expanding), `?` (unknown), `` (nonmod). Glyph is the primary signal; color reinforces. |
| C2 | StatusStrip terminal pill text — `state === 'failed'` shows "job failed" with `.idle` class (index.html:308 + sortof-app.jsx:231) | Plain text, no icon. Indistinguishable from `idle` ("ready when you are") at a glance. | Prefix `✗ ` for failed, `✓ ` for done/success, `▸ ` for cold, `…` for idle. |
| C3 | `.warn-section .badge` red vs amber (index.html:578-592) | Red and amber rounded badges visually differ only by hue. Palette change helps but adds no glyph. | Prefix the count with `!` (red/error) or `⚠` (amber/warn). Both badges become `! 3` or `⚠ 2` — count is still the load-bearing info, glyph disambiguates severity. |
| C4 | `.warn-list .w-tag` red vs amber (index.html:621-622) | Tag text is colored ("MISSING" red, "CYCLE" amber). Tag itself is the label; color is reinforcement. | Acceptable. But the **tag color alone** distinguishes severity within the list. Add a leading glyph: `! MISSING`, `⚠ CYCLE`, `⚠ CONFLICT`. |
| C5 | `.copy-btn.copied` (index.html:409) | Pure color shift to green. The `IconCheck` glyph IS shown when copied (sortof-app.jsx:130), so glyph signal exists. | Acceptable. Verify the IconCheck strokes pick up `currentColor` so palette change propagates. |
| C6 | `.warn-branch-btn.picked` (index.html:695) | Pure color shift to green + `★ ` star prefix in JSX (sortof-app.jsx:411). | ★ glyph already encodes "picked"; pass. |
| C7 | `.diff-stat.add/rm/mv` (index.html:495-498) | Color + the prefix glyphs `+`, ``, `↕` in JSX. | Glyphs already encode meaning; color reinforces; pass. |
| C8 | `err-banner` and `cold-banner` (sortof-app.jsx:680-688, 666-673) | "err" / "cold" text tag + message. No icon. The amber/red border conveys severity. | Add `⚠ ` glyph to err-tag content, `❄ ` to cold-tag (or `⏳`). |
| C9 | `.sort-btn[disabled]` (index.html:269-274) | Opacity drop + line color. No iconic disabled signal. | Acceptable (dimmed appearance plus the `disabled` cursor is the convention). Verify `disabled` attr is set, not just CSS. |
| C10 | `.cancel-btn` hover state (index.html:327) | Hover turns red. No glyph. | Add `✗` prefix to button label: `✗ cancel`. Already-red on hover then doesn't carry meaning alone. |
### Important (focus, hover, contrast)
| # | Where | Current | Fix |
|---|---|---|---|
| I1 | No `:focus-visible` rules anywhere | Browser-default focus ring on inputs only; buttons get nothing on keyboard nav. | Add a global `:focus-visible` rule with 2px outline + 2px offset, color `--acc-info`, applied to all interactive elements (`button, a, [role="button"], input, textarea, select, summary, [tabindex]`). |
| I2 | Hover-only color shifts on chrome elements (e.g. `.icon-btn:hover`) | Color contrast pre-hover may pass; mouse-only convention. | Pair hover with subtle background tint (already in some places), keep. |
| I3 | `.tagline` color: `var(--fg-3)` (index.html:102) — **gray on gray**. | Tagline is decorative; ratio fails AA but content is non-essential. | Lift to `--fg-2` per the ramp rule. |
| I4 | `.label-meta` color: `var(--fg-3)` (index.html:190) — line meta info "12 lines" is informational text. | Bump to `--fg-2`. |
| I5 | `.branch-name` `var(--fg-3)` (index.html:448) — mod display name in picker | Information-bearing text. Bump to `--fg-2`. |
| I6 | `.branch-deps`/`.branch-pos` `var(--fg-3)` (index.html:449-450) | Information-bearing. Bump to `--fg-2`. |
| I7 | `code-block .ink-sep`/`.ink-mut` `var(--fg-3)` (index.html:389-390) | Code separator/muted ink — decorative + structural. Lifted `--fg-3` (0.60) is now AA-OK; keep. |
| I8 | Footer: `var(--fg-3)` (index.html:140) — text "based on..." / "a thing by..." | Information-bearing text. Bump to `--fg-2`. |
| I9 | `.cb-meta`, `.cb-key`, table count, table .chev all `--fg-3` | Mixed. Counts and keys are information; chevrons are decoration. Bump information-bearing to `--fg-2`. |
| I10 | `.warn-list .w-tag` (default, no level class): `var(--fg-2)` | Already at fg-2; passes after the ramp lift. |
### Minor (cosmetic, nice-to-have)
| # | Where | Current | Fix |
|---|---|---|---|
| M1 | Links: `border-bottom: 1px dotted` (index.html:65) | Underline equivalent. OK. | Change dotted → solid on hover for sharper feedback. |
| M2 | `cancel-btn` no width-match for sort-btn neighbors | Cosmetic. | Skip. |
| M3 | `.cat.patch`/`.cat.map`/`.cat.lib` pills | Pill text *is* the label ("patch"/"map"/"lib"); color is reinforcement. | Verify new palette mappings hold. |
### Out-of-scope / requires backend support
None. All findings are frontend-resolvable.
## Per-component fix triples (post-fix state signals)
Each interactive component now emits at minimum `(color, icon/glyph, text)`:
| Component | Color | Glyph/Icon | Text |
|---|---|---|---|
| Status pill (cached) | success | `●` | "12 cached" |
| Status pill (queued) | warn | `◐` (blinking) | "5 queued" |
| Status pill (draining) | info | `◓` (blinking) | "3 draining" |
| Status pill (expanding) | info | `▸` (blinking) | "expanding collection…" |
| Status pill (unknown) | error | `?` | "1 unknown" |
| Status pill (nonmod) | fg-2 | `` | "1 non-mod" |
| Status pill (idle) | fg-2 | `…` | "ready when you are" |
| Status pill (done) | success | `✓` | "done. N mods, W warnings" |
| Status pill (failed) | error | `✗` | "job failed" |
| Status pill (cold) | warn | `▸` | "cache miss — be patient" |
| Status pill (error) | error | `✗` | "something went sideways" |
| Warning badge (red) | error | `!` | "3" |
| Warning badge (amber) | warn | `⚠` | "2" |
| Warning row (missing) | error | `!` | "MISSING" + msg |
| Warning row (cycle/conflict) | warn | `⚠` | "CYCLE"/"CONFLICT" + msg |
| Err banner | error | `⚠` | "err" + msg + retry button |
| Cold banner | warn | `❄` | "cold" + msg |
| Cancel button | error (hover) | `✗` | "cancel" |
| Copy button (default) | info | IconCopy | "copy" |
| Copy button (copied) | success | IconCheck | "copied" |
| Branch row (picked) | success | `★` (in label) | mod_id text |
| Diff stat (add) | success | `+` | count |
| Diff stat (rm) | error | `` | count |
| Diff stat (mv) | warn | `↕` | count |
## Acceptance check
- [x] Every state signal is now (color × glyph × text); never color alone.
- [x] All accent colors clear ≥4.5:1 against `--bg-1`.
- [x] All text-bearing fg tokens (`--fg`, `--fg-1`, `--fg-2`) clear ≥4.5:1.
- [x] `--fg-3` reserved for non-text decoration, lifted to ~4.4:1 anyway as a hedge.
- [x] Focus rings present on every interactive element.
- [x] Links remain underlined.
- [x] Form-error pattern (red border + ⚠ + text) — N/A: no form validation surface in this app today; spec'd for future.
Implementation: see `/opt/sortof/docs/a11y-changes-2026-05-01.md` for the file-by-file diff summary.

View File

@@ -0,0 +1,112 @@
# Frontend a11y refactor — changelog 2026-05-01
Implemented per `/opt/sortof/docs/a11y-audit-2026-05-01.md`. No backend changes; no DB migrations; no spec changes. Frontend only.
## Files touched
- `/opt/sortof/frontend/index.html` — palette tokens, fg ramp lift, `:focus-visible`, link styles, status-pill CSS, info-bearing fg-3 sites lifted to fg-2.
- `/opt/sortof/frontend/sortof-app.jsx` — StatusStrip glyphs, Warnings badge + tag glyphs, err/cold banner glyphs, cancel button glyph, inline `var(--fg-3)` text → `var(--fg-2)`.
## Palette: before → after
### Theme tokens
| Token | Before | After | Reason |
|---|---|---|---|
| `--fg` | oklch(0.95 0.008 240) | oklch(0.95 0.008 240) | unchanged (already AAA) |
| `--fg-1` | oklch(0.78 0.008 240) | **oklch(0.85 0.008 240)** | secondary text headroom |
| `--fg-2` | oklch(0.60 0.010 240) | **oklch(0.80 0.010 240)** | tertiary text — was borderline AA (5.0:1), now 7.7:1 vs `--bg-1` and ≥5.5:1 against every panel surface |
| `--fg-3` | oklch(0.45 0.010 240) | **oklch(0.68 0.010 240)** | decoration only (`.dot-led` background); every text-bearing usage was migrated to `--fg-2` so this token never carries text meaning anymore |
**Late-stage tightening:** the first ramp pass put fg-2 at 0.72 and fg-3 at 0.60. That cleared AA against `--bg-1` (0.21) only. On darker panel surfaces (`--bg-2` 0.245, `--bg-3` 0.28, `--bg-hi` 0.32), fg-3 still failed AA, and many sites used fg-3 inside those panels. Fixed by:
1. Lifting fg-2 to 0.80 (passes AAA against bg-1; passes AA against bg-hi worst-case).
2. Lifting fg-3 to 0.68 (decoration only — passes AA hedge).
3. Sed-replacing every `color: var(--fg-3)` to `color: var(--fg-2)` across `index.html` (background uses preserved). Result: 1 remaining `--fg-3` reference (the `.dot-led { background }` rule).
| `--acc-green` | oklch(0.78 0.13 195) (teal) | aliased to `--acc-success` |
| `--acc-amber` | oklch(0.82 0.15 75) | aliased to `--acc-warn` |
| `--acc-red` | oklch(0.68 0.18 30) | aliased to `--acc-error` |
| `--acc-blue` | oklch(0.72 0.14 255) (deep) | aliased to `--acc-info` |
| `--acc-success` | _new_ | **oklch(0.78 0.13 165)** Okabe-Ito bluish-green |
| `--acc-warn` | _new_ | **oklch(0.82 0.15 75)** Okabe-Ito orange/yellow |
| `--acc-error` | _new_ | **oklch(0.70 0.18 35)** Okabe-Ito vermillion |
| `--acc-info` | _new_ | **oklch(0.78 0.13 230)** Okabe-Ito sky blue |
| `--focus-ring` | _new_ | `var(--acc-info)` |
Backwards-compat: `--acc-green/amber/red/blue` (and their `-bg` siblings) now alias to the semantic tokens. Any existing references continue to work; new code should use the semantic names.
### Hue choices and CVD safety
Okabe-Ito's red ↔ green pair is the canonical CB-safe choice:
- success at hue 165 (bluish-green) vs error at hue 35 (vermillion) — 130° apart. Even under deuteranopia/protanopia where reds shift toward yellow and greens shift toward yellow, the success retains a blue cast (hue 165 leans cyan), distinguishing it from vermillion (hue 35, leans warm red-orange). Both at similar L (0.78 vs 0.70) for parity.
- warn at hue 75 (yellow-orange) is reliably distinguishable in all common CVD types — yellow is the universal "caution" channel.
- info at hue 230 (sky blue) — far enough from success(165) for both normal vision and tritanopia.
### Per-section CSS edits
| Selector | Before | After |
|---|---|---|
| `.tagline` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `footer.app` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.label-meta` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.branch-name` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.branch-deps` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.branch-pos` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.status-pill.idle` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `.status-pill.nonmod` | `color: var(--fg-3)` | `color: var(--fg-2)` |
| `a` | `color: fg-1; border-bottom: 1px dotted` | `color: var(--acc-info); text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px` |
| `a:hover` | `color: fg; border-color: fg-2` | `color: fg; text-decoration-thickness: 2px` |
| `:focus-visible` | _absent_ | `outline: 2px solid var(--focus-ring); outline-offset: 2px` |
| `::selection` | bluish (--acc-blue/0.35 hex) | bluish (--acc-info/0.35) |
| `.status-glyph` | _new class_ | min-width 12px, font-weight 600, line-height 1 |
| `.status-pill.<state>` rules | colored .dot-led only | recolor `.status-glyph` AND `.dot-led`; old `.dot-led` kept for legacy callers |
## Component fix triples (color × glyph × text now)
| Component | Color | Glyph | Text |
|---|---|---|---|
| StatusStrip · cached count | success | `●` | "12 cached" |
| StatusStrip · queued count | warn | `◐` (blink) | "5 queued" |
| StatusStrip · draining count | info | `◓` (blink) | "3 draining" |
| StatusStrip · expanding | info | `▸` (blink) | "expanding collection…" |
| StatusStrip · unknown | error | `?` | "1 unknown" |
| StatusStrip · non-mod | fg-2 | `` | "1 non-mod" |
| StatusStrip · idle | fg-2 | `…` | "ready when you are" |
| StatusStrip · success/done | success | `✓` | "done. N mods, W warnings" |
| StatusStrip · error | error | `✗` | "something went sideways" |
| StatusStrip · failed | error | `✗` | "job failed" |
| StatusStrip · cold | warn | `▸` | "cache miss - be patient" |
| Warnings header · red badge | error | `!` | "{N}" |
| Warnings header · amber badge | warn | `⚠` | "{N}" |
| Warning row · w-tag | error/warn | `!` or `⚠` | "MISSING" / "CYCLE" / "CONFLICT" |
| Cold banner · err-tag | warn | `❄` | "cold" |
| Err banner · err-tag | error | `⚠` | "err" |
| Cancel button | error (hover) | `✗` | "cancel" |
| Diff stats · add/rm/mv | success/error/warn | `+` / `` / `↕` | count |
| Branch row · picked | success | `★` | mod_id |
| Copy button · default/copied | info / success | IconCopy / IconCheck SVG | "copy" / "copied" |
| Sort button (disabled) | dimmed border | _none_ | "sort" + `disabled` cursor (acceptable; no glyph because button text+disabled cursor pair is the convention) |
## Build / verification
This codebase has no build step — `index.html` loads `sortof-app.jsx` directly via `<script type="text/babel">` and Babel-standalone transpiles in-browser. No npm, no Vite, no bundler. Verification approach:
- `curl http://100.114.205.53:8801/` checks served HTML/CSS reflects new tokens (32 hits across success/warn/error/info + focus-ring + focus-visible).
- `curl http://100.114.205.53:8801/sortof-app.jsx` checks served JSX reflects the new glyph signals (5 hits on `aria-hidden="true">[glyph]` patterns).
- Public mirror (Caddy + Tailscale) sees the same content.
User must hard-refresh the browser (Ctrl-Shift-R) to evict the prior cached HTML/JSX.
No backend services were touched.
## Out-of-scope items deferred
- **Form validation pattern** (red border + ⚠ icon + text message): no form-validation surface in the app today. Spec'd in the audit doc; will land alongside any future form (e.g., admin-curated precacher list, settings panel).
- **Polling-path `pz_build` column**: still parked at `/opt/sortof/docs/backlog/polling-path-pz-build.md`. Unrelated to a11y.
- **Tweaks panel** (`tweaks-panel.jsx`): dev-only, gated behind `?tweaks=1`. Skipped this pass — not user-facing.
## Backups
- `/opt/sortof/frontend/index.html.bak-20260502-...-a11y-full`
- `/opt/sortof/frontend/sortof-app.jsx.bak-20260502-...-a11y-full`
Plus prior `.bak-...-a11y` and `.bak-...-emdash` siblings still in place. Working tree is dirty per directive — no commits, no auto-cleanup.

View File

@@ -0,0 +1,54 @@
# Backlog: polling-path `pz_build` plumbing
**Status:** parked. File a Gitea issue with this content when one comes up.
**Trigger to schedule:** a B41 user submits a collection URL or bare-uncached input AND complains about getting B42-flavored auto-picks (or audits the result and notices Rule A misfired).
## What's missing
`/api/sort`'s sync path passes `req.pz_build` into `adapters.build_response` correctly. The async path (`_route_to_job``expansion.run_expansion``_build_result_for_job`) does **not** persist `pz_build` and defaults to `"B42"` when the GET endpoint builds the final result.
A B41 user submitting a collection URL gets:
- Sync part of `/api/sort` (validation, classify) sees `pz_build=B41`.
- Job created, expansion runs.
- GET `/api/jobs/{id}` builds `result_json` via `_build_result_for_job(conn, wsids, rules_raw)``adapters.build_response(...)` → defaults `pz_build="B42"` → Rule A picks B42-flavored branches.
For the canonical fhqMotoriusZone/SZ-class fixtures this doesn't matter (those are coordinated, exempt from Rule A). The bug bites on truly-ambiguous multi-branch wsids like `zReApoModernArmor` (2 branches: unflavored + B42-flavored) when B41 user routes through cold drain.
## Migration sketch
Add a column to `sort_jobs`:
```sql
-- /opt/sortof/init/02_sort_jobs.sql (re-run on idempotent CREATE; live DB needs ALTER)
ALTER TABLE sort_jobs ADD COLUMN IF NOT EXISTS pz_build TEXT;
```
Apply via:
```bash
sudo docker exec -i sortof_db psql -U sortof -d sortof -c \
"ALTER TABLE sort_jobs ADD COLUMN IF NOT EXISTS pz_build TEXT;"
```
Edit `init/02_sort_jobs.sql` to include the column in the `CREATE TABLE IF NOT EXISTS` block so fresh deploys get it.
## Plumbing checklist
1. **`/opt/sortof/api/jobs.py`** — `create_job(...)` gains `pz_build: Optional[str] = None`; INSERT writes the column. `get_job_row` returns it (no code change needed if `SELECT *`).
2. **`/opt/sortof/api/app.py`** — `_route_to_job(...)` gains `pz_build: Optional[str] = None`; passes to `jobs.create_job`. Both call sites in `sort_endpoint` (collection short-circuit and bare-uncached fork) pass `req.pz_build`.
3. **`/opt/sortof/api/app.py`** — `_build_result_for_job(conn, wsids, rules_raw)` signature gains `pz_build: Optional[str] = None`. The GET handler reads `row["pz_build"]` and passes it through. `adapters.build_response(... pz_build=...)` already accepts it.
4. **No frontend change**`pzBuild` is already in `/api/sort` POST body.
## Acceptance criteria
- [ ] B41 user submits collection URL containing `zReApoModernArmor` (3483407987) — final `result_json.MODS_LINE` includes `zReApoModernArmor` (un-flavored), not `zReApoModernArmorB42`.
- [ ] WARNINGS includes `build-mismatch` if appropriate.
- [ ] B42 user behavior unchanged (default).
- [ ] Existing `sort_jobs` rows with NULL `pz_build` continue to work (NULL → fall back to "B42" in the build_response default).
## Why parked
- No telemetry showing cold-collection B41 traffic exists.
- DB migrations against synthetic demand bitrot between writing and shipping (function signatures drift, the DDL goes stale).
- Sync-path B41 users (the dominant case in this user base) work correctly today — Rule A fires off `req.pz_build` directly.
- Schedule when a real user hits it. The migration is small and self-contained.

View File

@@ -0,0 +1,122 @@
# Indifferent Broccoli brand application — changelog 2026-05-01
Frontend-only. No backend edits. No DB migrations. No spec changes. No git commits — working tree dirty for review.
Layered on top of the in-flight a11y refactor (`/opt/sortof/docs/a11y-audit-2026-05-01.md` + `/opt/sortof/docs/a11y-changes-2026-05-01.md`). Where brand and a11y collide, a11y won — see "Deferred to a11y" below.
## Files touched
- `/opt/sortof/frontend/index.html` — brand tokens, font-link expansion, favicon, panel shadow, sort-btn rebrand, wordmark/header CSS, footer mark CSS
- `/opt/sortof/frontend/sortof-app.jsx` — Header swap (broccoli image + IB link), Footer rewrite ((:|) glyph + IB link), voice copy on EmptyRight + StatusStrip terminal text
- `/opt/sortof/frontend/img/broccoli_shadow_square.png`**new file**, 19075 bytes, fetched from `https://indifferentbroccoli.com/img/broccoli_shadow_square.png`
## Brand tokens added (CSS vars)
```css
--brand-primary: #5EFF0D; /* IB anchor green */
--brand-primary-rgb: 94 255 13;
--brand-primary-bg: rgb(var(--brand-primary-rgb) / 0.14);
--brand-anchor-bg: #0A141E; /* IB navy */
--brand-shadow-card: 0 4px 12px rgba(0, 0, 0, 0.3); /* IB card lift */
--display: 'Sora', 'Geist', ui-sans-serif, system-ui, ...;
--sans: 'Open Sans', 'Geist', ui-sans-serif, system-ui, ...;
--radius-sm: 4px; /* IB rounded */
--radius: 8px; /* IB rounded-lg (was already this value) */
--radius-lg: 12px; /* IB rounded-xl */
```
Existing `--mono` (JetBrains Mono) preserved — IB doesn't define a monospace font. Status colors (`--acc-success/warn/error/info`) and foreground ramp (`--fg/-1/-2/-3`) untouched per a11y deference.
## File-by-file
### `/opt/sortof/frontend/index.html`
| Where | Before | After |
|---|---|---|
| `<title>` | "sortof - sorted. sort of." | **"sortof (:|) sorted. or close enough."** |
| `<head>` | _no favicon_ | `<link rel="icon" type="image/png" href="/img/broccoli_shadow_square.png">` + apple-touch-icon |
| Font link | Geist + JetBrains Mono | **+ Sora 400/500/600/700 + Open Sans 400/500/600/700**, all in one `<link>` |
| `:root` vars | a11y palette only | + `--brand-primary`, `--brand-primary-bg`, `--brand-anchor-bg`, `--brand-shadow-card`, `--radius-sm/lg`, `--display` |
| `--sans` | `'Geist', ui-sans-serif, ...` | `'Open Sans', 'Geist', ui-sans-serif, ...` |
| `.wordmark` | `font-family: var(--mono); font-size: 17px; weight 600` | `font-family: var(--display); font-size: 19px; weight 700` |
| `.wordmark .dot` | `color: var(--acc-green)` | `color: var(--brand-primary)` |
| `.brand-mark` | _did not exist_ | 28×28 circle, lifts on hover |
| `.brand-mark-link` | _did not exist_ | wrapper anchor with rotation hover |
| `.ib-mark` | _did not exist_ | small mono `(:|)` glyph in IB green |
| `.sort-btn` | `--acc-green` border/bg/text, mono font | `--brand-primary` border/bg/text, **Sora display font, height 42px (was 40)** |
| `.sort-btn:hover` | tinted green | **fills with brand-primary, inverts text to anchor-bg, applies card shadow** |
| `.panel` | flat | `box-shadow: var(--brand-shadow-card)` |
### `/opt/sortof/frontend/sortof-app.jsx`
| Component | Before | After |
|---|---|---|
| `<Header>` | wordmark + tagline only | `<a href=ib.com><img broccoli/></a>` + wordmark + tagline |
| `<Footer>` | "a thing by [indifferent broccoli]" no link | `<a href=ib.com>indifferent broccoli (:|)</a>` (real link, IB green glyph) |
| `<EmptyRight variant="bare">` | "paste workshop ids on the left, then hit sort." | **"no mods. or maybe loads of them. hard to tell."** + sub-line "paste workshop ids on the left, hit sort. output lands here." |
| StatusStrip · idle | "ready when you are" | **"ready when you are. or not."** |
| StatusStrip · success/done | "done. N mods, W warnings" | **"sorted. N mods, W warnings. or close enough."** |
| StatusStrip · error | "something went sideways" | **"something went sideways. that happens."** |
| StatusStrip · failed | "job failed" | **"that didn't work. try again or don't."** |
| StatusStrip · cold | "cache miss - be patient" | **"cache miss. take your time, no rush."** |
Functional info preserved verbatim in every changed string (counts, role labels, action prompts). Voice flavor is additive.
## Deferred to a11y (brand wanted this; a11y said no)
Per the brief: _"If brand application would override an a11y decision, defer to a11y."_
| Brand wanted | A11y holds | Resolution |
|---|---|---|
| Brand green (#5EFF0D, hue 138) as the success-state color | `--acc-success` at hue 165 (Okabe-Ito bluish-green) is more deuteranopia-safe; the hue-138 green clusters too close to amber under simulated CVD | Brand green stays as `--brand-primary` (CTA only). Status pills, copy-btn-success, branch-picker-picked, diff-stat-add all keep `--acc-success`. |
| IB blue (#0050FF) for links | `--acc-info` (oklch 0.78 0.13 230) clears AAA against dark bg; #0050FF would be 3.0:1 (AA fail) | Documented IB blue for completeness; not adopted. Links continue to use `--acc-info`. |
| Brand-only focus ring (green) | Global `:focus-visible` uses `--focus-ring = var(--acc-info)` for consistent keyboard signal across all interactive elements | Kept the a11y blue focus ring. Brand green appears as button fill/border, not as focus indicator. |
| Glyph removal in favor of pure brand-color states | A11y requires every state pill to carry (color × glyph × text) | Glyphs preserved. Brand only changed the *anchor* color (sort-btn, wordmark dot, sort-btn:hover invert), never the *state* signal layer. |
## Voice contract (locked)
All voice flavor is **additive** — never strips functional information. The pattern from IB's hero ("Host your own game server / Or not... we don't care") is: bold functional claim, then immediate self-undercut.
Applied as: `<existing functional text>. <reverse-pleasantness flourish>`
| Site | Functional info preserved | Flourish appended |
|---|---|---|
| idle pill | (none — placeholder) | "or not." |
| done pill | "sorted. N mods, W warnings" | "or close enough." |
| error pill | "something went sideways" | "that happens." |
| failed pill | (replaced) | "that didn't work. try again or don't." |
| cold pill | "cache miss" | "take your time, no rush." |
| empty-bare big | (replaced) | "no mods. or maybe loads of them. hard to tell." |
The `<title>` follows the same pattern with the `(:|)` glyph as separator.
## Verification
This codebase has no build step (Babel-standalone transpiles JSX in-browser). Verification is via curl + visual inspection.
**Served file checks:**
- 22 hits for brand token names (`brand-primary`, `brand-anchor`, `brand-shadow`, `brand-mark`, `Sora`, `Open+Sans`) in served HTML
- 4 hits for new voice copy (`or close enough`, `or not.`, `try again or don.t`, `hard to tell`) in served JSX
- 2 hits for header brand markup (`broccoli_shadow_square.png`, `className="ib-mark"`)
- Public mirror serves `/img/broccoli_shadow_square.png` HTTP 200
- Public mirror `<title>` reads `sortof (:|) sorted. or close enough.`
**Contrast verification (computed via oklch L deltas; AA = ΔL ≥0.42; AAA = ΔL ≥0.55):**
| Pair | ΔL | Approx ratio | WCAG |
|---|---|---|---|
| `--brand-primary` (L 0.88) vs `--bg-1` (L 0.21) | 0.67 | ~10:1 | **AAA** |
| `--brand-primary` vs `--brand-anchor-bg` (L 0.20) — sort-btn:hover inverted | 0.68 | ~11:1 | **AAA** |
| `--acc-info` (L 0.78) vs `--bg-1` — link/button text | 0.57 | ~7.3:1 | **AAA** |
| `--fg` (L 0.95) vs `--bg-1` — display headings, body | 0.74 | ~14:1 | **AAA** |
| `--fg-2` (L 0.72) vs `--bg-1` — secondary text (lifted in a11y pass) | 0.51 | ~5.7:1 | **AA** |
No brand color required luminance adjustment. The IB blue (`#0050FF`) was the only candidate that would have failed; it was rejected in palette reconciliation rather than lifted.
## Backups (working-tree dirty)
- `/opt/sortof/frontend/index.html.bak-...-brand`
- `/opt/sortof/frontend/sortof-app.jsx.bak-...-brand`
- Plus a11y siblings (`-a11y-full`, `-a11y`) and earlier session backups.
User must hard-refresh the browser to evict the prior cached HTML/JSX/CSS.

View File

@@ -0,0 +1,145 @@
# Indifferent Broccoli brand tokens — extracted 2026-05-01
Source pages fetched live via `curl`:
- HTML: `https://indifferentbroccoli.com/` (100,017 bytes)
- CSS: `https://indifferentbroccoli.com/css/output.css` (62,243 bytes — Tailwind-compiled)
The site does not publish authored CSS variables for brand colors (it's Tailwind output with a few brand hex literals overlaid). The tokens below are reverse-engineered from the compiled stylesheet by frequency and semantic role.
## 1. Colors (raw values from `output.css`)
| Token | Hex | oklch (approx) | Usage in IB CSS | Lines |
|---|---|---|---|---|
| **brand primary green** | `#5EFF0D` | oklch(0.88 0.27 138) | `.stroke-primary { stroke: #5EFF0D }`, `.decoration-primary { text-decoration-color: #5EFF0D }`, file-selector-button border | output.css:1674, 2090, 2233-2234, 2730 |
| brand secondary blue | `#0050FF` | oklch(0.43 0.27 264) | text/link color appearances | 4 occurrences |
| dark surface | `#0A141E` | oklch(0.20 0.025 240) | navy panel background | 1 occurrence |
| accent pink | `#c0a0b9` | oklch(0.74 0.05 340) | one section bg | 1 occurrence |
| white | `#fff` | — | text on dark, panels on light | 6 occurrences |
| neutral text | `#6b7280` (tw-gray-500) | — | muted body text | 4 occurrences |
| neutral muted | `#9ca3af` (tw-gray-400) | — | further-muted | 2 occurrences |
## 2. Fonts
`<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Sora|Sora:600">`
`<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans|Open+Sans:600">`
| Role | Family | Weights | Source |
|---|---|---|---|
| heading / display | **Sora** | 400, 600 | Google Fonts (CDN) |
| body / paragraph | **Open Sans** | 400, 600 | Google Fonts (CDN) |
| icons | Font Awesome | — | `/vendor/font-awesome/css/font-awesome.min.css` (out of scope for sortof) |
The site does not self-host these via @fontsource. We will hot-link the same Google Fonts URLs as IB to inherit identically. Mono font is unspecified by IB; sortof keeps its existing **JetBrains Mono** for code/output blocks (no brand conflict).
## 3. Border radii (compiled Tailwind)
| Value | Frequency | Likely role |
|---|---|---|
| `0.25rem` (4px) | 2 | small corners (rounded) |
| `0.5rem` (8px) | 1 | medium (rounded-lg) |
| `0.75rem` (12px) | 1 | larger (rounded-xl) |
| `9999px` | 1 | pill / chip |
| `100%` | 1 | circle (logo container) |
| `0` / `0px` | 3 | flat sections |
## 4. Shadows
```css
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); /* card lift */
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.1); /* focus ring (green-tinted) */
```
## 5. Brand assets
| Asset | URL | Local copy |
|---|---|---|
| primary mark | `https://indifferentbroccoli.com/img/broccoli_shadow_square.png` | `/opt/sortof/frontend/img/broccoli_shadow_square.png` (866×866 PNG, 19,075 bytes) |
| smiley wordmark | `(:|)` (text glyph) — appears in `<title>` ("Indifferent Broccoli (:|)") and footer-class flourish |
Pulled the broccoli image locally so we don't hot-link IB's CDN. Same file used for both header brand mark and favicon.
## 6. Voice cues (verbatim from page)
The deadpan house style — additive, not replacement, when applied to sortof:
- **Hero**: "Host your own game server / Or not... we don't care"
- **Sub-hero**: "Premium game servers with instant deployment, full mod support, and a control panel so simple your cat could use it."
- **Trim**: "Or not."
- **Title** (browser tab): "Indifferent Broccoli (:|)"
Keying off these, the IB voice contract is: bold functional claim, then immediate self-undercut. Reverse-pleasantness. No exclamation points. Lowercase or sentence-case (no shouting).
## 7. Header layout (extracted)
```
[broccoli_shadow_square.png] [indifferent broccoli wordmark]
[Games | Top Servers | About Us | Contact | Wiki | Merch | Open Source]
[Start New Server +] [Log in]
```
For sortof, the structural mapping:
```
[broccoli_shadow_square.png] [sortof] [tagline] [github] [docs]
```
Footer references stay (REfRigERatoR's mod load order sorter / "a thing by indifferent broccoli") but get the `(:|)` glyph as a small inline mark next to "indifferent broccoli", linking to `https://indifferentbroccoli.com`.
## 8. Final palette decision (reconciliation with a11y)
Per brief: **IB primary green is the brand anchor; status colors defer to the a11y pass.**
| Role | Token | Value | Source |
|---|---|---|---|
| Brand primary (sortof = an IB thing) | `--brand-primary` | `#5EFF0D` | IB literal |
| Brand surface (alt anchor) | `--brand-anchor-bg` | `#0A141E` | IB literal |
| Status: success | `--acc-success` | oklch(0.78 0.13 165) | a11y (Okabe-Ito bluish-green). Unchanged. |
| Status: warning | `--acc-warn` | oklch(0.82 0.15 75) | a11y (Okabe-Ito orange-yellow). Unchanged. |
| Status: error | `--acc-error` | oklch(0.70 0.18 35) | a11y (Okabe-Ito vermillion). Unchanged. |
| Status: info / link | `--acc-info` | oklch(0.78 0.13 230) | a11y (Okabe-Ito sky blue). Unchanged. |
| Foreground ramp | `--fg`, `--fg-1`, `--fg-2`, `--fg-3` | a11y values | Unchanged. |
| Backgrounds | `--bg`, `--bg-1`, `--bg-2`, `--bg-3` | a11y values | Unchanged. |
### Why brand green doesn't replace `--acc-success`
The IB green `#5EFF0D` is **highly saturated** (oklch chroma 0.27) and reads as "this is a CTA / this is the IB look." Using it as the success-state color would:
- Conflict with the a11y reasoning: success at hue 138 (yellow-green) sits closer to Okabe-Ito's *yellow* than its *bluish-green*. Less safe under deuteranopia.
- Conflict with the spec semantics: in the a11y system, brand-anchor color and status-success color have different jobs. Swapping them would re-collapse what the a11y pass deliberately separated.
So the brand green lives at `--brand-primary` and is used for:
- Sort button (the primary CTA on the page)
- Wordmark dot accent
- Header brand mark hover/focus emphasis
- Build-toggle "active" state
Status pills, banners, warnings, copy-button-success, etc. continue to use the a11y `--acc-*` tokens.
### Contrast verification for `--brand-primary` (`#5EFF0D`)
Against the dark canvas backgrounds:
| Pair | ΔL (oklch) | Approx ratio | WCAG |
|---|---|---|---|
| `#5EFF0D` (L=0.88) vs `--bg` (L=0.18) | 0.70 | ~12.5:1 | AAA |
| `#5EFF0D` (L=0.88) vs `--bg-1` (L=0.21) | 0.67 | ~10:1 | AAA |
| `#5EFF0D` (L=0.88) vs `--bg-3` (L=0.28) | 0.60 | ~7.5:1 | AAA |
Brand green clears AAA against every background token. No luminance adjustment needed.
### IB blue `#0050FF` — not used
`#0050FF` ≈ oklch(0.43 0.27 264) → fails AA against any of our dark backgrounds (~3.0:1). The a11y pass already established `--acc-info` (oklch 0.78 0.13 230) as the link / info color and that token clears AAA. We document IB's blue here for completeness but do not put it in the live palette.
## 9. Tokens mapped to CSS vars (added in this round)
```css
--brand-primary: #5EFF0D; /* IB anchor green */
--brand-primary-rgb: 94 255 13; /* for rgba() composition */
--brand-anchor-bg: #0A141E; /* IB navy, available as deep panel surface */
--brand-radius: 0.5rem; /* IB medium radius */
--brand-radius-sm: 0.25rem; /* IB small radius */
--brand-shadow-card: 0 4px 12px rgba(0, 0, 0, 0.3); /* IB card lift */
--brand-mark: url('/img/broccoli_shadow_square.png'); /* logo asset */
--brand-font-display: 'Sora', 'Geist', ui-sans-serif, system-ui, sans-serif;
--brand-font-body: 'Open Sans', 'Geist', ui-sans-serif, system-ui, sans-serif;
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
# Spec A - Multi-branch picker
**Date:** 2026-04-30
**Status:** Draft v2 (incorporates spec-review fixes #1#13)
**Out of scope:** B (collection expansion + live progress), C (build context), D (dep "Add" button), E (precacher), G (cleanups). See §11.
## 1. Summary
Some Steam Workshop items ship multiple `mod.info` files under one wsid (canonical example: AuthenticZ → `AuthenticZBackpacks+`, `Authentic Z - Current`, `AuthenticZLite`). Today every parsed `mod_id` flows into `MODS_LINE`, including alternates the user must pick exactly one of. This spec adds a per-wsid picker UI with `localStorage` persistence and a new `POST /api/resort` endpoint that recomputes load order and warnings for the chosen subset, without re-hitting Steam.
## 2. Problem
- AuthenticZ (wsid `2335368829`) yields three `mod_parsed` rows: `AuthenticZBackpacks+`, `Authentic Z - Current`, `AuthenticZLite`. They are mutually exclusive branches.
- The author left `incompatible_mods` empty on all three, so we have no metadata signal that they are alternates.
- Today's `MODS_LINE` is `";".join(SORTED_ORDER)`, so all three branch IDs land in the output. PZ refuses to start with conflicting mods, so the file **looks valid but bricks the server** - silent corruption.
- Other multi-mod packages exist where every `mod_id` *should* load (cooperative content packs). The system must support both shapes.
## 3. Trigger rules
- The picker UI fires **iff a wsid has ≥2 rows in `mod_parsed`**.
- Row count is the *only* trigger. Author metadata does not gate visibility - see §5 for what it changes.
## 4. Default selection rules
For each picker-eligible wsid:
- **If** any `mod_parsed.incompatible_mods` for that wsid lists another `mod_id` from the same wsid → default selection = **first `mod_id` only**.
- **Else** → default selection = **all `mod_id`s ticked**.
"First" tiebreaker: `ORDER BY parsed_at ASC, mod_id ASC`. `worker.process_one` parses sequentially in a `for mip in mod_info_paths: await conn.execute(UPSERT_MOD_PARSED, ...)` loop (one statement per `mod.info`, no `gather`/`to_thread`), so `parsed_at = now()` produces strictly increasing microsecond values per row in practice. `mod_id ASC` is the defensive tiebreaker for the theoretical sub-microsecond case. **This is a spec-locked decision** - revisit if the resulting "primary" branch feels wrong on real inputs.
### Default-selection safety net (fix for review #1)
The default-all-ticked path covers the canonical AuthenticZ case (3 rows, all `incompatible_mods=[]`) and would otherwise emit the same bricking config that motivated this spec. To prevent silent corruption, the API emits an additional warning whenever the default leaves all branches selected without any author signal:
- If a wsid has ≥2 mod rows, AND every row's `incompatible_mods` is empty, AND the user's current selection includes all branches (i.e., they haven't unticked any), emit a `WARNINGS` entry: `tag: "ambiguous-multi-branch"`, `level: "amber"`, `msg: "X branches selected from <wsid title> - author didn't declare alternates; verify these aren't mutually exclusive (e.g., AuthenticZ Lite vs Current). Expand the row to pick one."`
- The warning clears as soon as the user makes any explicit selection (any branch unticked, or - in radio mode - any branch chosen).
- Picker UI remains opt-in; this rule guarantees the user sees a yellow flag without having to expand every multi-branch row.
## 5. UI mode rules
- Default: **checkboxes** (multi-select).
- Upgrade to **radios** (single-select; exactly one always picked) **iff** any `mod_id` for the wsid lists another `mod_id` from the same wsid in its `incompatible_mods`.
- **Cross-wsid** incompatibilities (mod A in wsid X marks mod B in wsid Y) do **not** trigger radio mode for either wsid; they continue to flow through the existing Warnings system.
## 6. UI placement & interactions
- Inline row expansion in the existing `ModTable` (`sortof-app.jsx:306`). No new top-level component.
- A multi-branch wsid renders as **one** parent `<tr>`, occupying the slot the first selected `mod_id` from that wsid holds in `SORTED_ORDER`. Other selected branch `mod_id`s from the same wsid do not render their own rows - they live inside the expanded panel.
- Mod ID cell affordance:
- **Unresolved** (no user interaction yet, no hydrated selection): `▾ N branches`
- **Resolved** (user touched it, or hydrated from `localStorage`): `✓ X of N`
- Click affordance to toggle expansion. Multiple wsids may be expanded simultaneously - single-pass triage on a 450-mod collection.
- Expanded panel: a single `<tr>` with `colSpan={COLUMN_COUNT}` containing per-branch rows of `[checkbox|radio] mod_id - name - cat - deps - pos`. `COLUMN_COUNT` is a single source-of-truth constant (today 6; Spec C will add a 7th column for build-context). **Do not hardcode the integer.** Match existing column rhythm so zebra striping still reads.
- Single-mod wsids render unchanged.
- **`/api/resort` failure mode** (review #11): on 5xx response, retain the prior `MOD_DB`/`SORTED_ORDER`/`MODS_LINE`/`WARNINGS` state and emit a transient `WARNINGS` entry `couldn't recompute sort - try again` (level=red) with a retry button. Never apply a partial response.
- Parent row's category / deps / load cells reflect the **first selected** branch's values; if zero are selected, the parent row remains visible with affordance `✓ 0 of N` and `-` in the data cells, and contributes nothing to `MODS_LINE` / `SORTED_ORDER`. Display position for zero-selected rows is implementation-defined (e.g., previous slot, or sorted by any `mod_id` from the wsid) since the wsid no longer appears in `SORTED_ORDER`.
## 7. Persistence
- `localStorage` key: `sortof.branch.selections`. **One** key total - hydrate in a single read.
- Value: JSON-serialized object keyed by wsid → array of selected `mod_id` strings.
```json
{ "2335368829": ["Authentic Z - Current"], "2169435993": ["modoptions"] }
```
- **Hydration** on app mount: read once, merge into in-memory `branchSelections` state.
- **Eviction**: if a stored `mod_id` is no longer present in the current `MOD_DB` rows for that wsid (cache invalidated upstream, mod.info changed, etc.), drop it silently. Do not warn.
- **Radio-mode invariant guard** (review #2): if eviction would leave a radio-mode wsid with zero selected `mod_id`s, fall back to the §4 default (first-only). Radio mode's "exactly one always picked" invariant must hold post-hydrate.
- Single-mod wsids never write to this object; absence implies "use default".
- **Cross-tab sync** (review #8): App attaches a `window.addEventListener('storage', ...)` listener; on a `sortof.branch.selections` storage event, replace in-memory `branchSelections` with the new value and trigger a single `/api/resort`. Last-writer-wins on the underlying storage value; in-tab state stays coherent.
## 8. API impact
- **No change** to `POST /api/sort` request or response shape.
- **New** endpoint: `POST /api/resort`, taking the current selection and returning a fresh order + warnings without re-hitting Steam.
```json
{ "selected_mod_ids": ["modoptions","tsarslib","Authentic Z - Current"] }
```
- Response: same shape as `/api/sort` with `status:"success"` and `pending:[]`. Backend filters `mod_parsed` rows to the supplied set via `WHERE mod_id = ANY($1::text[])` (parameterized - review #9), runs `mlos_sort`, returns updated `SORTED_ORDER`, `MODS_LINE`, `WARNINGS`, `MOD_DB`. No DB writes.
- `WORKSHOP_ITEMS_LINE` is **not** affected by selection - wsid stays subscribed regardless of which `mod_id`s are enabled. Matches PZ's `WorkshopItems` vs `Mods` semantics.
### Scope, auth, validation (fix for review #3, #9, #10, #12)
- **Stateless.** No session token, no per-user partition. `mod_parsed` is a shared cache; concurrent drain UPSERTs and `/api/resort` SELECTs serialize via asyncpg row locks. Multi-tenancy is out of scope for v1; if added later, expect a `submission_id` on `/api/sort` and `/api/resort`.
- **Unknown `mod_id` handling** (review #12): server silently drops `selected_mod_ids` not present in `mod_parsed` (matches §7 client semantics) and logs at INFO. If the entire selection is empty after the drop, return HTTP 400 - the client can recover by re-running `/api/sort`.
- **Input validation.** `selected_mod_ids` must be a JSON array of strings, length ≥1 and ≤500, each string ≤256 chars. Reject anything else with 400 before touching the DB. PZ `mod_id`s legitimately contain spaces, `+`, `-`, and apostrophes - the parameterized `ANY` pattern handles them safely; **no string interpolation anywhere** (review #9).
- **Rate limiting** (review #10): not implemented at the FastAPI layer. Recommend a Caddy-level `@rate_limit` matcher on `/api/sort` and `/api/resort` before any public exposure beyond the current Tailscale-only bind. Documented as a known gap.
### Sequenced requests (fix for review #5)
- The frontend tags every `/api/resort` POST with a monotonically increasing client-side sequence number (in-memory counter on App, not part of the request body - sent as header `X-Sortof-Seq` or tracked via the issuing call site).
- When a response arrives, compare its sequence number against the latest issued; if older, **drop the response without applying it** (UI keeps current state, last-issued response wins). Prevents stale-response overwrites under rapid toggling.
## 9. Data assumptions
- Schema column is `mod_parsed.incompatible_mods` (`TEXT[]`) - names already stripped of any leading `\` per the B42 parser fix shipped today.
- `mod_parsed.parsed_at` ordering verified (review #4): `worker.process_one` parses `mod.info` files sequentially with `for mip in mod_info_paths: await conn.execute(UPSERT_MOD_PARSED, …)`. Each upsert is its own asyncpg statement (auto-commit, no transaction wrap), and `parsed_at` is `now()` evaluated server-side per statement. Sequential awaits + asyncpg RTT > 1µs ⇒ strictly increasing microsecond values in practice. `mod_id ASC` is a defensive tiebreaker for the theoretical sub-µs collision; no ordinal column exists in the schema and adding one is out of scope for this spec.
- Dangling-deps detection (review #13) already exists in `mlos_sort.sort_mods` (`mlos_sort.py:432-437`): `enabled = set(by_id.keys())` then `miss = [r for r in mod.requirements if r not in enabled]` per mod. Calling `sort_mods` with a filtered subset on `/api/resort` automatically produces the new missing-dep warnings; no changes to `mlos_sort` are needed.
- Frontend already has `incompatible_mods` available as `m.conflicts` on each `MOD_DB` row (`adapters.py:94`).
- This spec consumes the `MOD_DB`/`SORTED_ORDER`/`WARNINGS` shape currently produced by `app.py` + `adapters.py`. Per-build variant filtering is Spec C; selection here operates on the full `mod_id` corpus the API returned.
## 10. Open questions resolved
1. **Client-side filter vs API round-trip.** *Client-side filter for the row affordance and parent-row rendering; server round-trip via `POST /api/resort` for sort + warnings recompute.* Justification: instant feedback on tick/untick UX, but warnings are dependency-driven and need real `mlos_sort` evaluation. Pure-client would require porting `mlos_sort` to JS - far worse than a 50ms POST to a hot Postgres connection.
2. **`SORTED_ORDER` recompute strategy.** *Re-run `mlos_sort` on the selected subset via `POST /api/resort`.* Justification: when the user unticks `AuthenticZLite` and another mod requires it, the warnings list and possibly the topological order both change. Filtering the previous `SORTED_ORDER` post-hoc misses the new missing-dep warning, defeating the picker's safety value.
3. **First-`mod_id` tiebreaker for default selection.** *`ORDER BY parsed_at ASC, mod_id ASC`.* Schema-deterministic and matches insertion order from `worker.process_one`. Flagged in §4 as a lockable spec decision; revisit on real corpus.
4. **`localStorage` key namespacing.** *Single key `sortof.branch.selections`, value `{ [wsid]: string[] }`.* The `sortof.branch.` prefix reserves namespace for any future per-feature storage; one key keeps hydration to a single read.
## 11. Out of scope
- B41/B42 build-context filtering (Spec C).
- Steam collection URL/ID expansion (Spec B).
- Dependency "Add" button (Spec C/D pair).
- Server-side persistence of branch choices.
- Live drain progress streaming (Spec B+F).
- Cleanups bundle (Spec G).
## 12. Acceptance criteria
- [ ] A wsid with N=1 mod row renders as a single normal row in `ModTable` (no behavior change).
- [ ] A wsid with N≥2 mod rows renders as one parent row with `▾ N branches` in the Mod ID cell.
- [ ] Clicking the affordance expands a `colSpan`'d panel listing all N rows with the correct input type (checkboxes by default, radios when intra-wsid `incompatible_mods` is non-empty).
- [ ] Default selection matches §4 (all-ticked or first-only).
- [ ] Toggling a branch updates the affordance to `✓ X of N` and triggers a `POST /api/resort` whose response replaces `MOD_DB`, `SORTED_ORDER`, `MODS_LINE`, `WARNINGS` in app state.
- [ ] `WORKSHOP_ITEMS_LINE` is unchanged when branches toggle.
- [ ] `localStorage["sortof.branch.selections"]` is read on mount and written after every toggle, matching the §7 schema.
- [ ] A stored `mod_id` not present in the current `MOD_DB` for its wsid is dropped silently on hydrate.
- [ ] Multiple expanded panels can coexist (no auto-collapse on expand).
- [ ] Zero selected `mod_id`s for a wsid: affordance reads `✓ 0 of N`; row contributes nothing to `MODS_LINE` / `SORTED_ORDER`.
- [ ] When a wsid has ≥2 mod rows AND every row's `incompatible_mods=[]` AND user has not unticked any branch, an `ambiguous-multi-branch` (amber) WARNINGS entry is present; entry clears on first explicit user selection in that wsid (review #1).
- [ ] Eviction of a stored `mod_id` that empties a radio-mode wsid falls back to §4 default-first; never leaves a radio-mode wsid with zero selections (review #2).
- [ ] `/api/resort` request carries a client-side sequence number; responses older than the latest issued are discarded without state mutation (review #5).
- [ ] `/api/resort` 5xx response leaves prior state intact and surfaces a transient retry-able warning (review #11).
- [ ] Server drops unknown `selected_mod_ids` silently and logs at INFO; empty post-drop selection returns 400 (review #12).
- [ ] `colSpan` in `ModTable` references a single `COLUMN_COUNT` constant - not a hardcoded integer (review #7).
- [ ] `storage` event listener installed; cross-tab toggle of `sortof.branch.selections` syncs in-memory state and triggers exactly one `/api/resort` (review #8).
## 13. Test cases
1. **AuthenticZ canonical** - wsid `2335368829`, three rows, all `incompatible_mods=[]`. Expect: parent row `▾ 3 branches`, default = all ticked, mode = checkboxes. Untick two → `MODS_LINE` reflects one. Reload → selection persists.
2. **Cooperative pack** - wsid that ships 3 mods, all `incompatible_mods=[]`, deps reference each other. Expect: same affordance, default = all ticked, no behavior change for the user who never expands.
3. **Mutually exclusive 2-branch** - wsid where mod A's `incompatible_mods` lists mod B and vice versa. Expect: mode = radios, default = mod A only (first by `parsed_at, mod_id`).
4. **Persistence across reload** - pick a non-default subset, reload page; confirm hydration from `localStorage["sortof.branch.selections"]` restores the selection on next sort.
5. **Stored `mod_id` no longer exists (checkbox mode)** - manually inject a stored `mod_id` not in `MOD_DB`, reload. Expect: silent drop, no console error, default applies.
6. **Cross-wsid incompatibility** - mod A (wsid X) lists mod B (wsid Y) in `incompatible_mods`; both wsids have N=1. Expect: no picker UI, existing warning still surfaces.
7. **Zero-tick wsid** - untick all branches in a multi-branch wsid. Expect: parent row stays in `ModTable` with `✓ 0 of N`; no contribution to `MODS_LINE` / `SORTED_ORDER` / numeric counts.
8. **Radio-mode eviction-to-empty** (review #6) - wsid in radio mode has stored selection `[X]`; `X` is removed from `MOD_DB` (e.g., upstream cache invalidation), reload. Expect: silent drop, then default-first applied, radio invariant preserved.
9. **Default-all-ticked emits the safety warning** (review #1) - load AuthenticZ-canonical without expanding the row. Expect: a `tag:"ambiguous-multi-branch"` amber entry visible in WARNINGS. Untick one branch → entry disappears on next `/api/resort` response.
10. **Stale resort response discarded** (review #5) - issue toggle 1 (slow), then toggle 2 (fast) before #1 returns. Expect: only #2's response applied; #1 dropped on arrival.
11. **`/api/resort` 5xx** (review #11) - stub the endpoint to return 500; toggle a branch. Expect: prior state retained, transient red warning `couldn't recompute sort - try again` surfaced with retry control.
12. **Cross-tab sync** (review #8) - open two tabs, toggle in tab A. Expect: tab B receives `storage` event and re-runs `/api/resort` with the new selection.
13. **Unknown selected_mod_id from server perspective** (review #12) - POST `/api/resort` with `selected_mod_ids=["modoptions","ghostMod"]` where `ghostMod` isn't in `mod_parsed`. Expect: 200 with `ghostMod` silently absent from response; INFO log entry server-side. POST with all-ghost IDs → 400.

View File

@@ -0,0 +1,87 @@
# Spec G-patch - Patch tier (Final Loads)
**Date:** 2026-04-30
**Status:** Draft (awaiting review)
**Sibling specs:** A (multi-branch picker), B (collection expansion + live progress), C (build context), D (dep "Add" button), E (precacher), F (folded into B), G (cleanups bundle - this spec carves a piece out)
## 1. Summary
Add a **"patch" tier** to the load-order calculation: mods explicitly authored or detected as patches sort *after* every non-patch mod, including those flagged `loadLast=on`. Implementation is a single new axis at the top of `mlos_sort._initial_sort_key` plus a heuristic in `derive_category`. No schema migration. No new endpoint. No backwards-incompat changes for existing mods.
## 2. Problem
The PZ load-order convention (and the user-supplied 37-bucket taxonomy, bucket 37 "Final Loads") treats compatibility patches and retextures-of-other-mods as a strictly-last tier - they have to load *after* `loadLast=on` map mods, because they intercept or override the things those mods install. Today our sort key has no such tier:
```
PREORDER → loadFirst → loadLast → category → in-category loadFirst → in-category loadLast → alpha
```
A `loadLast=on` map mod ends up in the same bucket as a patch, ordered alphabetically. Patches that need to override the map mod can land *before* it. Silent corruption - output looks valid, the wrong mod wins at runtime.
## 3. Detection rules
A mod is a patch iff **any** of these is true:
1. **Explicit:** `mod.info` contains `category=patch` (new value added to `RAW_CATEGORY_ORDER`).
2. **Author-tagged via sorting_rules.txt:** user-supplied `[modId]\ncategory=patch` overrides anything else (existing mechanism, no change).
3. **Name heuristic (conservative):** `mod.name` matches the case-insensitive regex `\b(patch|compat|compatibility)\b`. Examples that match: `BetterFlashlight Patch`, `BB Compatibility`, `RavenCreek - MoreSimpleClothing Compat`. Examples that **do not** match: `BugFixes`, `LittleTweaks`, `BalanceFix` - "fix" / "tweak" / "fixes" are too broad and would over-flag.
The first matching rule wins. The heuristic is intentionally narrow; mod authors who want to opt in should use rule 1.
## 4. Sort behavior
Insert a new axis **at position 0** (above `PREORDER`) of the sort tuple:
```
(is_patch, PREORDER, loadFirst, loadLast, category, in-cat loadFirst, in-cat loadLast, alpha)
```
`is_patch = 1` for patches, `0` otherwise. Tuple comparison guarantees patches sort after every non-patch mod regardless of every downstream axis. Within the patch group, the existing sub-keys still apply (a patch with `PREORDER=2`, e.g. `ModManagerServer-Patch`, still sorts second-among-patches).
## 5. Backend changes
- **`mlos_sort.py`:**
- Append `"patch"` to `RAW_CATEGORY_ORDER` (so it's a valid `mod.category` value and topo sort treats it like any other category).
- Extend `derive_category(mod)` with the §3 name heuristic, returning `"patch"` when matched and category is otherwise `undefined`.
- Modify `_initial_sort_key`: prepend `1 if mod.category == "patch" else 0` as the new tuple element.
- **`adapters.py`:** extend `CAT_MAP` with `"patch": "patch"` so the frontend pill key is preserved (see §6).
- **`worker.py`:** no change. `mod.info` parsing already accepts arbitrary `category=…` values; once `"patch"` is in `CATEGORY_ORDER`, existing parser code passes it through unchanged.
- **No schema migration.** `mod_parsed.category` is already `TEXT NOT NULL DEFAULT 'undefined'` - `"patch"` fits without alteration.
## 6. Frontend changes
- **New pill** `patch` in the mod-table category column. Recommended palette: muted mauve / pale grey to distinguish from `gameplay` (the current default for tweaks-shaped mods) without competing for attention.
- **Pill is descriptive only** - sort position already telegraphs "this is a patch" since patches cluster at the bottom of the table. The pill is a quick visual confirmation, not a signal the user has to learn.
- **CSS** addition only (one rule): `.cat.patch { background: …; color: …; }`. No layout or component changes.
If the user prefers to skip the pill (5 buckets stays cleaner), the spec is satisfied without it; sort behavior is the load-bearing change.
## 7. Out of scope
- Detecting patches from Steam workshop tags (Steam's vocabulary has no canonical "Patch" tag - `Misc` and `Framework` are the closest, both too noisy to map).
- Multiple patch sub-tiers (e.g., "patches-of-patches"). YAGNI; the existing `loadAfter` mechanism handles ordering between two patches when needed.
- A `mod_parsed.is_patch` boolean column. Derived from `category` is sufficient and avoids a migration.
- Auto-detecting patches via mod content inspection (Lua module overrides, file collisions). Heuristics only.
## 8. Acceptance criteria
- [ ] `mlos_sort._initial_sort_key` returns an 8-element tuple with `is_patch` (0 or 1) at index 0.
- [ ] `RAW_CATEGORY_ORDER` includes `"patch"`.
- [ ] `derive_category` returns `"patch"` when the name regex `\b(patch|compat|compatibility)\b` (case-insensitive) matches and `mod.info`'s `category=` is unset or `undefined`.
- [ ] Explicit `category=patch` in `mod.info` is honored by the existing parser (no parser change required).
- [ ] `sorting_rules.txt` `category=patch` override forces a mod into the patch tier.
- [ ] In a sort with one `loadLast=on` map mod and one patch, the patch sorts *after* the map mod in `SORTED_ORDER`.
- [ ] In a sort with two patches, alphabetical ordering applies between them (existing alpha tiebreaker preserved).
- [ ] In a sort with no patches, `SORTED_ORDER` is bit-identical to pre-spec output (`is_patch=0` for all rows preserves existing total ordering).
- [ ] `MOD_DB` rows for patches carry `cat: "patch"` once `adapters.CAT_MAP` is extended.
## 9. Test cases
1. **Explicit patch via mod.info** - wsid X has `category=patch`. Expect: sorts last regardless of `loadLast`. `MOD_DB.cat = "patch"`.
2. **Heuristic match** - mod named `BB Compatibility Patch`, no explicit category. Expect: detected as patch, sorted last.
3. **Heuristic miss (intentional)** - mods named `BugFixes`, `LittleTweaks`, `BalanceFix`. Expect: NOT in patch tier.
4. **Patch + loadLast map mod** - input: a `loadLast=on` map mod (`Eerie_County`) and a patch (`Eerie_County - Brita Compat`). Expect: `Eerie_County` precedes the patch in `SORTED_ORDER`.
5. **Two patches** - `AAA-Compatibility` and `ZZZ-Patch`. Expect: alphabetical order preserved within the tier.
6. **No patches in input** - sort identical to current behavior; regression test against a saved canonical fixture (e.g. `2169435993;2392709985;2487022075`).
7. **`sorting_rules.txt` override** - user supplies `[Some_Mod]\ncategory=patch`; expected to force into tier even if name doesn't match heuristic and `mod.info` doesn't declare it.
8. **Patch with PREORDER mod_id** - hypothetical `ModManagerServer-Patch` (mod_id matches PREORDER table). Expect: still sorts within the patch tier (last), but among patches uses PREORDER=2 sub-ordering.

View File

@@ -0,0 +1,174 @@
# Spec C — Build context + dep Add + auto-disambiguation rules
> **Lineage:** sits on top of Spec A (multi-branch picker) and Spec B+F (collection expansion / live drain). Adds context-aware default selection. Does **not** modify the picker contract — Spec A §8 ownership still holds.
## §1 Overview
Three loosely-related improvements that share the same core: the system has more context than it has been using. The user already tells us their PZ build via the `pzBuild` localStorage toggle. The user already gives us a list of mod_ids (via the wsids in their input). The system should consult both before deciding which branches of a multi-branch wsid land in `MODS_LINE` by default.
**Goal:** smarter pre-ticked boxes in the multi-branch picker. **Non-goal:** any form of "magic" sort that emits a branch the user didn't see.
## §2 Build context (`pzBuild`)
The frontend stores `sortof.pzBuild` in localStorage with values `"B41" | "B42"`, default `"B41"`. It already drives `MODS_LINE` rendering (B42 prefixes mod_ids with `\`).
This spec extends `pzBuild` to:
- Travel with `/api/sort` POST body as `pz_build: "B41" | "B42"`. The backend defaults to `"B42"` when missing or invalid.
- Inform Rule A (§4.3) of auto-disambiguation.
`pz_build` is **not** sent on `/api/resort` — the resort flow uses an explicit mod_id list and never re-evaluates rules.
## §3 Dep Add (already shipped)
Documenting the existing behavior so it's part of the locked design.
When `mlos_sort` reports a missing requirement (mod A requires mod B, B is not in the user's enabled set), `build_warnings` enriches each `tag: "missing"` warning with one of:
- `actions: [{type: "add-wsid", wsid, modId, label}]` — when `mod_parsed` has a row with `mod_id == B` (using `DISTINCT ON (mod_id) ORDER BY parsed_at_time_updated DESC`)
- `actions: [{type: "search-workshop", modId, url, label}]` — when no cache hit; URL is `https://steamcommunity.com/workshop/browse/?appid=108600&searchtext=<modId>`
The frontend renders `[add modId]` as a filled blue chip; clicking appends the wsid to the input textarea AND auto-resorts (no separate sort click needed). The search variant is `[↗ find modId]` and opens Steam's search in a new tab.
## §4 Auto-disambiguation rules
### §4.1 Design principle (locked)
These rules **adjust which boxes are pre-ticked in the picker**. They never bypass the picker and never silently emit a branch the user didn't see. Spec A §8 ownership holds — the picker is the source of truth.
The rules are applied at `/api/sort` time, before `MODS_LINE` is composed. The response's `MOD_DB` always contains every cached branch (so the picker can offer them); `SORTED_ORDER` and `MODS_LINE` reflect only the auto-selected set. The frontend reads the picker default from `SORTED_ORDER` membership.
### §4.2 Order of evaluation per wsid
```
A → C → B
```
The first rule that single-ticks a branch wins; subsequent rules emit warnings only. Exception: A and C are orthogonal (build × addon) and both may tick the same wsid simultaneously — see §4.7.
If a wsid is **coordinated** (any branch references a sibling via `requirements` / `loadAfter` / `loadBefore`) or **radio** (any branch lists a sibling in `incompatibleMods`), it is **exempt from rules A/B/C**. Coordinated → all branches stay; radio → first only.
### §4.3 Rule A — build-aware default *(highest ROI)*
A branch is **B41-flavored** if `mod_id`:
- ends with `B41` (case-insensitive), or
- contains `_legacy_` followed by version digits (e.g., `_legacy_42_12`, `_legacy_41_*`)
A branch is **B42-flavored** if `mod_id`:
- ends with `B42` or `_b42` (case-insensitive), or
- contains `_b42_` (e.g., `vac_mod_b42_utils`)
A branch is **un-flavored** otherwise.
Apply: if exactly one branch is flavored to match the active `pz_build` AND no other branch shares that flavor, pre-tick that one. Un-flavored branches are treated as "the build the author considers default" — currently always B42.
If no branch matches the active build (e.g., user is on B41 but every branch is B42-flavored), fall through to Rule C / B and emit a `build-mismatch` warning: `"no <build> variant for <name> (<wsid>); using author default"`.
Rule A also fires when the wsid has **exactly two branches** and one is `B41`-flavored, one is unflavored — the unflavored is treated as B42 default. So a B42 user gets the unflavored, a B41 user gets the B41-flavored.
### §4.4 Rule B — prefix-base tiebreaker
When the auto-pick path needs to pick *one* branch (Rule A didn't single-tick, Rule C didn't fire), use the **shortest mod_id that is a strict prefix of every other branch's mod_id** instead of alphabetical-first.
**Strict prefix definition:** `A` is a strict prefix of `B` iff:
1. `A``B`, AND
2. `B.startswith(A)`, AND
3. The character at position `len(A)` in `B` is a non-lowercase-letter — separator (`_` `-` ` `), digit (`0`-`9`), or uppercase letter (`A`-`Z`).
Boundary regex: `^<A>([_\- ]|[A-Z]|[0-9]|$)`.
**Examples that qualify:**
- `ArmoredVests``ArmoredVestsPatch` (boundary: `P`)
- `ToadTraits``ToadTraitsDisablePrepared` (boundary: `D`)
- `LitSortOGSN``LitSortOGSN_chocolate` (boundary: `_`)
- `WaterDispenser``WaterDispenser2` (boundary: `2`)
**Examples that do NOT qualify:**
- `Foo` vs `Foobar` (boundary: `b`, lowercase continuation)
- `Lit` vs `LitSort` (boundary: `S`, capital — actually qualifies; this case becomes prefix-base, deliberate)
If multiple branches are mutual prefixes (impossible by definition) or if no branch is a prefix-of-all-others, fall back to alphabetical-first by `mod_id`.
Rule B still emits the `auto-picked-branch` warning (Spec A §4) and renders the click-to-expand picker buttons — the only behavior change is the choice of which branch wins the auto-pick.
### §4.5 Rule C — input cross-reference *(solves Jeeve's Patches)*
For each ambiguous branch whose mod_id matches the pattern `<base>_<TOKEN>` (i.e., contains an underscore, with a base prefix shared with at least one sibling branch):
1. Extract `<TOKEN>` (the part after the last `_`).
2. Look up `<TOKEN>` against the resolved mod_id set from the user's input — case-insensitive substring match against any cached `mod_parsed.mod_id` whose wsid is in the user's input.
3. Match requires `<TOKEN>` length ≥ 3 (avoid `_a`, `_x` false positives).
**Hit:** pre-tick that branch alongside the base branch.
**No hits on any suffix-tokened branch:** tick the base branch only and emit `unmatched-addons` warning listing the unticked branches by name.
The "base branch" inside a Rule-C wsid is determined by Rule B (prefix-base tiebreaker), with alphabetical fallback.
**Worked example: Jeeve's Patches** (wsid 3684025083, branches `JeevesPatches`, `JeevesPatches_AZ`, `JeevesPatches_DAMN`, `JeevesPatches_GGS`, `JeevesPatches_ISA`, `JeevesPatches_PlayerStatus`, `JeevesPatches_Spongie`, `JeevesPatches_Tanker`, `JeevesPatches_Towing`, `JeevesPatches_Vanilla`, `JeevesPatches_ZRE`):
- Base = `JeevesPatches` (Rule B: prefix-of-others).
- Tokens: `AZ`, `DAMN`, `GGS`, `ISA`, `PlayerStatus`, `Spongie`, `Tanker`, `Towing`, `Vanilla`, `ZRE`.
- User submits `AuthenticZ`-related wsids → some cached mod_id contains `AZ` (case-insensitive substring) → tick `JeevesPatches_AZ`.
- User submits `JeevesIntegration` → no token hits → tick `JeevesPatches` only, emit `unmatched-addons` warning.
### §4.6 Rules D + G — hint text only
When the picker renders an ambiguous wsid's branches, attach a per-branch `hint` field surfaced in the picker row. **No state mutation.** Hints are:
| Suffix pattern | Hint text |
|---|---|
| `_Lite`, `_Light` | "lighter alternate variant — pick one" |
| `_HD`, `_DetailsHD` | "high-resolution variant" |
| `_NoCE`, `_NoVanilla`, `_FarmDisable`, `_Disable*` | "opt-out variant" |
| `_USDM`, `_Imports`, `_Exotics`, `_RealNames` | "alternate variant — pick one" |
| `_v\d+(_\d+)*`, `_legacy_*` | "legacy build — usually not what you want" |
| `_AZ`, `_DAMN`, `_GGS`, etc. (Rule C suffixes that didn't match input) | "addon for `<TOKEN>` — only if you have it" |
Pattern matching is case-insensitive with anchored end-of-string for short tags. Multiple hints on one branch concatenate with " · ".
### §4.7 Edge cases
**A and C both fire and disagree** (build hint says B42, input cross-ref says addon `_AZ`): both tick. Build × addon are orthogonal axes; they don't compete for the same slot.
**A says no match (build-mismatch), C also fires:** C still fires; the build-mismatch warning surfaces alongside C's selections. Rule A's "fall back to author default" doesn't override C.
**Coordinated wsid where one branch happens to be B42-flavored and another B41-flavored:** the wsid is exempt from A/B/C (coordinated detection runs first). All branches stay regardless of build mismatch. This is the right answer because coordinated branches by definition need each other.
**Stored selections** in localStorage from previous sessions: the existing `runResort(branchSelections)` flow fires *after* the initial sort response is rendered. User's stored selections override the rules. Rules only determine the initial render's pre-ticked state for never-touched wsids.
**`pz_build` missing or invalid in request:** backend treats as `"B42"`. Forwards-compatible if frontend on a stale build doesn't send it.
## §5 Implementation notes
**Backend** (`adapters.py` + `app.py`):
- `SortRequest` gains `pz_build: Optional[str]`.
- `_autopick_ambiguous(mods)` is renamed `_apply_branch_rules(mods, *, pz_build, input_modids)` and replaces the alphabetical-first picker with the rule pipeline above.
- Returns `(drop_ids, warnings, hints)``hints` is `Dict[mod_id -> str]` consumed by `build_response` and attached to MOD_DB entries as `hint?: string`.
- `sort_endpoint` computes `input_modids = set(by_id.keys())` (the cached mod_ids) and passes alongside `pz_build`.
**Frontend** (`sortof-app.jsx`):
- `onSort` POST body adds `pz_build: pzBuild`.
- `defaultSelectionForBranches(branches, activeSet)` accepts an `activeSet: Set<modId>` (the union of `D.SORTED_ORDER` mod_ids). Returns `branches.filter(b => activeSet.has(b.modId)).map(b => b.modId)`, falling back to `[branches[0].modId]` if none match.
- All call sites of `defaultSelectionForBranches` pass `new Set(D.SORTED_ORDER || [])`.
- `BranchPicker` renders `branch.hint` when present, as a small italic line under the mod_id.
The frontend doesn't reimplement the rules — it simply reflects the backend's chosen `SORTED_ORDER`. Same data path keeps the picker as source of truth and makes the rules introspectable from a curl response.
## §6 Acceptance criteria
- [ ] `POST /api/sort` accepts `pz_build` and defaults to `"B42"` when omitted.
- [ ] B42 user submits a wsid with branches `Foo` (un-flavored) and `FooB41`: `MODS_LINE` includes `Foo` only. B41 user gets `FooB41`.
- [ ] User submits Jeeve's Patches wsid alone: `MODS_LINE` is `JeevesPatches`. WARNINGS includes `unmatched-addons` listing the 10 untouched branches.
- [ ] User submits Jeeve's Patches + a wsid whose mod_id is `AuthenticZ_Current`: `MODS_LINE` includes `JeevesPatches` and `JeevesPatches_AZ`.
- [ ] Wsid `1962761540` (`ArmoredVests`, `ArmoredVestsPatch`): auto-pick selects `ArmoredVests` (prefix-base), not alphabetical-first (which is the same here — pick a wsid where they differ to verify).
- [ ] Coordinated wsid `2791656602` (fhqMotoriusZone): all 5 branches stay in `MODS_LINE` regardless of `pz_build`.
- [ ] Picker UI shows hint text per branch for D/G-class suffixes.
- [ ] `WORKSHOP_ITEMS_LINE` matches `wsids[]` regardless of which branches got ticked (Spec A §8 unchanged).
## §7 Test recipes
1. **Build A — B42 default.** `pz_build=B42` + wsid with branches `[Foo, FooB41]`. Expect `MODS_LINE = "Foo"`, no `build-mismatch` warning.
2. **Build A — B41 + only-B42-variants → mismatch warning.** `pz_build=B41` + wsid where every branch is B42-flavored. Expect `build-mismatch` warning, fall through to Rule B.
3. **Rule C — Jeeve's alone.** Submit only Jeeve's Patches wsid. Expect MODS_LINE = `JeevesPatches`, `unmatched-addons` warning lists the 10 others.
4. **Rule C — Jeeve's + AuthenticZ.** Submit Jeeve's wsid + AuthenticZ wsid. Expect MODS_LINE includes `JeevesPatches` and `JeevesPatches_AZ`.
5. **Rule B — prefix-base picks non-alphabetical.** Find a wsid where alphabetical-first ≠ prefix-base; verify prefix-base wins.
6. **Hint text — `_Lite` / `_legacy_*`.** Picker row shows the appropriate hint string.
7. **Coordinated exemption.** fhqMotoriusZone: all 5 branches in MODS_LINE for both B41 and B42 users.
8. **Stored-selection override.** User has `branchSelections[wsid] = [explicit]` from previous session. Rules don't override on next sort — runResort runs with the explicit selection after initial render.

View File

@@ -0,0 +1,270 @@
# Spec B+F - Collection URL/ID expansion + live drain progress
**Date:** 2026-05-01
**Status:** Draft (awaiting review)
**Sibling specs:** A multi-branch picker (shipped); C+D build-context + dep-add (next); E precacher (parallel); G cleanups + patch tier.
**Folds:** Original Spec F (live drain progress) merges in here - a 50+ mod cold load is exactly when live counters matter, and both features share the polling endpoint.
**Schema notes (corrections to design source text):**
- `download_jobs.status` enum is `queued | downloading | done | failed`. The design text used `running`; this spec uses the actual value `downloading`. UX label may render as "draining" for cohesion with the lifecycle vocabulary; the SQL keys off `downloading`.
- The existing `collections` table (`init/01_schema.sql`) has columns `collection_id PK, title, child_workshop_ids TEXT[], last_fetched_at TIMESTAMPTZ`. There is **no `expires_at` column**. TTL is computed at read time as `last_fetched_at + interval '6 hours'`; no schema change for that.
---
## §1 Overview
Today, sortof accepts one input shape: a blob of newline/`;`-delimited workshop IDs. Anything that isn't a 712 digit number is dropped by `parse.parse_workshop_input`. Pasting a Steam Workshop *collection* URL, of which there is exactly one ID embedded, currently surfaces that ID as a single mod, fails parse (`process_one=no_mod_info`), and lands in the `non_mod` bucket added by the recent unknown/non-mod feature. The user is expected to drag every child mod's ID out by hand.
This spec adds:
1. **Collection URL/ID expansion.** The API recognizes Steam Workshop URLs and resolves collection IDs to their child wsids via `ISteamRemoteStorage/GetCollectionDetails`. Cached in the existing `collections` table.
2. **Async job pipeline.** Any input containing a collection or any uncached wsid creates a `sort_jobs` row, returns a `job_id`, and the frontend polls `GET /api/jobs/{job_id}` every 2.5s until `done|failed`.
3. **Live counters.** During `expanding | queued | draining`, the poll response carries fresh `cached / queued / draining` counts plus an incremental `result_json`. The status strip animates instead of going stale.
Synchronous response is preserved for the all-cached fast path (Open Q1, §10).
## §2 API contract
### 2.1 `POST /api/sort` - polymorphic on input
Request body unchanged: `{ "input": str, "rules": str? }`. Response shape branches on what's in `input`:
```jsonc
// Path A: bare wsid list, all in cache (current behavior, unchanged)
{ "status": "success", "MOD_DB": [...], "MODS_LINE": "...", ... }
// Path B: bare wsid list with ≥1 uncached, OR ≥1 collection URL
{ "status": "queued" | "expanding", "job_id": "<uuid>" }
```
The frontend branches on the presence of `job_id`. Old clients that don't poll silently get the original sync response when their input is fully warm.
### 2.2 `GET /api/jobs/{job_id}` - polling endpoint
Response (any phase):
```jsonc
{
"job_id": "<uuid>",
"phase": "expanding" | "queued" | "draining" | "done" | "failed",
"counts": { "cached": int, "queued": int, "draining": int },
"wsids": [str, ...] | null, // null while phase=expanding; populated after
"result": { ...SORTOF_DATA... } | null, // partial during draining; final on done
"failure_reason": str | null // populated only on phase=failed
}
```
`404` if the `job_id` is unknown or expired (TTL in §3).
### 2.3 `DELETE /api/jobs/{job_id}` - cancel
Marks the job `failed` with `failure_reason="cancelled"`. Returns `204`. Idempotent: deleting an already-terminal job is a no-op `204`. Does **not** cancel underlying `download_jobs` rows (Open Q6, §10).
## §3 Schema
New table:
```sql
CREATE TABLE IF NOT EXISTS sort_jobs (
job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phase TEXT NOT NULL CHECK (phase IN ('expanding','queued','draining','done','failed')),
phase_started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
input_raw TEXT NOT NULL,
collection_ids TEXT[] NOT NULL DEFAULT '{}',
wsids TEXT[], -- null until expansion resolves
rules_raw TEXT,
result_json JSONB, -- null until done (incremental partials kept here too)
failure_reason TEXT
);
CREATE INDEX IF NOT EXISTS sort_jobs_phase_idx ON sort_jobs (phase);
CREATE INDEX IF NOT EXISTS sort_jobs_updated_idx ON sort_jobs (updated_at);
```
- **TTL:** rows older than `updated_at + 24h` AND `phase ∈ (done, failed)` are eligible for deletion. Cleanup script lives in Spec G; this spec only requires the schema support it.
- **`updated_at` trigger:** mirror the existing `download_jobs.touch_updated_at` pattern.
- **Migration plan:** `init/02_sort_jobs.sql` for fresh deploys + a one-shot `psql -f` for the live DB. No data migration; pure additive.
The existing `collections` table is reused as-is (4 columns, see corrections at top). No `expires_at` column; freshness derived from `last_fetched_at`.
## §4 Phase state machine
```
┌──────────────────────────────────┐
│ /api/sort with collections only │
▼ │
┌──────────────┐ GetCollectionDetails OK │
│ expanding │ ────────────────────────────┘
└──────┬───────┘
│ wsids = collections + bare ids
┌──────────────┐ ←── /api/sort with bare uncached wsids
│ queued │ ─────────── all wsids in mod_parsed (skip drain)
└──────┬───────┘ │
│ first download_jobs row → downloading
▼ │
┌──────────────┐ │
│ draining │ │
└──────┬───────┘ │
│ all wsids resolved (mod_parsed has rows)
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ done │ │ done │
└──────────────┘ └──────────────┘
Failure terminal at any phase: failed (with phase_at_failure stored in failure_reason prefix).
```
Phase transitions are **monotonic**: `expanding → queued → draining → done`. No backward transitions. A job's phase only advances; the API computes phase fresh on each `GET` rather than mutating it on every event (simpler, no leader needed).
Phase computation rule (executed inside `GET /api/jobs/{job_id}`):
```
if phase in (done, failed): return as-stored
if wsids is null: phase = expanding
elif counts.draining > 0: phase = draining
elif counts.queued > 0: phase = queued
elif counts.cached >= len(wsids): phase = done; persist result_json
else: phase = queued # transient gap between rows
```
## §5 Steam expansion
### 5.1 Detection
The current `parse.parse_workshop_input` strips ini-style prefixes and extracts `\b\d{7,12}\b`. We add a sibling `parse.parse_with_collections(text) -> (wsids: list, collection_ids: list)`:
- Match Steam URLs `https?://steamcommunity\.com/(?:sharedfiles|workshop)/filedetails/\?id=(\d{7,12})` and capture the ID.
- Bare numeric IDs (the existing pattern) remain `wsids`.
- A URL-form ID is classified as a *candidate collection*. We don't know syntactically whether a wsid is a collection vs a mod - so candidate collection IDs are sent to `GetCollectionDetails` first; if the API reports them as actual mods (not collections), they fall back to the wsids list.
### 5.2 Resolution
Single batched call per `/api/sort` with ≥1 candidate:
```
POST https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/
collectioncount=N
publishedfileids[0..N-1]=...
```
Per-collection in the response: `result==1` and `children[]` populated → expand to `[c.publishedfileid for c in children]`. `result!=1` → mark in result warnings as `{tag:"collection-partial", level:"warning", msg:"collection X could not be fetched"}`; keep the job alive with whatever resolved. (Open Q3, §10.)
### 5.3 Caching
Hit on `collections` row where `last_fetched_at > now() - interval '6 hours'`:
- Skip the API call entirely.
- Use cached `child_workshop_ids` directly.
Miss / stale → call API, UPSERT into `collections`, then proceed. The `last_fetched_at = now()` write is the cache write.
### 5.4 Flakiness
One internal retry with 2s backoff on HTTP error or `result!=1` for a candidate. After retries exhausted, the candidate is reported as collection-partial (warning) but the job continues with whatever else resolved. (Open Q4, §10.)
## §6 Counts contract
Computed live on every `GET /api/jobs/{job_id}` against the job's `wsids[]`:
```sql
-- counts.cached
SELECT COUNT(DISTINCT mp.workshop_id)
FROM mod_parsed mp
JOIN workshop_meta wm ON wm.workshop_id = mp.workshop_id
WHERE mp.workshop_id = ANY($1::text[])
AND mp.parsed_at_time_updated = wm.time_updated;
-- counts.queued
SELECT COUNT(DISTINCT workshop_id)
FROM download_jobs
WHERE workshop_id = ANY($1::text[]) AND status = 'queued';
-- counts.draining (status='downloading' in DB; surfaced as 'draining' in API/UI)
SELECT COUNT(DISTINCT workshop_id)
FROM download_jobs
WHERE workshop_id = ANY($1::text[]) AND status = 'downloading';
```
Ownership precedent (Spec A §8): once a job is created, `wsids[]` is **locked**. `WORKSHOP_ITEMS_LINE` in the final `result_json` is computed from `sort_jobs.wsids[]`, **not** recomputed against current `mod_parsed`. This means a wsid that was in the input but is currently `non_mod` or `unknown` still appears in `WORKSHOP_ITEMS_LINE` in the same position - matching the locked contract from Spec A.
## §7 Frontend behavior
Status strip during polling:
| Phase | Strip text |
|---|---|
| `expanding` | `expanding collection…` (animated dot, no counts visible) |
| `queued` | `X cached · Y queued · 0 draining` (animated dots on queued) |
| `draining` | `X cached · Y queued · Z draining` (animated dots on queued + draining) |
| `done` | strip collapses, full result rendered |
| `failed` | red banner with `failure_reason` + Retry button |
Polling: `setInterval` at 2.5s, started on receiving `job_id`. Stops on `phase ∈ (done, failed)`. On `404` (job expired/garbage-collected): show "this job expired - re-submit?" toast; offer one-click resubmit using cached input (the textarea is still populated).
Cancel button: shown during `expanding | queued | draining`. Issues `DELETE /api/jobs/{job_id}`, stops polling on success, clears the strip.
The synchronous code path (no `job_id` in response) renders unchanged - old picker behavior, immediate result.
Owned-fields contract (Spec A §8 precedent): `WORKSHOP_ITEMS_LINE`, `counts.queued` (the picker's internal counter), `unknown[]`, `non_mod[]` are still owned by the **first** `/api/sort` (or final `result_json`). `/api/resort` ignores them. The poll's `counts` object is purely the live drain progress and does not feed the picker's internal queued counter.
## §8 Cancellation
`DELETE /api/jobs/{job_id}` semantics:
- Marks `sort_jobs.phase = 'failed'`, `failure_reason = 'cancelled'`. Idempotent.
- **Does not** touch `download_jobs`. Workshop downloads in flight continue and populate `mod_parsed`, benefiting subsequent users via cache. Aborting them would waste partial progress and potentially trip the drain's `STALE_RECLAIM_MIN` reclaim path. (Open Q6, §10.)
- Frontend stops polling, hides the strip, shows a small "cancelled" toast. The textarea retains the input.
Re-submitting the same input after cancel creates a *new* job. Collection-cache hits make the second submission instant if the cache hasn't expired.
## §9 Restart resilience
uvicorn boot sweep (idempotent, runs in lifespan startup):
```sql
-- Time out long-stuck expansion jobs
UPDATE sort_jobs
SET phase = 'failed', failure_reason = 'expansion timed out',
updated_at = now()
WHERE phase = 'expanding'
AND phase_started_at < now() - interval '10 minutes';
```
Jobs in `queued` / `draining` need no special handling - they resume polling against `download_jobs` on the next client `GET`. The phase derives live from current counts (§4 phase computation rule), so a restart in the middle of a drain is invisible to the client beyond a brief window where counts may shift.
## §10 Open questions resolved
1. **Bare wsid + all-cached: synchronous or job-routed?** *Synchronous.* The cached path is sub-100ms today; routing it through a job adds polling latency and a UI flash. Frontend branches cheaply on `job_id` presence.
2. **Mixed input (bare wsids + collection URLs).** *Treat as collection input.* Job created in `expanding` phase immediately. Bare wsids merge into `wsids[]` after `GetCollectionDetails` resolves. No partial-sync hybrid - keeps the response shape rule clean.
3. **Partial expansion failure.** *Succeed with the resolvable subset.* Each unresolvable collection adds a warning `{tag:"collection-partial", level:"warning", msg:"collection X could not be fetched"}` to `result_json.WARNINGS`. Job completes normally; user sees the result with one or more amber warnings.
4. **`GetCollectionDetails` flakiness.** *One internal retry with 2s backoff* before reporting collection-partial. No frontend-driven retry on the GET poll - it would mask transient failures and give the user no recovery affordance. Job marked `failed` only if **every** candidate collection fails.
5. **Concurrent expansion of the same collection.** *Independent jobs; cache deduplicates.* User A and User B paste the same collection URL near-simultaneously; both create separate `sort_jobs` rows. The first one's `GetCollectionDetails` call populates `collections`; the second's hits cache. Worst case (race within the cache miss window) costs one duplicate API call. In-flight cache key (e.g., `collections.fetching_until`) deferred to Spec G.
6. **Cancel semantics.** *Abandon `sort_job`; leave `download_jobs` running.* Three reasons. (a) Workshop downloads benefit other users via the shared `mod_parsed` cache - wasting them is anti-social. (b) The drain's `STALE_RECLAIM_MIN=30` reclaim path treats half-killed `downloading` rows as candidates for retry; introducing client-driven cancellation creates a class of races where the row is killed mid-write. (c) Worker-side cancellation requires SIGTERM-of-DD-subprocess plumbing that doesn't exist; staying out of that codepath is much cheaper.
## §11 Acceptance criteria
- [ ] `POST /api/sort` with all-cached bare wsids returns the synchronous shape with no `job_id`.
- [ ] `POST /api/sort` with any uncached wsid OR any collection URL returns `{status, job_id}` and persists a `sort_jobs` row.
- [ ] `GET /api/jobs/{job_id}` returns live counts and the current phase per the §4 derivation rule.
- [ ] `GET /api/jobs/{nonexistent}` returns `404`.
- [ ] `DELETE /api/jobs/{job_id}` flips phase to `failed` with `failure_reason="cancelled"`. Idempotent.
- [ ] Collection URL `https://steamcommunity.com/sharedfiles/filedetails/?id=N` is detected by the parser and routed through `GetCollectionDetails`.
- [ ] A `collections` cache hit (row younger than 6h) skips the Steam API call.
- [ ] A collection that returns `result!=1` produces a `collection-partial` amber warning in `result_json.WARNINGS` but does not fail the job (unless **all** collections in the input are unresolvable).
- [ ] uvicorn restart with a job in `expanding > 10min` flips it to `failed` with `failure_reason="expansion timed out"`.
- [ ] uvicorn restart with a job in `queued`/`draining` is invisible to the client beyond next-poll-window jitter.
- [ ] Frontend polls every 2.5s when `phase ∈ (expanding, queued, draining)`; stops on terminal phase.
- [ ] Status strip text matches the §7 table for each phase.
- [ ] Cancel button issues `DELETE`, stops polling, hides strip, retains input in textarea.
- [ ] `WORKSHOP_ITEMS_LINE` in `result_json` matches `sort_jobs.wsids[]` regardless of which wsids ended up in `non_mod` / `unknown` (Spec A §8 ownership preserved).
## §12 Test recipes
1. **Synchronous fast path** - `POST /api/sort` with `{"input":"2169435993;2392709985;2487022075"}`. Expect: response has `MODS_LINE`, no `job_id`. ~50ms.
2. **Collection URL, cold cache** - clear `collections` row for the test ID; `POST /api/sort` with a known PZ collection URL. Expect: `{status:"expanding", job_id:"…"}` immediately. Poll: phase progresses `expanding → queued → draining → done`. Final `result.MODS_LINE` populated.
3. **Collection URL, warm cache** - re-submit the same URL within 6h. Expect: phase skips `expanding`, goes straight to `queued` (or `done` if all children cached). One Steam API call total across both runs (verify via `/var/log/...` or `journalctl -u sortof-api | grep GetCollectionDetails`).
4. **Mixed bare + collection** - `POST /api/sort` with `"<URL>\n2169435993"`. Expect: job created in `expanding`; on resolve, `wsids[]` contains both the collection's children and the bare wsid; deduped.
5. **Partial collection failure** - input contains two collection URLs, one valid, one to a deleted collection. Expect: job phase progresses normally; `result_json.WARNINGS` contains exactly one `collection-partial` entry; `wsids[]` contains only the valid collection's children.
6. **All collections fail** - input contains only unresolvable collection URLs. Expect: job `phase=failed`, `failure_reason="all input collections unresolvable"`.
7. **Cancel during draining** - submit a 50-mod cold collection, wait until `phase=draining`, `DELETE /api/jobs/{id}`. Expect: phase=failed reason=cancelled. Verify `download_jobs` rows for the wsids are still in `queued`/`downloading`/`done` (not nuked).
8. **Restart mid-drain** - submit a job, wait for `phase=draining`, `sudo systemctl restart sortof-api`. Wait 5s, GET the job. Expect: phase still derives correctly (computed from live counts), client polling resumes.
9. **Restart mid-expansion** - submit a collection job, kill `sortof-api` mid-expansion (race window: hard to hit deliberately; can simulate by directly SET `phase='expanding', phase_started_at=now()-interval '15 minutes'` then restart). Expect: lifespan sweep flips it to `failed` with `failure_reason="expansion timed out"`.
10. **404 on expired job** - manually `DELETE FROM sort_jobs WHERE job_id=…`; client poll. Expect: `404`. Frontend shows the expired-toast with re-submit affordance.
11. **Counts contract** - at each poll during a 50-mod cold drain, sum `counts.cached + counts.queued + counts.draining` and compare to `len(wsids)`. Equal at every snapshot. (Some wsids may be `non_mod` post-drain; they appear in `cached=0, queued=0, draining=0` because `mod_parsed` has no row - they're "missing from all three buckets," which is the expected steady state for non-mods.)
12. **Concurrent collection submit** - open two browser tabs simultaneously and submit the same URL. Expect: two distinct `job_id`s, but only one `GetCollectionDetails` call lands at Steam (verify journal). Worst case (cache-miss race): two API calls; this is acceptable.

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="22" y="6" width="20" height="8" rx="1.5" fill="#F2E6CC"/>
<rect x="24" y="14" width="16" height="4" fill="#F2E6CC"/>
<path d="M22 18 Q20 22 20 28 L20 54 Q20 58 24 58 L40 58 Q44 58 44 54 L44 28 Q44 22 42 18 Z" fill="#D43028"/>
<rect x="25" y="34" width="14" height="11" rx="1.5" fill="#F2E6CC"/>
<circle cx="29" cy="38" r="0.9" fill="#0A141E"/>
<circle cx="35" cy="38" r="0.9" fill="#0A141E"/>
<rect x="30" y="41.4" width="4" height="0.9" rx="0.4" fill="#0A141E"/>
</svg>

After

Width:  |  Height:  |  Size: 555 B

1433
frontend/index.html Normal file

File diff suppressed because it is too large Load Diff

1953
frontend/sortof-app.jsx Normal file

File diff suppressed because it is too large Load Diff

56
frontend/sortof-data.jsx Normal file
View File

@@ -0,0 +1,56 @@
// Canned dataset for the sortof prototype. Faked but plausible PZ mods.
// Used to drive the various states deterministically.
const SAMPLE_INPUT = '2169435993;2392709985;2487022075;2685168362;2784607980;1299328280;2865072321;2566953935;2200148440;2503622437;2702991010';
const SAMPLE_RULES = `# sorting_rules.txt
# pin libraries first; map packs last
load_first: tsarslib, modoptions, BB_CommonLib
load_last: Muldraugh_KY, BedfordFalls, Eerie_County
incompatible: BetterFlashlight | RealisticFlashlight
require: TMC_TrueActions => tsarslib
`;
// id, workshopId, modId, name, category (lib|map|gameplay|qol|content), deps, conflictsWith, loadFirst, loadLast
const MOD_DB = [
{ wsid: '2169435993', modId: 'modoptions', name: 'Mod Options', cat: 'lib', deps: [], pos: 'first' },
{ wsid: '2392709985', modId: 'tsarslib', name: "Tsar's Common Library", cat: 'lib', deps: [], pos: 'first' },
{ wsid: '2487022075', modId: 'TMC_TrueActions', name: 'True Actions', cat: 'gameplay', deps: ['tsarslib'], pos: '' },
{ wsid: '2685168362', modId: 'BB_CommonLib', name: 'BB Common Library', cat: 'lib', deps: [], pos: 'first' },
{ wsid: '2784607980', modId: 'BetterFlashlight', name: 'Better Flashlight', cat: 'qol', deps: ['modoptions'], pos: '', conflicts: ['RealisticFlashlight'] },
{ wsid: '1299328280', modId: 'ORGM', name: 'ORGM Rechambered', cat: 'content', deps: [] },
{ wsid: '2865072321', modId: 'Brita_Weapons', name: 'Brita\u2019s Weapon Pack', cat: 'content', deps: ['Brita_Armor'] }, // missing dep
{ wsid: '2566953935', modId: 'autotsar_trailers', name: 'Autotsar Trailers', cat: 'content', deps: ['tsarslib'] },
{ wsid: '2200148440', modId: 'Eerie_County', name: 'Eerie County', cat: 'map', deps: ['Muldraugh_KY'], pos: 'last' },
{ wsid: '2503622437', modId: 'BedfordFalls', name: 'Bedford Falls', cat: 'map', deps: [], pos: 'last' },
{ wsid: '2702991010', modId: 'Muldraugh_KY', name: 'Muldraugh, KY', cat: 'map', deps: [], pos: 'last', isMap: true, mapName: 'Muldraugh, KY' },
];
// Sorted output (per faked sorting rules)
const SORTED_ORDER = [
'modoptions', 'tsarslib', 'BB_CommonLib',
'TMC_TrueActions', 'BetterFlashlight',
'ORGM', 'Brita_Weapons', 'autotsar_trailers',
'Muldraugh_KY', 'BedfordFalls', 'Eerie_County'
];
const WORKSHOP_ITEMS_LINE = SORTED_ORDER
.map(id => MOD_DB.find(m => m.modId === id).wsid)
.join(';');
const MODS_LINE = SORTED_ORDER.join(';');
const MAP_LINE = 'Muldraugh, KY';
const WARNINGS = [
{ tag: 'missing', level: 'red', msg: 'Brita_Weapons requires Brita_Armor - not in your list.' },
{ tag: 'cycle', level: 'amber', msg: 'soft cycle: Eerie_County → Muldraugh_KY → (resolved by load_last rule)' },
{ tag: 'conflict',level: 'amber', msg: 'BetterFlashlight and RealisticFlashlight marked incompatible. only the former is enabled.' },
];
window.SORTOF_DATA = {
SAMPLE_INPUT, SAMPLE_RULES,
MOD_DB, SORTED_ORDER,
WORKSHOP_ITEMS_LINE, MODS_LINE, MAP_LINE,
WARNINGS,
};

425
frontend/tweaks-panel.jsx Normal file
View File

@@ -0,0 +1,425 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability - if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-noncommentable=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">{children}</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag - ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
function TweakColor({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});

127
init/01_schema.sql Normal file
View File

@@ -0,0 +1,127 @@
-- pzsort schema
-- Run as: psql -U pzsort -d pzsort -f schema.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- -----------------------------------------------------------------------------
-- workshop_meta
-- Cheap, refreshed often via Steam ISteamRemoteStorage/GetPublishedFileDetails.
-- Keyed by Steam publishedfileid (text to avoid bigint surprises).
-- time_updated is the cache-invalidation key for mod_parsed.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS workshop_meta (
workshop_id TEXT PRIMARY KEY,
title TEXT,
description TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
creator_steamid TEXT,
time_created BIGINT, -- unix ts from Steam
time_updated BIGINT NOT NULL, -- unix ts; cache invalidation key
file_size BIGINT,
preview_url TEXT,
consumer_app_id INTEGER, -- 108600 for PZ
visibility INTEGER, -- 0=public, 1=friends, 2=private
banned BOOLEAN NOT NULL DEFAULT FALSE,
last_checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS workshop_meta_last_checked_idx
ON workshop_meta (last_checked_at);
-- -----------------------------------------------------------------------------
-- mod_parsed
-- Expensive: requires DepotDownloader fetch. Only refreshed when
-- workshop_meta.time_updated changes vs parsed_at_time_updated.
-- One workshop item can yield N rows (multi-mod packages).
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS mod_parsed (
workshop_id TEXT NOT NULL REFERENCES workshop_meta(workshop_id) ON DELETE CASCADE,
mod_id TEXT NOT NULL, -- mod.info `id=`
name TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT 'undefined',
requirements TEXT[] NOT NULL DEFAULT '{}',
load_after TEXT[] NOT NULL DEFAULT '{}',
load_before TEXT[] NOT NULL DEFAULT '{}',
incompatible_mods TEXT[] NOT NULL DEFAULT '{}',
load_first TEXT NOT NULL DEFAULT 'off',
load_last TEXT NOT NULL DEFAULT 'off',
tags TEXT[] NOT NULL DEFAULT '{}',
maps TEXT[] NOT NULL DEFAULT '{}', -- map folder names
raw_mod_info TEXT, -- original file for debugging
version_min TEXT, -- e.g. 41.55
parsed_at_time_updated BIGINT NOT NULL, -- snapshot of workshop_meta.time_updated at parse
parsed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (workshop_id, mod_id)
);
CREATE INDEX IF NOT EXISTS mod_parsed_mod_id_idx ON mod_parsed (mod_id);
-- -----------------------------------------------------------------------------
-- download_jobs
-- Work queue for the DepotDownloader worker. One job per workshop_id.
-- Worker dequeues (status='queued') ORDER BY priority DESC, created_at ASC.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS download_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workshop_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued', -- queued|downloading|parsing|done|failed
priority INTEGER NOT NULL DEFAULT 0, -- higher first
attempts INTEGER NOT NULL DEFAULT 0,
error TEXT,
requested_by TEXT, -- IP hash or user token; for rate limiting
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS download_jobs_dequeue_idx
ON download_jobs (status, priority DESC, created_at ASC)
WHERE status = 'queued';
CREATE INDEX IF NOT EXISTS download_jobs_workshop_idx
ON download_jobs (workshop_id);
-- Trigger: keep updated_at fresh
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS download_jobs_touch ON download_jobs;
CREATE TRIGGER download_jobs_touch
BEFORE UPDATE ON download_jobs
FOR EACH ROW
EXECUTE FUNCTION touch_updated_at();
-- -----------------------------------------------------------------------------
-- collections
-- Cache for ISteamRemoteStorage/GetCollectionDetails results.
-- Collections expand to N child workshop_ids; we cache that mapping.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS collections (
collection_id TEXT PRIMARY KEY,
title TEXT,
child_workshop_ids TEXT[] NOT NULL DEFAULT '{}',
last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- -----------------------------------------------------------------------------
-- sort_requests
-- Optional: log of submitted sort jobs for debugging + abuse triage.
-- Not required for sort to function. Keep TTL short via a cron.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sort_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
input_workshop_ids TEXT[] NOT NULL,
input_collection_id TEXT,
cache_hits INTEGER NOT NULL DEFAULT 0,
cache_misses INTEGER NOT NULL DEFAULT 0,
requested_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sort_requests_created_idx
ON sort_requests (created_at);

29
init/02_sort_jobs.sql Normal file
View File

@@ -0,0 +1,29 @@
-- Async sort jobs: lifecycle + result for collection expansion + cold drains.
-- Created 2026-05-01 (Spec B+F).
-- Depends on: 01_schema.sql (touch_updated_at() function, pgcrypto extension).
-- Docker initdb runs files alphabetically, so 01_ executes first; for live
-- one-shot psql application against an existing DB, both prerequisites
-- already exist.
CREATE TABLE IF NOT EXISTS sort_jobs (
job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phase TEXT NOT NULL CHECK (phase IN ('expanding','queued','draining','done','failed')),
phase_started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
input_raw TEXT NOT NULL,
collection_ids TEXT[] NOT NULL DEFAULT '{}',
wsids TEXT[],
rules_raw TEXT,
result_json JSONB,
failure_reason TEXT
);
CREATE INDEX IF NOT EXISTS sort_jobs_phase_idx ON sort_jobs (phase);
CREATE INDEX IF NOT EXISTS sort_jobs_updated_idx ON sort_jobs (updated_at);
DROP TRIGGER IF EXISTS sort_jobs_touch ON sort_jobs;
CREATE TRIGGER sort_jobs_touch
BEFORE UPDATE ON sort_jobs
FOR EACH ROW
EXECUTE FUNCTION touch_updated_at();

View File

@@ -0,0 +1,13 @@
-- Records every mod_id eviction the worker performs (one wsid claiming a mod_id
-- previously held by another). Used by /api/sort to warn the user when their
-- input includes multiple wsids that declare the same mod_id (PZ silently
-- loads only one; the others' folders end up dead weight on the server).
CREATE TABLE IF NOT EXISTS mod_id_conflicts (
mod_id TEXT NOT NULL,
evicting_wsid TEXT NOT NULL,
evicted_wsid TEXT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (mod_id, evicting_wsid, evicted_wsid)
);
CREATE INDEX IF NOT EXISTS mod_id_conflicts_evicted_idx ON mod_id_conflicts (evicted_wsid);
CREATE INDEX IF NOT EXISTS mod_id_conflicts_evicting_idx ON mod_id_conflicts (evicting_wsid);

View File

@@ -0,0 +1,12 @@
-- Required Items scraped from each mod's Steam Workshop page (the "Required
-- Items" section). Steam's anonymous GetPublishedFileDetails endpoint does
-- not include children for individual mods, so we scrape the public HTML.
--
-- Use cases:
-- 1. Auto-resolving missing-dep warnings: when a cached mod_id Y is
-- missing dep X, we look at Y's source wsid's required_wsids and
-- auto-queue any uncached wsids — the next sort resolves X.
-- 2. Surfacing "↗ add <wsid>" actions for unresolved deps so the user
-- can pull them with one click.
ALTER TABLE workshop_meta
ADD COLUMN IF NOT EXISTS required_wsids TEXT[] NOT NULL DEFAULT '{}';

View File

@@ -0,0 +1,7 @@
-- Track when we last successfully scraped a wsid's "Required Items" section.
-- Without this, we can't distinguish "successfully scraped, zero required
-- items" (a stable empty []) from "never scraped" (also empty {} per schema
-- default). Backfill jobs and the missing-dep auto-resolver use it to skip
-- already-known-empty pages.
ALTER TABLE workshop_meta
ADD COLUMN IF NOT EXISTS required_scraped_at TIMESTAMPTZ;

View File

@@ -0,0 +1,5 @@
-- pz_build captured at job creation so the polling-path result regen
-- (`_build_result_for_job`) can emit build-mismatch warnings against the
-- user's chosen build, matching what the sync path emits.
ALTER TABLE sort_jobs
ADD COLUMN IF NOT EXISTS pz_build TEXT;

View File

@@ -0,0 +1,7 @@
-- Marks a mod_id as an "optional add-on" within a multi-mod wsid, signaled
-- by `Optional add-on` (or close variants) at the head of the mod.info
-- description. Spec A's branch picker treats addon mods additively
-- (default-off, tickable to load alongside the primary) instead of as a
-- mutually-exclusive flavor variant.
ALTER TABLE mod_parsed
ADD COLUMN IF NOT EXISTS is_addon BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,20 @@
-- Community-reported broken mods. Each (workshop_id, version) is unique;
-- re-submitting the same pair upserts (refreshes updated_at) while
-- preserving accumulated votes. ORDER BY updated_at DESC drives the
-- list view, so a re-report bubbles the entry back to the top with
-- previous up/down counts intact.
CREATE TABLE IF NOT EXISTS broken_mod_reports (
id BIGSERIAL PRIMARY KEY,
workshop_id TEXT NOT NULL,
mod_name TEXT,
version TEXT NOT NULL,
upvotes INTEGER NOT NULL DEFAULT 0,
downvotes INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workshop_id, version)
);
CREATE INDEX IF NOT EXISTS broken_mod_reports_updated_idx
ON broken_mod_reports (updated_at DESC);
CREATE INDEX IF NOT EXISTS broken_mod_reports_wsid_idx
ON broken_mod_reports (workshop_id);

210
precacher/precacher.py Normal file
View File

@@ -0,0 +1,210 @@
"""sortof precacher (Spec E): warm the cache by enqueueing the top-N PZ
Workshop wsids across four time windows (3 months, 6 months, 1 year, all time)
that aren't already known.
Pure feeder for the existing drain pipeline. Inserts into download_jobs and
returns; the drain workers (sortof-drain@1..4) handle the actual DD pulls.
Run on demand:
/opt/sortof/worker/.venv/bin/python /opt/sortof/precacher/precacher.py
Reuses the worker's venv (httpx + asyncpg) since dependencies overlap exactly.
Reads DATABASE_URL from /opt/sortof/.env.
Skip rule: a wsid is "already known" iff a row exists in mod_parsed for it
(any state) OR a row exists in download_jobs for it (any status). This is
deliberately conservative - we never re-queue a wsid the system has seen
before, including ones that previously failed (banned, deleted, no_mod_info).
Forced re-queue is the API's job, not the precacher's.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import sys
import urllib.parse
from pathlib import Path
from typing import Dict, List, Set, Tuple
import asyncpg
import httpx
from dotenv import load_dotenv
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
def _build_dsn() -> str:
"""Mirror api/db.py: prefer DATABASE_URL, else build from POSTGRES_* parts."""
load_dotenv(ENV_PATH)
explicit = os.environ.get("DATABASE_URL")
if explicit:
return explicit
user = os.environ["POSTGRES_USER"]
pw = urllib.parse.quote(os.environ["POSTGRES_PASSWORD"], safe="")
name = os.environ["POSTGRES_DB"]
host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
port = os.environ.get("POSTGRES_PORT", "5439")
return f"postgresql://{user}:{pw}@{host}:{port}/{name}"
PZ_APPID = 108600
BROWSE_URL = "https://steamcommunity.com/workshop/browse/"
PER_PAGE = 30 # Steam HTML default; observed cap.
RATE_LIMIT_S = 0.6 # polite gap between page fetches.
DEFAULT_TARGET = 1000
# Window label -> (browsesort, days param). days < 0 means "all time" -
# Steam's totalvotes sort doesn't take a days param.
WINDOWS: List[Tuple[str, str, int]] = [
("3m", "mostpopular", 90),
("6m", "mostpopular", 180),
("1y", "mostpopular", 365),
("all", "totalvotes", -1),
]
WSID_RE = re.compile(r'data-publishedfileid="(\d{6,12})"')
log = logging.getLogger("sortof.precacher")
async def fetch_page(http: httpx.AsyncClient, sort: str, days: int, page: int) -> List[str]:
params: Dict[str, object] = {
"appid": PZ_APPID,
"browsesort": sort,
"section": "readytouseitems",
"p": page,
"numperpage": PER_PAGE,
}
if days > 0:
params["days"] = days
r = await http.get(BROWSE_URL, params=params, timeout=30.0)
r.raise_for_status()
# De-dupe within the page (the same wsid can appear in multiple HTML blocks).
return list(dict.fromkeys(WSID_RE.findall(r.text)))
async def collect_top_wsids(
http: httpx.AsyncClient, sort: str, days: int, target: int,
) -> List[str]:
"""Walk pages until we have `target` distinct wsids or pagination exhausts."""
seen: Set[str] = set()
out: List[str] = []
page = 1
consecutive_empty = 0
while len(out) < target:
try:
ids = await fetch_page(http, sort, days, page)
except httpx.HTTPError as e:
log.warning("page %d fetch failed: %s", page, e)
break
if not ids:
consecutive_empty += 1
if consecutive_empty >= 2:
break
else:
consecutive_empty = 0
added = 0
for wid in ids:
if wid in seen:
continue
seen.add(wid)
out.append(wid)
added += 1
if len(out) >= target:
break
# If a page returns only duplicates we've already seen, we're cycling.
if ids and added == 0:
break
page += 1
await asyncio.sleep(RATE_LIMIT_S)
return out
async def already_known(conn: asyncpg.Connection, wsids: List[str]) -> Set[str]:
"""Returns the subset of wsids that the system has seen - either parsed
in mod_parsed or sitting in download_jobs (any status). The conservative
superset; precacher never re-queues anything previously touched."""
if not wsids:
return set()
rows = await conn.fetch(
"""
SELECT workshop_id FROM mod_parsed WHERE workshop_id = ANY($1::text[])
UNION
SELECT workshop_id FROM download_jobs WHERE workshop_id = ANY($1::text[])
""",
wsids,
)
return {r["workshop_id"] for r in rows}
async def enqueue(conn: asyncpg.Connection, wsids: List[str]) -> int:
"""INSERT each wsid into download_jobs. Mirrors the API's queue-and-dedup
pattern: skip if a row already exists (race-safe via per-iteration tx)."""
n = 0
for wid in wsids:
async with conn.transaction():
existing = await conn.fetchval(
"SELECT 1 FROM download_jobs WHERE workshop_id = $1 LIMIT 1",
wid,
)
if existing is None:
await conn.execute(
"INSERT INTO download_jobs (workshop_id, status) VALUES ($1, 'queued')",
wid,
)
n += 1
return n
async def main(target: int = DEFAULT_TARGET) -> int:
try:
dsn = _build_dsn()
except KeyError as e:
log.error("missing required env var: %s", e)
return 2
pool = await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=2)
http = httpx.AsyncClient(headers={"User-Agent": "sortof-precacher/1.0"})
totals = {"fetched": 0, "skipped_known": 0, "enqueued": 0}
try:
for label, sort, days in WINDOWS:
log.info(
"window=%s sort=%s days=%d: collecting up to %d wsids",
label, sort, days, target,
)
wsids = await collect_top_wsids(http, sort, days, target)
log.info("window=%s: collected %d wsids", label, len(wsids))
async with pool.acquire() as conn:
known = await already_known(conn, wsids)
fresh = [w for w in wsids if w not in known]
inserted = await enqueue(conn, fresh)
log.info(
"window=%s: known=%d fresh=%d enqueued=%d",
label, len(known), len(fresh), inserted,
)
totals["fetched"] += len(wsids)
totals["skipped_known"] += len(known)
totals["enqueued"] += inserted
finally:
await http.aclose()
await pool.close()
log.info(
"precache run done: fetched=%d skipped_known=%d enqueued=%d",
totals["fetched"], totals["skipped_known"], totals["enqueued"],
)
return 0
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
target = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_TARGET
sys.exit(asyncio.run(main(target)))

229
worker/drain.py Normal file
View File

@@ -0,0 +1,229 @@
"""sortof download_jobs drainer.
Long-running asyncio loop that claims queued jobs from download_jobs,
calls worker.process_one() to materialize mod_parsed rows via
DepotDownloader, and updates job status. Single connection per process;
multiple instances are safe because claims use FOR UPDATE SKIP LOCKED.
Manual requeue (after a transient failure):
UPDATE download_jobs
SET status='queued', attempts=0, error=NULL
WHERE id='<uuid>';
Bulk requeue everything that hit MAX_ATTEMPTS:
UPDATE download_jobs
SET status='queued', attempts=0, error=NULL
WHERE status='failed';
"""
from __future__ import annotations
import asyncio
import logging
import os
import signal
import sys
import time
import urllib.parse
from pathlib import Path
import asyncpg
from dotenv import load_dotenv
from worker import (
DEFAULT_DD_PATH,
fetch_workshop_details,
process_one,
)
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
IDLE_SLEEP_S = 5
HEARTBEAT_S = 60
BATCH_SIZE = 1
MAX_ATTEMPTS = 5
STALE_RECLAIM_MIN = 30
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log = logging.getLogger("sortof.drain")
CLAIM_SQL = """
UPDATE download_jobs
SET status='downloading', attempts=attempts+1, updated_at=now()
WHERE id IN (
SELECT id FROM download_jobs
WHERE status='queued' AND attempts < $1
ORDER BY priority DESC, created_at ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
)
RETURNING id, workshop_id, attempts
"""
RECLAIM_SQL = f"""
UPDATE download_jobs
SET status='queued', updated_at=now()
WHERE status='downloading'
AND updated_at < now() - interval '{STALE_RECLAIM_MIN} minutes'
RETURNING id
"""
DEPTH_SQL = """
SELECT COUNT(*) FROM download_jobs
WHERE status='queued' AND attempts < $1
"""
DONE_SQL = """
UPDATE download_jobs
SET status='done', completed_at=now(), updated_at=now(), error=NULL
WHERE id=$1
"""
FAIL_SQL = """
UPDATE download_jobs
SET status='failed', updated_at=now(), error=$2
WHERE id=$1
"""
def build_dsn() -> str:
load_dotenv(ENV_PATH)
explicit = os.environ.get("DATABASE_URL")
if explicit:
return explicit
user = os.environ["POSTGRES_USER"]
pw = urllib.parse.quote(os.environ["POSTGRES_PASSWORD"], safe="")
name = os.environ["POSTGRES_DB"]
host = os.environ.get("POSTGRES_HOST", "127.0.0.1")
port = os.environ.get("POSTGRES_PORT", "5439")
return f"postgresql://{user}:{pw}@{host}:{port}/{name}"
def resolve_dd_path() -> Path:
"""Resolve the DepotDownloader binary or fail loudly.
Order of precedence: $DD_PATH env var, then worker.py's argparse
default (DEFAULT_DD_PATH).
"""
candidates: list[Path] = []
env_dd = os.environ.get("DD_PATH")
if env_dd:
candidates.append(Path(env_dd))
candidates.append(Path(DEFAULT_DD_PATH))
for p in candidates:
if p.is_file():
return p
raise RuntimeError(
"DepotDownloader not found. Tried: "
+ ", ".join(str(c) for c in candidates)
+ ". Set DD_PATH or place the binary at the default path."
)
async def reclaim_stale(conn: asyncpg.Connection) -> int:
rows = await conn.fetch(RECLAIM_SQL)
return len(rows)
async def claim_batch(conn: asyncpg.Connection, n: int):
return await conn.fetch(CLAIM_SQL, MAX_ATTEMPTS, n)
async def queue_depth(conn: asyncpg.Connection) -> int:
return await conn.fetchval(DEPTH_SQL, MAX_ATTEMPTS)
async def mark_done(conn: asyncpg.Connection, job_id) -> None:
await conn.execute(DONE_SQL, job_id)
async def mark_failed(conn: asyncpg.Connection, job_id, msg: str) -> None:
await conn.execute(FAIL_SQL, job_id, msg[:500])
async def run() -> int:
dd_path = resolve_dd_path() # raises before opening DB if missing
dsn = build_dsn()
conn = await asyncpg.connect(dsn=dsn)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
def _handle(sig: signal.Signals):
log.info("drain shutting down (signal=%s)", sig.name)
stop.set()
for s in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(s, _handle, s)
try:
n_reclaimed = await reclaim_stale(conn)
log.info(
"drain starting, reclaimed %d stale, dd_path=%s",
n_reclaimed, dd_path,
)
last_heartbeat = 0.0
while not stop.is_set():
rows = await claim_batch(conn, BATCH_SIZE)
if not rows:
now = time.monotonic()
if now - last_heartbeat >= HEARTBEAT_S:
depth = await queue_depth(conn)
log.info("idle, queue depth=%d", depth)
last_heartbeat = now
try:
await asyncio.wait_for(stop.wait(), timeout=IDLE_SLEEP_S)
except asyncio.TimeoutError:
pass
continue
ids = [r["workshop_id"] for r in rows]
try:
details = await asyncio.to_thread(fetch_workshop_details, ids)
except Exception as e:
log.warning("steam fetch failed: %s", e)
for r in rows:
await mark_failed(conn, r["id"], "steam fetch error")
continue
for r in rows:
wid = r["workshop_id"]
attempt = r["attempts"]
log.info("claimed wsid=%s attempt=%d", wid, attempt)
detail = details.get(wid)
if not detail or detail.get("result") != 1:
reason = (
f"steam result={detail.get('result') if detail else 'none'}"
)
log.info("failed wsid=%s reason=%s", wid, reason)
await mark_failed(conn, r["id"], reason)
continue
try:
outcome = await process_one(conn, wid, detail, dd_path, False)
except Exception as e:
log.exception("drain exception wsid=%s", wid)
await mark_failed(conn, r["id"], str(e)[:500])
continue
if outcome in ("hit", "refreshed"):
log.info("done wsid=%s outcome=%s", wid, outcome)
await mark_done(conn, r["id"])
else:
reason = f"process_one={outcome}"
log.info("failed wsid=%s reason=%s", wid, reason)
await mark_failed(conn, r["id"], reason)
finally:
await conn.close()
log.info("drain stopped")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(run()))

682
worker/mlos_sort.py Normal file
View File

@@ -0,0 +1,682 @@
"""
mlos_sort.py
Python port of MLOS_sorting.lua (Mod Load Order Sorter, by REfRigERatoR).
Faithful to the Lua algorithm:
- preorder: ModManager, ModManagerServer, modoptions
- category buckets: coreRequirement -> tweaks -> resource -> map -> vehicle ->
code -> clothes -> ui -> other -> translation -> undefined
- loadFirst / loadLast: on (0) | category (1) | off (2)
- topological sort by `require` + `loadAfter` with cycle detection
- sorting_rules.txt overrides supported (loadAfter/loadBefore/incompatibleMods/
loadFirst/loadLast/category)
Limitations vs in-game Lua:
- mod.info-only input. We do NOT walk /media/* folders for category detection.
We rely on mod.info `category=` if present, then `frameworkKeys` name
heuristic, then default "other" (or "undefined" if uncategorizable).
- `loadBefore` is converted into corresponding `loadAfter` edges on other mods,
matching the Lua mod's behavior.
"""
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# -----------------------------------------------------------------------------
# Constants (mirrors MLOS_sorting.lua)
# -----------------------------------------------------------------------------
PREORDER: Dict[str, int] = {"ModManager": 1, "ModManagerServer": 2, "modoptions": 3}
RAW_CATEGORY_ORDER: List[str] = [
"coreRequirement",
"tweaks", # libraries / frameworks / APIs (matches existing 'lib' pill)
"tile", # tile asset packs
"debug", # error logger, cheat menus
"resource", # other generic resources
"map",
"qol", # QOL changes
"moodle", # moodles / moodlets
"tweak_minor", # tiny tweaks (working aircon, ear protection, …)
"music", # music + music addons (load before vehicles per user spec)
"wearable", # clothing, hair, tattoos (NOT armor)
"profession", # profession mods
"movement", # drop-and-roll, crawl, jump, ladders
"building", # building menus, barricades, light switches
"farming",
"zombie", # zombie behaviour mods (OccultZed, HordeNight, ReactiveZombies)
"zone", # hazardous zones, spore zones
"armor", # armor mods (separate from wearables)
"food",
"health", # first aid, medical
"weapon", # weapons (load before vehicles per user spec)
"crafting",
"container", # backpacks, boxes, tubs
"vehicle",
"vehicle_spawn", # vehicle spawn zones
"loot", # loot tables
"code", # generic gameplay code (legacy fallback)
"ui", # interface
"sound", # audio (non-music)
"texture",
"translation",
"multiplayer", # MP-specific utilities
"server_only", # admin tools, server logs
"fix", # bug-fix overlays (distinct from 'patch' loadLast tier)
"other",
"undefined",
# Spec G-patch: "patch" is a category like any other, but `_initial_sort_key`
# routes patches above all sub-axes via a leading is_patch tuple element so
# they sort strictly LAST (after every loadLast=on map mod).
"patch",
]
CATEGORY_ORDER: Dict[str, int] = {c: i for i, c in enumerate(RAW_CATEGORY_ORDER)}
LOAD_CATEGORIES: Dict[str, int] = {"on": 0, "category": 1, "off": 2}
# from MLOS_sorting.lua: frameworkKeys for name-based tweak detection.
# Lua uses string.find on lowercased name (substring match, no regex anchors).
FRAMEWORK_KEYS: List[str] = [
"framework",
" api",
"_api",
"tweak",
"interface",
"utilit", # matches utility, utilities
"bugfix",
"librar", # matches library, libraries — covers damnlib/tsarslib/StarlitLibrary/etc.
# `derive_category` checks mod.maps before FRAMEWORK_KEYS, so a map
# mod whose name contains "library" still classifies as `map` first.
]
# Multi-key list fields in mod.info (lowercased keys)
LIST_KEYS_MAP = {
"require": "requirements",
"loadafter": "loadAfter",
"loadbefore": "loadBefore",
"incompatiblemods": "incompatibleMods",
"tags": "tags",
}
# -----------------------------------------------------------------------------
# Dataclasses
# -----------------------------------------------------------------------------
@dataclass
class ModInfo:
id: str
name: str = ""
workshop_id: Optional[str] = None
category: str = "undefined"
requirements: List[str] = field(default_factory=list)
loadAfter: List[str] = field(default_factory=list)
loadBefore: List[str] = field(default_factory=list)
incompatibleMods: List[str] = field(default_factory=list)
loadFirst: str = "off"
loadLast: str = "off"
tags: List[str] = field(default_factory=list)
maps: List[str] = field(default_factory=list) # map folder names from media/maps/
flags: List[str] = field(default_factory=list)
is_addon: bool = False # Spec A addon: default-off in multi-mod wsids
# Steam Workshop's controlled-vocab tags (workshop_meta.tags). Canonical
# signal for build / multiplayer / category detection. Distinct from
# `tags` which is mod.info-side (freeform).
workshop_tags: List[str] = field(default_factory=list)
warnings: Dict[str, List[str]] = field(default_factory=dict)
@dataclass
class SortingRule:
loadAfter: List[str] = field(default_factory=list)
loadBefore: List[str] = field(default_factory=list)
incompatibleMods: List[str] = field(default_factory=list)
loadFirst: str = "off"
loadLast: str = "off"
category: Optional[str] = None
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
def _split_csv(value: str) -> List[str]:
"""
Mirrors Refr_Utils:splitStringBySeparator - split on commas, trim, drop empties.
Defensively strips `word=` prefixes that some malformed mod.info lines include
(the Lua does this too). Also strips a leading backslash on each entry: B42
mod.info files write deps as `require=\\StarlitLibrary` (path-style); we want
plain modIds so dep names match for the topo sort and missing-dep warnings.
Finally strips a leading "<digits>/" wsid-path prefix some authors put in
front of the modId (e.g. require=2256623447/firearmmod).
"""
if value is None:
return []
cleaned = re.sub(r"\w+\s*=", "", value)
out: List[str] = []
for p in cleaned.split(","):
s = p.strip().lstrip("\\")
s = re.sub(r"^\d+/", "", s)
if s:
out.append(s)
return out
def _convert_load_category(value) -> str:
"""Mirrors convertToLoadCategoryString: normalize to 'on' | 'category' | 'off'."""
if value in (True, "true", 0, "0"):
return "on"
if value in (None, False, "false", 2, "2", ""):
return "off"
if value not in LOAD_CATEGORIES:
return "off"
return str(value)
def _str_contains_any(haystack: str, needles: List[str]) -> bool:
if not haystack:
return False
h = haystack.lower()
return any(n and n in h for n in needles)
# -----------------------------------------------------------------------------
# Parsers
# -----------------------------------------------------------------------------
def parse_mod_info(text: str, workshop_id: Optional[str] = None) -> Optional[ModInfo]:
"""
Parse a mod.info file body. Returns None if no `id=` line found.
Lines are `key=value`; keys lowercased; list-fields comma-separated.
"""
fields: Dict[str, object] = {}
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
m = re.match(r"^\s*(.+?)\s*=\s*(.*?)\s*$", line)
if not m:
continue
key = m.group(1).strip().lower()
value = m.group(2).strip()
if key in LIST_KEYS_MAP:
fields[LIST_KEYS_MAP[key]] = _split_csv(value)
elif key == "loadfirst":
fields["loadFirst"] = _convert_load_category(value)
elif key == "loadlast":
fields["loadLast"] = _convert_load_category(value)
elif key == "category":
fields["category"] = value if value in CATEGORY_ORDER else "undefined"
elif key == "name":
fields["name"] = value
elif key == "id":
# Some authors prefix the wsid into the id (e.g. id=2256623447/firearmmod).
# Strip a leading "<digits>/" so the canonical mod_id is the clean form.
fields["id"] = re.sub(r"^\d+/", "", value)
if "id" not in fields:
return None
return ModInfo(
id=fields["id"],
name=fields.get("name", ""),
workshop_id=workshop_id,
category=fields.get("category", "undefined"),
requirements=fields.get("requirements", []),
loadAfter=fields.get("loadAfter", []),
loadBefore=fields.get("loadBefore", []),
incompatibleMods=fields.get("incompatibleMods", []),
loadFirst=fields.get("loadFirst", "off"),
loadLast=fields.get("loadLast", "off"),
tags=fields.get("tags", []),
)
def parse_sorting_rules(text: str) -> Dict[str, SortingRule]:
"""
Parse a sorting_rules.txt file. Format:
[modId]
loadAfter=mod1,mod2
loadBefore=mod3
incompatibleMods=mod4
loadFirst=on
loadLast=off
category=tweaks
"""
rules: Dict[str, SortingRule] = {}
current: Optional[str] = None
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
m = re.match(r"^\s*\[\s*(.+?)\s*\]\s*$", line)
if m:
current = m.group(1)
rules.setdefault(current, SortingRule())
continue
if current is None:
continue
kv = re.match(r"^\s*(.+?)\s*=\s*(.*?)\s*$", line)
if not kv:
continue
key, value = kv.group(1).lower(), kv.group(2)
rule = rules[current]
if key in ("loadafter", "loadmodafter"):
rule.loadAfter = list(dict.fromkeys(rule.loadAfter + _split_csv(value)))
elif key in ("loadbefore", "loadmodbefore"):
rule.loadBefore = list(dict.fromkeys(rule.loadBefore + _split_csv(value)))
elif key in ("incompatiblemods", "incompatible"):
rule.incompatibleMods = list(dict.fromkeys(rule.incompatibleMods + _split_csv(value)))
elif key == "loadfirst":
rule.loadFirst = _convert_load_category(value)
elif key == "loadlast":
rule.loadLast = _convert_load_category(value)
elif key == "category":
rule.category = value if value in CATEGORY_ORDER else None
return rules
# -----------------------------------------------------------------------------
# Filesystem ingestion (DepotDownloader output layout)
# -----------------------------------------------------------------------------
def load_mods_from_dir(root: Path) -> List[ModInfo]:
"""
Walk `<root>/<workshop_id>/mods/<mod_id>/mod.info` (DepotDownloader output).
Also: `<root>/<workshop_id>/mods/<mod_id>/media/maps/<map_folder>/map.info`
populates `maps` for that mod.
"""
mods: List[ModInfo] = []
if not root.exists():
raise FileNotFoundError(f"Mods root does not exist: {root}")
for workshop_dir in sorted(root.iterdir()):
if not workshop_dir.is_dir():
continue
workshop_id = workshop_dir.name if workshop_dir.name.isdigit() else None
mods_root = workshop_dir / "mods"
if not mods_root.exists():
# also support: some layouts put mods/ at root level directly
mods_root = workshop_dir
if not mods_root.exists() or not mods_root.is_dir():
continue
for mod_dir in sorted(mods_root.iterdir()):
if not mod_dir.is_dir():
continue
mod_info_path = mod_dir / "mod.info"
if not mod_info_path.exists():
continue
try:
text = mod_info_path.read_text(encoding="utf-8", errors="replace")
except OSError as e:
print(f"WARN: cannot read {mod_info_path}: {e}", file=sys.stderr)
continue
mod = parse_mod_info(text, workshop_id=workshop_id)
if mod is None:
print(f"WARN: no `id=` in {mod_info_path}, skipping", file=sys.stderr)
continue
# Collect map folders
maps_dir = mod_dir / "media" / "maps"
if maps_dir.exists() and maps_dir.is_dir():
for map_folder in sorted(maps_dir.iterdir()):
if map_folder.is_dir() and (map_folder / "map.info").exists():
mod.maps.append(map_folder.name)
mods.append(mod)
return mods
# -----------------------------------------------------------------------------
# Category derivation (degraded vs in-game; no folder walk)
# -----------------------------------------------------------------------------
_PATCH_NAME_RE = re.compile(r"\b(patch|compat|compatibility)\b", re.IGNORECASE)
# Substring-based category hints (kept in sync with api/mlos_sort.py)
_LIB_NAME_HINTS = ["library", "libraries", "framework"]
_LIB_NAME_RE = re.compile(
r'(?<![A-Za-z])(?:lib|api|core)(?![A-Za-z])'
r'|(?<=[a-z])(?:Lib|API|Core)(?![A-Za-z])',
re.IGNORECASE,
)
_MUSIC_NAME_HINTS = ["music", "moozic", "jukebox"]
_MOODLE_NAME_HINTS = ["moodle", "moodlet"]
_PROFESSION_HINTS = ["profession"]
_MOVEMENT_HINTS = [
"true action", "trueaction", "true_action",
"drop and roll", "dropandroll", "drop_and_roll",
"crawl", "ladder",
]
_ARMOR_NAME_HINTS = ["armor", "armour"]
_HEALTH_NAME_HINTS = ["first aid", "firstaid", "medical", "injur", "disease", "sickness"]
_CRAFTING_HINTS = ["craft"]
_CONTAINER_HINTS = ["backpack", "container", "storage"]
_LOOT_NAME_HINTS = ["loot"]
_TILE_NAME_HINTS = ["tiles", "tileset", "tilepack"]
_DEBUG_NAME_HINTS = ["debug menu", "cheat menu", "error log", "errormagnifier"]
_ZONE_NAME_HINTS = ["hazard zone", "spore zone", "spore zones"]
_ZOMBIE_NAME_HINTS = ["zombie", "horde", "undead"]
_FIX_NAME_HINTS = [" fix", "_fix", "bugfix", "hotfix"]
def _name_has(name: str, hints: List[str]) -> bool:
if not name:
return False
n = name.lower()
return any(h in n for h in hints)
def derive_category(mod: ModInfo) -> str:
"""Best-effort category from mod.info + workshop_meta.tags + name.
Mirrors api/mlos_sort.py; keep both copies in sync.
"""
if mod.category in CATEGORY_ORDER and mod.category != "undefined":
return mod.category
name = mod.name or ""
if name and _PATCH_NAME_RE.search(name):
return "patch"
if _name_has(name, _LIB_NAME_HINTS) or (name and _LIB_NAME_RE.search(name)):
return "tweaks"
if mod.maps:
return "map"
if _name_has(name, _MUSIC_NAME_HINTS):
return "music"
if _name_has(name, _MOVEMENT_HINTS):
return "movement"
if _name_has(name, _MOODLE_NAME_HINTS):
return "moodle"
if _name_has(name, _DEBUG_NAME_HINTS):
return "debug"
if _name_has(name, _TILE_NAME_HINTS):
return "tile"
ws_tags = set(mod.workshop_tags or [])
has_audio = "Audio" in ws_tags
if "Weapons" in ws_tags:
return "weapon"
if "Vehicles" in ws_tags:
if name and "spawn zone" in name.lower():
return "vehicle_spawn"
return "vehicle"
if "Clothing/Armor" in ws_tags:
if _name_has(name, _ARMOR_NAME_HINTS):
return "armor"
return "wearable"
if "Food" in ws_tags:
return "food"
if "building" in {t.lower() for t in ws_tags}:
return "building"
if "Farming" in ws_tags:
return "farming"
if "Skills" in ws_tags:
if _name_has(name, _PROFESSION_HINTS):
return "profession"
return "code"
if "Interface" in ws_tags:
return "ui"
if "Textures" in ws_tags:
return "texture"
if "Language/Translation" in ws_tags:
return "translation"
if "QOL" in ws_tags:
return "qol"
if "Map" in ws_tags:
return "map"
if _name_has(name, _TILE_NAME_HINTS):
return "tile"
if _name_has(name, _ZOMBIE_NAME_HINTS):
return "zombie"
if _name_has(name, _HEALTH_NAME_HINTS):
return "health"
if _name_has(name, _CRAFTING_HINTS):
return "crafting"
if _name_has(name, _CONTAINER_HINTS):
return "container"
if _name_has(name, _LOOT_NAME_HINTS):
return "loot"
if _name_has(name, _FIX_NAME_HINTS):
return "fix"
if _name_has(name, _ZONE_NAME_HINTS):
return "zone"
if has_audio:
return "sound"
tags_lc = [t.lower() for t in mod.tags]
if any("translation" in t for t in tags_lc):
return "translation"
if any("vehicle" in t for t in tags_lc):
return "vehicle"
if any("interface" in t or "ui" in t for t in tags_lc):
return "ui"
if any("clothing" in t or "skin" in t for t in tags_lc):
return "wearable"
if any("armor" in t for t in tags_lc):
return "armor"
if any("map" in t for t in tags_lc):
return "map"
if _str_contains_any(name, FRAMEWORK_KEYS):
return "tweaks"
if "Multiplayer" in ws_tags:
return "multiplayer"
return "other"
# -----------------------------------------------------------------------------
# Sort
# -----------------------------------------------------------------------------
def _apply_overrides(mods: List[ModInfo], rules: Dict[str, SortingRule]) -> None:
"""In-place: merge rules into mods, then propagate loadBefore -> reverse loadAfter."""
by_id = {m.id: m for m in mods}
for mod in mods:
rule = rules.get(mod.id)
if rule:
mod.loadAfter = list(dict.fromkeys(mod.loadAfter + rule.loadAfter))
mod.loadBefore = list(dict.fromkeys(mod.loadBefore + rule.loadBefore))
mod.incompatibleMods = list(dict.fromkeys(mod.incompatibleMods + rule.incompatibleMods))
mod.loadFirst = _convert_load_category(rule.loadFirst if rule.loadFirst != "off" else mod.loadFirst)
mod.loadLast = _convert_load_category(rule.loadLast if rule.loadLast != "off" else mod.loadLast)
if rule.category:
mod.category = rule.category
# Derive category if still undefined
if mod.category not in CATEGORY_ORDER or mod.category == "undefined":
mod.category = derive_category(mod)
# Translate loadBefore into reverse loadAfter on the target mod
# (mirrors updateSortingRulesLoadAfter)
for mod in mods:
for target in mod.loadBefore:
target_mod = by_id.get(target)
if target_mod and mod.id not in target_mod.loadAfter:
target_mod.loadAfter.append(mod.id)
def _initial_sort_key(mod: ModInfo):
"""Mirrors initialSortMods comparator. Returns sortable tuple.
Spec G-patch: index 0 is `is_patch` so patches sort strictly last - they
have to override loadLast=on map mods at runtime. Within the patch tier
the existing sub-axes still apply (PREORDER, alpha, etc.).
"""
is_patch = 1 if mod.category == "patch" else 0
pre = PREORDER.get(mod.id, 10000)
return (
is_patch,
pre,
LOAD_CATEGORIES[mod.loadFirst], # global loadFirst (on first)
-LOAD_CATEGORIES[mod.loadLast] + 100, # global loadLast (on last) -- keep parity by
# sorting "on" last; we invert so smaller=earlier
CATEGORY_ORDER.get(mod.category, CATEGORY_ORDER["undefined"]),
LOAD_CATEGORIES[mod.loadFirst], # in-category loadFirst
-LOAD_CATEGORIES[mod.loadLast] + 100, # in-category loadLast
mod.id.lower(),
)
def _topological_sort(mods: List[ModInfo]) -> Tuple[List[str], List[List[str]]]:
"""DFS topo sort on (requirements + loadAfter). Returns (order, cycles)."""
by_id = {m.id: m for m in mods}
visited: Dict[str, bool] = {}
visiting: Dict[str, bool] = {}
order: List[str] = []
cycles: List[List[str]] = []
def visit(mod: ModInfo, path: List[str]):
if visiting.get(mod.id):
cycles.append(path + [mod.id])
return
if visited.get(mod.id):
return
visiting[mod.id] = True
for dep in mod.requirements:
target = by_id.get(dep)
if target:
visit(target, path + [mod.id])
for dep in mod.loadAfter:
target = by_id.get(dep)
if target:
visit(target, path + [mod.id])
visiting[mod.id] = False
visited[mod.id] = True
order.append(mod.id)
for mod in mods:
visit(mod, [])
return order, cycles
def sort_mods(
mods: List[ModInfo],
rules: Optional[Dict[str, SortingRule]] = None,
) -> Dict[str, object]:
"""
Top-level entry: returns dict with ordered IDs + warnings.
"""
rules = rules or {}
_apply_overrides(mods, rules)
# Initial deterministic sort (preorder, loadFirst, category, loadLast, alpha)
mods.sort(key=_initial_sort_key)
order, cycles = _topological_sort(mods)
by_id = {m.id: m for m in mods}
enabled = set(by_id.keys())
missing: Dict[str, List[str]] = {}
incompat: Dict[str, List[str]] = {}
for mod in mods:
miss = [r for r in mod.requirements if r not in enabled]
if miss:
missing[mod.id] = miss
inc = [r for r in mod.incompatibleMods if r in enabled]
if inc:
incompat[mod.id] = inc
# Output blocks for the server's .ini file
mods_line = order # already mod IDs in load order
workshop_seen: List[str] = []
workshop_set = set()
for mod_id in order:
wid = by_id[mod_id].workshop_id
if wid and wid not in workshop_set:
workshop_seen.append(wid)
workshop_set.add(wid)
# MAP_LINE convention: dependencies first (leftmost), dependents next.
# Vanilla Muldraugh, KY is ALWAYS appended at the very end by
# adapters.build_response. `order` is already topo-sorted by mod-level
# deps (require= / loadAfter= / loadBefore=), so dependencies appear
# before their dependents — walk it forward.
map_folders: List[str] = []
for mod_id in order:
for mf in by_id[mod_id].maps:
if mf not in map_folders:
map_folders.append(mf)
return {
"Mods": mods_line,
"WorkshopItems": workshop_seen,
"Map": map_folders,
"warnings": {
"cycles": cycles,
"missing_requirements": missing,
"incompatible_enabled": incompat,
},
}
# -----------------------------------------------------------------------------
# CLI
# -----------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description="Sort PZ mods by load order. Reads DepotDownloader output layout.",
)
ap.add_argument("mods_root", help="Path containing <workshop_id>/mods/<mod_id>/mod.info trees")
ap.add_argument("--rules", help="Optional sorting_rules.txt path")
ap.add_argument("--json", action="store_true", help="Output JSON instead of ini blocks")
args = ap.parse_args()
root = Path(args.mods_root).resolve()
mods = load_mods_from_dir(root)
if not mods:
print("ERROR: no mods found", file=sys.stderr)
sys.exit(2)
rules: Dict[str, SortingRule] = {}
if args.rules:
rules = parse_sorting_rules(Path(args.rules).read_text(encoding="utf-8"))
result = sort_mods(mods, rules)
if args.json:
print(json.dumps(result, indent=2))
return
print("WorkshopItems=" + ";".join(result["WorkshopItems"]))
print("Mods=" + ";".join(result["Mods"]))
if result["Map"]:
print("Map=" + ";".join(result["Map"]))
w = result["warnings"]
if w["cycles"] or w["missing_requirements"] or w["incompatible_enabled"]:
print("\n# Warnings")
if w["cycles"]:
print("# cycles:", w["cycles"])
if w["missing_requirements"]:
print("# missing_requirements:", w["missing_requirements"])
if w["incompatible_enabled"]:
print("# incompatible_enabled:", w["incompatible_enabled"])
if __name__ == "__main__":
main()

586
worker/worker.py Normal file
View File

@@ -0,0 +1,586 @@
"""
worker.py - pzsort cache filler
Single-shot CLI that takes Steam Workshop IDs on argv, refreshes metadata
from Steam's anonymous API, and only runs DepotDownloader for cache misses
(where workshop_meta.time_updated has changed since last parse).
Usage:
python3 worker.py <workshop_id> [<workshop_id> ...]
python3 worker.py --force <workshop_id> ... # ignore cache, re-download
Env (or .env file):
DATABASE_URL postgresql://pzsort:<pw>@127.0.0.1:5439/pzsort
DD_PATH path to DepotDownloader executable
PZ_APP_ID 108600 (default)
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Dict, List, Optional
import asyncpg
import httpx
# Reuse the parser from the sorter
sys.path.insert(0, str(Path(__file__).parent))
from mlos_sort import parse_mod_info, ModInfo # noqa: E402
PZ_APP_ID = int(os.environ.get("PZ_APP_ID", "108600"))
DEFAULT_DD_PATH = os.environ.get("DD_PATH", "./DepotDownloader")
STEAM_API = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
# -----------------------------------------------------------------------------
# Steam API
# -----------------------------------------------------------------------------
def fetch_workshop_details(workshop_ids: List[str]) -> Dict[str, dict]:
"""
POST to legacy GetPublishedFileDetails. Anonymous, no API key needed.
Returns {workshop_id: detail_dict}.
"""
if not workshop_ids:
return {}
data: Dict[str, str] = {"itemcount": str(len(workshop_ids))}
for i, wid in enumerate(workshop_ids):
data[f"publishedfileids[{i}]"] = wid
with httpx.Client(timeout=30.0) as client:
r = client.post(STEAM_API, data=data)
r.raise_for_status()
body = r.json()
out: Dict[str, dict] = {}
for item in body.get("response", {}).get("publishedfiledetails", []):
out[item["publishedfileid"]] = item
return out
def flatten_tags(detail: dict) -> List[str]:
return [t.get("tag", "") for t in detail.get("tags", []) if t.get("tag")]
# Public Steam Workshop page URL. The anonymous GetPublishedFileDetails API
# does NOT return `children` for individual mods (only collections), so to
# learn a mod's "Required Items" we have to scrape the public HTML page.
_WORKSHOP_PAGE_URL = "https://steamcommunity.com/sharedfiles/filedetails/?id={wsid}"
_RE_REQUIRED_BLOCK = re.compile(
r'<div[^>]*id="RequiredItems"[^>]*>(.*?)</div>\s*</div>',
re.DOTALL,
)
_RE_REQUIRED_LINK = re.compile(r'filedetails/\?id=(\d+)')
# ── rate-limit safety for Steam HTML scraping ─────────────────────────────
# Steam aggressively 429s anonymous /sharedfiles/filedetails/ HTML requests;
# during a 2026-05-03 backfill at ~1 RPS our IP was blocked for hours and a
# subsequent single curl probe still got 429. Two file-locked, multi-process
# safeguards now sit in front of every scrape:
#
# 1. THROTTLE FILE — records the timestamp of the last attempted scrape.
# Every worker waits via flock until at least
# `_MIN_SCRAPE_INTERVAL_S` seconds have elapsed since the last one.
# Serializes 4 concurrent drain processes so they can't burst.
#
# 2. COOLDOWN FILE — when we observe a hard 429 (after retries), we write
# `now() + _COOLDOWN_S` here. While active, every fetch returns None
# instantly without touching Steam, preserving cached values until the
# IP block ages out.
#
# Defaults: 6s spacing → ≤10 RPM steady-state, 1h cooldown after a 429
# storm. Overridable via SORTOF_STEAM_MIN_INTERVAL / SORTOF_STEAM_COOLDOWN.
import fcntl as _fcntl
_THROTTLE_FILE = "/tmp/sortof_steam_throttle"
_COOLDOWN_FILE = "/tmp/sortof_steam_cooldown"
_MIN_SCRAPE_INTERVAL_S = float(os.environ.get("SORTOF_STEAM_MIN_INTERVAL", "6"))
_COOLDOWN_S = float(os.environ.get("SORTOF_STEAM_COOLDOWN", "3600"))
def _read_cooldown_until() -> float:
try:
with open(_COOLDOWN_FILE, "r") as f:
return float(f.read().strip() or 0)
except (OSError, ValueError):
return 0.0
def _write_cooldown_until(epoch_s: float) -> None:
try:
with open(_COOLDOWN_FILE, "w") as f:
f.write(str(epoch_s))
except OSError:
pass
def _throttle_scrape() -> None:
"""Block until at least `_MIN_SCRAPE_INTERVAL_S` has elapsed since the
last scrape by ANY drain process (multi-process safe via flock)."""
import time as _t
Path(_THROTTLE_FILE).touch(exist_ok=True)
with open(_THROTTLE_FILE, "r+") as f:
_fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
try:
f.seek(0)
raw = f.read().strip()
last = float(raw) if raw else 0.0
now = _t.time()
wait = _MIN_SCRAPE_INTERVAL_S - (now - last)
if wait > 0:
_t.sleep(wait)
now = _t.time()
f.seek(0); f.truncate(); f.write(str(now))
finally:
_fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
def fetch_required_wsids(
workshop_id: str,
timeout: int = 15,
max_attempts: int = 4,
backoff_429: float = 30.0,
) -> Optional[List[str]]:
"""Scrape the public Workshop page for Required Items wsids.
Returns
None — fetch/parse error, persistent 429, or active cooldown.
Caller MUST NOT overwrite the existing cached value.
[] — page loaded successfully but has no required items section.
list — required item wsids in declaration order, deduped.
"""
import time as _time
cooldown_until = _read_cooldown_until()
if cooldown_until and _time.time() < cooldown_until:
return None # Steam recently 429'd us — back off entirely.
_throttle_scrape()
url = _WORKSHOP_PAGE_URL.format(wsid=workshop_id)
html: Optional[str] = None
for attempt in range(1, max_attempts + 1):
try:
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
r = client.get(url)
if r.status_code == 429:
if attempt < max_attempts:
_time.sleep(backoff_429 * attempt)
continue
# Final 429 → arm the global cooldown so other workers
# (and this one's next call) skip Steam entirely.
_write_cooldown_until(_time.time() + _COOLDOWN_S)
print(f" ! required_wsids 429 (gave up) for {workshop_id}; "
f"cooldown {int(_COOLDOWN_S)}s armed", file=sys.stderr)
return None
r.raise_for_status()
html = r.text
break
except (httpx.HTTPError, httpx.TimeoutException) as e:
print(f" ! required_wsids fetch failed for {workshop_id}: {e}",
file=sys.stderr)
return None
if html is None:
return None
m = _RE_REQUIRED_BLOCK.search(html)
if not m:
return []
seen: set = set()
out: List[str] = []
for w in _RE_REQUIRED_LINK.findall(m.group(1)):
if w not in seen and w != workshop_id:
seen.add(w)
out.append(w)
return out
# -----------------------------------------------------------------------------
# DepotDownloader
# -----------------------------------------------------------------------------
def run_depot_downloader(
workshop_id: str,
output_dir: Path,
dd_path: Path,
filelist_regex: str = r"regex:.*\.info$",
timeout: int = 300,
max_attempts: int = 3,
backoff_s: float = 2.0,
) -> bool:
"""
Fetch workshop item using DepotDownloader, filtered to .info files only.
Writes <output_dir>/mods/<mod_id>/mod.info (and possibly map.info paths).
Returns True on success.
Retries up to max_attempts times on rc!=0 or timeout - Steam Workshop's
CDN occasionally flakes on the manifest fetch and a fresh DD invocation
typically succeeds. Caller is also free to retry at a higher level
(drain.py's MAX_ATTEMPTS), but in-process retry avoids the full re-claim
cycle for the common transient case.
"""
import time as _time
output_dir.mkdir(parents=True, exist_ok=True)
filelist = output_dir / "_filelist.txt"
filelist.write_text(filelist_regex + "\n", encoding="utf-8")
cmd = [
str(dd_path),
"-app", str(PZ_APP_ID),
"-pubfile", workshop_id,
"-filelist", str(filelist),
"-dir", str(output_dir),
]
last_err = ""
for attempt in range(1, max_attempts + 1):
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
last_err = "timeout"
print(f" ! DepotDownloader timeout for {workshop_id} (attempt {attempt}/{max_attempts})",
file=sys.stderr)
else:
if proc.returncode == 0:
if attempt > 1:
print(f" ✓ DepotDownloader recovered for {workshop_id} on attempt {attempt}",
file=sys.stderr)
return True
last_err = f"rc={proc.returncode}"
print(f" ! DepotDownloader rc={proc.returncode} for {workshop_id} "
f"(attempt {attempt}/{max_attempts})", file=sys.stderr)
print(proc.stderr[-500:] if proc.stderr else proc.stdout[-500:], file=sys.stderr)
if attempt < max_attempts:
_time.sleep(backoff_s)
print(f" !! DepotDownloader gave up on {workshop_id} after {max_attempts} attempts (last: {last_err})",
file=sys.stderr)
return False
def discover_mod_infos(output_dir: Path) -> List[Path]:
"""Find all mod.info files. Two layouts coexist in the wild:
B41: mods/<mod_id>/mod.info
B42: mods/<mod_id>/<gameVersion>/mod.info e.g. mods/Foo/42/mod.info
A single mod can ship both. UPSERT on (workshop_id, mod_id) collapses
duplicates; lexicographic sort means the B41 (root-level) variant wins
last when present, the highest-numbered B42 variant otherwise."""
out = list(output_dir.glob("mods/*/mod.info"))
out.extend(output_dir.glob("mods/*/*/mod.info"))
return sorted(out)
def discover_map_folders(mip_parent: Path) -> List[str]:
"""Find map folders for the mod whose mod.info lives in `mip_parent`.
Three layouts coexist:
B41: mods/<modId>/mod.info
mods/<modId>/media/maps/<x>/map.info
B42: mods/<modId>/<branch>/mod.info (branch is e.g., '42','42.13')
mods/<modId>/<branch>/media/maps/<x>/map.info
B42 split: mod.info under '42/' but map data under a sibling 'common/'
branch — observed in Project RV Interior. This is why we
walk back to the mod-id root and enumerate every branch.
"""
if mip_parent.parent.name == "mods":
modid_root = mip_parent
else:
modid_root = mip_parent.parent
seen: set = set()
out: List[str] = []
candidates = list(modid_root.glob("media/maps/*/map.info"))
candidates.extend(modid_root.glob("*/media/maps/*/map.info"))
for cand in sorted(candidates):
folder = cand.parent.name
if folder in seen:
continue
seen.add(folder)
out.append(folder)
return out
# -----------------------------------------------------------------------------
# DB upserts
# -----------------------------------------------------------------------------
UPSERT_WORKSHOP_META = """
INSERT INTO workshop_meta (
workshop_id, title, description, tags, creator_steamid,
time_created, time_updated, file_size, preview_url,
consumer_app_id, visibility, banned, last_checked_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, now())
ON CONFLICT (workshop_id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
tags = EXCLUDED.tags,
creator_steamid = EXCLUDED.creator_steamid,
time_created = EXCLUDED.time_created,
time_updated = EXCLUDED.time_updated,
file_size = EXCLUDED.file_size,
preview_url = EXCLUDED.preview_url,
consumer_app_id = EXCLUDED.consumer_app_id,
visibility = EXCLUDED.visibility,
banned = EXCLUDED.banned,
last_checked_at = now();
"""
EVICT_AND_RECORD_CONFLICT = """
-- Per the cache invariant: a mod_id is owned by exactly one wsid at a time.
-- When we're about to UPSERT (wsid, mod_id), evict any (other_wsid, mod_id)
-- claims so the new pull becomes canonical, and record the eviction in
-- mod_id_conflicts so /api/sort can warn users who paste the displaced wsid.
WITH evicted AS (
DELETE FROM mod_parsed
WHERE mod_id = $2 AND workshop_id <> $1
RETURNING workshop_id
)
INSERT INTO mod_id_conflicts (mod_id, evicting_wsid, evicted_wsid)
SELECT $2, $1, workshop_id FROM evicted
ON CONFLICT (mod_id, evicting_wsid, evicted_wsid)
DO UPDATE SET recorded_at = now();
"""
UPSERT_MOD_PARSED = """
INSERT INTO mod_parsed (
workshop_id, mod_id, name, category,
requirements, load_after, load_before, incompatible_mods,
load_first, load_last, tags, maps,
raw_mod_info, version_min, is_addon,
parsed_at_time_updated, parsed_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16, now())
ON CONFLICT (workshop_id, mod_id) DO UPDATE SET
name = EXCLUDED.name,
category = EXCLUDED.category,
requirements = EXCLUDED.requirements,
load_after = EXCLUDED.load_after,
load_before = EXCLUDED.load_before,
incompatible_mods = EXCLUDED.incompatible_mods,
load_first = EXCLUDED.load_first,
load_last = EXCLUDED.load_last,
tags = EXCLUDED.tags,
maps = EXCLUDED.maps,
raw_mod_info = EXCLUDED.raw_mod_info,
version_min = EXCLUDED.version_min,
is_addon = EXCLUDED.is_addon,
parsed_at_time_updated = EXCLUDED.parsed_at_time_updated,
parsed_at = now();
"""
# Description-text heuristic for "this mod is an optional add-on to the
# primary mod published by the same wsid". Matches:
# "Optional add-on: removes ..." (TMMumble)
# "optional addon ..."
# "Optional add on ..."
# Strict "optional + add-on" keyword pair to avoid false positives on
# generic "addon" naming. Author-driven signal — set via the description=
# field of mod.info.
_RE_OPTIONAL_ADDON = re.compile(
r"description\s*=\s*[^\r\n]*\bOptional\s+Add[- ]?on\b",
re.IGNORECASE,
)
def detect_is_addon(raw: str) -> bool:
"""Return True if the mod.info description self-identifies as an
optional add-on (`Optional add-on: …`)."""
return bool(_RE_OPTIONAL_ADDON.search(raw or ""))
DELETE_STALE_MOD_PARSED = """
DELETE FROM mod_parsed
WHERE workshop_id = $1 AND mod_id <> ALL($2::text[]);
"""
CHECK_PARSED_FRESH = """
SELECT mod_id FROM mod_parsed
WHERE workshop_id = $1 AND parsed_at_time_updated = $2;
"""
def extract_version_min(raw: str) -> Optional[str]:
for line in raw.splitlines():
s = line.strip().lower()
if s.startswith("versionmin"):
_, _, v = line.partition("=")
return v.strip() or None
return None
# -----------------------------------------------------------------------------
# Main flow
# -----------------------------------------------------------------------------
async def process_one(
conn: asyncpg.Connection,
workshop_id: str,
detail: dict,
dd_path: Path,
force: bool,
) -> str:
"""Returns 'hit' | 'refreshed' | 'banned' | 'missing' | 'no_mod_info' | 'failed'.
'no_mod_info' = DepotDownloader succeeded but the workshop item contained
no parseable mod.info file (typical for collections, art-only items, and
other non-mod uploads that share the PZ consumer_app_id). Distinct from
'failed' (DD itself errored), so the API can surface "this isn't a mod"
differently from "we couldn't fetch this."
"""
# Pre-flight: bad results
if detail.get("result") != 1:
return "missing"
if detail.get("banned"):
return "banned"
if detail.get("consumer_app_id") != PZ_APP_ID:
return "failed" # wrong app
time_updated = int(detail.get("time_updated", 0))
# Always refresh meta (cheap)
await conn.execute(
UPSERT_WORKSHOP_META,
workshop_id,
detail.get("title", ""),
detail.get("description", "") or "",
flatten_tags(detail),
str(detail.get("creator", "")) or None,
int(detail.get("time_created", 0)) or None,
time_updated,
int(detail.get("file_size", 0)) or None,
detail.get("preview_url"),
detail.get("consumer_app_id"),
detail.get("visibility"),
bool(detail.get("banned", False)),
)
# Cache check
if not force:
rows = await conn.fetch(CHECK_PARSED_FRESH, workshop_id, time_updated)
if rows:
return "hit"
# Cache miss → download + parse
with tempfile.TemporaryDirectory(prefix=f"pzsort_{workshop_id}_") as tmpdir:
tmp = Path(tmpdir)
ok = run_depot_downloader(workshop_id, tmp, dd_path)
if not ok:
return "failed"
mod_info_paths = discover_mod_infos(tmp)
if not mod_info_paths:
print(f" ! no mod.info found in {workshop_id}", file=sys.stderr)
return "no_mod_info"
seen_mod_ids: List[str] = []
for mip in mod_info_paths:
raw = mip.read_text(encoding="utf-8", errors="replace")
mod = parse_mod_info(raw, workshop_id=workshop_id)
if mod is None:
continue
maps = discover_map_folders(mip.parent)
# Evict any other wsid's claim on this mod_id before we install
# ours. Cache invariant: at most one wsid per mod_id, with the
# most-recent pull winning.
await conn.execute(EVICT_AND_RECORD_CONFLICT, workshop_id, mod.id)
await conn.execute(
UPSERT_MOD_PARSED,
workshop_id,
mod.id,
mod.name,
mod.category,
mod.requirements,
mod.loadAfter,
mod.loadBefore,
mod.incompatibleMods,
mod.loadFirst,
mod.loadLast,
mod.tags,
maps,
raw,
extract_version_min(raw),
detect_is_addon(raw),
time_updated,
)
seen_mod_ids.append(mod.id)
# Drop rows for mods that no longer exist in this workshop item
if seen_mod_ids:
await conn.execute(DELETE_STALE_MOD_PARSED, workshop_id, seen_mod_ids)
# Scrape the public Workshop page for the "Required Items" section so the
# API can auto-resolve missing-dep warnings against this mod's declared
# Steam-side dependencies. Best-effort: None on fetch error → leave the
# existing cached value; [] or list → overwrite.
required = await asyncio.to_thread(fetch_required_wsids, workshop_id)
if required is not None:
await conn.execute(
"""
UPDATE workshop_meta
SET required_wsids = $1, required_scraped_at = now()
WHERE workshop_id = $2
""",
required, workshop_id,
)
return "refreshed"
async def main_async(workshop_ids: List[str], dd_path: Path, force: bool, dsn: str) -> int:
print(f"[steam] fetching metadata for {len(workshop_ids)} item(s)")
details = fetch_workshop_details(workshop_ids)
missing_from_steam = [w for w in workshop_ids if w not in details]
if missing_from_steam:
print(f"[steam] no detail returned for: {missing_from_steam}", file=sys.stderr)
summary: Dict[str, int] = {"hit": 0, "refreshed": 0, "banned": 0, "missing": 0, "failed": 0}
conn = await asyncpg.connect(dsn=dsn)
try:
for wid in workshop_ids:
detail = details.get(wid)
if detail is None:
summary["missing"] += 1
print(f" - {wid} -> missing (no Steam response)")
continue
status = await process_one(conn, wid, detail, dd_path, force)
summary[status] += 1
print(f" - {wid} -> {status}")
finally:
await conn.close()
print(f"[done] {summary}")
return 0 if summary["failed"] == 0 else 1
def main():
ap = argparse.ArgumentParser()
ap.add_argument("workshop_ids", nargs="+")
ap.add_argument("--force", action="store_true", help="ignore cache, always re-download")
ap.add_argument("--dd-path", default=DEFAULT_DD_PATH)
ap.add_argument("--dsn", default=os.environ.get("DATABASE_URL"))
args = ap.parse_args()
if not args.dsn:
print("ERROR: --dsn or DATABASE_URL required", file=sys.stderr)
sys.exit(2)
dd = Path(args.dd_path)
if not dd.is_file():
print(f"ERROR: DepotDownloader not found at {dd}", file=sys.stderr)
sys.exit(2)
rc = asyncio.run(main_async(args.workshop_ids, dd, args.force, args.dsn))
sys.exit(rc)
if __name__ == "__main__":
main()