"""Translate mlos_sort + mod_parsed rows into the SORTOF_DATA shape the frontend expects.""" from __future__ import annotations import re from typing import Any, Dict, List, Optional, Set, Tuple from mlos_sort import ModInfo CAT_MAP: Dict[str, str] = { "coreRequirement": "lib", "tweaks": "lib", "tile": "tile", "debug": "debug", "resource": "resource", "map": "map", "qol": "qol", "moodle": "moodle", "tweak_minor": "tweak", "music": "music", "wearable": "wearable", "profession": "profession", "movement": "movement", "building": "building", "farming": "farming", "zombie": "zombie", "zone": "zone", "armor": "armor", "food": "food", "health": "health", "weapon": "weapon", "crafting": "crafting", "container": "container", "vehicle": "vehicle", "vehicle_spawn": "v.spawn", "loot": "loot", "code": "gameplay", "ui": "ui", "sound": "sound", "texture": "texture", "translation": "i18n", "multiplayer": "mp", "server_only": "server", "fix": "fix", "other": "gameplay", "undefined": "gameplay", # Spec G-patch §6: dedicated frontend pill so patches are visible at a # glance even though their position (last) already telegraphs the role. "patch": "patch", } def to_frontend_cat(mlos_cat: str) -> str: return CAT_MAP.get(mlos_cat, "gameplay") def position(load_first: str, load_last: str) -> str: if load_first == "on": return "first" if load_last == "on": return "last" return "" # Hand-curated mod_id alias map: when the user has any of the value mod_ids, # the key mod_id is considered "satisfied" for missing-dep purposes. This # stops a B42 mod whose mod_id was renamed across builds (e.g. TrueMoozic) # from showing up as a missing dep for downstream B41 mods that still # require the old mod_id (`truemusic`). MOD_ID_ALIASES: Dict[str, List[str]] = { "truemusic": ["TrueMoozic"], } def _resolve_satisfied_via_alias(input_modids: Set[str]) -> Set[str]: """Return the input mod_id set expanded with any keys whose alias values are present in the input.""" out = set(input_modids) for key, values in MOD_ID_ALIASES.items(): if any(v in input_modids for v in values): out.add(key) return out def build_warnings( mlos_warnings: Dict[str, Any], wsid_lookup: Dict[str, str] | None = None, source_wsids: Dict[str, str] | None = None, input_modids: Set[str] | None = None, ) -> List[Dict[str, Any]]: """Translate mlos_sort warnings to the SORTOF_DATA WARNINGS shape. wsid_lookup: optional {mod_id -> workshop_id} map of *deps* we already have cached. When provided, each 'missing' warning is augmented with `actions: [{type: "add-wsid", wsid, label}]` so the frontend can render a click-to-add button. Unknown deps fall back to a Steam Workshop search link the user follows manually. source_wsids: optional {mod_id -> workshop_id} map of *every cached mod in the current sort*. Used to attach the source mod's wsid onto 'missing' / 'conflict' warnings so the frontend can deep-link to the Workshop page of the mod that's complaining. """ lookup = wsid_lookup or {} sources = source_wsids or {} out: List[Dict[str, Any]] = [] for path in mlos_warnings.get("cycles", []) or []: out.append({ "tag": "cycle", "level": "red", "msg": "cycle: " + " → ".join(path), }) satisfied_via_alias = _resolve_satisfied_via_alias(input_modids or set()) for mod_id, deps in (mlos_warnings.get("missing_requirements") or {}).items(): # Drop deps that the user already has via an alias (e.g. # `truemusic` is satisfied if the user has `TrueMoozic`). If every # listed dep is satisfied via alias, skip the warning entirely. deps = [d for d in deps if d not in satisfied_via_alias] if not deps: continue actions: List[Dict[str, str]] = [] for dep in deps: wsid = lookup.get(dep) if wsid: actions.append({ "type": "add-wsid", "wsid": wsid, "modId": dep, "label": f"add {dep}", }) else: # No cache hit -> link to Steam Workshop search so the user # can find the wsid manually. PZ appid is 108600. from urllib.parse import quote_plus actions.append({ "type": "search-workshop", "modId": dep, "url": ( "https://steamcommunity.com/workshop/browse/" f"?appid=108600&searchtext={quote_plus(dep)}" ), "label": f"find {dep}", }) src_wsid = sources.get(mod_id) # When we know the source mod's wsid, the warning's leading mod name # is hyperlinked to the source's Workshop page (which lists every # required item natively). The 'find …' Steam Workshop search action # is redundant in that case (returns noisy generic results), so drop # it; keep concrete 'add ' actions for cache-resolved deps. if src_wsid: actions = [a for a in actions if a.get("type") != "search-workshop"] warn: Dict[str, Any] = { "tag": "missing", "level": "red", "msg": f"{mod_id} requires {', '.join(deps)} - not in your list.", } if src_wsid: warn["wsid"] = src_wsid if actions: warn["actions"] = actions out.append(warn) for mod_id, ene in (mlos_warnings.get("incompatible_enabled") or {}).items(): warn = { "tag": "conflict", "level": "amber", "msg": f"{mod_id} marked incompatible with {', '.join(ene)}.", } src_wsid = sources.get(mod_id) if src_wsid: warn["wsid"] = src_wsid out.append(warn) return out # ── Spec C §4: branch flavoring + prefix-base + suffix-token utilities ── # Build flavor detection for Rule A. Matches: # - "B42" / "B41" at end-of-string when preceded by a separator (`_`, `-`, # space, start-of-string) OR a lowercase letter (CamelCase boundary like # "ArmorB42"). # - "_b42" / "_b41" at end (snake_case). # - "_b42_" / "_b41_" mid-string token. # - "_legacy_" anywhere -> B41 (legacy is always old-build behavior). _RE_B41 = re.compile( r"_legacy_" r"|(?:^|[_\- ]|(?<=[a-z]))[Bb]41$" r"|_[Bb]41(?:[_\- ]|$)" ) _RE_B42 = re.compile( r"(?:^|[_\- ]|(?<=[a-z]))[Bb]42$" r"|_[Bb]42(?:[_\- ]|$)" ) def _build_flavor(mod_id: str) -> Optional[str]: """Return 'B41' / 'B42' / None per Spec C §4.3 suffix rule.""" if not mod_id: return None if _RE_B41.search(mod_id): return "B41" if _RE_B42.search(mod_id): return "B42" return None def _strict_prefix_of(a: str, b: str) -> bool: """`a` is a strict prefix of `b` per Spec C §4.4: a != b, b starts with a, boundary char is non-lowercase-letter (separator/digit/uppercase).""" if not a or not b or a == b or not b.startswith(a): return False boundary = b[len(a)] return boundary in "_- " or boundary.isdigit() or boundary.isupper() def _prefix_base(group: List[ModInfo]) -> ModInfo: """Return the branch whose mod_id is a strict prefix of every other branch in `group` (the 'core'/'main'). Falls back to alphabetical-first if no such universal prefix exists. Spec C §4.4.""" sorted_grp = sorted(group, key=lambda m: m.id) for cand in sorted_grp: if all(_strict_prefix_of(cand.id, m.id) for m in sorted_grp if m.id != cand.id): return cand return sorted_grp[0] # Spec C §4.6 hint patterns. Order matters: first match wins. _HINT_PATTERNS: List[Tuple[re.Pattern, str]] = [ (re.compile(r"_legacy_", re.IGNORECASE), "legacy build - usually not what you want"), (re.compile(r"_v\d+(?:_\d+)*$", re.IGNORECASE), "legacy build - usually not what you want"), (re.compile(r"(?:_lite|_light)$", re.IGNORECASE), "lighter alternate variant - pick one"), (re.compile(r"(?:_hd|_detailshd)$", re.IGNORECASE), "high-resolution variant"), (re.compile(r"_(?:noce|novanilla|farmdisable|disable[a-z]*)$", re.IGNORECASE), "opt-out variant"), (re.compile(r"_(?:usdm|imports|exotics|realnames)$", re.IGNORECASE), "alternate variant - pick one"), ] def _branch_hint(mod_id: str) -> Optional[str]: """Return the hint text per Spec C §4.6 D/G patterns, or None.""" if not mod_id: return None hits: List[str] = [] for pat, text in _HINT_PATTERNS: if pat.search(mod_id): hits.append(text) return " · ".join(dict.fromkeys(hits)) if hits else None def _suffix_token(mod_id: str, base_id: str) -> Optional[str]: """For Rule C: extract the trailing token after the last `_` if mod_id is a `_` form. Returns None for one-letter tokens (`_a`, `_x`). Two-letter tokens like `_AZ` (AuthenticZ patch) and `_GG` are kept - the user's spec parenthetical excludes only one-letter false positives.""" if not mod_id or not base_id or mod_id == base_id: return None if "_" not in mod_id: return None suffix = mod_id.rsplit("_", 1)[-1] if len(suffix) < 2: return None return suffix def _input_match_terms(mod_id: str) -> Set[str]: """Build the set of search terms for one input mod_id, used by Rule C. Produces both: - normalized form (lowercase + alphanumeric only) -> substring match - acronym (first letters of CamelCase / whitespace-separated tokens, lowercased) -> covers cases like "Authentic Z - Current" where the token `_AZ` is the initials, not a literal substring. """ out: Set[str] = set() if not mod_id: return out out.add(re.sub(r"[^a-z0-9]+", "", mod_id.lower())) pieces: List[str] = [] for chunk in re.split(r"[^A-Za-z0-9]+", mod_id): if not chunk: continue # CamelCase split: each capital-led run is a piece, plus trailing lowercase runs. parts = re.findall(r"[A-Z]+[a-z0-9]*|[a-z0-9]+", chunk) pieces.extend(parts) if pieces: acro = "".join(p[0].lower() for p in pieces if p and p[0].isalnum()) if len(acro) >= 2: out.add(acro) return out def _apply_branch_rules( mods: List[ModInfo], *, pz_build: str, input_modids: Set[str], is_resort: bool = False, selected_modids: Optional[Set[str]] = None, ) -> Tuple[Set[str], List[Dict[str, Any]], Dict[str, str]]: """Spec C §4 auto-disambiguation pipeline. Returns (drop_ids, warnings, hints). drop_ids = mod_ids to filter out of SORTED_ORDER/MODS_LINE. hints = {mod_id -> hint_text} for picker rows. Order per wsid: detect coordinated/radio (exempt), then A → C → B. A and C may both fire (orthogonal axes). B is the auto-pick fallback. `input_modids` is the full set across the user's input. Rule C excludes same-wsid mod_ids when matching to avoid self-references (a wsid's branches would otherwise always match each other's tokens). """ by_wsid: Dict[str, List[ModInfo]] = {} for m in mods: wsid = m.workshop_id or "" if not wsid: continue by_wsid.setdefault(wsid, []).append(m) # Per-wsid mod_id sets so Rule C can exclude same-wsid siblings. modids_by_wsid: Dict[str, Set[str]] = { w: {m.id for m in g} for w, g in by_wsid.items() } drop_ids: Set[str] = set() warnings: List[Dict[str, Any]] = [] hints: Dict[str, str] = {} pz_build = (pz_build or "").upper() if pz_build else "B42" if pz_build not in ("B41", "B42"): pz_build = "B42" for wsid, group in by_wsid.items(): if len(group) < 2: continue ids_in_group = {m.id for m in group} # Exemption 0: ADDON wsid — at least one mod self-identifies as an # "Optional add-on" (mod.info description) and at least one mod # doesn't. Treat as additive: primaries stay in MODS_LINE by default, # addons land in drop_ids so the user has to explicitly tick them in # the picker. Picker is naturally checkbox-mode (no incompatibles # between siblings), so toggling lights addons up. # # Resort path: user's explicit selection wins. Drop addons NOT in # the user's selection; keep those they ticked. addon_mods = [m for m in group if m.is_addon] primary_mods = [m for m in group if not m.is_addon] if addon_mods and primary_mods: for m in addon_mods: if not is_resort: drop_ids.add(m.id) elif selected_modids is not None and m.id not in selected_modids: drop_ids.add(m.id) hints[m.id] = "addon" for m in primary_mods: hints[m.id] = "main" continue # Exemption 1: radio-mode (incompatibleMods sibling). Picker handles. radio = any( any(other in ids_in_group for other in m.incompatibleMods) for m in group ) if radio: continue # Exemption 2: coordinated (cross-refs in requirements/loadAfter/loadBefore). coordinated = any( any(other in ids_in_group for other in m.requirements) or any(other in ids_in_group for other in m.loadAfter) or any(other in ids_in_group for other in m.loadBefore) for m in group ) if coordinated: # Coordinated wsids stay whole BY DEFAULT, but if a prefix-base # exists and a non-base addon branch declares an EXTERNAL require # (a mod_id not in the wsid's siblings) that isn't in input_modids, # drop the addon. The author's `require=` here means "this addon # only makes sense if you have the external mod"; if you don't, # the addon shouldn't be in the load order. Suppresses the # would-be missing-dep warning at the same time (post-build_warnings # filter strips warnings whose source mod_id is in drop_ids). base_candidates = [ m for m in group if any(_strict_prefix_of(m.id, o.id) for o in group if o.id != m.id) ] if base_candidates: base = max(base_candidates, key=lambda m: len(m.id)) for m in group: if m.id == base.id: continue externals = [r for r in m.requirements if r not in ids_in_group] if externals and not all(r in input_modids for r in externals): drop_ids.add(m.id) for m in group: h = _branch_hint(m.id) if h: hints[m.id] = h continue # Truly ambiguous: apply A → C → B. title = (group[0].name or wsid) kept_by_a: Optional[ModInfo] = None kept_by_c: List[ModInfo] = [] # ── Rule A: build-aware default ───────────────────────────────── flavored = [(m, _build_flavor(m.id)) for m in group] match_active = [m for m, fl in flavored if fl == pz_build] opposite_build = "B41" if pz_build == "B42" else "B42" match_opposite = [m for m, fl in flavored if fl == opposite_build] unflavored = [m for m, fl in flavored if fl is None] if len(match_active) == 1 and len(group) - 1 == len(match_opposite) + len(unflavored): # Exactly one branch matches active build; rest are opposite/unflavored. kept_by_a = match_active[0] elif not match_active and match_opposite: # No active-build variant exists -> author-default fallback (un-flavored # treated as B42), emit build-mismatch. warnings.append({ "tag": "build-mismatch", "level": "amber", "msg": ( f"no {pz_build} variant for {title} ({wsid}); using author default" ), "wsid": wsid, }) # ── Rule C: input cross-reference ─────────────────────────────── # Identify the base branch (longest mod_id that is a strict prefix # of any other) -- if multiple branches share an __ form. base_candidates = [ m for m in group if any(_strict_prefix_of(m.id, other.id) for other in group if other.id != m.id) ] rule_c_base: Optional[ModInfo] = None if base_candidates: # Pick the longest base that's a prefix of any sibling (i.e., the # most-specific shared root). For Jeeve's: `JeevesPatches` qualifies. rule_c_base = max(base_candidates, key=lambda m: len(m.id)) # Cross-reference set: every input mod_id EXCEPT the ones from # this wsid (otherwise sibling branches always match each other). same_wsid_ids = modids_by_wsid.get(wsid, set()) other_modids = input_modids - same_wsid_ids # Two flavors of search term per input mod_id: the normalized # form (substring match) and the acronym (first letters - so # token "AZ" matches "Authentic Z - Current"). See _input_match_terms. norm_input: Set[str] = set() for mid in other_modids: norm_input.update(_input_match_terms(mid)) unticked_addons: List[str] = [] for m in group: if m is rule_c_base: continue tok = _suffix_token(m.id, rule_c_base.id) if not tok: continue tok_l = re.sub(r"[^a-z0-9]+", "", tok.lower()) if not tok_l: continue hit = any(tok_l in mid_norm for mid_norm in norm_input) if hit: kept_by_c.append(m) else: unticked_addons.append(m.id) if kept_by_c: # Always include the base alongside matched addons. if rule_c_base not in kept_by_c: kept_by_c = [rule_c_base] + kept_by_c elif any(_suffix_token(m.id, rule_c_base.id) for m in group if m is not rule_c_base): # Suffix-tokened branches exist but none matched - emit warning. # `picked`/`alternatives` reuse the auto-picked-branch shape # so the WarnRow renders an inline picker (the warning's # "tick any you want" is otherwise unreachable from the # warnings panel). if unticked_addons: warnings.append({ "tag": "unmatched-addons", "level": "amber", "msg": ( f"{title} ({wsid}) ships addons for other mods you " f"don't have: {', '.join(unticked_addons[:8])}" + (" …" if len(unticked_addons) > 8 else "") + ". Tick any you want:" ), "wsid": wsid, "picked": rule_c_base.id, "alternatives": [m.id for m in group], }) # ── Resolve which set of branches is kept ─────────────────────── # A and C are orthogonal: build-flavor pick + addon picks both apply. kept_set: Set[str] = set() if kept_by_a: kept_set.add(kept_by_a.id) if kept_by_c: for m in kept_by_c: kept_set.add(m.id) if not kept_set: # Rule B fallback: prefix-base if available, else alphabetical first. primary = _prefix_base(group) kept_set.add(primary.id) skipped = [m.id for m in group if m.id != primary.id] warnings.append({ "tag": "auto-picked-branch", "level": "amber", "msg": ( f"{title} ({wsid}) ships {len(group)} branches; auto-picked " f"{primary.id}. Pick another:" ), "wsid": wsid, "picked": primary.id, "alternatives": [m.id for m in group], }) # Resort-mode override: the user has explicitly picked branches via # the picker. Replace the rules' kept_set with the user's selection # restricted to this group, and update the matching auto-picked- # branch warning's `picked` field so the picker UI shows their # current choice ticked. Without this, narrowing a multi-branch # wsid to one branch erases the picker section because the warning # was tied to the rule's auto-pick, not the user's pick. if is_resort and selected_modids is not None: user_picks = [m.id for m in group if m.id in selected_modids] if user_picks: kept_set = set(user_picks) for w in warnings: if (w.get("tag") == "auto-picked-branch" and w.get("wsid") == wsid): w["picked"] = user_picks[0] w["msg"] = ( f"{title} ({wsid}) ships {len(group)} branches; " f"selected {user_picks[0]}. Pick another:" ) for m in group: if m.id not in kept_set: drop_ids.add(m.id) h = _branch_hint(m.id) if h: hints[m.id] = h return (drop_ids, warnings, hints) # Backwards-compat shim used by existing call sites that don't yet pass # pz_build/input_modids. Removed once all callers migrate. def _autopick_ambiguous(mods: List[ModInfo]) -> Tuple[Set[str], List[Dict[str, Any]]]: drop_ids, warns, _hints = _apply_branch_rules( mods, pz_build="B42", input_modids={m.id for m in mods}, ) return (drop_ids, warns) def build_response( input_ids: List[str], hit_ids: List[str], mods: List[ModInfo], sort_result: Dict[str, Any], status: str, wsid_lookup: Dict[str, str] | None = None, pz_build: str = "B42", is_resort: bool = False, selected_modids: Optional[Set[str]] = None, ) -> Dict[str, Any]: by_id = {m.id: m for m in mods} sorted_order: List[str] = list(sort_result.get("Mods", [])) workshop_line_parts: List[str] = list(sort_result.get("WorkshopItems", [])) map_folders: List[str] = list(sort_result.get("Map", [])) # MAP_LINE composition rules: # 1. Hoisted bases (MAP_FIRST) at the FRONT, in the listed order. # These are library/parent map cells that many other map mods # overlay onto; they must load before everything else for the # dependents to attach correctly. # 2. Remaining modded map folders, in mod-load order. # 3. Vanilla Muldraugh, KY at the end ALWAYS. BASE_MAP = "Muldraugh, KY" MAP_FIRST = ("map_distanciado",) map_line_parts: List[str] = [] seen_maps: set = set() map_folder_set = set(map_folders) for m in MAP_FIRST: if m in map_folder_set and m not in seen_maps: seen_maps.add(m) map_line_parts.append(m) for m in map_folders: if m and m not in seen_maps: seen_maps.add(m) map_line_parts.append(m) if BASE_MAP not in seen_maps: map_line_parts.append(BASE_MAP) # Spec C §4: build-aware + input-cross-reference + prefix-base auto-pick. # MOD_DB still contains every cached branch so the picker can offer # alternates; SORTED_ORDER and MODS_LINE reflect the rule output only. input_modids = {m.id for m in mods} drop_ids, autopick_warnings, hints = _apply_branch_rules( mods, pz_build=pz_build or "B42", input_modids=input_modids, is_resort=is_resort, selected_modids=selected_modids, ) # Resort path: `sorted_order` already reflects the user's explicit # selection (sort_mods ran on the selected subset); the drop_ids from # _apply_branch_rules are for MOD_DB picker visibility, not for filtering # out user-selected mods. if not is_resort: sorted_order = [mid for mid in sorted_order if mid not in drop_ids] def _modentry(m: ModInfo) -> Dict[str, Any]: entry: Dict[str, Any] = { "wsid": m.workshop_id or "", "modId": m.id, "name": m.name or m.id, "cat": to_frontend_cat(m.category), "deps": list(m.requirements), "conflicts": list(m.incompatibleMods), "pos": position(m.loadFirst, m.loadLast), "isMap": bool(m.maps), "mapName": m.maps[0] if m.maps else None, } h = hints.get(m.id) if h: entry["hint"] = h return entry mod_db: List[Dict[str, Any]] = [] seen_in_db: set = set() for mod_id in sorted_order: m = by_id.get(mod_id) if m is None: continue seen_in_db.add(mod_id) mod_db.append(_modentry(m)) # Append auto-skipped branches so the picker can show them as alternatives. # They aren't in SORTED_ORDER, so the table doesn't render rows for them # directly - they only surface inside their parent wsid's BranchPicker. for mod_id in sorted(drop_ids): m = by_id.get(mod_id) if m is None or mod_id in seen_in_db: continue mod_db.append(_modentry(m)) hit_set = set(hit_ids) pending = [w for w in input_ids if w not in hit_set] # Filter out missing-dep warnings whose SOURCE mod_id was dropped by # _apply_branch_rules. Rationale: if we silently dropped, e.g., # fhqExpVehSpawnRedRace because its external require RedRacer wasn't # in input, the would-be "fhqExpVehSpawnRedRace requires RedRacer" warning # is just noise — that mod isn't in the user's effective load order # anymore. Pattern-match the leading source mod_id on each warning. # Map each cached mod_id to its source wsid so build_warnings can attach # `wsid` onto missing/conflict warnings (lets the UI deep-link to the # Workshop page of the mod that's actually complaining). source_wsids = {m.id: m.workshop_id for m in mods if m.workshop_id} raw_warnings = build_warnings( sort_result.get("warnings", {}) or {}, wsid_lookup, source_wsids=source_wsids, input_modids=input_modids, ) if drop_ids: kept_warnings: List[Dict[str, Any]] = [] for w in raw_warnings: if w.get("tag") not in ("missing", "conflict"): kept_warnings.append(w) continue m = re.match(r"^([A-Za-z0-9_+\-]{2,})\s+", w.get("msg", "")) if m and m.group(1) in drop_ids: continue # source mod_id was dropped → suppress its warning kept_warnings.append(w) raw_warnings = kept_warnings return { "status": status, "MOD_DB": mod_db, "SORTED_ORDER": sorted_order, "WORKSHOP_ITEMS_LINE": ";".join(workshop_line_parts), "MODS_LINE": ";".join(sorted_order), "MAP_LINE": ";".join(map_line_parts), "WARNINGS": raw_warnings + autopick_warnings, "pending": pending, }