Compare commits

..

2 Commits

Author SHA1 Message Date
b1471b739f feat: HellDrinx takeaways — conflict blocklist + vehicle path signal
Two narrow additions adopted from a review of HellDrinx Mod Manager
(/tmp/helldrinx-modmanager-PZ-main.zip), per
docs/plans/ (planning conversation, no spec checked in).

A. _IGNORED_FILENAMES in api/diagnostics.py — skip filenames that are
   intended merge points (PZ engine-concatenated or framework hooks)
   from /api/conflicts output. Multiple distinct sha1s for these files
   is by-design, not a conflict. Live cache had 33 providers shipping
   sandbox-options.txt with 31 distinct hashes — those would have been
   31 false-positive conflict rows on any sort spanning them.

B. Path-based Vehicles signal in worker.build_manifest_and_types —
   extended the existing scripts/vehicles[/] check with
   models_x/vehicles/ and models/vehicles/. Catches vehicle mods that
   ship 3D assets without scripts (rare but real); still requires the
   user-build's manifest to be rebuilt before mod_types reflects it.
2026-05-06 19:20:29 +00:00
f8b48fbacb refactor: diff panel now compares input wsids → sorted output
Drops the prev-sort snapshot ref. Diff is always available — no "sort once
first" empty state — and surfaces drops (banned/missing/collection IDs that
expanded), additions (collection expansion, branch picks), and reorderings
in one pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:10:52 +00:00
3 changed files with 85 additions and 91 deletions

View File

@@ -14,6 +14,29 @@ from typing import Dict, List, Tuple
from mlos_sort import ModInfo
# Filenames that legitimately appear across multiple mods because they're
# intended merge points — either PZ engine-merged at runtime, or framework
# extension hooks where multiple client mods coexist by design. Multiple
# distinct sha1s here is by-design, not a conflict, so we skip them in
# scan_file_conflicts. Sourced from HellDrinx Mod Manager's known false-
# positive list (backend/services/workshop.cjs); add more here as we
# encounter them.
_IGNORED_FILENAMES = {
# PZ engine-merged at load time
"sandbox-options.txt",
"fileguidtable.xml",
# Framework extension hooks (designed for many mods to override)
"mf_ismoodle.lua", # MoodleFramework hook
"kp_extrabodylocations.lua", # KP body-locations framework hook
"registries.lua", # damnlib-style registry framework
# Specific mod-side merge points
"hat_gasmask.xml",
"hat_gasmask_nofilter.xml",
"sounds_tmrremovemumble.txt",
"null.wav", # silent placeholder, deliberately shared
}
@dataclass
class FileConflict:
rel_path: str
@@ -59,7 +82,14 @@ async def scan_file_conflicts(conn, mods: List[ModInfo]) -> List[FileConflict]:
idx = order_index.get(mod_id)
if idx is None:
continue
by_path[r["rel_path"]].append((idx, mod_id, r["sha1"]))
rel_path = r["rel_path"]
# Skip known intended-merge-point filenames (engine-concatenated or
# framework hooks). These produce noisy false positives because
# multiple mods adding sandbox vars / framework hooks is by design.
basename = rel_path.rsplit("/", 1)[-1]
if basename in _IGNORED_FILENAMES:
continue
by_path[rel_path].append((idx, mod_id, r["sha1"]))
conflicts: List[FileConflict] = []
for rel, entries in by_path.items():

View File

