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

426 lines
12 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);
}
}
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) {
return bestOrder; // All constraints satisfied or no constraints
}
// 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
}
return bestOrder;
}
List<String> loadRequired() {
final toEnable = <String>[];
for (final modid in activeMods.keys) {
final mod = mods[modid]!;
for (final dep in mod.dependencies) {
toEnable.add(dep);
}
}
for (final modid in toEnable) {
setEnabled(modid, true);
}
return generateLoadOrder();
}
}
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);
}