diff --git a/api/app.py b/api/app.py index c10b407..cf0d106 100644 --- a/api/app.py +++ b/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) diff --git a/data/modpack_rules/helldrinx.txt b/data/rules/helldrinx.txt similarity index 95% rename from data/modpack_rules/helldrinx.txt rename to data/rules/helldrinx.txt index 3f64ad6..3a39760 100644 --- a/data/modpack_rules/helldrinx.txt +++ b/data/rules/helldrinx.txt @@ -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. ; diff --git a/data/modpack_rules/rv_expansion.txt b/data/rules/rv_expansion.txt similarity index 84% rename from data/modpack_rules/rv_expansion.txt rename to data/rules/rv_expansion.txt index 01becc3..4631c52 100644 --- a/data/modpack_rules/rv_expansion.txt +++ b/data/rules/rv_expansion.txt @@ -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. ;