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'; class LoadOrder { final List order = []; final List errors = []; List get loadOrder => order.map((e) => e.id).toList(); LoadOrder(); bool get hasErrors => errors.isNotEmpty; } class ModList { String configPath = ''; String modsPath = ''; // O(1) lookup Map activeMods = {}; Map mods = {}; 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(); final directory = Directory(modsPath); if (!directory.existsSync()) { logger.error('Error: Mods root directory does not exist: $modsPath'); return; } final List entities = directory.listSync(); // TODO: Count only the latest version of each mod and not all versions final List modDirectories = entities.whereType().map((dir) => dir.path).toList(); logger.info( 'Found ${modDirectories.length} mod directories (${stopwatch.elapsedMilliseconds}ms)', ); for (final modDir in modDirectories) { try { final modStart = stopwatch.elapsedMilliseconds; // Check if this directory contains a valid mod final aboutFile = File('$modDir/About/About.xml'); if (!aboutFile.existsSync()) { logger.warning('No About.xml found in directory: $modDir'); continue; } final mod = Mod.fromDirectory(modDir); logger.info('Loaded mod from directory: ${mod.name} (ID: ${mod.id})'); if (mods.containsKey(mod.id)) { logger.warning( 'Mod $mod.id already exists in mods list, overwriting', ); final existingMod = mods[mod.id]!; mods[mod.id] = Mod( name: mod.name, id: mod.id, path: mod.path, versions: mod.versions, description: mod.description, dependencies: mod.dependencies, loadAfter: mod.loadAfter, loadBefore: mod.loadBefore, incompatibilities: mod.incompatibilities, size: mod.size, enabled: existingMod.enabled, isBaseGame: existingMod.isBaseGame, isExpansion: existingMod.isExpansion, ); logger.info('Updated existing mod: ${mod.name} (ID: ${mod.id})'); } else { mods[mod.id] = mod; logger.info('Added new mod: ${mod.name} (ID: ${mod.id})'); } final modTime = stopwatch.elapsedMilliseconds - modStart; logger.info( 'Loaded mod from directory: ${mod.name} (ID: ${mod.id}) in $modTime ms', ); yield mod; } catch (e) { logger.error('Error loading mod from directory: $modDir'); logger.error('Error: $e'); } } } Stream loadActive() async* { final logger = Logger.instance; final file = File(configPath); logger.info('Loading configuration from: $configPath'); try { final xmlString = file.readAsStringSync(); logger.info('XML content read successfully.'); final xmlDocument = XmlDocument.parse(xmlString); logger.info('XML document parsed successfully.'); final modConfigData = xmlDocument.findElements("ModsConfigData").first; logger.info('Found ModsConfigData element.'); final modsElement = modConfigData.findElements("activeMods").first; logger.info('Found activeMods element.'); final modElements = modsElement.findElements("li"); logger.info('Found ${modElements.length} active mods.'); // Get the list of known expansions final knownExpansionsElement = modConfigData.findElements("knownExpansions").firstOrNull; final knownExpansionIds = knownExpansionsElement != null ? knownExpansionsElement .findElements("li") .map((e) => e.innerText.toLowerCase()) .toList() : []; logger.info('Found ${knownExpansionIds.length} known expansions.'); // Clear and recreate the mods list for (final modElement in modElements) { final modId = modElement.innerText.toLowerCase(); // Check if this is a special Ludeon mod final isBaseGame = modId == 'ludeon.rimworld'; final isExpansion = !isBaseGame && modId.startsWith('ludeon.rimworld.') && knownExpansionIds.contains(modId); final existingMod = mods[modId]; final mod = Mod( name: existingMod?.name ?? (isBaseGame ? "RimWorld" : isExpansion ? "RimWorld ${_expansionNameFromId(modId)}" : modId), id: existingMod?.id ?? modId, path: existingMod?.path ?? '', versions: existingMod?.versions ?? [], description: existingMod?.description ?? (isBaseGame ? "RimWorld base game" : isExpansion ? "RimWorld expansion" : ""), dependencies: existingMod?.dependencies ?? [], loadAfter: existingMod?.loadAfter ?? (isExpansion ? ['ludeon.rimworld'] : []), loadBefore: existingMod?.loadBefore ?? [], incompatibilities: existingMod?.incompatibilities ?? [], enabled: existingMod?.enabled ?? false, size: existingMod?.size ?? 0, isBaseGame: isBaseGame, isExpansion: isExpansion, ); if (mods.containsKey(modId)) { logger.warning('Mod $modId already exists in mods list, overwriting'); } mods[modId] = mod; setEnabled(modId, true); yield mod; } logger.info('Loaded ${modElements.length} mods from config file.'); } catch (e) { logger.error('Error loading configuration file: $e'); throw Exception('Failed to load config file: $e'); } } void setEnabled(String modId, bool enabled) { if (mods.containsKey(modId)) { final mod = mods[modId]!; mod.enabled = enabled; if (enabled) { activeMods[modId] = mod; } else { activeMods.remove(modId); } } } void enableAll() { for (final mod in mods.values) { setEnabled(mod.id, true); } } void disableAll() { for (final mod in mods.values) { setEnabled(mod.id, false); } } void enableMods(List modIds) { for (final modId in modIds) { setEnabled(modId, true); } } void disableMods(List modIds) { for (final modId in modIds) { setEnabled(modId, false); } } //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); //} LoadOrder generateLoadOrder([LoadOrder? loadOrder]) { loadOrder ??= LoadOrder(); final modMap = {for (final m in activeMods.values) m.id: m}; final errors = _validateIncompatibilities(modMap); if (errors.isNotEmpty) { loadOrder.errors.addAll(errors); } // Hard dependency graph final inDegree = {}; final adjacency = >{}; // Soft constraint reverse mappings final reverseLoadBefore = >{}; final reverseLoadAfter = >{}; // Initialize data structures for (final mod in activeMods.values) { mod.loadBeforeNotPlaced = mod.loadBefore.length; mod.loadAfterPlaced = 0; reverseLoadBefore[mod.id] = []; reverseLoadAfter[mod.id] = []; inDegree[mod.id] = 0; adjacency[mod.id] = []; } // Build dependency graph and reverse soft constraints for (final mod in activeMods.values) { for (final depId in mod.dependencies) { 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); } } } final heap = PriorityQueue((a, b) { // 1. Base game first if (a.isBaseGame != b.isBaseGame) { return a.isBaseGame ? -1 : 1; } // 2. Expansions next if (a.isExpansion != b.isExpansion) { return a.isExpansion ? -1 : 1; } // 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 } // 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 } // 4. Smaller size last return b.size.compareTo(a.size); }); // Initialize heap with available mods for (final mod in activeMods.values) { if (inDegree[mod.id] == 0) { heap.add(mod); } } while (heap.isNotEmpty) { final current = heap.removeFirst(); loadOrder.order.add(current); // Update dependents' in-degree for (final neighborId in adjacency[current.id]!) { inDegree[neighborId] = inDegree[neighborId]! - 1; if (inDegree[neighborId] == 0) { heap.add(modMap[neighborId]!); } } // Update soft constraints _updateReverseConstraints( current, reverseLoadBefore, loadOrder, heap, (mod) => mod.loadBeforeNotPlaced--, ); _updateReverseConstraints( current, reverseLoadAfter, loadOrder, heap, (mod) => mod.loadAfterPlaced++, ); } if (loadOrder.order.length != activeMods.length) { loadOrder.errors.add("Cyclic dependencies detected"); } return loadOrder; } List _validateIncompatibilities(Map modMap) { final errors = []; for (final mod in modMap.values) { for (final incompatibleId in mod.incompatibilities) { if (modMap.values.any((m) => m.id == incompatibleId)) { errors.add("Incompatible mods: ${mod.id} and $incompatibleId"); } } } return errors; } void _updateReverseConstraints( Mod current, Map> reverseMap, LoadOrder loadOrder, PriorityQueue heap, void Function(Mod) update, ) { reverseMap[current.id]?.forEach((affectedMod) { if (!loadOrder.order.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 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), ); } return loadOrder; } 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); } } String _expansionNameFromId(String id) { final parts = id.split('.'); if (parts.length < 3) return id; final expansionPart = parts[2]; return expansionPart.substring(0, 1).toUpperCase() + expansionPart.substring(1); }