Compare commits

...

10 Commits

Author SHA1 Message Date
8f8f727603 Oh God that shouldn't happen, right?
We cannot depend on a mod and loadbefore it.........
Hopefully
2025-03-16 14:03:36 +01:00
856d98ac12 Fix up load dependencies to handle circular and multi dependencies 2025-03-16 14:01:24 +01:00
0a6032d77b Refactor test completely 2025-03-16 14:01:14 +01:00
49a6caa127 More tests 2025-03-16 13:45:32 +01:00
9931e7bf89 Implement loading dependencies for mods 2025-03-16 13:31:15 +01:00
76363dd523 Fix throwing error on conflicts 2025-03-16 13:18:27 +01:00
c32101c238 lil bit more refactoring 2025-03-16 13:13:06 +01:00
2cd9d585e6 Implement everything... 2025-03-16 12:52:55 +01:00
c2e6d5a491 Fix up main for tests 2025-03-16 12:52:49 +01:00
606607a278 More slight refactoring 2025-03-16 12:47:26 +01:00
4 changed files with 654 additions and 76 deletions

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.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 'dart:io';
import 'package:rimworld_modman/modloader.dart'; import 'package:rimworld_modman/modloader.dart';
// Global variable to store loaded mods for access across the app // Global variable to store loaded mods for access across the app
@@ -410,10 +409,10 @@ class _ModManagerPageState extends State<ModManagerPage> {
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
if (mod.hardDependencies.isNotEmpty) if (mod.dependencies.isNotEmpty)
Tooltip( Tooltip(
message: message:
'Hard dependencies:\n${mod.hardDependencies.join('\n')}', 'Dependencies:\n${mod.dependencies.join('\n')}',
child: const Icon( child: const Icon(
Icons.link, Icons.link,
color: Colors.orange, color: Colors.orange,
@@ -605,10 +604,10 @@ class _ModManagerPageState extends State<ModManagerPage> {
size: 24, size: 24,
), ),
), ),
if (mod.hardDependencies.isNotEmpty) if (mod.dependencies.isNotEmpty)
Tooltip( Tooltip(
message: message:
'Dependencies:\n${mod.hardDependencies.join('\n')}', 'Dependencies:\n${mod.dependencies.join('\n')}',
child: const Icon( child: const Icon(
Icons.link, Icons.link,
color: Colors.orange, color: Colors.orange,
@@ -703,10 +702,10 @@ class _ModManagerPageState extends State<ModManagerPage> {
path: mod.path, path: mod.path,
versions: mod.versions, versions: mod.versions,
description: mod.description, description: mod.description,
hardDependencies: mod.hardDependencies, dependencies: mod.dependencies,
loadAfter: mod.loadAfter, loadAfter: mod.loadAfter,
loadBefore: mod.loadBefore, loadBefore: mod.loadBefore,
incompatabilities: mod.incompatabilities, incompatibilities: mod.incompatibilities,
enabled: !mod.enabled, enabled: !mod.enabled,
size: mod.size, size: mod.size,
isBaseGame: mod.isBaseGame, isBaseGame: mod.isBaseGame,

View File

@@ -22,25 +22,29 @@ class Mod {
final String path; // figure it out final String path; // figure it out
final List<String> versions; // ModMetaData.supportedVersions final List<String> versions; // ModMetaData.supportedVersions
final String description; // ModMetaData.description final String description; // ModMetaData.description
final List<String> hardDependencies; // ModMetaData.modDependencies final List<String> dependencies; // ModMetaData.modDependencies
final List<String> loadAfter; // ModMetaData.loadAfter final List<String> loadAfter; // ModMetaData.loadAfter
final List<String> loadBefore; // ModMetaData.loadBefore final List<String> loadBefore; // ModMetaData.loadBefore
final List<String> incompatabilities; // ModMetaData.incompatibleWith final List<String> incompatibilities; // ModMetaData.incompatibleWith
bool enabled; bool enabled;
final int size; // Count of files in the mod directory final int size; // Count of files in the mod directory
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;
bool mark = false;
int position = -1;
Mod({ Mod({
required this.name, required this.name,
required this.id, required this.id,
required this.path, required this.path,
required this.versions, required this.versions,
required this.description, required this.description,
required this.hardDependencies, required this.dependencies,
required this.loadAfter, required this.loadAfter,
required this.loadBefore, required this.loadBefore,
required this.incompatabilities, required this.incompatibilities,
required this.size, required this.size,
this.isBaseGame = false, this.isBaseGame = false,
this.isExpansion = false, this.isExpansion = false,
@@ -130,9 +134,9 @@ class Mod {
); );
} }
List<String> hardDependencies = []; List<String> dependencies = [];
try { try {
hardDependencies = dependencies =
metadata metadata
.findElements('modDependenciesByVersion') .findElements('modDependenciesByVersion')
.first .first
@@ -145,10 +149,10 @@ class Mod {
e.findElements("packageId").first.innerText.toLowerCase(), e.findElements("packageId").first.innerText.toLowerCase(),
) )
.toList(); .toList();
logger.info('Hard dependencies found: ${hardDependencies.join(", ")}'); logger.info('Dependencies found: ${dependencies.join(", ")}');
} catch (e) { } catch (e) {
logger.warning( logger.warning(
'Hard dependencies element is missing in ModMetaData ($aboutFile).', 'Dependencies element is missing in ModMetaData ($aboutFile).',
); );
} }
@@ -184,16 +188,16 @@ class Mod {
); );
} }
List<String> incompatabilities = []; List<String> incompatibilities = [];
try { try {
incompatabilities = incompatibilities =
metadata metadata
.findElements('incompatibleWith') .findElements('incompatibleWith')
.first .first
.findElements('li') .findElements('li')
.map((e) => e.innerText.toLowerCase()) .map((e) => e.innerText.toLowerCase())
.toList(); .toList();
logger.info('Incompatibilities found: ${incompatabilities.join(", ")}'); logger.info('Incompatibilities found: ${incompatibilities.join(", ")}');
} catch (e) { } catch (e) {
logger.warning( logger.warning(
'Incompatibilities element is missing in ModMetaData ($aboutFile).', 'Incompatibilities element is missing in ModMetaData ($aboutFile).',
@@ -245,10 +249,10 @@ class Mod {
path: path, path: path,
versions: versions, versions: versions,
description: description, description: description,
hardDependencies: hardDependencies, dependencies: dependencies,
loadAfter: loadAfter, loadAfter: loadAfter,
loadBefore: loadBefore, loadBefore: loadBefore,
incompatabilities: incompatabilities, incompatibilities: incompatibilities,
size: size, size: size,
isBaseGame: isBaseGame, isBaseGame: isBaseGame,
isExpansion: isExpansion, isExpansion: isExpansion,
@@ -261,10 +265,10 @@ class Mod {
String? path, String? path,
List<String>? versions, List<String>? versions,
String? description, String? description,
List<String>? hardDependencies, List<String>? dependencies,
List<String>? loadAfter, List<String>? loadAfter,
List<String>? loadBefore, List<String>? loadBefore,
List<String>? incompatabilities, List<String>? incompatibilities,
int? size, int? size,
bool? isBaseGame, bool? isBaseGame,
bool? isExpansion, bool? isExpansion,
@@ -276,10 +280,10 @@ class Mod {
path: path ?? this.path, path: path ?? this.path,
versions: versions ?? this.versions, versions: versions ?? this.versions,
description: description ?? this.description, description: description ?? this.description,
hardDependencies: hardDependencies ?? this.hardDependencies, dependencies: dependencies ?? this.dependencies,
loadAfter: loadAfter ?? this.loadAfter, loadAfter: loadAfter ?? this.loadAfter,
loadBefore: loadBefore ?? this.loadBefore, loadBefore: loadBefore ?? this.loadBefore,
incompatabilities: incompatabilities ?? this.incompatabilities, incompatibilities: incompatibilities ?? this.incompatibilities,
size: size ?? this.size, size: size ?? this.size,
isBaseGame: isBaseGame ?? this.isBaseGame, isBaseGame: isBaseGame ?? this.isBaseGame,
isExpansion: isExpansion ?? this.isExpansion, isExpansion: isExpansion ?? this.isExpansion,

View File

@@ -5,13 +5,13 @@ import 'package:rimworld_modman/mod.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
class ModList { class ModList {
final String configPath; String configPath = '';
final String modsPath; String modsPath = '';
// O(1) lookup // O(1) lookup
Map<String, bool> activeMods = {}; Map<String, bool> activeMods = {};
Map<String, Mod> mods = {}; Map<String, Mod> mods = {};
ModList({required this.configPath, required this.modsPath}); ModList({this.configPath = '', this.modsPath = ''});
Stream<Mod> loadAvailable() async* { Stream<Mod> loadAvailable() async* {
final logger = Logger.instance; final logger = Logger.instance;
@@ -57,10 +57,10 @@ class ModList {
path: mod.path, path: mod.path,
versions: mod.versions, versions: mod.versions,
description: mod.description, description: mod.description,
hardDependencies: mod.hardDependencies, dependencies: mod.dependencies,
loadAfter: mod.loadAfter, loadAfter: mod.loadAfter,
loadBefore: mod.loadBefore, loadBefore: mod.loadBefore,
incompatabilities: mod.incompatabilities, incompatibilities: mod.incompatibilities,
size: mod.size, size: mod.size,
enabled: existingMod.enabled, enabled: existingMod.enabled,
isBaseGame: existingMod.isBaseGame, isBaseGame: existingMod.isBaseGame,
@@ -147,12 +147,12 @@ class ModList {
: isExpansion : isExpansion
? "RimWorld expansion" ? "RimWorld expansion"
: ""), : ""),
hardDependencies: existingMod?.hardDependencies ?? [], dependencies: existingMod?.dependencies ?? [],
loadAfter: loadAfter:
existingMod?.loadAfter ?? existingMod?.loadAfter ??
(isExpansion ? ['ludeon.rimworld'] : []), (isExpansion ? ['ludeon.rimworld'] : []),
loadBefore: existingMod?.loadBefore ?? [], loadBefore: existingMod?.loadBefore ?? [],
incompatabilities: existingMod?.incompatabilities ?? [], incompatibilities: existingMod?.incompatibilities ?? [],
enabled: existingMod?.enabled ?? false, enabled: existingMod?.enabled ?? false,
size: existingMod?.size ?? 0, size: existingMod?.size ?? 0,
isBaseGame: isBaseGame, isBaseGame: isBaseGame,
@@ -162,7 +162,7 @@ class ModList {
logger.warning('Mod $modId already exists in mods list, overwriting'); logger.warning('Mod $modId already exists in mods list, overwriting');
} }
mods[modId] = mod; mods[modId] = mod;
activeMods[modId] = true; setEnabled(modId, mod.enabled);
yield mod; yield mod;
} }
@@ -172,6 +172,264 @@ class ModList {
throw Exception('Failed to load config 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> 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();
}
} }
String _expansionNameFromId(String id) { String _expansionNameFromId(String id) {

View File

@@ -8,72 +8,389 @@ const configRoot = '$root/AppData/RimWorld by Ludeon Studios/Config';
const configPath = '$configRoot/ModsConfig.xml'; const configPath = '$configRoot/ModsConfig.xml';
const logsPath = '$root/ModManager'; const logsPath = '$root/ModManager';
void main() { Mod makeDummy() {
final dummyMod = Mod( return Mod(
name: 'Dummy Mod', name: 'Dummy Mod',
id: 'dummy', id: 'dummy',
path: '', path: '',
versions: ["1.5"], versions: ["1.5"],
description: '', description: '',
hardDependencies: [], dependencies: [],
loadAfter: [], loadAfter: [],
loadBefore: [], loadBefore: [],
incompatabilities: [], incompatibilities: [],
size: 0, size: 0,
isBaseGame: false, isBaseGame: false,
isExpansion: false, isExpansion: false,
enabled: false, enabled: false,
); );
}
final dummyMods = { void main() {
'harmony': dummyMod.copyWith(
name: 'Harmony',
id: 'harmony',
loadBefore: ["ludeon.rimworld"],
size: 47,
enabled: true,
),
'ludeon.rimworld': dummyMod.copyWith(
name: 'RimWorld',
id: 'ludeon.rimworld',
enabled: true,
isBaseGame: true,
),
'ludeon.rimworld.anomaly': dummyMod.copyWith(
name: 'RimWorld Anomaly',
id: 'ludeon.rimworld.anomaly',
enabled: true,
isExpansion: true,
),
'disabledDummy': dummyMod.copyWith(
name: 'Disabled Dummy',
id: 'disabledDummy',
enabled: false,
),
};
final dummyList = ModList(configPath: configPath, modsPath: modsRoot);
dummyList.mods.addAll(dummyMods);
for (final mod in dummyMods.keys) {
dummyList.activeMods[mod] = true;
}
// final sortedMods = dummyList.sort();
final sortedMods = ['harmony', 'ludeon.rimworld', 'ludeon.rimworld.anomaly'];
group('Test sorting', () { group('Test sorting', () {
test('Harmony should load before RimWorld', () { test('Harmony should load before RimWorld', () {
final harmonyIndex = sortedMods.indexOf('harmony'); final list = ModList();
final rimworldIndex = sortedMods.indexOf('ludeon.rimworld'); list.mods = {
'harmony': makeDummy().copyWith(name: 'Harmony', id: 'harmony'),
'ludeon.rimworld': makeDummy().copyWith(
name: 'RimWorld',
id: 'ludeon.rimworld',
),
};
list.enableAll();
final order = list.generateLoadOrder();
final harmonyIndex = order.indexOf('harmony');
final rimworldIndex = order.indexOf('ludeon.rimworld');
expect(harmonyIndex, lessThan(rimworldIndex)); expect(harmonyIndex, lessThan(rimworldIndex));
}); });
test('Prepatcher should load after Harmony and RimWorld', () {
final list = ModList();
list.mods = {
'prepatcher': makeDummy().copyWith(
name: 'Prepatcher',
id: 'prepatcher',
dependencies: ['harmony'],
loadAfter: ['ludeon.rimworld'],
),
'harmony': makeDummy().copyWith(
name: 'Harmony',
id: 'harmony',
loadBefore: ['ludeon.rimworld'],
),
'ludeon.rimworld': makeDummy().copyWith(
name: 'RimWorld',
id: 'ludeon.rimworld',
),
};
list.enableAll();
final order = list.generateLoadOrder();
final prepatcherIndex = order.indexOf('prepatcher');
final harmonyIndex = order.indexOf('harmony');
final rimworldIndex = order.indexOf('ludeon.rimworld');
expect(prepatcherIndex, greaterThan(harmonyIndex));
expect(prepatcherIndex, greaterThan(rimworldIndex));
});
test('RimWorld should load before Anomaly', () { test('RimWorld should load before Anomaly', () {
final rimworldIndex = sortedMods.indexOf('ludeon.rimworld'); final list = ModList();
final anomalyIndex = sortedMods.indexOf('ludeon.rimworld.anomaly'); list.mods = {
'ludeon.rimworld': makeDummy().copyWith(
name: 'RimWorld',
id: 'ludeon.rimworld',
),
'ludeon.rimworld.anomaly': makeDummy().copyWith(
name: 'RimWorld Anomaly',
id: 'ludeon.rimworld.anomaly',
),
};
list.enableAll();
final order = list.generateLoadOrder();
final rimworldIndex = order.indexOf('ludeon.rimworld');
final anomalyIndex = order.indexOf('ludeon.rimworld.anomaly');
expect(rimworldIndex, lessThan(anomalyIndex)); expect(rimworldIndex, lessThan(anomalyIndex));
}); });
test('Disabled dummy mod should not be loaded', () { test('Disabled dummy mod should not be loaded', () {
final disabledIndex = sortedMods.indexOf('disabledDummy'); final list = ModList();
list.mods = {
'disabledDummy': makeDummy().copyWith(
name: 'Disabled Dummy',
id: 'disabledDummy',
),
};
list.disableAll();
final order = list.generateLoadOrder();
final disabledIndex = order.indexOf('disabledDummy');
expect(disabledIndex, isNegative); expect(disabledIndex, isNegative);
}); });
test('Larger mods should load before smaller ones', () {
final list = ModList();
list.mods = {
'smol': makeDummy().copyWith(name: 'Smol', id: 'smol', size: 100),
'yuuuge': makeDummy().copyWith(
name: 'Yuuuge',
id: 'yuuuge',
size: 10000,
),
};
list.enableAll();
final order = list.generateLoadOrder();
final smolIndex = order.indexOf('smol');
final yuuugeIndex = order.indexOf('yuuuge');
expect(yuuugeIndex, lessThan(smolIndex));
});
test('Incompatible mods should throw exception', () {
final list = ModList();
list.mods = {
'incompatible': makeDummy().copyWith(
name: 'Incompatible',
id: 'incompatible',
incompatibilities: ['harmony'],
),
'harmony': makeDummy().copyWith(name: 'Harmony', id: 'harmony'),
};
list.enableAll();
expect(() => list.generateLoadOrder(), throwsException);
});
});
group('Test loadRequired', () {
test('Dependencies should be automatically enabled', () {
final list = ModList();
list.mods = {
'prepatcher': makeDummy().copyWith(
name: 'Prepatcher',
id: 'prepatcher',
dependencies: ['harmony'],
),
'harmony': makeDummy().copyWith(name: 'Harmony', id: 'harmony'),
};
list.disableAll();
list.setEnabled('prepatcher', true);
final order = list.loadRequired();
expect(order.indexOf('harmony'), isNot(-1));
});
test('Only required mods should be enabled', () {
final list = ModList();
list.mods = {
'prepatcher': makeDummy().copyWith(
name: 'Prepatcher',
id: 'prepatcher',
dependencies: ['harmony'],
),
'harmony': makeDummy().copyWith(name: 'Harmony', id: 'harmony'),
'dummy': makeDummy(),
};
list.disableAll();
list.setEnabled('prepatcher', true);
final order = list.loadRequired();
expect(order.indexOf('harmony'), isNot(-1));
expect(order.indexOf('dummy'), -1);
});
test('Incompatible mods should throw exception', () {
final list = ModList();
list.mods = {
'incompatible': makeDummy().copyWith(
name: 'Incompatible',
id: 'incompatible',
incompatibilities: ['harmony'],
),
'prepatcher': makeDummy().copyWith(
name: 'Prepatcher',
id: 'prepatcher',
dependencies: ['harmony'],
),
'harmony': makeDummy().copyWith(name: 'Harmony', id: 'harmony'),
};
list.disableAll();
list.setEnabled('incompatible', true);
list.setEnabled('prepatcher', true);
expect(() => list.loadRequired(), throwsException);
});
test('Dependencies of dependencies should be loaded', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['modB'],
),
'modB': makeDummy().copyWith(
name: 'Mod B',
id: 'modB',
dependencies: ['modC'],
),
'modC': makeDummy().copyWith(name: 'Mod C', id: 'modC'),
};
list.disableAll();
list.setEnabled('modA', true);
final order = list.loadRequired();
expect(order.indexOf('modA'), isNot(-1));
expect(order.indexOf('modB'), isNot(-1));
expect(order.indexOf('modC'), isNot(-1));
});
});
group('Test cyclic dependencies', () {
test('Cyclic dependencies should throw exception', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['modB'],
),
'modB': makeDummy().copyWith(
name: 'Mod B',
id: 'modB',
dependencies: ['modC'],
),
'modC': makeDummy().copyWith(
name: 'Mod C',
id: 'modC',
dependencies: ['modA'],
),
};
list.disableAll();
list.setEnabled('modA', true);
expect(() => list.loadRequired(), throwsException);
});
});
group('Test soft constraints', () {
test('Load preferences should be respected when possible', () {
final dummy = makeDummy();
final list = ModList();
list.mods = {
'modA': dummy.copyWith(
name: 'Mod A',
id: 'modA',
loadAfter: ['modB'],
loadBefore: ['modC'],
),
'modB': dummy.copyWith(name: 'Mod B', id: 'modB'),
'modC': dummy.copyWith(name: 'Mod C', id: 'modC'),
};
list.enableAll();
final order = list.generateLoadOrder();
final aIndex = order.indexOf('modA');
final bIndex = order.indexOf('modB');
final cIndex = order.indexOf('modC');
expect(aIndex, greaterThan(bIndex));
expect(aIndex, lessThan(cIndex));
});
});
group('Test conflict detection', () {
test('All conflicts should be correctly identified', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
incompatibilities: ['modB', 'modC'],
),
'modB': makeDummy().copyWith(name: 'Mod B', id: 'modB'),
'modC': makeDummy().copyWith(name: 'Mod C', id: 'modC'),
};
list.enableAll();
final conflicts = list.checkIncompatibilities();
expect(conflicts.length, equals(2));
// Check if conflicts contain these pairs (order doesn't matter)
expect(
conflicts.any(
(c) =>
(c[0] == 'modA' && c[1] == 'modB') ||
(c[0] == 'modB' && c[1] == 'modA'),
),
isTrue,
);
expect(
conflicts.any(
(c) =>
(c[0] == 'modA' && c[1] == 'modC') ||
(c[0] == 'modC' && c[1] == 'modA'),
),
isTrue,
);
});
});
group('Test enable/disable functionality', () {
test('Enable and disable methods should work correctly', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(name: 'Mod A', id: 'modA'),
'modB': makeDummy().copyWith(name: 'Mod B', id: 'modB'),
};
list.enableAll();
for (final mod in list.mods.values) {
expect(mod.enabled, isTrue);
}
list.disableAll();
for (final mod in list.mods.values) {
expect(mod.enabled, isFalse);
}
});
});
group('Test base game and expansion handling', () {
test('Base game and expansions should be correctly ordered', () {
final list = ModList();
list.mods = {
'ludeon.rimworld': makeDummy().copyWith(
name: 'RimWorld',
id: 'ludeon.rimworld',
isBaseGame: true,
),
'ludeon.rimworld.anomaly': makeDummy().copyWith(
name: 'RimWorld Anomaly',
id: 'ludeon.rimworld.anomaly',
dependencies: ['ludeon.rimworld'],
isExpansion: true,
),
};
list.enableAll();
final order = list.generateLoadOrder();
// Base game should load before any expansions
final baseGameIndex = order.indexOf('ludeon.rimworld');
final expansionIndex = order.indexOf('ludeon.rimworld.anomaly');
expect(baseGameIndex, lessThan(expansionIndex));
});
});
group('Test complex dependency chains', () {
test('Complex dependency chains should resolve correctly', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['modB'],
),
'modB': makeDummy().copyWith(
name: 'Mod B',
id: 'modB',
dependencies: ['modC'],
),
'modC': makeDummy().copyWith(
name: 'Mod C',
id: 'modC',
dependencies: ['modD'],
),
'modD': makeDummy().copyWith(name: 'Mod D', id: 'modD'),
};
list.disableAll();
list.setEnabled('modA', true);
final result = list.loadRequired();
// All mods in the chain should be enabled
expect(result.contains('modA'), isTrue);
expect(result.contains('modB'), isTrue);
expect(result.contains('modC'), isTrue);
expect(result.contains('modD'), isTrue);
// The order should be D -> C -> B -> A
expect(result.indexOf('modD'), lessThan(result.indexOf('modC')));
expect(result.indexOf('modC'), lessThan(result.indexOf('modB')));
expect(result.indexOf('modB'), lessThan(result.indexOf('modA')));
});
}); });
} }