413 lines
12 KiB
Dart
413 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<Mod> order = [];
|
|
final List<String> errors = [];
|
|
List<String> get loadOrder => order.map((e) => e.id).toList();
|
|
|
|
LoadOrder();
|
|
|
|
bool get hasErrors => errors.isNotEmpty;
|
|
}
|
|
|
|
class ModList {
|
|
String configPath = '';
|
|
String modsPath = '';
|
|
// O(1) lookup
|
|
Map<String, Mod> 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)) {
|
|
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<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([LoadOrder? loadOrder]) {
|
|
loadOrder ??= LoadOrder();
|
|
final modMap = {for (final m in activeMods.values) m.id: m};
|
|
_validateIncompatibilities(loadOrder);
|
|
|
|
// 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 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<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 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;
|
|
}
|
|
|
|
void _validateIncompatibilities(LoadOrder loadOrder) {
|
|
final enabledMods = loadOrder.order.where((m) => m.enabled).toList();
|
|
for (final mod in enabledMods) {
|
|
for (final incompatibleId in mod.incompatibilities) {
|
|
if (enabledMods.any((m) => m.id == incompatibleId)) {
|
|
loadOrder.errors.add("Incompatible mods: ${mod.id} and $incompatibleId");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void _updateReverseConstraints(
|
|
Mod current,
|
|
Map<String, List<Mod>> reverseMap,
|
|
LoadOrder loadOrder,
|
|
PriorityQueue<Mod> 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 loadRequired() {
|
|
final loadOrder = generateLoadOrder();
|
|
for (final modId in loadOrder.order.map((e) => e.id)) {
|
|
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);
|
|
}
|