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
|
# 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)
|
||||||
|
|||||||
@@ -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.
|
||||||
;
|
;
|
||||||
@@ -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.
|
||||||
;
|
;
|
||||||
Reference in New Issue
Block a user