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
# user's input, we prepend the matching file's contents to the user's rules
# text before parse_sorting_rules runs. User-supplied rules come last so they
# override modpack defaults on conflicting keys.
_MODPACK_RULES_DIR = Path(__file__).resolve().parent.parent / "data" / "modpack_rules"
_MODPACK_RULES_TRIGGERS: Dict[str, str] = {
# HellDrinx FULL + LITE both bundle the same sorting_rules.txt.
# Auto-injected sorting rules: when a trigger wsid is in the user's input,
# we prepend the matching file's contents to the user's rules text before
# parse_sorting_rules runs. User-supplied rules come last so they override
# auto-injected defaults on conflicting keys.
#
# Use cases vary — modpacks (HellDrinx) shipping their own load order,
# 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
"3662909244": "helldrinx.txt", # HellDrinx LITE
# RVInteriorExpansion + Part2 chain after PROJECTRVInterior42.
@@ -349,7 +355,7 @@ _MODPACK_RULES_TRIGGERS: Dict[str, str] = {
"3622163276": "rv_expansion.txt", # RVInteriorExpansionPart2 (rv2)
}
# Human-readable trigger labels for the warning message.
_MODPACK_RULES_LABELS: Dict[str, str] = {
_RULES_LABELS: Dict[str, str] = {
"3672556207": "HellDrinx FULL",
"3662909244": "HellDrinx LITE",
"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)
tuples that fired and rules_text is the concatenated content of every
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] = []
triggers: List[Tuple[str, str]] = []
for wsid in input_wsids:
fname = _MODPACK_RULES_TRIGGERS.get(wsid)
fname = _RULES_TRIGGERS.get(wsid)
if not fname:
continue
triggers.append((wsid, _MODPACK_RULES_LABELS.get(wsid, wsid)))
triggers.append((wsid, _RULES_LABELS.get(wsid, wsid)))
if fname in files_seen:
continue
files_seen.add(fname)
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:
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)
def _parse_rules_with_modpacks(
def _parse_rules_combined(
rules_raw: Optional[str],
input_wsids: List[str],
) -> Tuple[Dict[str, Any], List[Tuple[str, str]]]:
"""Parse user-supplied rules with any auto-detected modpack rules
prepended. Returns (parsed_rules, triggers). Caller emits a warning per
trigger so the user knows the auto-injection happened."""
modpack_text, triggers = _modpack_rules_for(input_wsids)
"""Parse user-supplied rules with any auto-injected rules prepended.
Returns (parsed_rules, triggers). Caller emits a warning per trigger
so the user knows the auto-injection happened."""
auto_text, triggers = _auto_rules_for(input_wsids)
combined = (
(modpack_text + "\n\n" + (rules_raw or "")).strip()
if modpack_text else (rules_raw or "")
(auto_text + "\n\n" + (rules_raw or "")).strip()
if auto_text else (rules_raw or "")
)
if not combined:
return ({}, triggers)
try:
return (parse_sorting_rules(combined), triggers)
except Exception:
log.warning("failed to parse modpack+user rules; ignoring")
log.warning("failed to parse auto+user rules; ignoring")
return ({}, triggers)
def _emit_modpack_rules_warnings(
def _emit_rules_applied_warnings(
payload: Dict[str, Any],
triggers: List[Tuple[str, str]],
) -> None:
"""Append a `modpack-rules-applied` warning per modpack trigger so users
know the modpack's bundled sorting_rules.txt was auto-injected. Skips
triggers already flagged (idempotent across resort cycles)."""
"""Append a `rules-applied` warning per auto-rules trigger so users know
sorting rules were auto-injected for that wsid. Skips triggers already
flagged (idempotent across resort cycles)."""
if not triggers:
return
existing = payload.get("WARNINGS") or []
already_flagged = {
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]] = []
for wsid, label in triggers:
if wsid in already_flagged:
continue
new_warnings.append({
"tag": "modpack-rules-applied",
"tag": "rules-applied",
"level": "amber",
"wsid": wsid,
"msg": (
f"{label} ({wsid}) detected — auto-applied its bundled "
f"sorting_rules.txt. Your manually-entered rules (if any) "
f"override modpack rules."
f"{label} ({wsid}) detected — auto-applied sorting rules. "
f"Your manually-entered rules (if any) override these on "
f"conflicting keys."
),
})
if new_warnings:
@@ -855,7 +861,7 @@ async def _build_result_for_job(
wsids,
)
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)
sort_result = sort_mods(mods, rules)
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)
if 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)
# Spec A §8 ownership: WORKSHOP_ITEMS_LINE preserves the SET of input
# 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]
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)
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(
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)
# 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"
@@ -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]
selected_mods: List[ModInfo] = [m for m in all_mods if m.id in selected_set]
_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
# what they ticked. _apply_branch_rules below still sees the full group
# 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".
# The frontend reconciles its sent list against MOD_DB to detect drops.
async with pool.acquire() as conn:
@@ -1442,6 +1456,7 @@ async def resort_endpoint(req: ResortRequest, request: Request) -> Dict[str, Any
)
if conflict_warnings:
payload["WARNINGS"] = list(payload.get("WARNINGS") or []) + conflict_warnings
_emit_rules_applied_warnings(payload, rules_triggers)
_reorder_warnings(payload)
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).
; 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
; 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:
; - 3618427553 (RVInteriorExpansion, map folder rvupdate)
; - 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
; override these on conflicting keys.
;