@@ -145,67 +145,47 @@ function WsidLink({ wsid, children, className }) {
);
}
// Snapshot the fields needed to diff against the next sort/resort. Called
// before each fetch fires so the snapshot captures "what the user saw
// before this action". Polling-mid-flight ticks intentionally don't snapshot
// (would erase the prior visible state on every 2.5s update).
function snapshotForDiff(src) {
if (!src) return null;
return {
SORTED_ORDER: [...(src.SORTED_ORDER || [])],
MOD_DB: (src.MOD_DB || []).map(m => ({ modId: m.modId, wsid: m.wsid, name: m.name })),
MODS_LINE: src.MODS_LINE || '',
WORKSHOP_ITEMS_LINE: src.WORKSHOP_ITEMS_LINE || '',
};
}
function computeDiff(prev, curr) {
if (!prev || !curr) return null;
const prevSorted = prev.SORTED_ORDER || [];
const currSorted = curr.SORTED_ORDER || [];
const prevSet = new Set(prevSorted);
const currSet = new Set(currSorted);
const added = currSorted.filter(id => !prevSet.has(id));
const removed = prevSorted.filter(id => !currSet.has(id));
const prevPos = new Map(prevSorted.map((id, i) => [id, i]));
// Compare the wsids the user pasted (input order, deduped) against the wsids
// in WORKSHOP_ITEMS_LINE (sort order). Always available — no "previous sort"
// required. Surfaces drops (banned / missing mod.info / unknown / collection
// IDs that expanded), additions (collection expansion, branch picks), and
// position changes.
function computeDiff(inputWsids, outputWsids) {
if (!inputWsids || !outputWsids) return null;
const inSet = new Set(inputWsids);
const outSet = new Set(outputWsids);
const added = outputWsids.filter(w => !inSet.has(w));
const removed = inputWsids.filter(w => !outSet.has(w));
const inPos = new Map(inputWsids.map((w, i) => [w, i]));
const movers = [];
currSorted.forEach((id, ci) => {
if (!prevSet.has(id)) return;
const pi = prevPos.get(id);
if (pi !== ci) movers.push({ id, from: pi, to: ci, delta: ci - pi });
outputWsids.forEach((w, oi) => {
if (!inSet.has(w)) return;
const ii = inPos.get(w);
if (ii !== oi) movers.push({ wsid: w, from: ii, to: oi, delta: oi - ii });
});
movers.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
const prevWsl = (prev.WORKSHOP_ITEMS_LINE || '').replace(/;+$/, '').split(';').filter(Boolean);
const currWsl = (curr.WORKSHOP_ITEMS_LINE || '').replace(/;+$/, '').split(';').filter(Boolean);
const prevWsidPos = new Map(prevWsl.map((w, i) => [w, i]));
const wsidMovers = [];
currWsl.forEach((w, ci) => {
const pi = prevWsidPos.get(w);
if (pi !== undefined && pi !== ci) wsidMovers.push({ wsid: w, from: pi, to: ci });
});
wsidMovers.sort((a, b) => Math.abs(b.to - b.from) - Math.abs(a.to - a.from));
return { added, removed, movers, wsidMovers };
return { added, removed, movers };
}
function DiffPanel({ prev, curr, onClose }) {
const diff = computeDiff(prev, curr);
function DiffPanel({ inputWsids, outputWsids, onClose }) {
const diff = computeDiff(inputWsids, outputWsids);
if (!diff) {
return (
<div className="diff-panel">
<div className="diff-head">
<span className="diff-title">diff vs previous sort</span>
<span className="diff-title">diff: input sorted</span>
<button type="button" className="diff-close" onClick={onClose}>close</button>
</div>
<div className="diff-empty">no previous sort to compare against - sort once first.</div>
<div className="diff-empty">no input or output yet.</div>
</div>
);
}
const { added, removed, movers, wsidMovers } = diff;
const empty = !added.length && !removed.length && !movers.length && !wsidMovers.length;
const { added, removed, movers } = diff;
const empty = !added.length && !removed.length && !movers.length;
return (
<div className="diff-panel">
<div className="diff-head">
<span className="diff-title">diff vs previous sort</span>
<span className="diff-title">diff: input sorted</span>
<span className="diff-summary">
<span className="diff-stat add">+{added.length}</span>
<span className="diff-stat rm">{removed.length}</span>
@@ -213,41 +193,29 @@ function DiffPanel({ prev, curr, onClose }) {
</span>
<button type="button" className="diff-close" onClick={onClose}>close</button>
</div>
{empty && <div className="diff-empty">nothing changed.</div>}
{empty && <div className="diff-empty">order matches your input. nothing dropped or added.</div>}
{added.length > 0 && (
<div className="diff-section">
<div className="diff-label">added ({added.length})</div>
{added.slice(0, 30).map(id => <div key={id} className="diff-row diff-add">+ {id}</div>)}
<div className="diff-label">added ({added.length}) collection expansion or branch-picker</div>
{added.slice(0, 30).map(w => <div key={w} className="diff-row diff-add">+ {w}</div>)}
{added.length > 30 && <div className="diff-more">and {added.length - 30} more</div>}
</div>
)}
{removed.length > 0 && (
<div className="diff-section">
<div className="diff-label">removed ({removed.length})</div>
{removed.slice(0, 30).map(id => <div key={id} className="diff-row diff-rm"> {id}</div>)}
<div className="diff-label">removed ({removed.length}) dropped, banned, missing mod.info, or a collection ID that expanded</div>
{removed.slice(0, 30).map(w => <div key={w} className="diff-row diff-rm"> {w}</div>)}
{removed.length > 30 && <div className="diff-more">and {removed.length - 30} more</div>}
</div>
)}
{movers.length > 0 && (
<div className="diff-section">
<div className="diff-label">moved by load order ({movers.length}, top {Math.min(10, movers.length)} shown)</div>
{movers.slice(0, 10).map(({ id, from, to, delta }) => (
<div key={id} className="diff-row diff-mv">
<span className="diff-arrow">{delta < 0 ? '↑' : '↓'}</span>
{id}
<span className="diff-pos">pos {from + 1} {to + 1} ({delta > 0 ? '+' : ''}{delta})</span>
</div>
))}
</div>
)}
{wsidMovers.length > 0 && (
<div className="diff-section">
<div className="diff-label">WorkshopItems= reorder ({wsidMovers.length})</div>
{wsidMovers.slice(0, 10).map(({ wsid, from, to }) => (
<div className="diff-label">reordered by load order ({movers.length}, top {Math.min(10, movers.length)} shown)</div>
{movers.slice(0, 10).map(({ wsid, from, to, delta }) => (
<div key={wsid} className="diff-row diff-mv">
<span className="diff-arrow">{to < from ? '↑' : '↓'}</span>
<span className="diff-arrow">{delta < 0 ? '↑' : '↓'}</span>
{wsid}
<span className="diff-pos">pos {from + 1} {to + 1}</span>
<span className="diff-pos">pos {from + 1} {to + 1} ({delta > 0 ? '+' : ''}{delta})</span>
</div>
))}
</div>
@@ -1039,7 +1007,7 @@ function BuildToggle({ value, onChange }) {
);
}
function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onAutoFixAddDeps, onAutoFixSwaps, onAutoFixRemoves, onRetry, previousResult, diffOpen, setDiffOpen }) {
function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onAutoFixAddDeps, onAutoFixSwaps, onAutoFixRemoves, onRetry, inputWsidList, diffOpen, setDiffOpen }) {
// Phase-state mapping: legacy `success` ≈ B+F `done`; legacy `partial` ≈ B+F `queued`/`draining`.
const isTerminalDone = state === 'success' || state === 'done';
const isInflightPartial = state === 'partial' || state === 'queued' || state === 'draining';
@@ -1131,21 +1099,15 @@ function RightColumn({ state, counts, progress, emptyVariant, successVariant, mo
type="button"
className={'diff-toggle' + (diffOpen ? ' open' : '')}
onClick={() => setDiffOpen(o => !o)}
disabled={!previousResult}
title={previousResult ? 'compare to previous sort' : 'sort once first'}
title="diff your input against the sorted output"
>
{diffOpen ? ' diff' : ' diff'}
</button>
</div>
{diffOpen && (
<DiffPanel
prev={previousResult}
curr={{
SORTED_ORDER: D.SORTED_ORDER || [],
MOD_DB: D.MOD_DB || [],
MODS_LINE: D.MODS_LINE || '',
WORKSHOP_ITEMS_LINE: D.WORKSHOP_ITEMS_LINE || '',
}}
inputWsids={inputWsidList}
outputWsids={(D.WORKSHOP_ITEMS_LINE || '').replace(/;+$/, '').split(';').filter(Boolean)}
onClose={() => setDiffOpen(false)}
/>
)}
@@ -1434,15 +1396,16 @@ function App() {
const pollAbortRef = useRef(null);
const [activeJobId, setActiveJobId] = useState(null);
const [expandedWsids, setExpandedWsids] = useState(() => new Set());
// Diff: snapshot of the result that's about to be replaced; toggle for the
// panel. Snapshot is taken at the START of any sort/resort - polling-mid-flight
// ticks don't snapshot so the user's prior visible state stays available.
const previousResultRef = useRef(null);
// Diff toggle. The diff is input-vs-output (textarea wsids vs sorted
// WORKSHOP_ITEMS_LINE), recomputed live every render — no snapshot ref
// needed. Always available, even on the first sort.
const [diffOpen, setDiffOpen] = useState(false);
// Set of wsids currently in the input textarea, used by warning rows to
// derive their staged state. Memoized off `input` so re-renders triggered
// by unrelated state changes don't churn the Set.
const inputWsids = useMemo(() => new Set(parseWorkshopInput(input)), [input]);
// Ordered list of wsids currently in the input textarea (deduped, first-seen
// order). The Set wrapper below is for warning rows that need O(1) `.has()`
// lookup when deriving their staged state. Both are memoized off `input` so
// re-renders triggered by unrelated state changes don't churn them.
const inputWsidList = useMemo(() => parseWorkshopInput(input), [input]);
const inputWsids = useMemo(() => new Set(inputWsidList), [inputWsidList]);
// True when the user has staged edits since the last successful sort. Stays
// false until the first sort completes (lastSortedInputRef starts null).
const sortPending = lastSortedInputRef.current !== null && input !== lastSortedInputRef.current;
@@ -1589,8 +1552,6 @@ function App() {
const onRetry = () => onSort();
async function runResort(nextSelections) {
// Snapshot for the diff panel: capture state before this resort.
previousResultRef.current = snapshotForDiff(_liveSortData);
// Compose the flat list of selected mod_ids from MOD_DB + nextSelections.
// For wsids not in nextSelections, use the §4 default (all-ticked or
// first-only depending on radio mode). For wsids with N=1, include the
@@ -1794,9 +1755,6 @@ function App() {
// event as arg 0 - reject anything that isn't a string and fall back
// to the state input.
const submitInput = typeof inputOverride === 'string' ? inputOverride : input;
// Snapshot the about-to-be-replaced result so the [diff] button can
// surface what changed. Skip if there's nothing meaningful yet.
previousResultRef.current = snapshotForDiff(_liveSortData);
clearTimers();
setState('loading');
setProgress(15);
@@ -2065,7 +2023,7 @@ function App() {
onAutoFixSwaps={onAutoFixSwaps}
onAutoFixRemoves={onAutoFixRemoves}
onRetry={onRetry}
previousResult={previousResultRef.current}
inputWsidList={inputWsidList}
diffOpen={diffOpen}
setDiffOpen={setDiffOpen}
/>

View File

@@ -431,7 +431,13 @@ def build_manifest_and_types(
or suffix in {".png", ".dds"}):
if path.name.lower() != "poster.png":
tags.add("Textures")
if rel_below.startswith(("scripts/vehicles/", "scripts/vehicle")):
# Path-based vehicle detection. scripts/vehicles is the universal
# signal; models[_x]/vehicles/ catches mods that ship 3D assets
# without scripts (rarer, but real — borrowed from HellDrinx Mod
# Manager's heuristic). rel_below is already lowercased so the
# capital "X" in the original PZ "models_X" path lands as "models_x".
if rel_below.startswith(("scripts/vehicles/", "scripts/vehicle",
"models_x/vehicles/", "models/vehicles/")):
tags.add("Vehicles")
if rel_below.startswith(("clothing/", "scripts/clothing/")):
tags.add("Clothing")