Files
flutter-rimworld-modman/lib/mod_list.dart

414 lines
12 KiB
Dart

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<String> loadOrder = [];
final List<String> errors = [];
LoadOrder();
bool get hasErrors => errors.isNotEmpty;
}
class ModList {
String configPath = '';
String modsPath = '';
// O(1) lookup
Map<String, bool> activeMods = {};
Map<String, Mod> mods = {};
ModList({this.configPath = '', this.modsPath = ''});
ModList copyWith({
String? configPath,
String? modsPath,
Map<String, Mod>? mods,
Map<String, bool>? 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<Mod> 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<FileSystemEntity> entities = directory.listSync();
// TODO: Count only the latest version of each mod and not all versions
final List<String> modDirectories =
entities.whereType<Directory>().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<Mod> 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()
: <String>[];
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)) {
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);
}
}
void enableMods(List<String> modIds) {
for (final modId in modIds) {
setEnabled(modId, true);
}
}
void disableMods(List<String> modIds) {
for (final modId in modIds) {
setEnabled(modId, false);
}
}
//LoadOrder loadRequired([LoadOrder? loadOrder]) {
// loadOrder ??= LoadOrder();
// final toEnable = <String>[];
// for (final modid in activeMods.keys) {
// loadDependencies(modid, loadOrder, toEnable);
// }
// for (final modid in toEnable) {
// setEnabled(modid, true);
// }
// return generateLoadOrder(loadOrder);
//}
LoadOrder generateLoadOrder() {
final modMap = {for (final m in mods.values) m.id: m};
_validateIncompatibilities(mods.values.toList());
// Hard dependency graph
final inDegree = <String, int>{};
final adjacency = <String, List<String>>{};
// Soft constraint reverse mappings
final reverseLoadBefore = <String, List<Mod>>{};
final reverseLoadAfter = <String, List<Mod>>{};
// 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] = [];
}
// Build dependency graph and reverse soft constraints
for (final mod in mods.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<Mod>((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 modId in activeMods.keys) {
final mod = modMap[modId];
if (mod != null && inDegree[modId] == 0) {
heap.add(mod);
}
}
final sortedMods = <Mod>[];
while (heap.isNotEmpty) {
final current = heap.removeFirst();
sortedMods.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,
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;
}
void _validateIncompatibilities(List<Mod> 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");
}
}
}
}
void _updateReverseConstraints(
Mod current,
Map<String, List<Mod>> reverseMap,
List<Mod> sortedMods,
PriorityQueue<Mod> 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;
}
}
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);
}