Files
sortof/api/adapters.py
indifferentketchup 9602801f5e chore: drop dead code, sync stale comments
- delete api/categorize.py: orphaned module, never imported. The live
  pzmm-types→category mapping is _types_to_category in mlos_sort.py.
- delete api/adapters.py:_autopick_ambiguous: 5-line wrapper around
  _apply_branch_rules with zero callers in current source.
- delete docs/backlog/polling-path-pz-build.md: described work that
  shipped — init/06_sort_jobs_pz_build.sql plus pz_build plumbing in
  jobs.create_job, app._route_to_job, and app._build_result_for_job.
- sync MAP_LINE convention comment in api/mlos_sort.py with the worker
  copy (Muldraugh, KY is appended at the end, not prepended at the
  front — see adapters.build_response:577).
- update init/04_required_wsids.sql header to reflect the authed-API
  fetch path (HTML scrape was retired in 3a34b71).
- soften the now-stale '~14 rows' count in app._strip_path_prefix's
  docstring.
2026-05-07 17:58:57 +00:00

685 lines
28 KiB
Python

"""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, Tuple[str, str]] | None = None,
source_wsids: Dict[str, str] | None = None,
input_modids: Set[str] | None = None,
pz_build: str = "B42",
) -> List[Dict[str, Any]]:
"""Translate mlos_sort warnings to the SORTOF_DATA WARNINGS shape.
wsid_lookup: optional {missing_mod_id -> (workshop_id, suggested_mod_id)}
map produced by app._lookup_wsids_for_missing. The `suggested_mod_id`
differs from the missing mod_id when a MOD_ID_ALIASES alternative was
used because the canonical mod_id's only cached wsid is wrong-build
(e.g. user on B42 needs `truemusic` → suggest `TrueMoozic`'s wsid).
When provided, each 'missing' warning is augmented with `actions:
[{type: "add-wsid", wsid, modId, label}]`. 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:
entry = lookup.get(dep)
if entry:
wsid, suggested = entry
# The build suffix tells the user which build the suggested
# wsid is for. Both canonical and MOD_ID_ALIASES branches
# land on the user's pz_build (the lookup helper already
# filtered wrong-build wsids out).
actions.append({
"type": "add-wsid",
"wsid": wsid,
"modId": suggested,
"label": f"add {suggested} {pz_build}",
})
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 <wsid>' 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 `<base>_<TOKEN>` 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 _<TOKEN>_ 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 DROP the matching auto-picked-branch
# warning — the user has already chosen, and the BranchPicker in the
# ModTable expansion (frontend sortof-app.jsx ~818) keeps the picker
# accessible if they want to revisit. Leaving the amber warning in
# place after an explicit pick reads as "you should review this"
# which is exactly wrong — they already did.
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)
warnings = [
w for w in warnings
if not (w.get("tag") == "auto-picked-branch"
and w.get("wsid") == wsid)
]
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 build_response(
input_ids: List[str],
hit_ids: List[str],
mods: List[ModInfo],
sort_result: Dict[str, Any],
status: str,
wsid_lookup: Dict[str, Tuple[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,
pz_build=pz_build,
)
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,
}