Add full sortof codebase: API, drain workers, frontend, schema, specs
This commit is contained in:
683
api/adapters.py
Normal file
683
api/adapters.py
Normal file
@@ -0,0 +1,683 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user