684 lines
28 KiB
Python
684 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, 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 <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 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,
|
|
}
|