From 72b6f3486d3eab595abb33ef9051f11669ca3c0f Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Mon, 17 Mar 2025 23:46:25 +0100 Subject: [PATCH] Rework everything to be less dogshit --- lib/mod.dart | 5 +- lib/mod_list.dart | 551 +++++++++++----------------------------- test/mod_list_test.dart | 111 ++++---- 3 files changed, 205 insertions(+), 462 deletions(-) diff --git a/lib/mod.dart b/lib/mod.dart index 0c2fd2f..ce80c52 100644 --- a/lib/mod.dart +++ b/lib/mod.dart @@ -31,9 +31,8 @@ class Mod { final bool isBaseGame; // Is this the base RimWorld game final bool isExpansion; // Is this a RimWorld expansion - bool visited = false; - bool mark = false; - int position = -1; + int loadBeforeNotPlaced = 0; + int loadAfterPlaced = 0; Mod({ required this.name, diff --git a/lib/mod_list.dart b/lib/mod_list.dart index 44c9f8a..3583545 100644 --- a/lib/mod_list.dart +++ b/lib/mod_list.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:rimworld_modman/logger.dart'; import 'package:rimworld_modman/mod.dart'; import 'package:xml/xml.dart'; @@ -22,6 +23,21 @@ class ModList { ModList({this.configPath = '', this.modsPath = ''}); + ModList copyWith({ + String? configPath, + String? modsPath, + Map? mods, + Map? activeMods, + }) { + final newModlist = ModList( + configPath: configPath ?? this.configPath, + modsPath: modsPath ?? this.modsPath, + ); + newModlist.mods = Map.from(mods ?? this.mods); + newModlist.activeMods = Map.from(activeMods ?? this.activeMods); + return newModlist; + } + Stream loadAvailable() async* { final logger = Logger.instance; final stopwatch = Stopwatch()..start(); @@ -219,438 +235,171 @@ class ModList { } } - List> checkIncompatibilities(List activeModIds) { - List> conflicts = []; + //LoadOrder loadRequired([LoadOrder? loadOrder]) { + // loadOrder ??= LoadOrder(); + // final toEnable = []; + // for (final modid in activeMods.keys) { + // loadDependencies(modid, loadOrder, toEnable); + // } + // for (final modid in toEnable) { + // setEnabled(modid, true); + // } + // return generateLoadOrder(loadOrder); + //} - // Only check each pair once - for (final modId in activeModIds) { - final mod = mods[modId]!; + LoadOrder generateLoadOrder() { + final modMap = {for (final m in mods.values) m.id: m}; + _validateIncompatibilities(mods.values.toList()); - for (final incompId in mod.incompatibilities) { - // Only process if other mod is active and we haven't checked this pair yet - if (activeMods.containsKey(incompId)) { - conflicts.add([modId, incompId]); - } - } - } - return conflicts; - } + // Hard dependency graph + final inDegree = {}; + final adjacency = >{}; - /// Generate a load order for active mods - LoadOrder generateLoadOrder([LoadOrder? loadOrder]) { - loadOrder ??= LoadOrder(); - // Check for incompatibilities first - final conflicts = checkIncompatibilities(activeMods.keys.toList()); - if (conflicts.isNotEmpty) { - for (final conflict in conflicts) { - loadOrder.errors.add( - "Incompatible mods selected: ${conflict[0]} and ${conflict[1]}", - ); - } + // Soft constraint reverse mappings + final reverseLoadBefore = >{}; + final reverseLoadAfter = >{}; + + // Initialize data structures + for (final mod in mods.values) { + mod.loadBeforeNotPlaced = mod.loadBefore.length; + mod.loadAfterPlaced = 0; + + reverseLoadBefore[mod.id] = []; + reverseLoadAfter[mod.id] = []; + inDegree[mod.id] = 0; + adjacency[mod.id] = []; } - // Check for missing dependencies - for (final modId in activeMods.keys) { - final mod = mods[modId]!; + // Build dependency graph and reverse soft constraints + for (final mod in mods.values) { for (final depId in mod.dependencies) { - if (!mods.containsKey(depId)) { - loadOrder.errors.add( - "Missing dependency: ${mod.name} requires mod with ID $depId", - ); + adjacency[depId]!.add(mod.id); + inDegree[mod.id] = (inDegree[mod.id] ?? 0) + 1; + } + + for (final targetId in mod.loadBefore) { + final target = modMap[targetId]; + if (target != null) { + reverseLoadBefore[targetId]!.add(mod); + } + } + + for (final targetId in mod.loadAfter) { + final target = modMap[targetId]; + if (target != null) { + reverseLoadAfter[targetId]!.add(mod); } } } - // Reset all marks for topological sort - for (final mod in mods.values) { - mod.visited = false; - mod.mark = false; - mod.position = -1; - } - - int position = 0; - - // Topological sort - void visit(Mod mod, LoadOrder loadOrder) { - if (!mod.enabled) { - mod.visited = true; - return; + final heap = PriorityQueue((a, b) { + // 1. Base game first + if (a.isBaseGame != b.isBaseGame) { + return a.isBaseGame ? -1 : 1; } - if (mod.mark) { - final cyclePath = - mods.values.where((m) => m.mark).map((m) => m.name).toList(); - loadOrder.errors.add( - "Cyclic dependency detected: ${cyclePath.join(' -> ')}", - ); - return; + // 2. Expansions next + if (a.isExpansion != b.isExpansion) { + return a.isExpansion ? -1 : 1; } - - if (!mod.visited) { - mod.mark = true; - - // Visit all dependencies - for (String depId in mod.dependencies) { - if (activeMods.containsKey(depId)) { - visit(mods[depId]!, loadOrder); - } - } - - mod.mark = false; - mod.visited = true; - mod.position = position++; - loadOrder.loadOrder.add(mod.id); + // 3. Soft constraints: Prioritize mods that need to be placed earlier + final aUnmetBefore = a.loadBeforeNotPlaced; + final bUnmetBefore = b.loadBeforeNotPlaced; + if (aUnmetBefore != bUnmetBefore) { + return bUnmetBefore.compareTo(aUnmetBefore); // Higher unmetBefore first } - } - - // Visit all nodes - for (final mod in mods.values) { - if (!mod.visited) { - visit(mod, loadOrder); + // If tied, deprioritize mods with more unmet `loadAfter` + final aUnmetAfter = a.loadAfter.length - a.loadAfterPlaced; + final bUnmetAfter = b.loadAfter.length - b.loadAfterPlaced; + if (aUnmetAfter != bUnmetAfter) { + return aUnmetAfter.compareTo(bUnmetAfter); // Lower unmetAfter first } - } - - // Optimize for soft constraints - _optimizeSoftConstraints(loadOrder: loadOrder); - for (final modId in loadOrder.loadOrder) { - final mod = mods[modId]!; - print( - 'Mod ID: ${mod.id}, Name: ${mod.name}, Enabled: ${mod.enabled}, Size: ${mod.size}, Dependencies: ${mod.dependencies}, Load After: ${mod.loadAfter}, Load Before: ${mod.loadBefore}, Incompatibilities: ${mod.incompatibilities}', - ); - } - return loadOrder; - } - - /// Calculate how many soft constraints are satisfied - Map _calculateSoftConstraintsScore(List order) { - Map positions = {}; - for (int i = 0; i < order.length; i++) { - positions[order[i]] = i; - } - - int satisfied = 0; - int total = 0; - - for (String modId in order) { - Mod mod = mods[modId]!; - - // Check "load before" preferences - for (String beforeId in mod.loadBefore) { - if (positions.containsKey(beforeId)) { - total++; - if (positions[modId]! < positions[beforeId]!) { - satisfied++; - } - } - } - - // Check "load after" preferences - for (String afterId in mod.loadAfter) { - if (positions.containsKey(afterId)) { - total++; - if (positions[modId]! > positions[afterId]!) { - satisfied++; - } - } - } - } - - return {'satisfied': satisfied, 'total': total}; - } - - /// Optimize for soft constraints using a greedy approach - LoadOrder _optimizeSoftConstraints({ - int maxIterations = 5, - LoadOrder? loadOrder, - }) { - loadOrder ??= LoadOrder(); - - // First, ensure base game and expansions are at the beginning in the correct order - List baseAndExpansions = []; - List harmony = []; - List otherMods = []; - - // Separate mods into categories - for (final modId in loadOrder.loadOrder) { - final mod = mods[modId]!; - if (modId == 'brrainz.harmony') { - harmony.add(modId); - } else if (mod.isBaseGame) { - baseAndExpansions.add(modId); - } else if (mod.isExpansion) { - baseAndExpansions.add(modId); - } else { - otherMods.add(modId); - } - } - - // Sort expansions to ensure correct order - baseAndExpansions.sort((a, b) { - final modA = mods[a]!; - final modB = mods[b]!; - - // Base game always first - if (modA.isBaseGame) return -1; - if (modB.isBaseGame) return 1; - - // Sort expansions alphabetically by ID (which should work for Ludeon expansions) - return a.compareTo(b); + // 4. Smaller size last + return b.size.compareTo(a.size); }); - // Combine the lists with harmony first, then base game and expansions, then other mods - loadOrder.loadOrder.clear(); - loadOrder.loadOrder.addAll(harmony); - loadOrder.loadOrder.addAll(baseAndExpansions); - - // Now apply the normal optimization for the remaining mods - List remainingMods = otherMods; - - Map scoreInfo = _calculateSoftConstraintsScore(remainingMods); - int bestScore = scoreInfo['satisfied']!; - int total = scoreInfo['total']!; - - if (total == 0 || bestScore == total) { - // All constraints satisfied or no constraints for remaining mods, sort by size where possible - _sortSizeWithinConstraints(loadOrder: loadOrder, modList: remainingMods); - loadOrder.loadOrder.addAll(remainingMods); - return loadOrder; + // Initialize heap with available mods + for (final modId in activeMods.keys) { + final mod = modMap[modId]; + if (mod != null && inDegree[modId] == 0) { + heap.add(mod); + } } - // Use a limited number of improvement passes for the remaining mods - for (int iteration = 0; iteration < maxIterations; iteration++) { - bool improved = false; + final sortedMods = []; + while (heap.isNotEmpty) { + final current = heap.removeFirst(); + sortedMods.add(current); - // Try moving each mod to improve score - for (int i = 0; i < remainingMods.length; i++) { - String modId = remainingMods[i]; - Mod mod = mods[modId]!; - - // Calculate current local score for this mod - Map currentPositions = {}; - for (int idx = 0; idx < remainingMods.length; idx++) { - currentPositions[remainingMods[idx]] = idx; - } - - // Try moving this mod to different positions - for (int newPos = 0; newPos < remainingMods.length; newPos++) { - if (newPos == i) continue; - - // Skip if move would break hard dependencies - bool skip = false; - if (newPos < i) { - // Moving earlier - // Check if any mod between newPos and i depends on this mod - for (int j = newPos; j < i; j++) { - String depModId = remainingMods[j]; - if (mods[depModId]!.dependencies.contains(modId)) { - skip = true; - break; - } - } - } else { - // Moving later - // Check if this mod depends on any mod between i and newPos - for (int j = i + 1; j <= newPos; j++) { - String depModId = remainingMods[j]; - if (mod.dependencies.contains(depModId)) { - skip = true; - break; - } - } - } - - if (skip) continue; - - // Create a new order with the mod moved - List newOrder = List.from(remainingMods); - newOrder.removeAt(i); - newOrder.insert(newPos, modId); - - // Calculate new score - Map newScoreInfo = _calculateSoftConstraintsScore( - newOrder, - ); - int newScore = newScoreInfo['satisfied']!; - - if (newScore > bestScore) { - bestScore = newScore; - remainingMods.clear(); - remainingMods.addAll(newOrder); - improved = true; - break; // Break inner loop, move to next mod - } - } - - if (improved) break; // If improved, start a new iteration - } - - if (!improved) break; // If no improvements in this pass, stop - } - - // Sort by size where possible for the remaining mods - _sortSizeWithinConstraints(loadOrder: loadOrder, modList: remainingMods); - loadOrder.loadOrder.addAll(remainingMods); - - return loadOrder; - } - - /// Sort mods by size within compatible groups - LoadOrder _sortSizeWithinConstraints({ - LoadOrder? loadOrder, - List? modList, - }) { - loadOrder ??= LoadOrder(); - List modsToSort = modList ?? loadOrder.loadOrder; - - // Find groups of mods that can be reordered without breaking constraints - List> groups = []; - List currentGroup = []; - - for (int i = 0; i < modsToSort.length; i++) { - String modId = modsToSort[i]; - Mod mod = mods[modId]!; - - if (currentGroup.isEmpty) { - currentGroup.add(modId); - continue; - } - - // Check if this mod can join the current group - bool canJoin = true; - for (String groupModId in currentGroup) { - Mod groupMod = mods[groupModId]!; - - // Check hard dependencies - if (mod.dependencies.contains(groupModId) || - groupMod.dependencies.contains(modId)) { - canJoin = false; - break; - } - - // Check soft constraints - if (mod.loadAfter.contains(groupModId) || - groupMod.loadBefore.contains(modId) || - mod.loadBefore.contains(groupModId) || - groupMod.loadAfter.contains(modId)) { - canJoin = false; - break; + // Update dependents' in-degree + for (final neighborId in adjacency[current.id]!) { + inDegree[neighborId] = inDegree[neighborId]! - 1; + if (inDegree[neighborId] == 0) { + heap.add(modMap[neighborId]!); } } - if (canJoin) { - currentGroup.add(modId); - } else { - // Start a new group - if (currentGroup.isNotEmpty) { - groups.add(List.from(currentGroup)); - currentGroup = [modId]; - } - } - } - - // Add the last group if not empty - if (currentGroup.isNotEmpty) { - groups.add(currentGroup); - } - - // Sort each group by size - for (List group in groups) { - if (group.length > 1) { - group.sort((a, b) => mods[b]!.size.compareTo(mods[a]!.size)); - } - } - - // Reconstruct the order - modsToSort.clear(); - for (List group in groups) { - modsToSort.addAll(group); - } - - // If we were given the loadOrder directly, update it - if (modList == null) { - loadOrder.loadOrder.clear(); - loadOrder.loadOrder.addAll(modsToSort); - } - - return loadOrder; - } - - LoadOrder loadDependencies( - String modId, [ - LoadOrder? loadOrder, - List? toEnable, - Map? seen, - List? cyclePath, - ]) { - final mod = mods[modId]!; - loadOrder ??= LoadOrder(); - toEnable ??= []; - seen ??= {}; - cyclePath ??= []; - - // Add current mod to cycle path - cyclePath.add(modId); - - for (final dep in mod.dependencies) { - if (!mods.containsKey(dep)) { - loadOrder.errors.add( - 'Missing dependency: ${mod.name} requires mod with ID $dep', - ); - continue; - } - final depMod = mods[dep]!; - if (seen[dep] == true) { - // Find the start of the cycle - int cycleStart = cyclePath.indexOf(dep); - if (cycleStart >= 0) { - // Extract the cycle part - List cycleIds = [...cyclePath.sublist(cycleStart), modId]; - loadOrder.errors.add( - 'Cyclic dependency detected: ${cycleIds.join(' -> ')}', - ); - } else { - loadOrder.errors.add('Cyclic dependency detected: $modId -> $dep'); - } - continue; - } - seen[dep] = true; - toEnable.add(depMod.id); - loadDependencies( - depMod.id, - loadOrder, - toEnable, - seen, - List.from(cyclePath), + // Update soft constraints + _updateReverseConstraints( + current, + reverseLoadBefore, + sortedMods, + heap, + (mod) => mod.loadBeforeNotPlaced--, + ); + _updateReverseConstraints( + current, + reverseLoadAfter, + sortedMods, + heap, + (mod) => mod.loadAfterPlaced++, ); } + if (sortedMods.length != mods.length) { + throw Exception("Cyclic dependencies detected"); + } + + final loadOrder = LoadOrder(); + loadOrder.loadOrder.addAll(sortedMods.map((e) => e.id)); return loadOrder; } - LoadOrder loadRequired([LoadOrder? loadOrder]) { - loadOrder ??= LoadOrder(); - final toEnable = []; - for (final modid in activeMods.keys) { - loadDependencies(modid, loadOrder, toEnable); + void _validateIncompatibilities(List mods) { + final enabledMods = mods.where((m) => m.enabled).toList(); + for (final mod in enabledMods) { + for (final incompatibleId in mod.incompatibilities) { + if (enabledMods.any((m) => m.id == incompatibleId)) { + throw Exception("Conflict: ${mod.id} vs $incompatibleId"); + } + } } - for (final modid in toEnable) { - setEnabled(modid, true); - } - return generateLoadOrder(loadOrder); } - ModList copyWith({ - String? configPath, - String? modsPath, - Map? mods, - Map? activeMods, - }) { - final newModlist = ModList( - configPath: configPath ?? this.configPath, - modsPath: modsPath ?? this.modsPath, - ); - newModlist.mods = Map.from(mods ?? this.mods); - newModlist.activeMods = Map.from(activeMods ?? this.activeMods); - return newModlist; + void _updateReverseConstraints( + Mod current, + Map> reverseMap, + List sortedMods, + PriorityQueue heap, + void Function(Mod) update, + ) { + reverseMap[current.id]?.forEach((affectedMod) { + if (!sortedMods.contains(affectedMod)) { + update(affectedMod); + // If mod is already in heap, re-add to update position + if (heap.contains(affectedMod)) { + heap.remove(affectedMod); + heap.add(affectedMod); + } + } + }); + } + + LoadOrder loadRequired() { + final loadOrder = generateLoadOrder(); + for (final modId in loadOrder.loadOrder) { + setEnabled(modId, true); + } + return loadOrder; } } diff --git a/test/mod_list_test.dart b/test/mod_list_test.dart index 90573e4..7d25d3d 100644 --- a/test/mod_list_test.dart +++ b/test/mod_list_test.dart @@ -32,7 +32,11 @@ void main() { test('Harmony should load before RimWorld', () { final list = ModList(); list.mods = { - 'harmony': makeDummy().copyWith(name: 'Harmony', id: 'harmony'), + 'harmony': makeDummy().copyWith( + name: 'Harmony', + id: 'harmony', + loadBefore: ['ludeon.rimworld'], + ), 'ludeon.rimworld': makeDummy().copyWith( name: 'RimWorld', id: 'ludeon.rimworld', @@ -40,11 +44,9 @@ void main() { }; list.enableAll(); final order = list.generateLoadOrder(); - - final harmonyIndex = order.loadOrder.indexOf('harmony'); - final rimworldIndex = order.loadOrder.indexOf('ludeon.rimworld'); + final expected = ['harmony', 'ludeon.rimworld']; expect(order.errors, isEmpty); - expect(harmonyIndex, lessThan(rimworldIndex)); + expect(order.loadOrder, equals(expected)); }); test('Prepatcher should load after Harmony and RimWorld', () { @@ -68,13 +70,9 @@ void main() { }; list.enableAll(); final order = list.generateLoadOrder(); - - final prepatcherIndex = order.loadOrder.indexOf('prepatcher'); - final harmonyIndex = order.loadOrder.indexOf('harmony'); - final rimworldIndex = order.loadOrder.indexOf('ludeon.rimworld'); + final expected = ['harmony', 'ludeon.rimworld', 'prepatcher']; expect(order.errors, isEmpty); - expect(prepatcherIndex, greaterThan(harmonyIndex)); - expect(prepatcherIndex, greaterThan(rimworldIndex)); + expect(order.loadOrder, equals(expected)); }); test('RimWorld should load before Anomaly', () { @@ -87,15 +85,14 @@ void main() { 'ludeon.rimworld.anomaly': makeDummy().copyWith( name: 'RimWorld Anomaly', id: 'ludeon.rimworld.anomaly', + dependencies: ['ludeon.rimworld'], ), }; list.enableAll(); final order = list.generateLoadOrder(); - - final rimworldIndex = order.loadOrder.indexOf('ludeon.rimworld'); - final anomalyIndex = order.loadOrder.indexOf('ludeon.rimworld.anomaly'); + final expected = ['ludeon.rimworld', 'ludeon.rimworld.anomaly']; expect(order.errors, isEmpty); - expect(rimworldIndex, lessThan(anomalyIndex)); + expect(order.loadOrder, equals(expected)); }); test('Disabled dummy mod should not be loaded', () { @@ -108,9 +105,9 @@ void main() { }; list.disableAll(); final order = list.generateLoadOrder(); - - final disabledIndex = order.loadOrder.indexOf('disabledDummy'); - expect(disabledIndex, isNegative); + final expected = []; + expect(order.errors, isEmpty); + expect(order.loadOrder, equals(expected)); }); test('Larger mods should load before smaller ones', () { @@ -125,11 +122,9 @@ void main() { }; list.enableAll(); final order = list.generateLoadOrder(); - - final smolIndex = order.loadOrder.indexOf('smol'); - final yuuugeIndex = order.loadOrder.indexOf('yuuuge'); + final expected = ['yuuuge', 'smol']; expect(order.errors, isEmpty); - expect(yuuugeIndex, lessThan(smolIndex)); + expect(order.loadOrder, equals(expected)); }); test('Incompatible mods should return errors', () { @@ -332,43 +327,43 @@ void main() { }); }); - group('Test conflict detection', () { - test('All conflicts should be correctly identified', () { - final list = ModList(); - list.mods = { - 'modA': makeDummy().copyWith( - name: 'Mod A', - id: 'modA', - incompatibilities: ['modB', 'modC'], - ), - 'modB': makeDummy().copyWith(name: 'Mod B', id: 'modB'), - 'modC': makeDummy().copyWith(name: 'Mod C', id: 'modC'), - }; - list.enableAll(); - final conflicts = list.checkIncompatibilities( - list.activeMods.keys.toList(), - ); - expect(conflicts.length, equals(2)); + //group('Test conflict detection', () { + // test('All conflicts should be correctly identified', () { + // final list = ModList(); + // list.mods = { + // 'modA': makeDummy().copyWith( + // name: 'Mod A', + // id: 'modA', + // incompatibilities: ['modB', 'modC'], + // ), + // 'modB': makeDummy().copyWith(name: 'Mod B', id: 'modB'), + // 'modC': makeDummy().copyWith(name: 'Mod C', id: 'modC'), + // }; + // list.enableAll(); + // final conflicts = list.checkIncompatibilities( + // list.activeMods.keys.toList(), + // ); + // expect(conflicts.length, equals(2)); - // Check if conflicts contain these pairs (order doesn't matter) - expect( - conflicts.any( - (c) => - (c[0] == 'modA' && c[1] == 'modB') || - (c[0] == 'modB' && c[1] == 'modA'), - ), - isTrue, - ); - expect( - conflicts.any( - (c) => - (c[0] == 'modA' && c[1] == 'modC') || - (c[0] == 'modC' && c[1] == 'modA'), - ), - isTrue, - ); - }); - }); + // // Check if conflicts contain these pairs (order doesn't matter) + // expect( + // conflicts.any( + // (c) => + // (c[0] == 'modA' && c[1] == 'modB') || + // (c[0] == 'modB' && c[1] == 'modA'), + // ), + // isTrue, + // ); + // expect( + // conflicts.any( + // (c) => + // (c[0] == 'modA' && c[1] == 'modC') || + // (c[0] == 'modC' && c[1] == 'modA'), + // ), + // isTrue, + // ); + // }); + //}); group('Test enable/disable functionality', () { test('Enable and disable methods should work correctly', () {