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:
85
api/app.py
85
api/app.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user