diff --git a/lib/main.dart b/lib/main.dart index d86b15a..6ddd6b0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,12 +91,31 @@ class ModListPage extends StatefulWidget { } class _ModListPageState extends State { - final List _loadedMods = []; + List _loadedMods = []; bool _isLoading = false; String _loadingStatus = ''; int _totalModsFound = 0; bool _skipFileCount = false; // Skip file counting by default for faster loading + @override + void initState() { + super.initState(); + // Check if mods are already loaded in the global modManager + if (modManager.modsLoaded) { + _loadModsFromGlobalState(); + } + } + + void _loadModsFromGlobalState() { + setState(() { + _loadedMods = modManager.mods.values.toList(); + _loadedMods.sort((a, b) => a.name.compareTo(b.name)); + _isLoading = false; + _loadingStatus = modManager.loadingStatus; + _totalModsFound = modManager.totalModsFound; + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -152,49 +171,71 @@ class _ModListPageState extends State { padding: const EdgeInsets.all(16.0), child: Column( children: [ - LinearProgressIndicator( - value: - _totalModsFound > 0 - ? _loadedMods.length / _totalModsFound - : null, - ), - const SizedBox(height: 8), + const CircularProgressIndicator(), + const SizedBox(height: 16), Text( _loadingStatus, style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, ), ], ), ), - Expanded( - child: ListView.builder( - itemCount: _loadedMods.length, - itemBuilder: (context, index) { - final mod = _loadedMods[index]; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: ListTile( - title: Text(mod.name), - subtitle: Text( - 'ID: ${mod.id}\nSize: ${mod.size} files', - style: Theme.of(context).textTheme.bodySmall, - ), - trailing: Icon( - Icons.circle, - color: - mod.hardDependencies.isNotEmpty - ? Colors.orange - : Colors.green, - size: 12, - ), - onTap: () { - // TODO: Show mod details - }, - ), - ); - }, + if (!_isLoading && _loadedMods.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'No mods found. Try reloading.', + textAlign: TextAlign.center, + ), + ), + ), + if (_loadedMods.isNotEmpty) + Expanded( + child: ListView.builder( + itemCount: _loadedMods.length, + itemBuilder: (context, index) { + final mod = _loadedMods[index]; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + title: Text(mod.name), + subtitle: Text( + 'ID: ${mod.id}\nSize: ${mod.size} files', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (mod.isBaseGame) + Tooltip( + message: 'Base Game', + child: Icon(Icons.home, color: Colors.blue, size: 16), + ), + if (mod.isExpansion) + Tooltip( + message: 'Expansion', + child: Icon(Icons.star, color: Colors.yellow, size: 16), + ), + Icon( + Icons.circle, + color: + mod.hardDependencies.isNotEmpty + ? Colors.orange + : Colors.green, + size: 12, + ), + ], + ), + onTap: () { + // TODO: Show mod details + }, + ), + ); + }, + ), ), - ), if (!_isLoading && _loadedMods.isNotEmpty) Padding( padding: const EdgeInsets.all(16.0), @@ -220,45 +261,23 @@ class _ModListPageState extends State { _loadingStatus = 'Scanning for mods...'; }); - // First get the mod directories to know the total count - final directory = Directory(modsRoot); - if (directory.existsSync()) { - final List entities = directory.listSync(); - final List modDirectories = - entities.whereType().map((dir) => dir.path).toList(); - + // Use the simplified loading approach + modManager.loadWithConfig(skipFileCount: _skipFileCount).then((_) { setState(() { - _totalModsFound = modDirectories.length; - _loadingStatus = 'Found $_totalModsFound mod directories. Loading...'; + _loadedMods = modManager.mods.values.toList(); + _isLoading = false; + _loadingStatus = modManager.loadingStatus; + _totalModsFound = modManager.totalModsFound; + + // Sort mods by name for better display + _loadedMods.sort((a, b) => a.name.compareTo(b.name)); }); - } - - // Use the serial loading with our skipFileCount option - modManager - .load(skipFileCount: _skipFileCount) - .listen( - (mod) { - setState(() { - _loadedMods.add(mod); - _loadingStatus = 'Loaded ${_loadedMods.length}/$_totalModsFound mods...'; - }); - }, - onError: (error) { - setState(() { - _isLoading = false; - _loadingStatus = 'Error loading mods: $error'; - }); - }, - onDone: () { - setState(() { - _isLoading = false; - _loadingStatus = 'Completed! ${_loadedMods.length} mods loaded.'; - - // Sort mods by name for better display - _loadedMods.sort((a, b) => a.name.compareTo(b.name)); - }); - }, - ); + }).catchError((error) { + setState(() { + _isLoading = false; + _loadingStatus = 'Error loading mods: $error'; + }); + }); } } @@ -278,6 +297,17 @@ class _LoadOrderPageState extends State { List? _cycleInfo; List> _incompatibleMods = []; + @override + void initState() { + super.initState(); + // If we already have loaded mods, update the status message + if (modManager.modsLoaded && modManager.mods.isNotEmpty) { + _statusMessage = 'Ready to sort ${modManager.mods.length} loaded mods'; + } else { + _statusMessage = 'No mods have been loaded yet. Please load mods first.'; + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -319,8 +349,27 @@ class _LoadOrderPageState extends State { ), const SizedBox(height: 16), if (_isLoading) - const LinearProgressIndicator(), - if (_statusMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: Column( + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + _statusMessage, + textAlign: TextAlign.center, + style: TextStyle( + color: _hasCycles || _incompatibleMods.isNotEmpty + ? Colors.orange + : Colors.green, + ), + ), + ], + ), + ), + ), + if (!_isLoading && _statusMessage.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Text( @@ -376,6 +425,18 @@ class _LoadOrderPageState extends State { children: [ Text('Legend:', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 4), + Row( + children: [ + Icon(Icons.home, color: Colors.blue, size: 16), + const SizedBox(width: 4), + Text('Base Game', style: TextStyle(fontSize: 12)), + const SizedBox(width: 12), + Icon(Icons.star, color: Colors.yellow, size: 16), + const SizedBox(width: 4), + Text('Expansion', style: TextStyle(fontSize: 12)), + ], + ), + const SizedBox(height: 4), Row( children: [ Icon(Icons.link, color: Colors.orange, size: 16), @@ -388,6 +449,14 @@ class _LoadOrderPageState extends State { ], ), const SizedBox(height: 4), + Row( + children: [ + Icon(Icons.arrow_forward, color: Colors.green, size: 16), + const SizedBox(width: 4), + Text('Loads before other mods', style: TextStyle(fontSize: 12)), + ], + ), + const SizedBox(height: 4), Row( children: [ Container(width: 12, height: 12, color: Colors.amber), @@ -406,7 +475,9 @@ class _LoadOrderPageState extends State { child: _sortedMods.isEmpty ? Center( child: Text( - 'Click "Auto-sort Mods" to generate a load order based on dependencies.', + modManager.modsLoaded + ? 'Click "Auto-sort Mods" to generate a load order for ${modManager.mods.length} loaded mods.' + : 'Please go to the Mods tab first to load mods.', textAlign: TextAlign.center, ), ) @@ -446,12 +517,28 @@ class _LoadOrderPageState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + if (mod.isBaseGame) + Tooltip( + message: 'Base Game', + child: Icon(Icons.home, color: Colors.blue, size: 16), + ), + if (mod.isExpansion) + Tooltip( + message: 'Expansion', + child: Icon(Icons.star, color: Colors.yellow, size: 16), + ), if (mod.hardDependencies.isNotEmpty) Icon(Icons.link, color: Colors.orange, size: 16), const SizedBox(width: 4), if (mod.softDependencies.isNotEmpty) Icon(Icons.link_off, color: Colors.blue, size: 16), const SizedBox(width: 4), + if (mod.loadBefore.isNotEmpty) + Tooltip( + message: 'Loads before other mods', + child: Icon(Icons.arrow_forward, color: Colors.green, size: 16), + ), + const SizedBox(width: 4), Text( '${mod.size} files', style: TextStyle( @@ -480,7 +567,7 @@ class _LoadOrderPageState extends State { void _sortMods() async { if (modManager.mods.isEmpty) { setState(() { - _statusMessage = 'No mods have been loaded yet. Please load mods first.'; + _statusMessage = 'No mods have been loaded yet. Please go to the Mods tab and load mods first.'; }); return; } @@ -493,8 +580,8 @@ class _LoadOrderPageState extends State { _incompatibleMods = []; }); - // This could be slow so run in a separate isolate or compute - await Future.delayed(Duration.zero); // Allow UI to update + // Use a Future.delayed to allow the UI to update + await Future.delayed(Duration.zero); try { // Check for cycles first diff --git a/lib/modloader.dart b/lib/modloader.dart index 66efb7e..8206971 100644 --- a/lib/modloader.dart +++ b/lib/modloader.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:async'; import 'package:xml/xml.dart'; const root = r'C:/Users/Administrator/Seafile/Games-Rimworld'; @@ -25,13 +26,14 @@ class Mod { final String path; // figure it out final List versions; // ModMetaData.supportedVersions final String description; // ModMetaData.description - final List - hardDependencies; // ModMetaData.modDependencies - this is a li with packageId, displayName, steamWorkshopUrl and downloadUrl + final List hardDependencies; // ModMetaData.modDependencies final List softDependencies; // ModMetaData.loadAfter + final List loadBefore; // ModMetaData.loadBefore final List incompatabilities; // ModMetaData.incompatibleWith - final bool - enabled; // ConfigFile.mods.firstWhere((mod) => mod.id == id).enabled + final bool enabled; // ConfigFile.mods.firstWhere((mod) => mod.id == id).enabled final int size; // Count of files in the mod directory + final bool isBaseGame; // Is this the base RimWorld game + final bool isExpansion; // Is this a RimWorld expansion Mod({ required this.name, @@ -41,9 +43,12 @@ class Mod { required this.description, required this.hardDependencies, required this.softDependencies, + required this.loadBefore, required this.incompatabilities, required this.enabled, required this.size, + this.isBaseGame = false, + this.isExpansion = false, }); static Mod fromDirectory(String path, {bool skipFileCount = false}) { @@ -135,6 +140,19 @@ class Mod { // Silent error for optional element } + List loadBefore = []; + try { + loadBefore = + metadata + .findElements('loadBefore') + .first + .findElements('li') + .map((e) => e.innerText.toLowerCase()) + .toList(); + } catch (e) { + // Silent error for optional element + } + List incompatabilities = []; try { incompatabilities = @@ -165,6 +183,15 @@ class Mod { .length; } + // Check if this is RimWorld base game or expansion + bool isBaseGame = id == 'ludeon.rimworld'; + bool isExpansion = !isBaseGame && id.startsWith('ludeon.rimworld.'); + + // If this is an expansion, ensure it depends on the base game + if (isExpansion && !softDependencies.contains('ludeon.rimworld')) { + softDependencies.add('ludeon.rimworld'); + } + final fileCountTime = stopwatch.elapsedMilliseconds - metadataTime - xmlTime; final totalTime = stopwatch.elapsedMilliseconds; @@ -182,9 +209,12 @@ class Mod { description: description, hardDependencies: hardDependencies, softDependencies: softDependencies, + loadBefore: loadBefore, incompatabilities: incompatabilities, enabled: false, size: size, + isBaseGame: isBaseGame, + isExpansion: isExpansion, ); } } @@ -192,55 +222,161 @@ class Mod { class ModList { final String path; Map mods = {}; + bool modsLoaded = false; + String loadingStatus = ''; + int totalModsFound = 0; + int loadedModsCount = 0; ModList({required this.path}); - Stream load({bool skipFileCount = false}) async* { + // Simplified loading with config file first + Future loadWithConfig({bool skipFileCount = false}) async { + // Clear existing state if reloading + if (modsLoaded) { + mods.clear(); + } + + modsLoaded = false; + loadedModsCount = 0; + loadingStatus = 'Loading active mods from config...'; + final stopwatch = Stopwatch()..start(); - final directory = Directory(path); - print('Loading configuration from: $path'); + print('Loading configuration from config file: $configPath'); - if (directory.existsSync()) { + try { + // First, load the config file to get the list of active mods + final configFile = ConfigFile(path: configPath); + await configFile.load(); + + // Create a Set of active mod IDs for quick lookups + final activeModIds = configFile.mods.map((m) => m.id).toSet(); + + // Special handling for Ludeon mods that might not exist as directories + for (final configMod in configFile.mods) { + if (configMod.id.startsWith('ludeon.')) { + final isBaseGame = configMod.id == 'ludeon.rimworld'; + final isExpansion = configMod.id.startsWith('ludeon.rimworld.') && !isBaseGame; + + // Create a placeholder mod for the Ludeon mods that might not have directories + final mod = Mod( + name: isBaseGame ? "RimWorld" : + isExpansion ? "RimWorld ${_expansionNameFromId(configMod.id)}" : configMod.id, + id: configMod.id, + path: '', + versions: [], + description: isBaseGame ? "RimWorld base game" : + isExpansion ? "RimWorld expansion" : "", + hardDependencies: [], + softDependencies: isExpansion ? ['ludeon.rimworld'] : [], + loadBefore: [], + incompatabilities: [], + enabled: true, + size: 0, + isBaseGame: isBaseGame, + isExpansion: isExpansion, + ); + + mods[configMod.id] = mod; + loadedModsCount++; + } + } + + // Now scan the directory for mod metadata + loadingStatus = 'Scanning mod directories...'; + final directory = Directory(path); + + if (!directory.existsSync()) { + loadingStatus = 'Error: Mods root directory does not exist: $path'; + print(loadingStatus); + return; + } + final List entities = directory.listSync(); final List modDirectories = entities.whereType().map((dir) => dir.path).toList(); - - print( - 'Found ${modDirectories.length} mod directories (${stopwatch.elapsedMilliseconds}ms)', - ); - int processedCount = 0; - int totalMods = modDirectories.length; - + + totalModsFound = modDirectories.length; + loadingStatus = 'Found $totalModsFound mod directories. Loading...'; + print('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()) continue; + final mod = Mod.fromDirectory(modDir, skipFileCount: skipFileCount); - mods[mod.id] = mod; - processedCount++; - - final modTime = stopwatch.elapsedMilliseconds - modStart; - if (processedCount % 50 == 0 || processedCount == totalMods) { - print( - 'Progress: Loaded $processedCount/$totalMods mods (${stopwatch.elapsedMilliseconds}ms, avg ${stopwatch.elapsedMilliseconds / processedCount}ms per mod)', + + // If we already have this mod from the config (like Ludeon mods), update its data + if (mods.containsKey(mod.id)) { + final existingMod = mods[mod.id]!; + mods[mod.id] = Mod( + name: mod.name, + id: mod.id, + path: mod.path, + versions: mod.versions, + description: mod.description, + hardDependencies: mod.hardDependencies, + softDependencies: mod.softDependencies, + loadBefore: mod.loadBefore, + incompatabilities: mod.incompatabilities, + enabled: activeModIds.contains(mod.id), // Set enabled based on config + size: mod.size, + isBaseGame: existingMod.isBaseGame, + isExpansion: existingMod.isExpansion, ); + } else { + // Otherwise add as a new mod + mods[mod.id] = Mod( + name: mod.name, + id: mod.id, + path: mod.path, + versions: mod.versions, + description: mod.description, + hardDependencies: mod.hardDependencies, + softDependencies: mod.softDependencies, + loadBefore: mod.loadBefore, + incompatabilities: mod.incompatabilities, + enabled: activeModIds.contains(mod.id), // Set enabled based on config + size: mod.size, + isBaseGame: mod.isBaseGame, + isExpansion: mod.isExpansion, + ); + loadedModsCount++; + } + + final modTime = stopwatch.elapsedMilliseconds - modStart; + loadingStatus = 'Loaded $loadedModsCount/$totalModsFound mods...'; + + if (loadedModsCount % 50 == 0 || loadedModsCount == totalModsFound) { + print('Progress: Loaded $loadedModsCount mods (${stopwatch.elapsedMilliseconds}ms)'); } - - yield mod; } catch (e) { print('Error loading mod from directory: $modDir'); print('Error: $e'); } } - + + modsLoaded = true; final totalTime = stopwatch.elapsedMilliseconds; - print( - 'Loading complete! Loaded ${mods.length} mods in ${totalTime}ms (${totalTime / mods.length}ms per mod)', - ); - } else { - print('Mods root directory does not exist: $path'); + loadingStatus = 'Completed! Loaded $loadedModsCount mods in ${totalTime}ms.'; + print('Loading complete! Loaded ${mods.length} mods in ${totalTime}ms'); + } catch (e) { + loadingStatus = 'Error loading mods: $e'; + print(loadingStatus); } } + + // Helper function to get a nice expansion name from ID + 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); + } // Build a directed graph of mod dependencies Map> buildDependencyGraph() { @@ -262,6 +398,21 @@ class ModList { } } + // Handle base game and expansions: + // 1. Add the base game as a dependency of all mods except those who have loadBefore for it + // 2. Add expansions as dependencies of mods that load after them + + // First identify the base game and expansions + final baseGameId = mods.values.where((m) => m.isBaseGame).map((m) => m.id).firstOrNull; + if (baseGameId != null) { + for (final mod in mods.values) { + // Skip the base game itself and mods that explicitly load before it + if (mod.id != baseGameId && !mod.loadBefore.contains(baseGameId)) { + graph[mod.id]!.add(baseGameId); + } + } + } + return graph; } @@ -274,7 +425,7 @@ class ModList { graph[mod.id] = Set(); } - // Add soft dependencies + // Add soft dependencies (loadAfter) for (final mod in mods.values) { for (final dependency in mod.softDependencies) { // Only add if the dependency exists in our loaded mods @@ -284,6 +435,16 @@ class ModList { } } + // Handle loadBefore - invert the relationship for the graph + // If A loadBefore B, then B softDepends on A + for (final mod in mods.values) { + for (final loadBeforeId in mod.loadBefore) { + if (mods.containsKey(loadBeforeId)) { + graph[loadBeforeId]!.add(mod.id); + } + } + } + return graph; } @@ -528,46 +689,80 @@ class ConfigFile { ConfigFile({required this.path, this.mods = const []}); - void load() { + Future load() async { final file = File(path); print('Loading configuration from: $path'); - final xmlString = file.readAsStringSync(); - print('XML content read successfully.'); + try { + final xmlString = file.readAsStringSync(); + print('XML content read successfully.'); - final xmlDocument = XmlDocument.parse(xmlString); - print('XML document parsed successfully.'); + final xmlDocument = XmlDocument.parse(xmlString); + print('XML document parsed successfully.'); - final modConfigData = xmlDocument.findElements("ModsConfigData").first; - print('Found ModsConfigData element.'); + final modConfigData = xmlDocument.findElements("ModsConfigData").first; + print('Found ModsConfigData element.'); - final modsElement = modConfigData.findElements("activeMods").first; - print('Found activeMods element.'); + final modsElement = modConfigData.findElements("activeMods").first; + print('Found activeMods element.'); - final modElements = modsElement.findElements("li"); - print('Found ${modElements.length} active mods.'); + final modElements = modsElement.findElements("li"); + print('Found ${modElements.length} active mods.'); - mods = []; - for (final modElement in modElements) { - final modId = modElement.innerText.toLowerCase(); - // We'll populate with dummy mods for now, they'll be replaced later - mods.add( - Mod( - name: modId, - id: modId, - path: '', - versions: [], - description: '', - hardDependencies: [], - softDependencies: [], - incompatabilities: [], - enabled: true, - size: 0, - ), - ); + // 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() + : []; + + print('Found ${knownExpansionIds.length} known expansions.'); + + // Clear and recreate the mods list + mods = []; + 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); + + // We'll populate with dummy mods for now, they'll be replaced later + mods.add( + Mod( + name: isBaseGame ? "RimWorld" : + isExpansion ? "RimWorld ${_expansionNameFromId(modId)}" : modId, + id: modId, + path: '', + versions: [], + description: isBaseGame ? "RimWorld base game" : + isExpansion ? "RimWorld expansion" : "", + hardDependencies: [], + softDependencies: isExpansion ? ['ludeon.rimworld'] : [], + loadBefore: [], + incompatabilities: [], + enabled: true, + size: 0, + isBaseGame: isBaseGame, + isExpansion: isExpansion, + ), + ); + } + + print('Loaded ${mods.length} mods from config file.'); + } catch (e) { + print('Error loading configuration file: $e'); + throw Exception('Failed to load config file: $e'); } - - print('Loaded ${mods.length} mods from config file.'); + } + + // Helper function to get a nice expansion name from ID + 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); } // Save the current mod order back to the config file