13 KiB
Spec C — Build context + dep Add + auto-disambiguation rules
Lineage: sits on top of Spec A (multi-branch picker) and Spec B+F (collection expansion / live drain). Adds context-aware default selection. Does not modify the picker contract — Spec A §8 ownership still holds.
§1 Overview
Three loosely-related improvements that share the same core: the system has more context than it has been using. The user already tells us their PZ build via the pzBuild localStorage toggle. The user already gives us a list of mod_ids (via the wsids in their input). The system should consult both before deciding which branches of a multi-branch wsid land in MODS_LINE by default.
Goal: smarter pre-ticked boxes in the multi-branch picker. Non-goal: any form of "magic" sort that emits a branch the user didn't see.
§2 Build context (pzBuild)
The frontend stores sortof.pzBuild in localStorage with values "B41" | "B42", default "B41". It already drives MODS_LINE rendering (B42 prefixes mod_ids with \).
This spec extends pzBuild to:
- Travel with
/api/sortPOST body aspz_build: "B41" | "B42". The backend defaults to"B42"when missing or invalid. - Inform Rule A (§4.3) of auto-disambiguation.
pz_build is not sent on /api/resort — the resort flow uses an explicit mod_id list and never re-evaluates rules.
§3 Dep Add (already shipped)
Documenting the existing behavior so it's part of the locked design.
When mlos_sort reports a missing requirement (mod A requires mod B, B is not in the user's enabled set), build_warnings enriches each tag: "missing" warning with one of:
actions: [{type: "add-wsid", wsid, modId, label}]— whenmod_parsedhas a row withmod_id == B(usingDISTINCT ON (mod_id) ORDER BY parsed_at_time_updated DESC)actions: [{type: "search-workshop", modId, url, label}]— when no cache hit; URL ishttps://steamcommunity.com/workshop/browse/?appid=108600&searchtext=<modId>
The frontend renders [add modId] as a filled blue chip; clicking appends the wsid to the input textarea AND auto-resorts (no separate sort click needed). The search variant is [↗ find modId] and opens Steam's search in a new tab.
§4 Auto-disambiguation rules
§4.1 Design principle (locked)
These rules adjust which boxes are pre-ticked in the picker. They never bypass the picker and never silently emit a branch the user didn't see. Spec A §8 ownership holds — the picker is the source of truth.
The rules are applied at /api/sort time, before MODS_LINE is composed. The response's MOD_DB always contains every cached branch (so the picker can offer them); SORTED_ORDER and MODS_LINE reflect only the auto-selected set. The frontend reads the picker default from SORTED_ORDER membership.
§4.2 Order of evaluation per wsid
A → C → B
The first rule that single-ticks a branch wins; subsequent rules emit warnings only. Exception: A and C are orthogonal (build × addon) and both may tick the same wsid simultaneously — see §4.7.
If a wsid is coordinated (any branch references a sibling via requirements / loadAfter / loadBefore) or radio (any branch lists a sibling in incompatibleMods), it is exempt from rules A/B/C. Coordinated → all branches stay; radio → first only.
§4.3 Rule A — build-aware default (highest ROI)
A branch is B41-flavored if mod_id:
- ends with
B41(case-insensitive), or - contains
_legacy_followed by version digits (e.g.,_legacy_42_12,_legacy_41_*)
A branch is B42-flavored if mod_id:
- ends with
B42or_b42(case-insensitive), or - contains
_b42_(e.g.,vac_mod_b42_utils)
A branch is un-flavored otherwise.
Apply: if exactly one branch is flavored to match the active pz_build AND no other branch shares that flavor, pre-tick that one. Un-flavored branches are treated as "the build the author considers default" — currently always B42.
If no branch matches the active build (e.g., user is on B41 but every branch is B42-flavored), fall through to Rule C / B and emit a build-mismatch warning: "no <build> variant for <name> (<wsid>); using author default".
Rule A also fires when the wsid has exactly two branches and one is B41-flavored, one is unflavored — the unflavored is treated as B42 default. So a B42 user gets the unflavored, a B41 user gets the B41-flavored.
§4.4 Rule B — prefix-base tiebreaker
When the auto-pick path needs to pick one branch (Rule A didn't single-tick, Rule C didn't fire), use the shortest mod_id that is a strict prefix of every other branch's mod_id instead of alphabetical-first.
Strict prefix definition: A is a strict prefix of B iff:
A≠B, ANDB.startswith(A), AND- The character at position
len(A)inBis a non-lowercase-letter — separator (_-), digit (0-9), or uppercase letter (A-Z).
Boundary regex: ^<A>([_\- ]|[A-Z]|[0-9]|$).
Examples that qualify:
ArmoredVests⊂ArmoredVestsPatch(boundary:P)ToadTraits⊂ToadTraitsDisablePrepared(boundary:D)LitSortOGSN⊂LitSortOGSN_chocolate(boundary:_)WaterDispenser⊂WaterDispenser2(boundary:2)
Examples that do NOT qualify:
FoovsFoobar(boundary:b, lowercase continuation)LitvsLitSort(boundary:S, capital — actually qualifies; this case becomes prefix-base, deliberate)
If multiple branches are mutual prefixes (impossible by definition) or if no branch is a prefix-of-all-others, fall back to alphabetical-first by mod_id.
Rule B still emits the auto-picked-branch warning (Spec A §4) and renders the click-to-expand picker buttons — the only behavior change is the choice of which branch wins the auto-pick.
§4.5 Rule C — input cross-reference (solves Jeeve's Patches)
For each ambiguous branch whose mod_id matches the pattern <base>_<TOKEN> (i.e., contains an underscore, with a base prefix shared with at least one sibling branch):
- Extract
<TOKEN>(the part after the last_). - Look up
<TOKEN>against the resolved mod_id set from the user's input — case-insensitive substring match against any cachedmod_parsed.mod_idwhose wsid is in the user's input. - Match requires
<TOKEN>length ≥ 3 (avoid_a,_xfalse positives).
Hit: pre-tick that branch alongside the base branch.
No hits on any suffix-tokened branch: tick the base branch only and emit unmatched-addons warning listing the unticked branches by name.
The "base branch" inside a Rule-C wsid is determined by Rule B (prefix-base tiebreaker), with alphabetical fallback.
Worked example: Jeeve's Patches (wsid 3684025083, branches JeevesPatches, JeevesPatches_AZ, JeevesPatches_DAMN, JeevesPatches_GGS, JeevesPatches_ISA, JeevesPatches_PlayerStatus, JeevesPatches_Spongie, JeevesPatches_Tanker, JeevesPatches_Towing, JeevesPatches_Vanilla, JeevesPatches_ZRE):
- Base =
JeevesPatches(Rule B: prefix-of-others). - Tokens:
AZ,DAMN,GGS,ISA,PlayerStatus,Spongie,Tanker,Towing,Vanilla,ZRE. - User submits
AuthenticZ-related wsids → some cached mod_id containsAZ(case-insensitive substring) → tickJeevesPatches_AZ. - User submits
JeevesIntegration→ no token hits → tickJeevesPatchesonly, emitunmatched-addonswarning.
§4.6 Rules D + G — hint text only
When the picker renders an ambiguous wsid's branches, attach a per-branch hint field surfaced in the picker row. No state mutation. Hints are:
| Suffix pattern | Hint text |
|---|---|
_Lite, _Light |
"lighter alternate variant — pick one" |
_HD, _DetailsHD |
"high-resolution variant" |
_NoCE, _NoVanilla, _FarmDisable, _Disable* |
"opt-out variant" |
_USDM, _Imports, _Exotics, _RealNames |
"alternate variant — pick one" |
_v\d+(_\d+)*, _legacy_* |
"legacy build — usually not what you want" |
_AZ, _DAMN, _GGS, etc. (Rule C suffixes that didn't match input) |
"addon for <TOKEN> — only if you have it" |
Pattern matching is case-insensitive with anchored end-of-string for short tags. Multiple hints on one branch concatenate with " · ".
§4.7 Edge cases
A and C both fire and disagree (build hint says B42, input cross-ref says addon _AZ): both tick. Build × addon are orthogonal axes; they don't compete for the same slot.
A says no match (build-mismatch), C also fires: C still fires; the build-mismatch warning surfaces alongside C's selections. Rule A's "fall back to author default" doesn't override C.
Coordinated wsid where one branch happens to be B42-flavored and another B41-flavored: the wsid is exempt from A/B/C (coordinated detection runs first). All branches stay regardless of build mismatch. This is the right answer because coordinated branches by definition need each other.
Stored selections in localStorage from previous sessions: the existing runResort(branchSelections) flow fires after the initial sort response is rendered. User's stored selections override the rules. Rules only determine the initial render's pre-ticked state for never-touched wsids.
pz_build missing or invalid in request: backend treats as "B42". Forwards-compatible if frontend on a stale build doesn't send it.
§5 Implementation notes
Backend (adapters.py + app.py):
SortRequestgainspz_build: Optional[str]._autopick_ambiguous(mods)is renamed_apply_branch_rules(mods, *, pz_build, input_modids)and replaces the alphabetical-first picker with the rule pipeline above.- Returns
(drop_ids, warnings, hints)—hintsisDict[mod_id -> str]consumed bybuild_responseand attached to MOD_DB entries ashint?: string. sort_endpointcomputesinput_modids = set(by_id.keys())(the cached mod_ids) and passes alongsidepz_build.
Frontend (sortof-app.jsx):
onSortPOST body addspz_build: pzBuild.defaultSelectionForBranches(branches, activeSet)accepts anactiveSet: Set<modId>(the union ofD.SORTED_ORDERmod_ids). Returnsbranches.filter(b => activeSet.has(b.modId)).map(b => b.modId), falling back to[branches[0].modId]if none match.- All call sites of
defaultSelectionForBranchespassnew Set(D.SORTED_ORDER || []). BranchPickerrendersbranch.hintwhen present, as a small italic line under the mod_id.
The frontend doesn't reimplement the rules — it simply reflects the backend's chosen SORTED_ORDER. Same data path keeps the picker as source of truth and makes the rules introspectable from a curl response.
§6 Acceptance criteria
POST /api/sortacceptspz_buildand defaults to"B42"when omitted.- B42 user submits a wsid with branches
Foo(un-flavored) andFooB41:MODS_LINEincludesFooonly. B41 user getsFooB41. - User submits Jeeve's Patches wsid alone:
MODS_LINEisJeevesPatches. WARNINGS includesunmatched-addonslisting the 10 untouched branches. - User submits Jeeve's Patches + a wsid whose mod_id is
AuthenticZ_Current:MODS_LINEincludesJeevesPatchesandJeevesPatches_AZ. - Wsid
1962761540(ArmoredVests,ArmoredVestsPatch): auto-pick selectsArmoredVests(prefix-base), not alphabetical-first (which is the same here — pick a wsid where they differ to verify). - Coordinated wsid
2791656602(fhqMotoriusZone): all 5 branches stay inMODS_LINEregardless ofpz_build. - Picker UI shows hint text per branch for D/G-class suffixes.
WORKSHOP_ITEMS_LINEmatcheswsids[]regardless of which branches got ticked (Spec A §8 unchanged).
§7 Test recipes
- Build A — B42 default.
pz_build=B42+ wsid with branches[Foo, FooB41]. ExpectMODS_LINE = "Foo", nobuild-mismatchwarning. - Build A — B41 + only-B42-variants → mismatch warning.
pz_build=B41+ wsid where every branch is B42-flavored. Expectbuild-mismatchwarning, fall through to Rule B. - Rule C — Jeeve's alone. Submit only Jeeve's Patches wsid. Expect MODS_LINE =
JeevesPatches,unmatched-addonswarning lists the 10 others. - Rule C — Jeeve's + AuthenticZ. Submit Jeeve's wsid + AuthenticZ wsid. Expect MODS_LINE includes
JeevesPatchesandJeevesPatches_AZ. - Rule B — prefix-base picks non-alphabetical. Find a wsid where alphabetical-first ≠ prefix-base; verify prefix-base wins.
- Hint text —
_Lite/_legacy_*. Picker row shows the appropriate hint string. - Coordinated exemption. fhqMotoriusZone: all 5 branches in MODS_LINE for both B41 and B42 users.
- Stored-selection override. User has
branchSelections[wsid] = [explicit]from previous session. Rules don't override on next sort — runResort runs with the explicit selection after initial render.