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 { List order = []; final List errors = []; List get loadOrder { return order.map((mod) => mod.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 logger = Logger.instance; for (final mod in activeMods.values) { for (final incomp in mod.incompatibilities) { if (activeMods.containsKey(incomp)) { loadOrder.errors.add( 'Incompatibility detected: ${mod.id} is incompatible with $incomp', ); } } } loadOrder.order.addAll(activeMods.values.toList()); 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; 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); } return loadOrder!; } // The point of relations and the recursive call is to handle the case where // A mod depends on a mod that depends on another mod // So we move our first mod A to after B // But then we move B after C and A is no longer guranteed to be after B // So we update it too just in case // To make sure we have A B C // Now it opens us to a stack overflow... LoadOrder shuffleMod( Mod mod, LoadOrder loadOrder, Map> relations, [ Map? seen, ]) { final logger = Logger.instance; logger.info('Starting shuffleMod for mod: ${mod.id}'); // Prevent infinite loops seen ??= {}; if (seen[mod.id] == true) { logger.info('Mod ${mod.id} has already been seen, skipping.'); return loadOrder; } seen[mod.id] = true; logger.info('Marking mod ${mod.id} as seen.'); for (final dependency in mod.dependencies) { logger.info('Checking dependency: $dependency for mod ${mod.id}'); final depMod = mods[dependency]; if (depMod == null) { loadOrder.errors.add( 'Missing dependency: ${mod.id} requires mod with ID $dependency', ); logger.warning( 'Missing dependency: ${mod.id} requires mod with ID $dependency', ); continue; } if (loadOrder.order.indexOf(mod) < loadOrder.order.indexOf(depMod)) { logger.info('Reordering: ${mod.id} should come after ${depMod.id}'); loadOrder.order.removeAt(loadOrder.order.indexOf(mod)); loadOrder.order.insert(loadOrder.order.indexOf(depMod) + 1, mod); relations[mod.id] = [...relations[mod.id] ?? [], depMod]; } } for (final loadAfter in mod.loadAfter) { logger.info('Checking loadAfter: $loadAfter for mod ${mod.id}'); final loadAfterMod = mods[loadAfter]; if (loadAfterMod != null && loadOrder.order.indexOf(mod) < loadOrder.order.indexOf(loadAfterMod)) { logger.info( 'Reordering: ${mod.id} should come after ${loadAfterMod.id}', ); loadOrder.order.removeAt(loadOrder.order.indexOf(mod)); loadOrder.order.insert(loadOrder.order.indexOf(loadAfterMod) + 1, mod); relations[mod.id] = [...relations[mod.id] ?? [], loadAfterMod]; } } for (final loadBefore in mod.loadBefore) { logger.info('Checking loadBefore: $loadBefore for mod ${mod.id}'); final loadBeforeMod = mods[loadBefore]; if (loadBeforeMod != null && loadOrder.order.indexOf(mod) > loadOrder.order.indexOf(loadBeforeMod)) { logger.info( 'Reordering: ${mod.id} should come before ${loadBeforeMod.id}', ); loadOrder.order.removeAt(loadOrder.order.indexOf(mod)); loadOrder.order.insert(loadOrder.order.indexOf(loadBeforeMod), mod); relations[mod.id] = [...relations[mod.id] ?? [], loadBeforeMod]; } } for (final relatedMod in relations[mod.id] ?? []) { logger.info('Recursively shuffling related mod: ${relatedMod.id}'); loadOrder = shuffleMod(relatedMod, loadOrder, relations, seen); } logger.info('Completed shuffleMod for mod: ${mod.id}'); 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), ); } 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); }