Rework everything to be less dogshit

This commit is contained in:
2025-03-17 23:46:25 +01:00
parent 179bebf188
commit 72b6f3486d
3 changed files with 205 additions and 462 deletions

View File

@@ -31,9 +31,8 @@ class Mod {
final bool isBaseGame; // Is this the base RimWorld game final bool isBaseGame; // Is this the base RimWorld game
final bool isExpansion; // Is this a RimWorld expansion final bool isExpansion; // Is this a RimWorld expansion
bool visited = false; int loadBeforeNotPlaced = 0;
bool mark = false; int loadAfterPlaced = 0;
int position = -1;
Mod({ Mod({
required this.name, required this.name,

View File

@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:collection/collection.dart';
import 'package:rimworld_modman/logger.dart'; import 'package:rimworld_modman/logger.dart';
import 'package:rimworld_modman/mod.dart'; import 'package:rimworld_modman/mod.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
@@ -22,6 +23,21 @@ class ModList {
ModList({this.configPath = '', this.modsPath = ''}); 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* { Stream<Mod> loadAvailable() async* {
final logger = Logger.instance; final logger = Logger.instance;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
@@ -219,438 +235,171 @@ class ModList {
} }
} }
List<List<String>> checkIncompatibilities(List<String> activeModIds) { //LoadOrder loadRequired([LoadOrder? loadOrder]) {
List<List<String>> conflicts = []; // 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);
//}
// Only check each pair once LoadOrder generateLoadOrder() {
for (final modId in activeModIds) { final modMap = {for (final m in mods.values) m.id: m};
final mod = mods[modId]!; _validateIncompatibilities(mods.values.toList());
for (final incompId in mod.incompatibilities) { // Hard dependency graph
// Only process if other mod is active and we haven't checked this pair yet final inDegree = <String, int>{};
if (activeMods.containsKey(incompId)) { final adjacency = <String, List<String>>{};
conflicts.add([modId, incompId]);
}
}
}
return conflicts;
}
/// Generate a load order for active mods // Soft constraint reverse mappings
LoadOrder generateLoadOrder([LoadOrder? loadOrder]) { final reverseLoadBefore = <String, List<Mod>>{};
loadOrder ??= LoadOrder(); final reverseLoadAfter = <String, List<Mod>>{};
// Check for incompatibilities first
final conflicts = checkIncompatibilities(activeMods.keys.toList()); // Initialize data structures
if (conflicts.isNotEmpty) { for (final mod in mods.values) {
for (final conflict in conflicts) { mod.loadBeforeNotPlaced = mod.loadBefore.length;
loadOrder.errors.add( mod.loadAfterPlaced = 0;
"Incompatible mods selected: ${conflict[0]} and ${conflict[1]}",
); reverseLoadBefore[mod.id] = [];
} reverseLoadAfter[mod.id] = [];
inDegree[mod.id] = 0;
adjacency[mod.id] = [];
} }
// Check for missing dependencies // Build dependency graph and reverse soft constraints
for (final modId in activeMods.keys) { for (final mod in mods.values) {
final mod = mods[modId]!;
for (final depId in mod.dependencies) { for (final depId in mod.dependencies) {
if (!mods.containsKey(depId)) { adjacency[depId]!.add(mod.id);
loadOrder.errors.add( inDegree[mod.id] = (inDegree[mod.id] ?? 0) + 1;
"Missing dependency: ${mod.name} requires mod with ID $depId", }
);
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);
} }
} }
} }
// Reset all marks for topological sort final heap = PriorityQueue<Mod>((a, b) {
for (final mod in mods.values) { // 1. Base game first
mod.visited = false; if (a.isBaseGame != b.isBaseGame) {
mod.mark = false; return a.isBaseGame ? -1 : 1;
mod.position = -1;
}
int position = 0;
// Topological sort
void visit(Mod mod, LoadOrder loadOrder) {
if (!mod.enabled) {
mod.visited = true;
return;
} }
if (mod.mark) { // 2. Expansions next
final cyclePath = if (a.isExpansion != b.isExpansion) {
mods.values.where((m) => m.mark).map((m) => m.name).toList(); return a.isExpansion ? -1 : 1;
loadOrder.errors.add(
"Cyclic dependency detected: ${cyclePath.join(' -> ')}",
);
return;
} }
// 3. Soft constraints: Prioritize mods that need to be placed earlier
if (!mod.visited) { final aUnmetBefore = a.loadBeforeNotPlaced;
mod.mark = true; final bUnmetBefore = b.loadBeforeNotPlaced;
if (aUnmetBefore != bUnmetBefore) {
// Visit all dependencies return bUnmetBefore.compareTo(aUnmetBefore); // Higher unmetBefore first
for (String depId in mod.dependencies) {
if (activeMods.containsKey(depId)) {
visit(mods[depId]!, loadOrder);
}
}
mod.mark = false;
mod.visited = true;
mod.position = position++;
loadOrder.loadOrder.add(mod.id);
} }
} // If tied, deprioritize mods with more unmet `loadAfter`
final aUnmetAfter = a.loadAfter.length - a.loadAfterPlaced;
// Visit all nodes final bUnmetAfter = b.loadAfter.length - b.loadAfterPlaced;
for (final mod in mods.values) { if (aUnmetAfter != bUnmetAfter) {
if (!mod.visited) { return aUnmetAfter.compareTo(bUnmetAfter); // Lower unmetAfter first
visit(mod, loadOrder);
} }
} // 4. Smaller size last
return b.size.compareTo(a.size);
// Optimize for soft constraints
_optimizeSoftConstraints(loadOrder: loadOrder);
for (final modId in loadOrder.loadOrder) {
final mod = mods[modId]!;
print(
'Mod ID: ${mod.id}, Name: ${mod.name}, Enabled: ${mod.enabled}, Size: ${mod.size}, Dependencies: ${mod.dependencies}, Load After: ${mod.loadAfter}, Load Before: ${mod.loadBefore}, Incompatibilities: ${mod.incompatibilities}',
);
}
return loadOrder;
}
/// 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
LoadOrder _optimizeSoftConstraints({
int maxIterations = 5,
LoadOrder? loadOrder,
}) {
loadOrder ??= LoadOrder();
// First, ensure base game and expansions are at the beginning in the correct order
List<String> baseAndExpansions = [];
List<String> harmony = [];
List<String> otherMods = [];
// Separate mods into categories
for (final modId in loadOrder.loadOrder) {
final mod = mods[modId]!;
if (modId == 'brrainz.harmony') {
harmony.add(modId);
} else if (mod.isBaseGame) {
baseAndExpansions.add(modId);
} else if (mod.isExpansion) {
baseAndExpansions.add(modId);
} else {
otherMods.add(modId);
}
}
// Sort expansions to ensure correct order
baseAndExpansions.sort((a, b) {
final modA = mods[a]!;
final modB = mods[b]!;
// Base game always first
if (modA.isBaseGame) return -1;
if (modB.isBaseGame) return 1;
// Sort expansions alphabetically by ID (which should work for Ludeon expansions)
return a.compareTo(b);
}); });
// Combine the lists with harmony first, then base game and expansions, then other mods // Initialize heap with available mods
loadOrder.loadOrder.clear(); for (final modId in activeMods.keys) {
loadOrder.loadOrder.addAll(harmony); final mod = modMap[modId];
loadOrder.loadOrder.addAll(baseAndExpansions); if (mod != null && inDegree[modId] == 0) {
heap.add(mod);
// Now apply the normal optimization for the remaining mods }
List<String> remainingMods = otherMods;
Map<String, int> scoreInfo = _calculateSoftConstraintsScore(remainingMods);
int bestScore = scoreInfo['satisfied']!;
int total = scoreInfo['total']!;
if (total == 0 || bestScore == total) {
// All constraints satisfied or no constraints for remaining mods, sort by size where possible
_sortSizeWithinConstraints(loadOrder: loadOrder, modList: remainingMods);
loadOrder.loadOrder.addAll(remainingMods);
return loadOrder;
} }
// Use a limited number of improvement passes for the remaining mods final sortedMods = <Mod>[];
for (int iteration = 0; iteration < maxIterations; iteration++) { while (heap.isNotEmpty) {
bool improved = false; final current = heap.removeFirst();
sortedMods.add(current);
// Try moving each mod to improve score // Update dependents' in-degree
for (int i = 0; i < remainingMods.length; i++) { for (final neighborId in adjacency[current.id]!) {
String modId = remainingMods[i]; inDegree[neighborId] = inDegree[neighborId]! - 1;
Mod mod = mods[modId]!; if (inDegree[neighborId] == 0) {
heap.add(modMap[neighborId]!);
// Calculate current local score for this mod
Map<String, int> currentPositions = {};
for (int idx = 0; idx < remainingMods.length; idx++) {
currentPositions[remainingMods[idx]] = idx;
}
// Try moving this mod to different positions
for (int newPos = 0; newPos < remainingMods.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 = remainingMods[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 = remainingMods[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(remainingMods);
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;
remainingMods.clear();
remainingMods.addAll(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
}
// Sort by size where possible for the remaining mods
_sortSizeWithinConstraints(loadOrder: loadOrder, modList: remainingMods);
loadOrder.loadOrder.addAll(remainingMods);
return loadOrder;
}
/// Sort mods by size within compatible groups
LoadOrder _sortSizeWithinConstraints({
LoadOrder? loadOrder,
List<String>? modList,
}) {
loadOrder ??= LoadOrder();
List<String> modsToSort = modList ?? loadOrder.loadOrder;
// Find groups of mods that can be reordered without breaking constraints
List<List<String>> groups = [];
List<String> currentGroup = [];
for (int i = 0; i < modsToSort.length; i++) {
String modId = modsToSort[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) { // Update soft constraints
currentGroup.add(modId); _updateReverseConstraints(
} else { current,
// Start a new group reverseLoadBefore,
if (currentGroup.isNotEmpty) { sortedMods,
groups.add(List.from(currentGroup)); heap,
currentGroup = [modId]; (mod) => mod.loadBeforeNotPlaced--,
} );
} _updateReverseConstraints(
} current,
reverseLoadAfter,
// Add the last group if not empty sortedMods,
if (currentGroup.isNotEmpty) { heap,
groups.add(currentGroup); (mod) => mod.loadAfterPlaced++,
}
// 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
modsToSort.clear();
for (List<String> group in groups) {
modsToSort.addAll(group);
}
// If we were given the loadOrder directly, update it
if (modList == null) {
loadOrder.loadOrder.clear();
loadOrder.loadOrder.addAll(modsToSort);
}
return loadOrder;
}
LoadOrder loadDependencies(
String modId, [
LoadOrder? loadOrder,
List<String>? toEnable,
Map<String, bool>? seen,
List<String>? cyclePath,
]) {
final mod = mods[modId]!;
loadOrder ??= LoadOrder();
toEnable ??= <String>[];
seen ??= <String, bool>{};
cyclePath ??= <String>[];
// Add current mod to cycle path
cyclePath.add(modId);
for (final dep in mod.dependencies) {
if (!mods.containsKey(dep)) {
loadOrder.errors.add(
'Missing dependency: ${mod.name} requires mod with ID $dep',
);
continue;
}
final depMod = mods[dep]!;
if (seen[dep] == true) {
// Find the start of the cycle
int cycleStart = cyclePath.indexOf(dep);
if (cycleStart >= 0) {
// Extract the cycle part
List<String> cycleIds = [...cyclePath.sublist(cycleStart), modId];
loadOrder.errors.add(
'Cyclic dependency detected: ${cycleIds.join(' -> ')}',
);
} else {
loadOrder.errors.add('Cyclic dependency detected: $modId -> $dep');
}
continue;
}
seen[dep] = true;
toEnable.add(depMod.id);
loadDependencies(
depMod.id,
loadOrder,
toEnable,
seen,
List.from(cyclePath),
); );
} }
if (sortedMods.length != mods.length) {
throw Exception("Cyclic dependencies detected");
}
final loadOrder = LoadOrder();
loadOrder.loadOrder.addAll(sortedMods.map((e) => e.id));
return loadOrder; return loadOrder;
} }
LoadOrder loadRequired([LoadOrder? loadOrder]) { void _validateIncompatibilities(List<Mod> mods) {
loadOrder ??= LoadOrder(); final enabledMods = mods.where((m) => m.enabled).toList();
final toEnable = <String>[]; for (final mod in enabledMods) {
for (final modid in activeMods.keys) { for (final incompatibleId in mod.incompatibilities) {
loadDependencies(modid, loadOrder, toEnable); if (enabledMods.any((m) => m.id == incompatibleId)) {
throw Exception("Conflict: ${mod.id} vs $incompatibleId");
}
}
} }
for (final modid in toEnable) {
setEnabled(modid, true);
}
return generateLoadOrder(loadOrder);
} }
ModList copyWith({ void _updateReverseConstraints(
String? configPath, Mod current,
String? modsPath, Map<String, List<Mod>> reverseMap,
Map<String, Mod>? mods, List<Mod> sortedMods,
Map<String, bool>? activeMods, PriorityQueue<Mod> heap,
}) { void Function(Mod) update,
final newModlist = ModList( ) {
configPath: configPath ?? this.configPath, reverseMap[current.id]?.forEach((affectedMod) {
modsPath: modsPath ?? this.modsPath, if (!sortedMods.contains(affectedMod)) {
); update(affectedMod);
newModlist.mods = Map.from(mods ?? this.mods); // If mod is already in heap, re-add to update position
newModlist.activeMods = Map.from(activeMods ?? this.activeMods); if (heap.contains(affectedMod)) {
return newModlist; heap.remove(affectedMod);
heap.add(affectedMod);
}
}
});
}
LoadOrder loadRequired() {
final loadOrder = generateLoadOrder();
for (final modId in loadOrder.loadOrder) {
setEnabled(modId, true);
}
return loadOrder;
} }
} }

View File

@@ -32,7 +32,11 @@ void main() {
test('Harmony should load before RimWorld', () { test('Harmony should load before RimWorld', () {
final list = ModList(); final list = ModList();
list.mods = { list.mods = {
'harmony': makeDummy().copyWith(name: 'Harmony', id: 'harmony'), 'harmony': makeDummy().copyWith(
name: 'Harmony',
id: 'harmony',
loadBefore: ['ludeon.rimworld'],
),
'ludeon.rimworld': makeDummy().copyWith( 'ludeon.rimworld': makeDummy().copyWith(
name: 'RimWorld', name: 'RimWorld',
id: 'ludeon.rimworld', id: 'ludeon.rimworld',
@@ -40,11 +44,9 @@ void main() {
}; };
list.enableAll(); list.enableAll();
final order = list.generateLoadOrder(); final order = list.generateLoadOrder();
final expected = ['harmony', 'ludeon.rimworld'];
final harmonyIndex = order.loadOrder.indexOf('harmony');
final rimworldIndex = order.loadOrder.indexOf('ludeon.rimworld');
expect(order.errors, isEmpty); expect(order.errors, isEmpty);
expect(harmonyIndex, lessThan(rimworldIndex)); expect(order.loadOrder, equals(expected));
}); });
test('Prepatcher should load after Harmony and RimWorld', () { test('Prepatcher should load after Harmony and RimWorld', () {
@@ -68,13 +70,9 @@ void main() {
}; };
list.enableAll(); list.enableAll();
final order = list.generateLoadOrder(); final order = list.generateLoadOrder();
final expected = ['harmony', 'ludeon.rimworld', 'prepatcher'];
final prepatcherIndex = order.loadOrder.indexOf('prepatcher');
final harmonyIndex = order.loadOrder.indexOf('harmony');
final rimworldIndex = order.loadOrder.indexOf('ludeon.rimworld');
expect(order.errors, isEmpty); expect(order.errors, isEmpty);
expect(prepatcherIndex, greaterThan(harmonyIndex)); expect(order.loadOrder, equals(expected));
expect(prepatcherIndex, greaterThan(rimworldIndex));
}); });
test('RimWorld should load before Anomaly', () { test('RimWorld should load before Anomaly', () {
@@ -87,15 +85,14 @@ void main() {
'ludeon.rimworld.anomaly': makeDummy().copyWith( 'ludeon.rimworld.anomaly': makeDummy().copyWith(
name: 'RimWorld Anomaly', name: 'RimWorld Anomaly',
id: 'ludeon.rimworld.anomaly', id: 'ludeon.rimworld.anomaly',
dependencies: ['ludeon.rimworld'],
), ),
}; };
list.enableAll(); list.enableAll();
final order = list.generateLoadOrder(); final order = list.generateLoadOrder();
final expected = ['ludeon.rimworld', 'ludeon.rimworld.anomaly'];
final rimworldIndex = order.loadOrder.indexOf('ludeon.rimworld');
final anomalyIndex = order.loadOrder.indexOf('ludeon.rimworld.anomaly');
expect(order.errors, isEmpty); expect(order.errors, isEmpty);
expect(rimworldIndex, lessThan(anomalyIndex)); expect(order.loadOrder, equals(expected));
}); });
test('Disabled dummy mod should not be loaded', () { test('Disabled dummy mod should not be loaded', () {
@@ -108,9 +105,9 @@ void main() {
}; };
list.disableAll(); list.disableAll();
final order = list.generateLoadOrder(); final order = list.generateLoadOrder();
final expected = [];
final disabledIndex = order.loadOrder.indexOf('disabledDummy'); expect(order.errors, isEmpty);
expect(disabledIndex, isNegative); expect(order.loadOrder, equals(expected));
}); });
test('Larger mods should load before smaller ones', () { test('Larger mods should load before smaller ones', () {
@@ -125,11 +122,9 @@ void main() {
}; };
list.enableAll(); list.enableAll();
final order = list.generateLoadOrder(); final order = list.generateLoadOrder();
final expected = ['yuuuge', 'smol'];
final smolIndex = order.loadOrder.indexOf('smol');
final yuuugeIndex = order.loadOrder.indexOf('yuuuge');
expect(order.errors, isEmpty); expect(order.errors, isEmpty);
expect(yuuugeIndex, lessThan(smolIndex)); expect(order.loadOrder, equals(expected));
}); });
test('Incompatible mods should return errors', () { test('Incompatible mods should return errors', () {
@@ -332,43 +327,43 @@ void main() {
}); });
}); });
group('Test conflict detection', () { //group('Test conflict detection', () {
test('All conflicts should be correctly identified', () { // test('All conflicts should be correctly identified', () {
final list = ModList(); // final list = ModList();
list.mods = { // list.mods = {
'modA': makeDummy().copyWith( // 'modA': makeDummy().copyWith(
name: 'Mod A', // name: 'Mod A',
id: 'modA', // id: 'modA',
incompatibilities: ['modB', 'modC'], // incompatibilities: ['modB', 'modC'],
), // ),
'modB': makeDummy().copyWith(name: 'Mod B', id: 'modB'), // 'modB': makeDummy().copyWith(name: 'Mod B', id: 'modB'),
'modC': makeDummy().copyWith(name: 'Mod C', id: 'modC'), // 'modC': makeDummy().copyWith(name: 'Mod C', id: 'modC'),
}; // };
list.enableAll(); // list.enableAll();
final conflicts = list.checkIncompatibilities( // final conflicts = list.checkIncompatibilities(
list.activeMods.keys.toList(), // list.activeMods.keys.toList(),
); // );
expect(conflicts.length, equals(2)); // expect(conflicts.length, equals(2));
// Check if conflicts contain these pairs (order doesn't matter) // // Check if conflicts contain these pairs (order doesn't matter)
expect( // expect(
conflicts.any( // conflicts.any(
(c) => // (c) =>
(c[0] == 'modA' && c[1] == 'modB') || // (c[0] == 'modA' && c[1] == 'modB') ||
(c[0] == 'modB' && c[1] == 'modA'), // (c[0] == 'modB' && c[1] == 'modA'),
), // ),
isTrue, // isTrue,
); // );
expect( // expect(
conflicts.any( // conflicts.any(
(c) => // (c) =>
(c[0] == 'modA' && c[1] == 'modC') || // (c[0] == 'modA' && c[1] == 'modC') ||
(c[0] == 'modC' && c[1] == 'modA'), // (c[0] == 'modC' && c[1] == 'modA'),
), // ),
isTrue, // isTrue,
); // );
}); // });
}); //});
group('Test enable/disable functionality', () { group('Test enable/disable functionality', () {
test('Enable and disable methods should work correctly', () { test('Enable and disable methods should work correctly', () {