refactor: data/modpack_rules → data/rules; auto-apply on resort path

The directory was misnamed — these are wsid-triggered sorting-rule
overlays, not modpack-specific. HellDrinx happens to be a modpack but
RV Interior Expansion isn't; both use the same generic mechanism.

Renames:
  data/modpack_rules/        → data/rules/
  _MODPACK_RULES_*           → _RULES_*
  _modpack_rules_for         → _auto_rules_for
  _parse_rules_with_modpacks → _parse_rules_combined
  _emit_modpack_rules_warnings → _emit_rules_applied_warnings
  warning tag "modpack-rules-applied" → "rules-applied"

Bug fix: /api/resort was passing {} rules to sort_mods, silently
dropping every auto-injected rule. The frontend runs an automatic
resort after each /api/sort (driven by localStorage branchSelections),
so the user always saw the resort output — where rv_expansion.txt's
loadAfter chain wasn't taking effect. Now resort goes through
_parse_rules_combined and emits rules-applied warnings the same way
/api/sort does.
This commit is contained in:
2026-05-04 16:31:50 +00:00
parent 3336b2f661
commit ae408ea437
3 changed files with 54 additions and 39 deletions

View File

@@ -333,13 +333,19 @@ MANUAL_BUILD_PAIRS: Dict[str, str] = {
} }
# Modpack-bundled sorting_rules.txt files: when a trigger wsid is in the # Auto-injected sorting rules: when a trigger wsid is in the user's input,
# user's input, we prepend the matching file's contents to the user's rules # we prepend the matching file's contents to the user's rules text before
# text before parse_sorting_rules runs. User-supplied rules come last so they # parse_sorting_rules runs. User-supplied rules come last so they override
# override modpack defaults on conflicting keys. # auto-injected defaults on conflicting keys.
_MODPACK_RULES_DIR = Path(__file__).resolve().parent.parent / "data" / "modpack_rules" #
_MODPACK_RULES_TRIGGERS: Dict[str, str] = { # Use cases vary — modpacks (HellDrinx) shipping their own load order,
# HellDrinx FULL + LITE both bundle the same sorting_rules.txt. # individual mods (RV Interior Expansion) whose authors didn't declare
# loadAfter in mod.info, or any other wsid whose presence implies a
# specific sort constraint. The mechanism is generic; each rules file
# describes its own scope in its header.
_RULES_DIR = Path(__file__).resolve().parent.parent / "data" / "rules"
_RULES_TRIGGERS: Dict[str, str] = {
# HellDrinx FULL + LITE both bundle the same sorting rules.
"3672556207": "helldrinx.txt", # HellDrinx FULL "3672556207": "helldrinx.txt", # HellDrinx FULL
"3662909244": "helldrinx.txt", # HellDrinx LITE "3662909244": "helldrinx.txt", # HellDrinx LITE
# RVInteriorExpansion + Part2 chain after PROJECTRVInterior42. # RVInteriorExpansion + Part2 chain after PROJECTRVInterior42.
@@ -349,7 +355,7 @@ _MODPACK_RULES_TRIGGERS: Dict[str, str] = {
"3622163276": "rv_expansion.txt", # RVInteriorExpansionPart2 (rv2) "3622163276": "rv_expansion.txt", # RVInteriorExpansionPart2 (rv2)
} }
# Human-readable trigger labels for the warning message. # Human-readable trigger labels for the warning message.
_MODPACK_RULES_LABELS: Dict[str, str] = { _RULES_LABELS: Dict[str, str] = {
"3672556207": "HellDrinx FULL", "3672556207": "HellDrinx FULL",
"3662909244": "HellDrinx LITE", "3662909244": "HellDrinx LITE",
"3618427553": "RV Interior Expansion", "3618427553": "RV Interior Expansion",
@@ -357,7 +363,7 @@ _MODPACK_RULES_LABELS: Dict[str, str] = {
} }
def _modpack_rules_for(input_wsids: List[str]) -> Tuple[str, List[Tuple[str, str]]]: def _auto_rules_for(input_wsids: List[str]) -> Tuple[str, List[Tuple[str, str]]]:
"""Return (rules_text, triggers) where triggers is a list of (wsid, label) """Return (rules_text, triggers) where triggers is a list of (wsid, label)
tuples that fired and rules_text is the concatenated content of every tuples that fired and rules_text is the concatenated content of every
distinct file referenced. Each rules file is included at most once even distinct file referenced. Each rules file is included at most once even
@@ -366,67 +372,67 @@ def _modpack_rules_for(input_wsids: List[str]) -> Tuple[str, List[Tuple[str, str
parts: List[str] = [] parts: List[str] = []
triggers: List[Tuple[str, str]] = [] triggers: List[Tuple[str, str]] = []
for wsid in input_wsids: for wsid in input_wsids:
fname = _MODPACK_RULES_TRIGGERS.get(wsid) fname = _RULES_TRIGGERS.get(wsid)
if not fname: if not fname:
continue continue
triggers.append((wsid, _MODPACK_RULES_LABELS.get(wsid, wsid))) triggers.append((wsid, _RULES_LABELS.get(wsid, wsid)))
if fname in files_seen: if fname in files_seen:
continue continue
files_seen.add(fname) files_seen.add(fname)
try: try:
parts.append((_MODPACK_RULES_DIR / fname).read_text(encoding="utf-8")) parts.append((_RULES_DIR / fname).read_text(encoding="utf-8"))
except OSError as e: except OSError as e:
log.warning("modpack rules load failed for %s (%s): %s", wsid, fname, e) log.warning("auto rules load failed for %s (%s): %s", wsid, fname, e)
return ("\n\n".join(parts), triggers) return ("\n\n".join(parts), triggers)
def _parse_rules_with_modpacks( def _parse_rules_combined(
rules_raw: Optional[str], rules_raw: Optional[str],
input_wsids: List[str], input_wsids: List[str],
) -> Tuple[Dict[str, Any], List[Tuple[str, str]]]: ) -> Tuple[Dict[str, Any], List[Tuple[str, str]]]:
"""Parse user-supplied rules with any auto-detected modpack rules """Parse user-supplied rules with any auto-injected rules prepended.
prepended. Returns (parsed_rules, triggers). Caller emits a warning per Returns (parsed_rules, triggers). Caller emits a warning per trigger
trigger so the user knows the auto-injection happened.""" so the user knows the auto-injection happened."""
modpack_text, triggers = _modpack_rules_for(input_wsids) auto_text, triggers = _auto_rules_for(input_wsids)
combined = ( combined = (
(modpack_text + "\n\n" + (rules_raw or "")).strip() (auto_text + "\n\n" + (rules_raw or "")).strip()
if modpack_text else (rules_raw or "") if auto_text else (rules_raw or "")
) )
if not combined: if not combined:
return ({}, triggers) return ({}, triggers)
try: try:
return (parse_sorting_rules(combined), triggers) return (parse_sorting_rules(combined), triggers)
except Exception: except Exception:
log.warning("failed to parse modpack+user rules; ignoring") log.warning("failed to parse auto+user rules; ignoring")
return ({}, triggers) return ({}, triggers)
def _emit_modpack_rules_warnings( def _emit_rules_applied_warnings(
payload: Dict[str, Any], payload: Dict[str, Any],
triggers: List[Tuple[str, str]], triggers: List[Tuple[str, str]],
) -> None: ) -> None:
"""Append a `modpack-rules-applied` warning per modpack trigger so users """Append a `rules-applied` warning per auto-rules trigger so users know
know the modpack's bundled sorting_rules.txt was auto-injected. Skips sorting rules were auto-injected for that wsid. Skips triggers already
triggers already flagged (idempotent across resort cycles).""" flagged (idempotent across resort cycles)."""
if not triggers: if not triggers:
return return
existing = payload.get("WARNINGS") or [] existing = payload.get("WARNINGS") or []
already_flagged = { already_flagged = {
w.get("wsid") for w in existing w.get("wsid") for w in existing
if w.get("tag") == "modpack-rules-applied" and w.get("wsid") if w.get("tag") == "rules-applied" and w.get("wsid")
} }
new_warnings: List[Dict[str, Any]] = [] new_warnings: List[Dict[str, Any]] = []
for wsid, label in triggers: for wsid, label in triggers:
if wsid in already_flagged: if wsid in already_flagged:
continue continue
new_warnings.append({ new_warnings.append({
"tag": "modpack-rules-applied", "tag": "rules-applied",
"level": "amber", "level": "amber",
"wsid": wsid, "wsid": wsid,
"msg": ( "msg": (
f"{label} ({wsid}) detected — auto-applied its bundled " f"{label} ({wsid}) detected — auto-applied sorting rules. "
f"sorting_rules.txt. Your manually-entered rules (if any) " f"Your manually-entered rules (if any) override these on "
f"override modpack rules." f"conflicting keys."
), ),
}) })
if new_warnings: if new_warnings:
@@ -855,7 +861,7 @@ async def _build_result_for_job(
wsids, wsids,
) )
mods = [_row_to_modinfo(r) for r in rows] mods = [_row_to_modinfo(r) for r in rows]
rules, modpack_triggers = _parse_rules_with_modpacks(rules_raw, wsids) rules, rules_triggers = _parse_rules_combined(rules_raw, wsids)
_inject_addon_loadafter(mods) _inject_addon_loadafter(mods)
sort_result = sort_mods(mods, rules) sort_result = sort_mods(mods, rules)
cached_ids = {r["workshop_id"] for r in rows} cached_ids = {r["workshop_id"] for r in rows}
@@ -878,7 +884,7 @@ async def _build_result_for_job(
await _augment_with_required_items(conn, payload, wsids) await _augment_with_required_items(conn, payload, wsids)
if pz_build: if pz_build:
await _emit_build_mismatch_warnings(conn, payload, wsids, pz_build) await _emit_build_mismatch_warnings(conn, payload, wsids, pz_build)
_emit_modpack_rules_warnings(payload, modpack_triggers) _emit_rules_applied_warnings(payload, rules_triggers)
_reorder_warnings(payload) _reorder_warnings(payload)
# Spec A §8 ownership: WORKSHOP_ITEMS_LINE preserves the SET of input # Spec A §8 ownership: WORKSHOP_ITEMS_LINE preserves the SET of input
# wsids regardless of which are cached/non-mod/unknown. Within that set, # wsids regardless of which are cached/non-mod/unknown. Within that set,
@@ -1172,7 +1178,7 @@ async def sort_endpoint(req: SortRequest, request: Request) -> Dict[str, Any]:
mods: List[ModInfo] = [_row_to_modinfo(r) for r in rows] mods: List[ModInfo] = [_row_to_modinfo(r) for r in rows]
rules, modpack_triggers = _parse_rules_with_modpacks(req.rules, input_ids) rules, rules_triggers = _parse_rules_combined(req.rules, input_ids)
_inject_addon_loadafter(mods) _inject_addon_loadafter(mods)
sort_result = sort_mods(mods, rules) sort_result = sort_mods(mods, rules)
@@ -1206,7 +1212,7 @@ async def sort_endpoint(req: SortRequest, request: Request) -> Dict[str, Any]:
await _emit_build_mismatch_warnings( await _emit_build_mismatch_warnings(
conn, payload, input_ids, req.pz_build or "B42", conn, payload, input_ids, req.pz_build or "B42",
) )
_emit_modpack_rules_warnings(payload, modpack_triggers) _emit_rules_applied_warnings(payload, rules_triggers)
_reorder_warnings(payload) _reorder_warnings(payload)
# Surface ghost / non-mod IDs separately from real pending so the UI can # Surface ghost / non-mod IDs separately from real pending so the UI can
# distinguish "indexing 2 mods" from "2 of your IDs are deleted/typo'd" # distinguish "indexing 2 mods" from "2 of your IDs are deleted/typo'd"
@@ -1389,10 +1395,18 @@ async def resort_endpoint(req: ResortRequest, request: Request) -> Dict[str, Any
all_mods: List[ModInfo] = [_row_to_modinfo(r) for r in rows] all_mods: List[ModInfo] = [_row_to_modinfo(r) for r in rows]
selected_mods: List[ModInfo] = [m for m in all_mods if m.id in selected_set] selected_mods: List[ModInfo] = [m for m in all_mods if m.id in selected_set]
_inject_addon_loadafter(selected_mods) _inject_addon_loadafter(selected_mods)
# Resort has no user-supplied rules text, but it MUST still apply the
# auto-injected rules — otherwise the frontend's automatic post-sort
# resort (driven by localStorage branchSelections) silently drops the
# rules that /api/sort just applied, and ordering chains established
# by e.g. data/rules/rv_expansion.txt vanish on the next render.
auto_rules, rules_triggers = _parse_rules_combined(
None, req.input_wsids or [],
)
# Sort over the user's explicit selection so SORTED_ORDER reflects only # Sort over the user's explicit selection so SORTED_ORDER reflects only
# what they ticked. _apply_branch_rules below still sees the full group # what they ticked. _apply_branch_rules below still sees the full group
# via `mods=all_mods`, so it can emit picker-driving warnings. # via `mods=all_mods`, so it can emit picker-driving warnings.
sort_result = sort_mods(selected_mods, {}) sort_result = sort_mods(selected_mods, auto_rules)
# Per spec review #12: silently drop unknown mod_ids and return status="success". # Per spec review #12: silently drop unknown mod_ids and return status="success".
# The frontend reconciles its sent list against MOD_DB to detect drops. # The frontend reconciles its sent list against MOD_DB to detect drops.
async with pool.acquire() as conn: async with pool.acquire() as conn:
@@ -1442,6 +1456,7 @@ async def resort_endpoint(req: ResortRequest, request: Request) -> Dict[str, Any
) )
if conflict_warnings: if conflict_warnings:
payload["WARNINGS"] = list(payload.get("WARNINGS") or []) + conflict_warnings payload["WARNINGS"] = list(payload.get("WARNINGS") or []) + conflict_warnings
_emit_rules_applied_warnings(payload, rules_triggers)
_reorder_warnings(payload) _reorder_warnings(payload)
elapsed_ms = int((time.monotonic() - t0) * 1000) elapsed_ms = int((time.monotonic() - t0) * 1000)

View File

@@ -1,6 +1,6 @@
; HellDrinx modpack — bundled sorting_rules.txt ; HellDrinx modpack — bundled sorting rules
; Triggers: HellDrinx FULL (3672556207), HellDrinx LITE (3662909244). ; Triggers: HellDrinx FULL (3672556207), HellDrinx LITE (3662909244).
; Auto-injected by app.py:_modpack_rules_for() when either trigger wsid is in ; Auto-injected by app.py:_auto_rules_for() when either trigger wsid is in
; the user's input. User-supplied rules are appended afterward and override ; the user's input. User-supplied rules are appended afterward and override
; these on conflicting keys. ; these on conflicting keys.
; ;

View File

@@ -1,8 +1,8 @@
; RV Interior Expansion (B42) — bundled sorting_rules.txt ; RV Interior Expansion (B42) — sorting rules
; Triggers: ; Triggers:
; - 3618427553 (RVInteriorExpansion, map folder rvupdate) ; - 3618427553 (RVInteriorExpansion, map folder rvupdate)
; - 3622163276 (RVInteriorExpansionPart2, map folder rv2) ; - 3622163276 (RVInteriorExpansionPart2, map folder rv2)
; Auto-injected by app.py:_modpack_rules_for() when either trigger wsid is ; Auto-injected by app.py:_auto_rules_for() when either trigger wsid is
; in the user's input. User-supplied rules are appended afterward and ; in the user's input. User-supplied rules are appended afterward and
; override these on conflicting keys. ; override these on conflicting keys.
; ;