diff --git a/api/app.py b/api/app.py index a2a0b34..7fb0341 100644 --- a/api/app.py +++ b/api/app.py @@ -14,7 +14,7 @@ import os import time from contextlib import asynccontextmanager from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from uuid import UUID import httpx @@ -290,9 +290,107 @@ MANUAL_BUILD_PAIRS: Dict[str, str] = { # builds; B42 wsid bundles a TMMumble addon as a second mod_id. "2613146550": "3632610172", "3632610172": "2613146550", + # betterLockpicking (B41) ↔ NFsBetterLockpicking (B42). Different author + # picked up the B42 port and renamed the mod_id, so the mod_parsed-based + # cross-reference can't find the pair. + "2368058459": "3440867775", + "3440867775": "2368058459", } +# 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. + "3672556207": "helldrinx.txt", # HellDrinx FULL + "3662909244": "helldrinx.txt", # HellDrinx LITE +} +# Human-readable trigger labels for the warning message. +_MODPACK_RULES_LABELS: Dict[str, str] = { + "3672556207": "HellDrinx FULL", + "3662909244": "HellDrinx LITE", +} + + +def _modpack_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 + if multiple trigger wsids share it (HellDrinx FULL + LITE case).""" + files_seen: set = set() + parts: List[str] = [] + triggers: List[Tuple[str, str]] = [] + for wsid in input_wsids: + fname = _MODPACK_RULES_TRIGGERS.get(wsid) + if not fname: + continue + triggers.append((wsid, _MODPACK_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")) + except OSError as e: + log.warning("modpack rules load failed for %s (%s): %s", wsid, fname, e) + return ("\n\n".join(parts), triggers) + + +def _parse_rules_with_modpacks( + 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) + combined = ( + (modpack_text + "\n\n" + (rules_raw or "")).strip() + if modpack_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") + return ({}, triggers) + + +def _emit_modpack_rules_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).""" + 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") + } + new_warnings: List[Dict[str, Any]] = [] + for wsid, label in triggers: + if wsid in already_flagged: + continue + new_warnings.append({ + "tag": "modpack-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." + ), + }) + if new_warnings: + payload["WARNINGS"] = list(existing) + new_warnings + + async def _find_swap_candidate( conn, wsid: str, @@ -405,10 +503,13 @@ async def _emit_build_mismatch_warnings( Build tags don't include the requested build. `workshop_meta.tags` is Steam's controlled vocabulary — `Build 41` / - `Build 42` are the canonical signal. Three cases per wsid: + `Build 42` are the canonical signal. Cases per wsid: - both builds tagged → multi-build, no warn - only the OPPOSITE build tagged → wrong build, emit warn - - neither build tagged → author didn't mark, can't tell, no warn + - neither build tagged BUT a MANUAL_BUILD_PAIRS partner is tagged for + the target build → infer wrong build, emit warn (covers untagged + wsids whose author never set a Steam build tag) + - neither build tagged AND no paired partner → can't tell, no warn - only the TARGET build tagged → fine, no warn Multi-branch wsids already get their own `build-mismatch` from @@ -417,14 +518,22 @@ async def _emit_build_mismatch_warnings( """ if not input_wsids or pz_build not in ("B41", "B42"): return + # Expand the lookup set to include MANUAL_BUILD_PAIRS partners so we can + # check the partner's Steam tags in the same query (no N+1). + lookup_wsids = set(input_wsids) + for w in input_wsids: + if w in MANUAL_BUILD_PAIRS: + lookup_wsids.add(MANUAL_BUILD_PAIRS[w]) rows = await conn.fetch( """ SELECT workshop_id, title, tags FROM workshop_meta WHERE workshop_id = ANY($1::text[]) """, - list(input_wsids), + list(lookup_wsids), ) + tags_by_wsid: Dict[str, List[str]] = {r["workshop_id"]: list(r["tags"] or []) for r in rows} + titles_by_wsid: Dict[str, str] = {r["workshop_id"]: r["title"] for r in rows} target_tag = "Build 41" if pz_build == "B41" else "Build 42" other_tag = "Build 42" if pz_build == "B41" else "Build 41" existing = payload.get("WARNINGS") or [] @@ -433,16 +542,24 @@ async def _emit_build_mismatch_warnings( if w.get("tag") == "build-mismatch" and w.get("wsid") } new_warnings: List[Dict[str, Any]] = [] - for r in rows: - wsid = r["workshop_id"] + for wsid in input_wsids: if wsid in already_flagged: continue - tags = list(r["tags"] or []) + tags = tags_by_wsid.get(wsid) + if tags is None: + continue # no workshop_meta row cached if target_tag in tags: continue # supports the picked build - if other_tag not in tags: - continue # author didn't tag a build, can't tell - title = r["title"] or wsid + # Two paths to "this is the wrong build": + # (a) Steam directly tags it as the OPPOSITE build. + # (b) MANUAL_BUILD_PAIRS partner is tagged for our target build. + partner_supports_target = ( + wsid in MANUAL_BUILD_PAIRS + and target_tag in tags_by_wsid.get(MANUAL_BUILD_PAIRS[wsid], []) + ) + if other_tag not in tags and not partner_supports_target: + continue # untagged with no helpful partner — can't tell + title = titles_by_wsid.get(wsid) or wsid warn: Dict[str, Any] = { "tag": "build-mismatch", "level": "amber", @@ -696,12 +813,7 @@ async def _build_result_for_job( wsids, ) mods = [_row_to_modinfo(r) for r in rows] - rules: Dict[str, Any] = {} - if rules_raw: - try: - rules = parse_sorting_rules(rules_raw) - except Exception: - log.warning("job result: failed to parse sorting_rules") + rules, modpack_triggers = _parse_rules_with_modpacks(rules_raw, wsids) _inject_addon_loadafter(mods) sort_result = sort_mods(mods, rules) cached_ids = {r["workshop_id"] for r in rows} @@ -724,6 +836,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) _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, @@ -1017,13 +1130,7 @@ async def sort_endpoint(req: SortRequest, request: Request) -> Dict[str, Any]: mods: List[ModInfo] = [_row_to_modinfo(r) for r in rows] - rules: Dict[str, Any] = {} - if req.rules: - try: - rules = parse_sorting_rules(req.rules) - except Exception: - log.warning("failed to parse sorting_rules; ignoring") - rules = {} + rules, modpack_triggers = _parse_rules_with_modpacks(req.rules, input_ids) _inject_addon_loadafter(mods) sort_result = sort_mods(mods, rules) @@ -1057,6 +1164,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) _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" diff --git a/api/mlos_sort.py b/api/mlos_sort.py index fbd0831..0d8d15b 100644 --- a/api/mlos_sort.py +++ b/api/mlos_sort.py @@ -32,7 +32,26 @@ from typing import Dict, List, Optional, Tuple # Constants (mirrors MLOS_sorting.lua) # ----------------------------------------------------------------------------- -PREORDER: Dict[str, int] = {"ModManager": 1, "ModManagerServer": 2, "modoptions": 3} +PREORDER: Dict[str, int] = { + # B41 in-game MLOS sorter (wsid 3180893708) + server companion + vanilla + # mod options panel — slot at the very front by historical MLOS convention. + "ModManager": 1, + "ModManagerServer": 2, + "modoptions": 3, + # Project-specific forced order: tsarslib is a foundational lib that many + # vehicle/map mods require, so it must precede them. AquatsarYachtClub + + # AquatsarRVAddon + ProjectRVInterior42 form an authored cluster whose + # interior overlays only render correctly in this exact order. Slots 4-7 + # land them immediately after the management tools, before any category- + # sorted content. + "tsarslib": 4, + "AquatsarYachtClubB42": 5, + "AquatsarRVAddon": 6, + "PROJECTRVInterior42": 7, + # damnlib (wsid 3171167894) — same nature as tsarslib: foundational lib + # consumed by many B42 mods. Slots after the Aquatsar block. + "damnlib": 8, +} RAW_CATEGORY_ORDER: List[str] = [ "coreRequirement", diff --git a/data/modpack_rules/helldrinx.txt b/data/modpack_rules/helldrinx.txt new file mode 100644 index 0000000..3f64ad6 --- /dev/null +++ b/data/modpack_rules/helldrinx.txt @@ -0,0 +1,71 @@ +; HellDrinx modpack — bundled sorting_rules.txt +; Triggers: HellDrinx FULL (3672556207), HellDrinx LITE (3662909244). +; Auto-injected by app.py:_modpack_rules_for() when either trigger wsid is in +; the user's input. User-supplied rules are appended afterward and override +; these on conflicting keys. +; +; Source: HellDrinx FULL/LITE — keep this file in sync with the modpack's +; upstream sorting_rules.txt when it updates. + +[PROJECTRVInterior42] +loadFirst=on +[TombBody] +loadAfter=SPNCC,SPNCCDetails,SPNCCDetailsHD,SpnHairAPI,SpnCloth,SpnOpenCloth,SPNCCFaces,SpnHair,FH,KATTAJ1_ClothesCore,KATTAJ1_Military,[J&G] Black Multicam Uniform,[J&G] British DPM Uniform,[J&G] Caution Pack,[J&G] Firefighter Uniform,[J&G] Neon Vandals Uniform,[J&G] SWAT Uniform,[J&G] Trauma Responder Uniform,[J&G] Umbrella Corp Uniform,[J&G] Alpine Multicam Uniform,[J&G] Desert Multicam Uniform,[J&G] Flecktarn Uniform,[J&G] Forest Multicam Uniform,[J&G] Jordan's Tailoring Buff,[J&G] MARPAT Camo Uniform,[J&G] XKU Uniform,AliceGear,CamisetasSeleccion,CerberusPack,DarkWpnSlings,EFTBP,GanydeBielovzki's Frockin Shirts n Ties,GanydeBielovzki's Frockin Splendor!,GanydeBielovzki's Frockin Splendor! Vol.2,GanydeBielovzki's Frockin Splendor! Vol.3,GanydeBielovzki's Frockin Splendor! Vol.4,GanydeBielovzki's Frockin Splendor! Vol.5,GanydeBielovzki's Frockin Wiseguys,H_E_C_U,HardwoodsTurnoutGear,KITTYOWO,LynxPack,MilitaryPonchosRELOADED,newclothesmodels,TwoWeaponsB,VanillaGearExpanded,zReApoPonchosB42,zReArmorPackBYKB42,ArmoredVests,ArmoredVestsPatch +loadLast=on +[TombBodyCustom] +loadAfter=TombBody +loadLast=on +[TombBodyTexDOLL] +loadAfter=TombBodyCustom +incompatibleMods=TombBodyTexNUDE,TombBodyTex +loadLast=on +[TombGothTexDOLL] +loadAfter=TombBodyTexDOLL +incompatibleMods=TombGothTexNUDE,TombBodyTexNUDE,TombBodyTex +loadLast=on +[TombGothHair] +loadAfter=TombBody,FH +[TombBodyCompat] +loadAfter=TombBody +loadLast=on +[TombWardrobeALT] +loadAfter=TombBody +loadLast=on +[Optimal] +loadLast=on +[Performance] +loadLast=on +[ProjectSummerCar] +loadAfter=VehicleRepairOverhaul,VehicleSalvageOverhaulB42 +incompatibleMods=Ivmakk_RestoreEngineQuality +loadLast=on +category=undefined +[ItemretexturePSC] +loadLast=on +category=undefined +[VehicleRepairOverhaul] +loadBefore=ProjectSummerCar,Ivmakk_RestoreEngineQuality +incompatibleMods=SimpleAutoParts +loadLast=on +[VehicleSalvageOverhaulB42] +loadBefore=ProjectSummerCar,Ivmakk_RestoreEngineQuality +loadLast=on +[MoreDescriptionForTraits4213] +loadBefore=FIX_DetailedDescriptionsForOccupationsAndTraits +[HellDrinxJSONFix] +loadAfter=ItemretexturePSC,TombBodyCompat +loadLast=on +[HellDrinxTranslations] +loadAfter=ItemretexturePSC +loadLast=on +[HellDrinxBugFixes] +loadAfter=ItemretexturePSC,TombBodyCompat,TombWardrobeALT,HellDrinxStrangers,SpnCloth +loadLast=on +[HellDrinxEssentials] +loadLast=on +[HellDrinxBugFixesFirst] +loadFirst=on +[TACPDM2] +loadLast=on +[damnlib] +loadFirst=on diff --git a/worker/mlos_sort.py b/worker/mlos_sort.py index ec2af72..b739105 100644 --- a/worker/mlos_sort.py +++ b/worker/mlos_sort.py @@ -32,7 +32,26 @@ from typing import Dict, List, Optional, Tuple # Constants (mirrors MLOS_sorting.lua) # ----------------------------------------------------------------------------- -PREORDER: Dict[str, int] = {"ModManager": 1, "ModManagerServer": 2, "modoptions": 3} +PREORDER: Dict[str, int] = { + # B41 in-game MLOS sorter (wsid 3180893708) + server companion + vanilla + # mod options panel — slot at the very front by historical MLOS convention. + "ModManager": 1, + "ModManagerServer": 2, + "modoptions": 3, + # Project-specific forced order: tsarslib is a foundational lib that many + # vehicle/map mods require, so it must precede them. AquatsarYachtClub + + # AquatsarRVAddon + ProjectRVInterior42 form an authored cluster whose + # interior overlays only render correctly in this exact order. Slots 4-7 + # land them immediately after the management tools, before any category- + # sorted content. + "tsarslib": 4, + "AquatsarYachtClubB42": 5, + "AquatsarRVAddon": 6, + "PROJECTRVInterior42": 7, + # damnlib (wsid 3171167894) — same nature as tsarslib: foundational lib + # consumed by many B42 mods. Slots after the Aquatsar block. + "damnlib": 8, +} RAW_CATEGORY_ORDER: List[str] = [ "coreRequirement",