Fix size sorting and add regressive test

This commit is contained in:
2025-03-17 22:29:13 +01:00
parent 878244ead0
commit 179bebf188
3 changed files with 441 additions and 24 deletions

View File

@@ -310,7 +310,14 @@ class ModList {
} }
// Optimize for soft constraints // Optimize for soft constraints
return _optimizeSoftConstraints(loadOrder: loadOrder); _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 /// Calculate how many soft constraints are satisfied
@@ -356,34 +363,75 @@ class ModList {
LoadOrder? loadOrder, LoadOrder? loadOrder,
}) { }) {
loadOrder ??= LoadOrder(); loadOrder ??= LoadOrder();
Map<String, int> scoreInfo = _calculateSoftConstraintsScore(
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
loadOrder.loadOrder.clear();
loadOrder.loadOrder.addAll(harmony);
loadOrder.loadOrder.addAll(baseAndExpansions);
// Now apply the normal optimization for the remaining mods
List<String> remainingMods = otherMods;
Map<String, int> scoreInfo = _calculateSoftConstraintsScore(remainingMods);
int bestScore = scoreInfo['satisfied']!; int bestScore = scoreInfo['satisfied']!;
int total = scoreInfo['total']!; int total = scoreInfo['total']!;
if (total == 0 || bestScore == total) { if (total == 0 || bestScore == total) {
// All constraints satisfied or no constraints, sort by size where possible // All constraints satisfied or no constraints for remaining mods, sort by size where possible
return _sortSizeWithinConstraints(loadOrder: loadOrder); _sortSizeWithinConstraints(loadOrder: loadOrder, modList: remainingMods);
loadOrder.loadOrder.addAll(remainingMods);
return loadOrder;
} }
// Use a limited number of improvement passes // Use a limited number of improvement passes for the remaining mods
for (int iteration = 0; iteration < maxIterations; iteration++) { for (int iteration = 0; iteration < maxIterations; iteration++) {
bool improved = false; bool improved = false;
// Try moving each mod to improve score // Try moving each mod to improve score
for (int i = 0; i < loadOrder.loadOrder.length; i++) { for (int i = 0; i < remainingMods.length; i++) {
String modId = loadOrder.loadOrder[i]; String modId = remainingMods[i];
Mod mod = mods[modId]!; Mod mod = mods[modId]!;
// Calculate current local score for this mod // Calculate current local score for this mod
Map<String, int> currentPositions = {}; Map<String, int> currentPositions = {};
for (int idx = 0; idx < loadOrder.loadOrder.length; idx++) { for (int idx = 0; idx < remainingMods.length; idx++) {
currentPositions[loadOrder.loadOrder[idx]] = idx; currentPositions[remainingMods[idx]] = idx;
} }
// Try moving this mod to different positions // Try moving this mod to different positions
for (int newPos = 0; newPos < loadOrder.loadOrder.length; newPos++) { for (int newPos = 0; newPos < remainingMods.length; newPos++) {
if (newPos == i) continue; if (newPos == i) continue;
// Skip if move would break hard dependencies // Skip if move would break hard dependencies
@@ -392,7 +440,7 @@ class ModList {
// Moving earlier // Moving earlier
// Check if any mod between newPos and i depends on this mod // Check if any mod between newPos and i depends on this mod
for (int j = newPos; j < i; j++) { for (int j = newPos; j < i; j++) {
String depModId = loadOrder.loadOrder[j]; String depModId = remainingMods[j];
if (mods[depModId]!.dependencies.contains(modId)) { if (mods[depModId]!.dependencies.contains(modId)) {
skip = true; skip = true;
break; break;
@@ -402,7 +450,7 @@ class ModList {
// Moving later // Moving later
// Check if this mod depends on any mod between i and newPos // Check if this mod depends on any mod between i and newPos
for (int j = i + 1; j <= newPos; j++) { for (int j = i + 1; j <= newPos; j++) {
String depModId = loadOrder.loadOrder[j]; String depModId = remainingMods[j];
if (mod.dependencies.contains(depModId)) { if (mod.dependencies.contains(depModId)) {
skip = true; skip = true;
break; break;
@@ -413,7 +461,7 @@ class ModList {
if (skip) continue; if (skip) continue;
// Create a new order with the mod moved // Create a new order with the mod moved
List<String> newOrder = List.from(loadOrder.loadOrder); List<String> newOrder = List.from(remainingMods);
newOrder.removeAt(i); newOrder.removeAt(i);
newOrder.insert(newPos, modId); newOrder.insert(newPos, modId);
@@ -425,8 +473,8 @@ class ModList {
if (newScore > bestScore) { if (newScore > bestScore) {
bestScore = newScore; bestScore = newScore;
loadOrder.loadOrder.clear(); remainingMods.clear();
loadOrder.loadOrder.addAll(newOrder); remainingMods.addAll(newOrder);
improved = true; improved = true;
break; // Break inner loop, move to next mod break; // Break inner loop, move to next mod
} }
@@ -438,19 +486,27 @@ class ModList {
if (!improved) break; // If no improvements in this pass, stop if (!improved) break; // If no improvements in this pass, stop
} }
// After optimizing for soft constraints, sort by size where possible // Sort by size where possible for the remaining mods
return _sortSizeWithinConstraints(loadOrder: loadOrder); _sortSizeWithinConstraints(loadOrder: loadOrder, modList: remainingMods);
loadOrder.loadOrder.addAll(remainingMods);
return loadOrder;
} }
/// Sort mods by size within compatible groups /// Sort mods by size within compatible groups
LoadOrder _sortSizeWithinConstraints({LoadOrder? loadOrder}) { LoadOrder _sortSizeWithinConstraints({
LoadOrder? loadOrder,
List<String>? modList,
}) {
loadOrder ??= LoadOrder(); loadOrder ??= LoadOrder();
List<String> modsToSort = modList ?? loadOrder.loadOrder;
// Find groups of mods that can be reordered without breaking constraints // Find groups of mods that can be reordered without breaking constraints
List<List<String>> groups = []; List<List<String>> groups = [];
List<String> currentGroup = []; List<String> currentGroup = [];
for (int i = 0; i < loadOrder.loadOrder.length; i++) { for (int i = 0; i < modsToSort.length; i++) {
String modId = loadOrder.loadOrder[i]; String modId = modsToSort[i];
Mod mod = mods[modId]!; Mod mod = mods[modId]!;
if (currentGroup.isEmpty) { if (currentGroup.isEmpty) {
@@ -504,9 +560,15 @@ class ModList {
} }
// Reconstruct the order // Reconstruct the order
loadOrder.loadOrder.clear(); modsToSort.clear();
for (List<String> group in groups) { for (List<String> group in groups) {
loadOrder.loadOrder.addAll(group); modsToSort.addAll(group);
}
// If we were given the loadOrder directly, update it
if (modList == null) {
loadOrder.loadOrder.clear();
loadOrder.loadOrder.addAll(modsToSort);
} }
return loadOrder; return loadOrder;

View File

@@ -0,0 +1,314 @@
import 'package:rimworld_modman/mod.dart';
import 'package:rimworld_modman/mod_list.dart';
import 'package:test/test.dart';
Mod makeDummy() {
return Mod(
name: 'Dummy Mod',
id: 'dummy',
path: '',
versions: ["1.5"],
description: '',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
size: 0,
isBaseGame: false,
isExpansion: false,
enabled: false,
);
}
void main() {
test('Mods should be sorted by size while respecting constraints', () {
final list = ModList();
list.mods = {
'dubwise.rimatomics': makeDummy().copyWith(
id: 'dubwise.rimatomics',
name: 'Dubs Rimatomics',
enabled: true,
size: 1563,
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
),
'brrainz.justignoremepassing': makeDummy().copyWith(
id: 'brrainz.justignoremepassing',
name: 'Just Ignore Me Passing',
enabled: true,
size: 28,
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
),
'brrainz.harmony': makeDummy().copyWith(
id: 'brrainz.harmony',
name: 'Harmony',
enabled: true,
size: 17,
dependencies: [],
loadAfter: [],
loadBefore: ['ludeon.rimworld'],
incompatibilities: [],
),
'jecrell.doorsexpanded': makeDummy().copyWith(
id: 'jecrell.doorsexpanded',
name: 'Doors Expanded',
enabled: true,
size: 765,
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
),
'dubwise.rimefeller': makeDummy().copyWith(
id: 'dubwise.rimefeller',
name: 'Rimefeller',
enabled: true,
size: 744,
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
),
'neronix17.toolbox': makeDummy().copyWith(
id: 'neronix17.toolbox',
name: 'Tabula Rasa',
enabled: true,
size: 415,
dependencies: [],
loadAfter: [
'brrainz.harmony',
'ludeon.rimworld',
'ludeon.rimworld.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
'ludeon.rimworld.anomaly',
'unlimitedhugs.hugslib',
'erdelf.humanoidalienraces',
],
loadBefore: [],
incompatibilities: [],
),
'automatic.bionicicons': makeDummy().copyWith(
id: 'automatic.bionicicons',
name: 'Bionic icons',
enabled: true,
size: 365,
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
),
'lwm.deepstorage': makeDummy().copyWith(
id: 'lwm.deepstorage',
name: "LWM's Deep Storage",
enabled: true,
size: 256,
dependencies: [],
loadAfter: [
'brrainz.harmony',
'ludeon.rimworld.core',
'rimfridge.kv.rw',
'mlie.cannibalmeals',
],
loadBefore: ['com.github.alandariva.moreplanning'],
incompatibilities: [],
),
'dubwise.dubsmintmenus': makeDummy().copyWith(
id: 'dubwise.dubsmintmenus',
name: 'Dubs Mint Menus',
enabled: true,
size: 190,
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
),
'dubwise.dubsmintminimap': makeDummy().copyWith(
id: 'dubwise.dubsmintminimap',
name: 'Dubs Mint Minimap',
enabled: true,
size: 190,
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
),
'ludeon.rimworld': makeDummy().copyWith(
id: 'ludeon.rimworld',
name: 'RimWorld',
enabled: true,
size: 0,
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
isBaseGame: true,
),
'ludeon.rimworld.royalty': makeDummy().copyWith(
id: 'ludeon.rimworld.royalty',
name: 'RimWorld Royalty',
enabled: true,
size: 0,
dependencies: [],
loadAfter: ['ludeon.rimworld'],
loadBefore: [],
incompatibilities: [],
isExpansion: true,
),
'ludeon.rimworld.ideology': makeDummy().copyWith(
id: 'ludeon.rimworld.ideology',
name: 'RimWorld Ideology',
enabled: true,
size: 0,
dependencies: [],
loadAfter: ['ludeon.rimworld'],
loadBefore: [],
incompatibilities: [],
isExpansion: true,
),
'ludeon.rimworld.biotech': makeDummy().copyWith(
id: 'ludeon.rimworld.biotech',
name: 'RimWorld Biotech',
enabled: true,
size: 0,
dependencies: [],
loadAfter: ['ludeon.rimworld'],
loadBefore: [],
incompatibilities: [],
isExpansion: true,
),
'ludeon.rimworld.anomaly': makeDummy().copyWith(
id: 'ludeon.rimworld.anomaly',
name: 'RimWorld Anomaly',
enabled: true,
size: 0,
dependencies: [],
loadAfter: ['ludeon.rimworld'],
loadBefore: [],
incompatibilities: [],
isExpansion: true,
),
};
list.enableAll();
final result = list.generateLoadOrder();
final expected = [
'brrainz.harmony',
'ludeon.rimworld',
'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',
'brrainz.justignoremepassing',
];
expect(result.errors, isEmpty);
expect(result.loadOrder, equals(expected));
});
test('Prepatcher should load before Harmony', () {
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',
enabled: true,
size: 21,
loadBefore: ['ludeon.rimworld', 'brrainz.harmony'],
),
'brrainz.harmony': makeDummy().copyWith(
id: 'brrainz.harmony',
name: 'Harmony',
enabled: true,
size: 17,
loadBefore: ['ludeon.rimworld'],
),
'ludeon.rimworld': makeDummy().copyWith(
id: 'ludeon.rimworld',
name: 'RimWorld',
enabled: true,
size: 0,
isBaseGame: true,
),
'ludeon.rimworld.anomaly': makeDummy().copyWith(
id: 'ludeon.rimworld.anomaly',
name: 'RimWorld Anomaly',
enabled: true,
size: 0,
loadAfter: ['ludeon.rimworld'],
isExpansion: true,
),
'ludeon.rimworld.biotech': makeDummy().copyWith(
id: 'ludeon.rimworld.biotech',
name: 'RimWorld Biotech',
enabled: true,
size: 0,
loadAfter: ['ludeon.rimworld'],
isExpansion: true,
),
'ludeon.rimworld.ideology': makeDummy().copyWith(
id: 'ludeon.rimworld.ideology',
name: 'RimWorld Ideology',
enabled: true,
size: 0,
loadAfter: ['ludeon.rimworld'],
isExpansion: true,
),
'ludeon.rimworld.royalty': makeDummy().copyWith(
id: 'ludeon.rimworld.royalty',
name: 'RimWorld Royalty',
enabled: true,
size: 0,
loadAfter: ['ludeon.rimworld'],
isExpansion: true,
),
};
list.enableAll();
final order = list.generateLoadOrder();
final expected = [
'zetrith.prepatcher',
'brrainz.harmony',
'ludeon.rimworld',
'bs.betterlog',
'ludeon.rimworld.anomaly',
'ludeon.rimworld.biotech',
'ludeon.rimworld.ideology',
'ludeon.rimworld.royalty',
];
expect(order.loadOrder, equals(expected));
});
}

View File

@@ -1,3 +1,4 @@
import 'package:rimworld_modman/logger.dart';
import 'package:rimworld_modman/mod.dart'; import 'package:rimworld_modman/mod.dart';
import 'package:rimworld_modman/mod_list.dart'; import 'package:rimworld_modman/mod_list.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@@ -147,6 +148,46 @@ void main() {
expect(result.errors.any((e) => e.contains('incompatible')), isTrue); expect(result.errors.any((e) => e.contains('incompatible')), isTrue);
expect(result.errors.any((e) => e.contains('harmony')), isTrue); expect(result.errors.any((e) => e.contains('harmony')), isTrue);
}); });
test('Base game should load before other mods', () {
final list = ModList();
list.mods = {
'dummy': makeDummy().copyWith(size: 10000),
'ludeon.rimworld': makeDummy().copyWith(
name: 'RimWorld',
id: 'ludeon.rimworld',
isBaseGame: true,
),
};
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['ludeon.rimworld', 'dummy'];
expect(result.errors, isEmpty);
expect(result.loadOrder, equals(expected));
});
test('Base game and expansions should load before other mods', () {
final list = ModList();
list.mods = {
'dummy': makeDummy().copyWith(size: 10000),
'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 result = list.generateLoadOrder();
final expected = ['ludeon.rimworld', 'ludeon.rimworld.anomaly', 'dummy'];
expect(result.errors, isEmpty);
expect(result.loadOrder, equals(expected));
});
}); });
group('Test loadRequired', () { group('Test loadRequired', () {