From a6cfd3e16ea5d8059e658cae1014c76424030fb2 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Tue, 18 Mar 2025 20:42:22 +0100 Subject: [PATCH] Use fucking graphs again --- lib/mod.dart | 11 + lib/mod_list.dart | 133 ++++++++- test/mod_list_regressive_test.dart | 417 ++++++++++++++++++++++++++++- test/mod_list_test.dart | 20 +- 4 files changed, 556 insertions(+), 25 deletions(-) diff --git a/lib/mod.dart b/lib/mod.dart index 25df596..f1603cc 100644 --- a/lib/mod.dart +++ b/lib/mod.dart @@ -47,6 +47,17 @@ class Mod { this.enabled = false, }); + int get tier { + if (isBaseGame) return 0; + if (isExpansion) return 1; + return 2; + } + + @override + String toString() { + return 'Mod{name: $name, id: $id, path: $path, dependencies: $dependencies, loadAfter: $loadAfter, loadBefore: $loadBefore, incompatibilities: $incompatibilities, size: $size, isBaseGame: $isBaseGame, isExpansion: $isExpansion}'; + } + static Mod fromDirectory(String path, {bool skipFileCount = false}) { final logger = Logger.instance; final stopwatch = Stopwatch()..start(); diff --git a/lib/mod_list.dart b/lib/mod_list.dart index 7d88311..23219a6 100644 --- a/lib/mod_list.dart +++ b/lib/mod_list.dart @@ -255,34 +255,145 @@ class ModList { LoadOrder generateLoadOrder([LoadOrder? loadOrder]) { loadOrder ??= LoadOrder(); final logger = Logger.instance; + logger.info('Generating load order...'); + for (final mod in activeMods.values) { + logger.info('Checking mod: ${mod.id}'); + logger.info('Mod details: ${mod.toString()}'); for (final incomp in mod.incompatibilities) { if (activeMods.containsKey(incomp)) { loadOrder.errors.add( 'Incompatibility detected: ${mod.id} is incompatible with $incomp', ); + logger.warning( + 'Incompatibility detected: ${mod.id} is incompatible with $incomp', + ); + } else { + logger.info('No incompatibility found for: $incomp'); + } + } + for (final dep in mod.dependencies) { + if (!activeMods.containsKey(dep)) { + loadOrder.errors.add('Missing dependency: ${mod.id} requires $dep'); + logger.warning('Missing dependency: ${mod.id} requires $dep'); + } else { + logger.info('Dependency found: ${mod.id} requires $dep'); } } } + logger.info('Adding active mods to load order...'); loadOrder.order.addAll(activeMods.values.toList()); + logger.info( + 'Active mods added: ${loadOrder.order.map((mod) => mod.id).join(', ')}', + ); - loadOrder.order.sort((a, b) { - if (a.isBaseGame && !b.isBaseGame) return -1; - if (!a.isBaseGame && b.isBaseGame) return 1; - if (a.isExpansion && !b.isExpansion) return -1; - if (!a.isExpansion && b.isExpansion) return 1; + final modMap = {for (final mod in loadOrder.order) mod.id: mod}; + final graph = >{}; + final inDegree = {}; + + // Step 1: Initialize graph and inDegree + for (final mod in loadOrder.order) { + graph[mod.id] = {}; + inDegree[mod.id] = 0; + } + + // Step 2: Build dependency graph + void addEdge(String from, String to) { + final fromMod = modMap[from]; + if (fromMod == null) { + logger.warning('Missing dependency: $from'); + return; + } + final toMod = modMap[to]; + if (toMod == null) { + logger.warning('Missing dependency: $to'); + return; + } + if (graph[from]!.add(to)) { + inDegree[to] = inDegree[to]! + 1; + } + } + + for (final mod in loadOrder.order) { + for (final target in mod.loadBefore) { + addEdge(mod.id, target); + } + for (final target in mod.loadAfter) { + addEdge(target, mod.id); + } + for (final dep in mod.dependencies) { + addEdge(dep, mod.id); + } + } + + // Step 3: Calculate tiers dynamically with cross-tier dependencies + final tiers = {}; + for (final mod in loadOrder.order) { + int tier = 2; // Default to Tier 2 + + // Check if mod loads before any base game mod (Tier 0) + final loadsBeforeBase = mod.loadBefore.any( + (id) => modMap[id]?.isBaseGame ?? false, + ); + if (mod.isBaseGame || loadsBeforeBase) { + tier = 0; + } else { + // Check if mod loads before any expansion (Tier 1) + final loadsBeforeExpansion = mod.loadBefore.any( + (id) => modMap[id]?.isExpansion ?? false, + ); + if (mod.isExpansion || loadsBeforeExpansion) { + tier = 1; + } + } + + tiers[mod] = tier; + } + + // Step 4: Global priority queue (tier ascending, size descending) + final pq = PriorityQueue((a, b) { + final tierA = tiers[a]!; + final tierB = tiers[b]!; + if (tierA != tierB) return tierA.compareTo(tierB); return b.size.compareTo(a.size); }); - Map> relations = {}; - for (int i = loadOrder.order.length - 1; i >= 0; i--) { - final mod = loadOrder!.order[i]; - logger.info('Processing mod: ${mod.id}'); - loadOrder = shuffleMod(mod, loadOrder, relations); + // Initialize queue with mods having inDegree 0 + for (final mod in loadOrder.order) { + if (inDegree[mod.id] == 0) { + pq.add(mod); + } } - return loadOrder!; + final orderedMods = []; + while (pq.isNotEmpty) { + final current = pq.removeFirst(); + orderedMods.add(current); + + for (final neighborId in graph[current.id]!) { + inDegree[neighborId] = inDegree[neighborId]! - 1; + if (inDegree[neighborId] == 0) { + final neighbor = modMap[neighborId]!; + pq.add(neighbor); + } + } + } + if (orderedMods.length != loadOrder.order.length) { + loadOrder.errors.add('Cycle detected in dependencies'); + logger.warning( + 'Cycle detected in dependencies: expected ${loadOrder.order.length}, got ${orderedMods.length}.', + ); + } + + loadOrder.order = orderedMods; + logger.info( + 'Load order generated successfully with ${loadOrder.order.length} mods.', + ); + for (final mod in loadOrder.order) { + logger.info('Mod: ${mod.toString()}'); + } + return loadOrder; } // The point of relations and the recursive call is to handle the case where diff --git a/test/mod_list_regressive_test.dart b/test/mod_list_regressive_test.dart index 7ebb715..138bd2d 100644 --- a/test/mod_list_regressive_test.dart +++ b/test/mod_list_regressive_test.dart @@ -200,18 +200,18 @@ void main() { final expected = [ 'brrainz.harmony', 'ludeon.rimworld', - 'ludeon.rimworld.royalty', - 'ludeon.rimworld.ideology', - 'ludeon.rimworld.biotech', 'ludeon.rimworld.anomaly', + 'ludeon.rimworld.biotech', + 'ludeon.rimworld.ideology', + 'ludeon.rimworld.royalty', 'dubwise.rimatomics', 'jecrell.doorsexpanded', 'dubwise.rimefeller', 'neronix17.toolbox', 'automatic.bionicicons', 'lwm.deepstorage', - 'dubwise.dubsmintmenus', 'dubwise.dubsmintminimap', + 'dubwise.dubsmintmenus', 'brrainz.justignoremepassing', ]; expect(result.errors, isEmpty); @@ -299,6 +299,415 @@ void main() { list.enableAll(); final order = list.generateLoadOrder(); + final expected = [ + 'zetrith.prepatcher', + 'brrainz.harmony', + 'ludeon.rimworld', + 'bs.betterlog', + 'ludeon.rimworld.anomaly', + 'ludeon.rimworld.royalty', + 'ludeon.rimworld.ideology', + 'ludeon.rimworld.biotech', + ]; + expect(order.loadOrder, equals(expected)); + }); + test('Expansions should load before most mods', () { + final list = ModList(); + list.mods = { + 'bs.betterlog': makeDummy().copyWith( + id: 'bs.betterlog', + name: 'Better Log - Fix your errors', + enabled: true, + size: 69, + dependencies: [], + loadAfter: [ + 'brrainz.harmony', + 'me.samboycoding.betterloading', + 'zetrith.prepatcher', + 'ludeon.rimworld', + ], + loadBefore: [ + 'ludeon.rimworld.royalty', + 'ludeon.rimworld.ideology', + 'ludeon.rimworld.biotech', + 'ludeon.rimworld.anomaly', + 'bs.performance', + 'unlimitedhugs.hugslib', + ], + incompatibilities: [], + ), + 'zetrith.prepatcher': makeDummy().copyWith( + id: 'zetrith.prepatcher', + name: 'Prepatcher', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2934420800', + dependencies: [], + loadAfter: [], + loadBefore: ['ludeon.rimworld', 'brrainz.harmony'], + incompatibilities: [], + size: 21, + isBaseGame: false, + isExpansion: false, + ), + 'brrainz.harmony': makeDummy().copyWith( + id: 'brrainz.harmony', + name: 'Harmony', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2009463077', + dependencies: [], + loadAfter: [], + loadBefore: ['ludeon.rimworld'], + incompatibilities: [], + size: 17, + isBaseGame: false, + isExpansion: false, + ), + 'ludeon.rimworld': makeDummy().copyWith( + id: 'ludeon.rimworld', + name: 'RimWorld', + path: '', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 0, + isBaseGame: true, + isExpansion: false, + ), + 'rah.rbse': makeDummy().copyWith( + id: 'rah.rbse', + name: 'RBSE', + path: 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\850429707', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 1729, + isBaseGame: false, + isExpansion: false, + ), + 'mlie.usethisinstead': makeDummy().copyWith( + id: 'mlie.usethisinstead', + name: 'Use This Instead', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3396308787', + dependencies: [], + loadAfter: ['brrainz.harmony'], + loadBefore: [], + incompatibilities: [], + size: 1651, + isBaseGame: false, + isExpansion: false, + ), + 'dubwise.rimatomics': makeDummy().copyWith( + id: 'dubwise.rimatomics', + name: 'Dubs Rimatomics', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\1127530465', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 1563, + isBaseGame: false, + isExpansion: false, + ), + 'jecrell.doorsexpanded': makeDummy().copyWith( + id: 'jecrell.doorsexpanded', + name: 'Doors Expanded', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\1316188771', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 765, + isBaseGame: false, + isExpansion: false, + ), + 'balistafreak.stopdropandroll': makeDummy().copyWith( + id: 'balistafreak.stopdropandroll', + name: 'Stop, Drop, And Roll! [BAL]', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2362707956', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 755, + isBaseGame: false, + isExpansion: false, + ), + 'fluffy.animaltab': makeDummy().copyWith( + id: 'fluffy.animaltab', + name: 'Animal Tab', + path: 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\712141500', + dependencies: ['brrainz.harmony'], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 752, + isBaseGame: false, + isExpansion: false, + ), + 'gt.sam.glittertech': makeDummy().copyWith( + id: 'gt.sam.glittertech', + name: 'Glitter Tech Classic', + path: 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\725576127', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 747, + isBaseGame: false, + isExpansion: false, + ), + 'dubwise.rimefeller': makeDummy().copyWith( + id: 'dubwise.rimefeller', + name: 'Rimefeller', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\1321849735', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 744, + isBaseGame: false, + isExpansion: false, + ), + 'darthcy.misc.morebetterdeepdrill': makeDummy().copyWith( + id: 'darthcy.misc.morebetterdeepdrill', + name: 'More and Better Deep Drill', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3378527302', + dependencies: [], + loadAfter: ['brrainz.harmony', 'spdskatr.projectrimfactory'], + loadBefore: [], + incompatibilities: [], + size: 738, + isBaseGame: false, + isExpansion: false, + ), + 'haplo.miscellaneous.training': makeDummy().copyWith( + id: 'haplo.miscellaneous.training', + name: 'Misc. Training', + path: 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\717575199', + dependencies: [], + loadAfter: ['haplo.miscellaneous.core'], + loadBefore: [], + incompatibilities: ['haplo.miscellaneous.trainingnotask'], + size: 733, + isBaseGame: false, + isExpansion: false, + ), + 'linkolas.stabilize': makeDummy().copyWith( + id: 'linkolas.stabilize', + name: 'Stabilize', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2023407836', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 627, + isBaseGame: false, + isExpansion: false, + ), + 'dubwise.dubsperformanceanalyzer.steam': makeDummy().copyWith( + id: 'dubwise.dubsperformanceanalyzer.steam', + name: 'Dubs Performance Analyzer', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2038874626', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 570, + isBaseGame: false, + isExpansion: false, + ), + 'memegoddess.searchanddestroy': makeDummy().copyWith( + id: 'memegoddess.searchanddestroy', + name: 'Search and Destroy (Unofficial Update)', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3232242247', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 495, + isBaseGame: false, + isExpansion: false, + ), + 'gogatio.mechanoidupgrades': makeDummy().copyWith( + id: 'gogatio.mechanoidupgrades', + name: 'Mechanoid Upgrades', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3365118555', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 487, + isBaseGame: false, + isExpansion: false, + ), + 'issaczhuang.muzzleflash': makeDummy().copyWith( + id: 'issaczhuang.muzzleflash', + name: 'Muzzle Flash', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2917732219', + dependencies: [], + loadAfter: ['ludeon.rimworld'], + loadBefore: [], + incompatibilities: [], + size: 431, + isBaseGame: false, + isExpansion: false, + ), + 'smashphil.vehicleframework': makeDummy().copyWith( + id: 'smashphil.vehicleframework', + name: 'Vehicle Framework', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3014915404', + dependencies: [], + loadAfter: ['brrainz.harmony'], + loadBefore: [], + incompatibilities: [], + size: 426, + isBaseGame: false, + isExpansion: false, + ), + 'cabbage.rimcities': makeDummy().copyWith( + id: 'cabbage.rimcities', + name: 'RimCities', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\1775170117', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 421, + isBaseGame: false, + isExpansion: false, + ), + 'vis.staticquality': makeDummy().copyWith( + id: 'vis.staticquality', + name: 'Static Quality', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2801204005', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 385, + isBaseGame: false, + isExpansion: false, + ), + 'automatic.bionicicons': makeDummy().copyWith( + id: 'automatic.bionicicons', + name: 'Bionic icons', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\1677616980', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 365, + isBaseGame: false, + isExpansion: false, + ), + 'vanillaexpanded.vanillatraitsexpanded': makeDummy().copyWith( + id: 'vanillaexpanded.vanillatraitsexpanded', + name: 'Vanilla Traits Expanded', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2296404655', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 338, + isBaseGame: false, + isExpansion: false, + ), + 'tk421storm.ragdoll': makeDummy().copyWith( + id: 'tk421storm.ragdoll', + name: 'Ragdoll Physics', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3179116177', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 329, + isBaseGame: false, + isExpansion: false, + ), + 'andromeda.nicehealthtab': makeDummy().copyWith( + id: 'andromeda.nicehealthtab', + name: 'Nice Health Tab', + path: + 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3328729902', + dependencies: [], + loadAfter: [], + loadBefore: [], + incompatibilities: [], + size: 319, + isBaseGame: false, + isExpansion: false, + ), + 'ludeon.rimworld.anomaly': makeDummy().copyWith( + id: 'ludeon.rimworld.anomaly', + name: 'RimWorld Anomaly', + path: '', + dependencies: [], + loadAfter: ['ludeon.rimworld'], + loadBefore: [], + incompatibilities: [], + size: 0, + isBaseGame: false, + isExpansion: true, + ), + 'ludeon.rimworld.biotech': makeDummy().copyWith( + id: 'ludeon.rimworld.biotech', + name: 'RimWorld Biotech', + path: '', + dependencies: [], + loadAfter: ['ludeon.rimworld'], + loadBefore: [], + incompatibilities: [], + size: 0, + isBaseGame: false, + isExpansion: true, + ), + 'ludeon.rimworld.ideology': makeDummy().copyWith( + id: 'ludeon.rimworld.ideology', + name: 'RimWorld Ideology', + path: '', + dependencies: [], + loadAfter: ['ludeon.rimworld'], + loadBefore: [], + incompatibilities: [], + size: 0, + isBaseGame: false, + isExpansion: true, + ), + 'ludeon.rimworld.royalty': makeDummy().copyWith( + id: 'ludeon.rimworld.royalty', + name: 'RimWorld Royalty', + path: '', + dependencies: [], + loadAfter: ['ludeon.rimworld'], + loadBefore: [], + incompatibilities: [], + size: 0, + isBaseGame: false, + isExpansion: true, + ), + }; + list.enableAll(); + final order = list.generateLoadOrder(); + final expected = [ 'zetrith.prepatcher', 'brrainz.harmony', diff --git a/test/mod_list_test.dart b/test/mod_list_test.dart index 432ee35..96d713a 100644 --- a/test/mod_list_test.dart +++ b/test/mod_list_test.dart @@ -245,7 +245,7 @@ void main() { final result = list.loadRequired(); // We say the mods are incompatible but load them anyway, who are we to decide what isn't loaded? - final expected = ['incompatible', 'harmony', 'prepatcher']; + final expected = ['harmony', 'prepatcher', 'incompatible']; expect(result.errors, isNotEmpty); expect(result.errors.any((e) => e.contains('incompatible')), isTrue); expect(result.errors.any((e) => e.contains('harmony')), isTrue); @@ -302,7 +302,7 @@ void main() { // We try to not disable mods...... But cyclic dependencies are just hell // Can not handle it - final expected = ['modB', 'modA', 'modC']; + final expected = []; expect(result.errors, isNotEmpty); expect(result.errors.any((e) => e.contains('modA')), isTrue); expect(result.errors.any((e) => e.contains('modB')), isTrue); @@ -487,7 +487,7 @@ void main() { list.enableAll(); final result = list.generateLoadOrder(); - final expected = ['modA', 'modB']; + final expected = ['modB', 'modA']; expect(result.errors, isNotEmpty); expect(result.errors.any((e) => e.contains('missing1')), isTrue); expect(result.errors.any((e) => e.contains('missing2')), isTrue); @@ -548,7 +548,7 @@ void main() { list.enableAll(); final result = list.generateLoadOrder(); - final expected = ['modA', 'modB']; + final expected = ['modB', 'modA']; expect(result.errors, isNotEmpty); expect(result.errors.any((e) => e.contains('incompatible')), isTrue); expect(result.errors.any((e) => e.contains('modA')), isTrue); @@ -570,7 +570,7 @@ void main() { list.enableAll(); final result = list.generateLoadOrder(); - final expected = ['modA', 'modB']; + final expected = ['modB', 'modA']; expect(result.errors, isNotEmpty); expect(result.errors.any((e) => e.contains('missingDep')), isTrue); expect(result.errors.any((e) => e.contains('incompatible')), isTrue); @@ -653,7 +653,7 @@ void main() { list.enableAll(); final result = list.generateLoadOrder(); - final expected = ['modA', 'modB']; + final expected = ['modB', 'modA']; expect(result.errors, isEmpty); expect(result.loadOrder, equals(expected)); @@ -691,7 +691,7 @@ void main() { list.enableAll(); final result = list.generateLoadOrder(); - final expected = ['modA', 'modB']; + final expected = ['modB', 'modA']; expect(result.errors, isNotEmpty); expect(result.errors.any((e) => e.contains('nonExistentMod')), isTrue); @@ -778,7 +778,6 @@ void main() { final result = list.generateLoadOrder(); final expected = ['existingMod', 'modA']; - // Should still generatdeequals(mopected) expect(result.loadOrder.contains('existingMod'), isTrue); @@ -809,7 +808,8 @@ void main() { }; list.enableAll(); - final expected = ['modA']; final result = list.generateLoadOrder(); + final expected = ['modA']; + final result = list.generateLoadOrder(); // Should report the missing dependency expect(result.errors, isNotEmpty); @@ -852,7 +852,7 @@ void main() { list.enableAll(); final result = list.generateLoadOrder(); - final expected = ['modB', 'modC', 'modA']; + final expected = ['modC', 'modB', 'modA']; // Should report all missing dependencies expect(result.errors, isNotEmpty);