541 lines
16 KiB
Dart
541 lines
16 KiB
Dart
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<String, bool> activeMods = {};
|
|
Map<String, Mod> mods = {};
|
|
|
|
ModList({this.configPath = '', this.modsPath = ''});
|
|
|
|
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();
|
|
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',
|
|
);
|
|
} 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, 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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
List<List<String>> checkIncompatibilities() {
|
|
List<List<String>> conflicts = [];
|
|
List<String> 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<String> 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 = <String>[];
|
|
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<String, int> _calculateSoftConstraintsScore(List<String> order) {
|
|
Map<String, int> 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<String> _optimizeSoftConstraints(
|
|
List<String> initialOrder, {
|
|
int maxIterations = 5,
|
|
}) {
|
|
List<String> bestOrder = List.from(initialOrder);
|
|
Map<String, int> scoreInfo = _calculateSoftConstraintsScore(bestOrder);
|
|
int bestScore = scoreInfo['satisfied']!;
|
|
int total = scoreInfo['total']!;
|
|
|
|
if (total == 0 || bestScore == total) {
|
|
// All constraints satisfied or no constraints, sort by size where possible
|
|
return _sortSizeWithinConstraints(bestOrder);
|
|
}
|
|
|
|
// 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<String, int> 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<String> newOrder = List.from(bestOrder);
|
|
newOrder.removeAt(i);
|
|
newOrder.insert(newPos, modId);
|
|
|
|
// Calculate new score
|
|
Map<String, int> 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
|
|
}
|
|
|
|
// After optimizing for soft constraints, sort by size where possible
|
|
return _sortSizeWithinConstraints(bestOrder);
|
|
}
|
|
|
|
/// Sort mods by size within compatible groups
|
|
List<String> _sortSizeWithinConstraints(List<String> order) {
|
|
// Find groups of mods that can be reordered without breaking constraints
|
|
List<List<String>> groups = [];
|
|
List<String> currentGroup = [];
|
|
|
|
for (int i = 0; i < order.length; i++) {
|
|
String modId = order[i];
|
|
Mod mod = mods[modId]!;
|
|
|
|
if (currentGroup.isEmpty) {
|
|
currentGroup.add(modId);
|
|
continue;
|
|
}
|
|
|
|
// Check if this mod can join the current group
|
|
bool canJoin = true;
|
|
for (String groupModId in currentGroup) {
|
|
Mod groupMod = mods[groupModId]!;
|
|
|
|
// Check hard dependencies
|
|
if (mod.dependencies.contains(groupModId) ||
|
|
groupMod.dependencies.contains(modId)) {
|
|
canJoin = false;
|
|
break;
|
|
}
|
|
|
|
// Check soft constraints
|
|
if (mod.loadAfter.contains(groupModId) ||
|
|
groupMod.loadBefore.contains(modId) ||
|
|
mod.loadBefore.contains(groupModId) ||
|
|
groupMod.loadAfter.contains(modId)) {
|
|
canJoin = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (canJoin) {
|
|
currentGroup.add(modId);
|
|
} else {
|
|
// Start a new group
|
|
if (currentGroup.isNotEmpty) {
|
|
groups.add(List.from(currentGroup));
|
|
currentGroup = [modId];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add the last group if not empty
|
|
if (currentGroup.isNotEmpty) {
|
|
groups.add(currentGroup);
|
|
}
|
|
|
|
// Sort each group by size
|
|
for (List<String> group in groups) {
|
|
if (group.length > 1) {
|
|
group.sort((a, b) => mods[b]!.size.compareTo(mods[a]!.size));
|
|
}
|
|
}
|
|
|
|
// Reconstruct the order
|
|
List<String> result = [];
|
|
for (List<String> group in groups) {
|
|
result.addAll(group);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
List<String> loadDependencies(
|
|
String modId, [
|
|
List<String>? toEnable,
|
|
Map<String, bool>? seen,
|
|
]) {
|
|
final mod = mods[modId]!;
|
|
toEnable ??= <String>[];
|
|
seen ??= <String, bool>{};
|
|
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<String> loadRequired() {
|
|
final toEnable = <String>[];
|
|
for (final modid in activeMods.keys) {
|
|
loadDependencies(modid, toEnable);
|
|
}
|
|
for (final modid in toEnable) {
|
|
setEnabled(modid, true);
|
|
}
|
|
return generateLoadOrder();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|