Use fucking graphs again

This commit is contained in:
2025-03-18 20:42:22 +01:00
parent efe74b404e
commit a6cfd3e16e
4 changed files with 556 additions and 25 deletions

View File

@@ -47,6 +47,17 @@ class Mod {
this.enabled = false,
});
int get tier {
if (isBaseGame) return 0;
if (isExpansion) return 1;
return 2;
}
@override
String toString() {
return 'Mod{name: $name, id: $id, path: $path, dependencies: $dependencies, loadAfter: $loadAfter, loadBefore: $loadBefore, incompatibilities: $incompatibilities, size: $size, isBaseGame: $isBaseGame, isExpansion: $isExpansion}';
}
static Mod fromDirectory(String path, {bool skipFileCount = false}) {
final logger = Logger.instance;
final stopwatch = Stopwatch()..start();

View File

@@ -255,34 +255,145 @@ class ModList {
LoadOrder generateLoadOrder([LoadOrder? loadOrder]) {
loadOrder ??= LoadOrder();
final logger = Logger.instance;
logger.info('Generating load order...');
for (final mod in activeMods.values) {
logger.info('Checking mod: ${mod.id}');
logger.info('Mod details: ${mod.toString()}');
for (final incomp in mod.incompatibilities) {
if (activeMods.containsKey(incomp)) {
loadOrder.errors.add(
'Incompatibility detected: ${mod.id} is incompatible with $incomp',
);
logger.warning(
'Incompatibility detected: ${mod.id} is incompatible with $incomp',
);
} else {
logger.info('No incompatibility found for: $incomp');
}
}
for (final dep in mod.dependencies) {
if (!activeMods.containsKey(dep)) {
loadOrder.errors.add('Missing dependency: ${mod.id} requires $dep');
logger.warning('Missing dependency: ${mod.id} requires $dep');
} else {
logger.info('Dependency found: ${mod.id} requires $dep');
}
}
}
logger.info('Adding active mods to load order...');
loadOrder.order.addAll(activeMods.values.toList());
logger.info(
'Active mods added: ${loadOrder.order.map((mod) => mod.id).join(', ')}',
);
loadOrder.order.sort((a, b) {
if (a.isBaseGame && !b.isBaseGame) return -1;
if (!a.isBaseGame && b.isBaseGame) return 1;
if (a.isExpansion && !b.isExpansion) return -1;
if (!a.isExpansion && b.isExpansion) return 1;
final modMap = {for (final mod in loadOrder.order) mod.id: mod};
final graph = <String, Set<String>>{};
final inDegree = <String, int>{};
// Step 1: Initialize graph and inDegree
for (final mod in loadOrder.order) {
graph[mod.id] = <String>{};
inDegree[mod.id] = 0;
}
// Step 2: Build dependency graph
void addEdge(String from, String to) {
final fromMod = modMap[from];
if (fromMod == null) {
logger.warning('Missing dependency: $from');
return;
}
final toMod = modMap[to];
if (toMod == null) {
logger.warning('Missing dependency: $to');
return;
}
if (graph[from]!.add(to)) {
inDegree[to] = inDegree[to]! + 1;
}
}
for (final mod in loadOrder.order) {
for (final target in mod.loadBefore) {
addEdge(mod.id, target);
}
for (final target in mod.loadAfter) {
addEdge(target, mod.id);
}
for (final dep in mod.dependencies) {
addEdge(dep, mod.id);
}
}
// Step 3: Calculate tiers dynamically with cross-tier dependencies
final tiers = <Mod, int>{};
for (final mod in loadOrder.order) {
int tier = 2; // Default to Tier 2
// Check if mod loads before any base game mod (Tier 0)
final loadsBeforeBase = mod.loadBefore.any(
(id) => modMap[id]?.isBaseGame ?? false,
);
if (mod.isBaseGame || loadsBeforeBase) {
tier = 0;
} else {
// Check if mod loads before any expansion (Tier 1)
final loadsBeforeExpansion = mod.loadBefore.any(
(id) => modMap[id]?.isExpansion ?? false,
);
if (mod.isExpansion || loadsBeforeExpansion) {
tier = 1;
}
}
tiers[mod] = tier;
}
// Step 4: Global priority queue (tier ascending, size descending)
final pq = PriorityQueue<Mod>((a, b) {
final tierA = tiers[a]!;
final tierB = tiers[b]!;
if (tierA != tierB) return tierA.compareTo(tierB);
return b.size.compareTo(a.size);
});
Map<String, List<Mod>> relations = {};
for (int i = loadOrder.order.length - 1; i >= 0; i--) {
final mod = loadOrder!.order[i];
logger.info('Processing mod: ${mod.id}');
loadOrder = shuffleMod(mod, loadOrder, relations);
// Initialize queue with mods having inDegree 0
for (final mod in loadOrder.order) {
if (inDegree[mod.id] == 0) {
pq.add(mod);
}
}
return loadOrder!;
final orderedMods = <Mod>[];
while (pq.isNotEmpty) {
final current = pq.removeFirst();
orderedMods.add(current);
for (final neighborId in graph[current.id]!) {
inDegree[neighborId] = inDegree[neighborId]! - 1;
if (inDegree[neighborId] == 0) {
final neighbor = modMap[neighborId]!;
pq.add(neighbor);
}
}
}
if (orderedMods.length != loadOrder.order.length) {
loadOrder.errors.add('Cycle detected in dependencies');
logger.warning(
'Cycle detected in dependencies: expected ${loadOrder.order.length}, got ${orderedMods.length}.',
);
}
loadOrder.order = orderedMods;
logger.info(
'Load order generated successfully with ${loadOrder.order.length} mods.',
);
for (final mod in loadOrder.order) {
logger.info('Mod: ${mod.toString()}');
}
return loadOrder;
}
// The point of relations and the recursive call is to handle the case where

View File

@@ -200,18 +200,18 @@ void main() {
final expected = [
'brrainz.harmony',
'ludeon.rimworld',
'ludeon.rimworld.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
'ludeon.rimworld.anomaly',
'ludeon.rimworld.biotech',
'ludeon.rimworld.ideology',
'ludeon.rimworld.royalty',
'dubwise.rimatomics',
'jecrell.doorsexpanded',
'dubwise.rimefeller',
'neronix17.toolbox',
'automatic.bionicicons',
'lwm.deepstorage',
'dubwise.dubsmintmenus',
'dubwise.dubsmintminimap',
'dubwise.dubsmintmenus',
'brrainz.justignoremepassing',
];
expect(result.errors, isEmpty);
@@ -299,6 +299,415 @@ void main() {
list.enableAll();
final order = list.generateLoadOrder();
final expected = [
'zetrith.prepatcher',
'brrainz.harmony',
'ludeon.rimworld',
'bs.betterlog',
'ludeon.rimworld.anomaly',
'ludeon.rimworld.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
];
expect(order.loadOrder, equals(expected));
});
test('Expansions should load before most mods', () {
final list = ModList();
list.mods = {
'bs.betterlog': makeDummy().copyWith(
id: 'bs.betterlog',
name: 'Better Log - Fix your errors',
enabled: true,
size: 69,
dependencies: [],
loadAfter: [
'brrainz.harmony',
'me.samboycoding.betterloading',
'zetrith.prepatcher',
'ludeon.rimworld',
],
loadBefore: [
'ludeon.rimworld.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
'ludeon.rimworld.anomaly',
'bs.performance',
'unlimitedhugs.hugslib',
],
incompatibilities: [],
),
'zetrith.prepatcher': makeDummy().copyWith(
id: 'zetrith.prepatcher',
name: 'Prepatcher',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2934420800',
dependencies: [],
loadAfter: [],
loadBefore: ['ludeon.rimworld', 'brrainz.harmony'],
incompatibilities: [],
size: 21,
isBaseGame: false,
isExpansion: false,
),
'brrainz.harmony': makeDummy().copyWith(
id: 'brrainz.harmony',
name: 'Harmony',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2009463077',
dependencies: [],
loadAfter: [],
loadBefore: ['ludeon.rimworld'],
incompatibilities: [],
size: 17,
isBaseGame: false,
isExpansion: false,
),
'ludeon.rimworld': makeDummy().copyWith(
id: 'ludeon.rimworld',
name: 'RimWorld',
path: '',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 0,
isBaseGame: true,
isExpansion: false,
),
'rah.rbse': makeDummy().copyWith(
id: 'rah.rbse',
name: 'RBSE',
path: 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\850429707',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 1729,
isBaseGame: false,
isExpansion: false,
),
'mlie.usethisinstead': makeDummy().copyWith(
id: 'mlie.usethisinstead',
name: 'Use This Instead',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3396308787',
dependencies: [],
loadAfter: ['brrainz.harmony'],
loadBefore: [],
incompatibilities: [],
size: 1651,
isBaseGame: false,
isExpansion: false,
),
'dubwise.rimatomics': makeDummy().copyWith(
id: 'dubwise.rimatomics',
name: 'Dubs Rimatomics',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\1127530465',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 1563,
isBaseGame: false,
isExpansion: false,
),
'jecrell.doorsexpanded': makeDummy().copyWith(
id: 'jecrell.doorsexpanded',
name: 'Doors Expanded',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\1316188771',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 765,
isBaseGame: false,
isExpansion: false,
),
'balistafreak.stopdropandroll': makeDummy().copyWith(
id: 'balistafreak.stopdropandroll',
name: 'Stop, Drop, And Roll! [BAL]',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2362707956',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 755,
isBaseGame: false,
isExpansion: false,
),
'fluffy.animaltab': makeDummy().copyWith(
id: 'fluffy.animaltab',
name: 'Animal Tab',
path: 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\712141500',
dependencies: ['brrainz.harmony'],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 752,
isBaseGame: false,
isExpansion: false,
),
'gt.sam.glittertech': makeDummy().copyWith(
id: 'gt.sam.glittertech',
name: 'Glitter Tech Classic',
path: 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\725576127',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 747,
isBaseGame: false,
isExpansion: false,
),
'dubwise.rimefeller': makeDummy().copyWith(
id: 'dubwise.rimefeller',
name: 'Rimefeller',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\1321849735',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 744,
isBaseGame: false,
isExpansion: false,
),
'darthcy.misc.morebetterdeepdrill': makeDummy().copyWith(
id: 'darthcy.misc.morebetterdeepdrill',
name: 'More and Better Deep Drill',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3378527302',
dependencies: [],
loadAfter: ['brrainz.harmony', 'spdskatr.projectrimfactory'],
loadBefore: [],
incompatibilities: [],
size: 738,
isBaseGame: false,
isExpansion: false,
),
'haplo.miscellaneous.training': makeDummy().copyWith(
id: 'haplo.miscellaneous.training',
name: 'Misc. Training',
path: 'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\717575199',
dependencies: [],
loadAfter: ['haplo.miscellaneous.core'],
loadBefore: [],
incompatibilities: ['haplo.miscellaneous.trainingnotask'],
size: 733,
isBaseGame: false,
isExpansion: false,
),
'linkolas.stabilize': makeDummy().copyWith(
id: 'linkolas.stabilize',
name: 'Stabilize',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2023407836',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 627,
isBaseGame: false,
isExpansion: false,
),
'dubwise.dubsperformanceanalyzer.steam': makeDummy().copyWith(
id: 'dubwise.dubsperformanceanalyzer.steam',
name: 'Dubs Performance Analyzer',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2038874626',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 570,
isBaseGame: false,
isExpansion: false,
),
'memegoddess.searchanddestroy': makeDummy().copyWith(
id: 'memegoddess.searchanddestroy',
name: 'Search and Destroy (Unofficial Update)',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3232242247',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 495,
isBaseGame: false,
isExpansion: false,
),
'gogatio.mechanoidupgrades': makeDummy().copyWith(
id: 'gogatio.mechanoidupgrades',
name: 'Mechanoid Upgrades',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3365118555',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 487,
isBaseGame: false,
isExpansion: false,
),
'issaczhuang.muzzleflash': makeDummy().copyWith(
id: 'issaczhuang.muzzleflash',
name: 'Muzzle Flash',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2917732219',
dependencies: [],
loadAfter: ['ludeon.rimworld'],
loadBefore: [],
incompatibilities: [],
size: 431,
isBaseGame: false,
isExpansion: false,
),
'smashphil.vehicleframework': makeDummy().copyWith(
id: 'smashphil.vehicleframework',
name: 'Vehicle Framework',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3014915404',
dependencies: [],
loadAfter: ['brrainz.harmony'],
loadBefore: [],
incompatibilities: [],
size: 426,
isBaseGame: false,
isExpansion: false,
),
'cabbage.rimcities': makeDummy().copyWith(
id: 'cabbage.rimcities',
name: 'RimCities',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\1775170117',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 421,
isBaseGame: false,
isExpansion: false,
),
'vis.staticquality': makeDummy().copyWith(
id: 'vis.staticquality',
name: 'Static Quality',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2801204005',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 385,
isBaseGame: false,
isExpansion: false,
),
'automatic.bionicicons': makeDummy().copyWith(
id: 'automatic.bionicicons',
name: 'Bionic icons',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\1677616980',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 365,
isBaseGame: false,
isExpansion: false,
),
'vanillaexpanded.vanillatraitsexpanded': makeDummy().copyWith(
id: 'vanillaexpanded.vanillatraitsexpanded',
name: 'Vanilla Traits Expanded',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\2296404655',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 338,
isBaseGame: false,
isExpansion: false,
),
'tk421storm.ragdoll': makeDummy().copyWith(
id: 'tk421storm.ragdoll',
name: 'Ragdoll Physics',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3179116177',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 329,
isBaseGame: false,
isExpansion: false,
),
'andromeda.nicehealthtab': makeDummy().copyWith(
id: 'andromeda.nicehealthtab',
name: 'Nice Health Tab',
path:
'C:/Users/Administrator/Seafile/Games-RimWorld/294100\\3328729902',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 319,
isBaseGame: false,
isExpansion: false,
),
'ludeon.rimworld.anomaly': makeDummy().copyWith(
id: 'ludeon.rimworld.anomaly',
name: 'RimWorld Anomaly',
path: '',
dependencies: [],
loadAfter: ['ludeon.rimworld'],
loadBefore: [],
incompatibilities: [],
size: 0,
isBaseGame: false,
isExpansion: true,
),
'ludeon.rimworld.biotech': makeDummy().copyWith(
id: 'ludeon.rimworld.biotech',
name: 'RimWorld Biotech',
path: '',
dependencies: [],
loadAfter: ['ludeon.rimworld'],
loadBefore: [],
incompatibilities: [],
size: 0,
isBaseGame: false,
isExpansion: true,
),
'ludeon.rimworld.ideology': makeDummy().copyWith(
id: 'ludeon.rimworld.ideology',
name: 'RimWorld Ideology',
path: '',
dependencies: [],
loadAfter: ['ludeon.rimworld'],
loadBefore: [],
incompatibilities: [],
size: 0,
isBaseGame: false,
isExpansion: true,
),
'ludeon.rimworld.royalty': makeDummy().copyWith(
id: 'ludeon.rimworld.royalty',
name: 'RimWorld Royalty',
path: '',
dependencies: [],
loadAfter: ['ludeon.rimworld'],
loadBefore: [],
incompatibilities: [],
size: 0,
isBaseGame: false,
isExpansion: true,
),
};
list.enableAll();
final order = list.generateLoadOrder();
final expected = [
'zetrith.prepatcher',
'brrainz.harmony',

View File

@@ -245,7 +245,7 @@ void main() {
final result = list.loadRequired();
// We say the mods are incompatible but load them anyway, who are we to decide what isn't loaded?
final expected = ['incompatible', 'harmony', 'prepatcher'];
final expected = ['harmony', 'prepatcher', 'incompatible'];
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('incompatible')), isTrue);
expect(result.errors.any((e) => e.contains('harmony')), isTrue);
@@ -302,7 +302,7 @@ void main() {
// We try to not disable mods...... But cyclic dependencies are just hell
// Can not handle it
final expected = ['modB', 'modA', 'modC'];
final expected = [];
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('modA')), isTrue);
expect(result.errors.any((e) => e.contains('modB')), isTrue);
@@ -487,7 +487,7 @@ void main() {
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modA', 'modB'];
final expected = ['modB', 'modA'];
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('missing1')), isTrue);
expect(result.errors.any((e) => e.contains('missing2')), isTrue);
@@ -548,7 +548,7 @@ void main() {
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modA', 'modB'];
final expected = ['modB', 'modA'];
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('incompatible')), isTrue);
expect(result.errors.any((e) => e.contains('modA')), isTrue);
@@ -570,7 +570,7 @@ void main() {
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modA', 'modB'];
final expected = ['modB', 'modA'];
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('missingDep')), isTrue);
expect(result.errors.any((e) => e.contains('incompatible')), isTrue);
@@ -653,7 +653,7 @@ void main() {
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modA', 'modB'];
final expected = ['modB', 'modA'];
expect(result.errors, isEmpty);
expect(result.loadOrder, equals(expected));
@@ -691,7 +691,7 @@ void main() {
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modA', 'modB'];
final expected = ['modB', 'modA'];
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('nonExistentMod')), isTrue);
@@ -778,7 +778,6 @@ void main() {
final result = list.generateLoadOrder();
final expected = ['existingMod', 'modA'];
// Should still generatdeequals(mopected)
expect(result.loadOrder.contains('existingMod'), isTrue);
@@ -809,7 +808,8 @@ void main() {
};
list.enableAll();
final expected = ['modA']; final result = list.generateLoadOrder();
final expected = ['modA'];
final result = list.generateLoadOrder();
// Should report the missing dependency
expect(result.errors, isNotEmpty);
@@ -852,7 +852,7 @@ void main() {
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modB', 'modC', 'modA'];
final expected = ['modC', 'modB', 'modA'];
// Should report all missing dependencies
expect(result.errors, isNotEmpty);