import 'dart:io'; import 'package:rimworld_modman/logger.dart'; import 'package:rimworld_modman/mod.dart'; import 'package:xml/xml.dart'; class ModList { String configPath = ''; String modsPath = ''; // O(1) lookup Map activeMods = {}; Map mods = {}; ModList({this.configPath = '', this.modsPath = ''}); 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(); 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', ); } 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, mod.enabled); 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)) { mods[modId]!.enabled = enabled; if (enabled) { activeMods[modId] = true; } 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); } } List> checkIncompatibilities() { List> conflicts = []; List activeModIds = activeMods.keys.toList(); // Only check each pair once for (final modId in activeModIds) { final mod = mods[modId]!; 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; } /// Generate a load order for active mods List generateLoadOrder() { // Check for incompatibilities first final conflicts = checkIncompatibilities(); if (conflicts.isNotEmpty) { throw Exception( "Incompatible mods selected: ${conflicts.map((c) => "${c[0]} and ${c[1]}").join(', ')}", ); } // Reset all marks for topological sort for (final mod in mods.values) { mod.visited = false; mod.mark = false; mod.position = -1; } final result = []; int position = 0; // Topological sort void visit(Mod mod) { if (!mod.enabled) { mod.visited = true; return; } if (mod.mark) { final cyclePath = mods.values.where((m) => m.mark).map((m) => m.name).toList(); throw Exception( "Cyclic dependency detected: ${cyclePath.join(' -> ')}", ); } if (!mod.visited) { mod.mark = true; // Visit all dependencies for (String depId in mod.dependencies) { if (activeMods.containsKey(depId)) { visit(mods[depId]!); } } mod.mark = false; mod.visited = true; mod.position = position++; result.add(mod.id); } } // Visit all nodes for (final mod in mods.values) { if (!mod.visited) { visit(mod); } } // Optimize for soft constraints return _optimizeSoftConstraints(result); } /// 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 List _optimizeSoftConstraints( List initialOrder, { int maxIterations = 5, }) { List bestOrder = List.from(initialOrder); Map scoreInfo = _calculateSoftConstraintsScore(bestOrder); int bestScore = scoreInfo['satisfied']!; int total = scoreInfo['total']!; if (total == 0 || bestScore == total) { return bestOrder; // All constraints satisfied or no constraints } // Use a limited number of improvement passes for (int iteration = 0; iteration < maxIterations; iteration++) { bool improved = false; // Try moving each mod to improve score for (int i = 0; i < bestOrder.length; i++) { String modId = bestOrder[i]; Mod mod = mods[modId]!; // Calculate current local score for this mod Map currentPositions = {}; for (int idx = 0; idx < bestOrder.length; idx++) { currentPositions[bestOrder[idx]] = idx; } // Try moving this mod to different positions for (int newPos = 0; newPos < bestOrder.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 = bestOrder[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 = bestOrder[j]; if (mod.dependencies.contains(depModId)) { skip = true; break; } } } if (skip) continue; // Create a new order with the mod moved List newOrder = List.from(bestOrder); newOrder.removeAt(i); newOrder.insert(newPos, modId); // Calculate new score Map newScoreInfo = _calculateSoftConstraintsScore( newOrder, ); int newScore = newScoreInfo['satisfied']!; if (newScore > bestScore) { bestScore = newScore; bestOrder = 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 } return bestOrder; } List loadDependencies( String modId, [ List? toEnable, Map? seen, ]) { final mod = mods[modId]!; toEnable ??= []; seen ??= {}; for (final dep in mod.dependencies) { final depMod = mods[dep]!; if (seen[dep] == true) { throw Exception('Cyclic dependency detected: $modId -> $dep'); } seen[dep] = true; toEnable.add(depMod.id); loadDependencies(depMod.id, toEnable, seen); } return toEnable; } List loadRequired() { final toEnable = []; for (final modid in activeMods.keys) { loadDependencies(modid, toEnable); } for (final modid in toEnable) { setEnabled(modid, true); } return generateLoadOrder(); } } 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); }