Compare commits

...

64 Commits

Author SHA1 Message Date
07d81eca71 Add release script 2025-03-22 00:16:56 +01:00
2e6bfb84de Remove all log statements
Because THEY were causing the lag?????????
2025-03-22 00:14:14 +01:00
0384e8012e Make skip counting file size for existing mods 2025-03-22 00:09:21 +01:00
1bb8ed9084 Fix up some of the todos 2025-03-22 00:00:32 +01:00
573ad05514 TODO 2025-03-19 01:01:51 +01:00
9a8b7fd2d3 Center on startup 2025-03-19 00:50:31 +01:00
d00c20397f Clean up code 2025-03-19 00:48:46 +01:00
40d251f400 Jesus they're not BBCode... They're html and bbcode and markdown
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
2025-03-19 00:44:14 +01:00
09b7fe539e Fix up markdown rendering to be scrollable 2025-03-19 00:28:59 +01:00
5f20368fe2 The expansions must be loaded in correct order.... fuck 2025-03-19 00:17:45 +01:00
9eb71e94c1 Implement a popup card that renders description markdown 2025-03-19 00:03:32 +01:00
f90371109c Make errors larger 2025-03-18 23:41:56 +01:00
7f4b944101 Also parse forceLoadAfter 2025-03-18 23:40:19 +01:00
8f466420f2 Add special mods for the base game and expansions
Just for the metadata
2025-03-18 23:40:19 +01:00
160488849f Implement counting files only of the latest version 2025-03-18 23:12:13 +01:00
6826b272aa Indent xml for saving 2025-03-18 23:04:47 +01:00
1c6af27c7e Rework saving to config 2025-03-18 23:01:10 +01:00
71ad392fb6 Clean up troubleshooter 2025-03-18 22:41:55 +01:00
a4ee202971 Implement a little better "loadRequired" 2025-03-18 22:17:14 +01:00
a37b67873e Implement mod troubleshooter 2025-03-18 21:57:19 +01:00
164e95fa54 Refactor a large part of main into theme 2025-03-18 21:40:34 +01:00
02cfe01ae0 Clean up main a little 2025-03-18 21:31:03 +01:00
1dabc804b4 Fix up test(s) 2025-03-18 21:31:03 +01:00
a6cfd3e16e Use fucking graphs again 2025-03-18 21:01:32 +01:00
efe74b404e Fix stroking out on missing loadbefore/after 2025-03-18 17:27:35 +01:00
e3cd0c13a4 Uncomment main 2025-03-18 17:23:02 +01:00
1e4b4db220 Rework everything again 2025-03-18 17:18:03 +01:00
43a7efa1aa Fix up more test cases 2025-03-18 00:31:39 +01:00
4c768a7fd4 Remove main again 2025-03-18 00:31:32 +01:00
69635ec8a0 Add the old loadRequired 2025-03-18 00:04:59 +01:00
9daae41e1c Fix up incompatibilities 2025-03-18 00:03:29 +01:00
512bd644ab Fix up activemods to be a String, Mod 2025-03-18 00:01:05 +01:00
72b6f3486d Rework everything to be less dogshit 2025-03-17 23:46:25 +01:00
179bebf188 Fix size sorting and add regressive test 2025-03-17 22:40:58 +01:00
878244ead0 Fix up the entire frontend 2025-03-17 21:33:53 +01:00
07264d1f75 Add a few more tests 2025-03-17 21:05:27 +01:00
294219cef3 Fix cyclic dependencies 2025-03-17 20:56:24 +01:00
a022576f7b Fix checking for dependencies on sort 2025-03-17 20:53:38 +01:00
fb8d3195db Refactor mod list to return an object with a list of errors
So we don't throw and catch like Java
2025-03-17 20:53:38 +01:00
86a7c16194 Implement "move" to troubleshooter to simulate movement 2025-03-17 09:31:20 +01:00
c27ae80b5e Vomit up more test cases 2025-03-17 09:31:20 +01:00
872a59b27c Fix linear forwards backwards 2025-03-17 09:07:49 +01:00
2df23dde06 Add more test cases 2025-03-16 23:48:32 +01:00
dbfe627877 Implement parts of linear search and add more test cases for bisect 2025-03-16 23:14:12 +01:00
70198ff293 Fix up bisect every which way 2025-03-16 22:37:36 +01:00
9192e68bd3 Implement backward bisect 2025-03-16 22:33:38 +01:00
0a9d97074f Implement forward bisect 2025-03-16 22:31:51 +01:00
51d9526aa3 Implement some more helper methods and proper copy in modlist 2025-03-16 22:30:10 +01:00
5deaf35b95 Comment out our main and broken test
We don't care about them right now, this commit will be reverted
eventually
2025-03-16 22:18:03 +01:00
d913d8ca60 Remove original modlist 2025-03-16 22:16:57 +01:00
4d2b676c8b Add mod list troubleshooter skeleton 2025-03-16 22:16:54 +01:00
2a7b3f8345 Add copy with to modlist 2025-03-16 22:14:50 +01:00
d41413dbcd Outline the plan for troubleshooter 2025-03-16 14:21:26 +01:00
958de3e4a1 Implement sorting by size
This AI bullshit will be the end of me...
2025-03-16 14:14:58 +01:00
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
21 changed files with 6114 additions and 1132 deletions

View File

@@ -0,0 +1,345 @@
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:url_launcher/url_launcher.dart';
import '../format_converter.dart';
import 'dart:math' as math;
class HtmlTooltip extends StatefulWidget {
final Widget child;
final String content;
final double maxWidth;
final double maxHeight;
final EdgeInsets padding;
final Duration showDuration;
final Duration fadeDuration;
final Color backgroundColor;
final Color textColor;
final BorderRadius borderRadius;
final bool preferBelow;
final String? title;
const HtmlTooltip({
super.key,
required this.child,
required this.content,
this.maxWidth = 800.0,
this.maxHeight = 800.0,
this.padding = const EdgeInsets.all(8.0),
this.showDuration = const Duration(milliseconds: 0),
this.fadeDuration = const Duration(milliseconds: 200),
this.backgroundColor = const Color(0xFF232323),
this.textColor = Colors.white,
this.borderRadius = const BorderRadius.all(Radius.circular(4.0)),
this.preferBelow = true,
this.title = 'Mod Description',
});
@override
State<HtmlTooltip> createState() => _HtmlTooltipState();
}
class _HtmlTooltipState extends State<HtmlTooltip> {
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
bool _isTooltipVisible = false;
bool _isMouseInside = false;
bool _isMouseInsideTooltip = false;
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
_hideTooltip();
super.dispose();
}
// Launch a URL
Future<void> _launchUrl(String? urlString) async {
if (urlString == null || urlString.isEmpty) return;
final Uri url = Uri.parse(urlString);
try {
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
} catch (e) {
debugPrint('Error launching URL: $e');
}
}
void _showTooltip(BuildContext context) {
if (_overlayEntry != null) return;
// Get render box of the trigger widget
final RenderBox box = context.findRenderObject() as RenderBox;
final Size childSize = box.size;
// Use the specified maxWidth without adjusting based on content length
final double tooltipWidth = widget.maxWidth;
_overlayEntry = OverlayEntry(
builder: (context) {
return Positioned(
width: tooltipWidth,
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: Offset(
(childSize.width / 2) - (tooltipWidth / 2),
widget.preferBelow ? childSize.height + 5 : -5,
),
child: Material(
color: Colors.transparent,
child: MouseRegion(
onEnter: (_) {
setState(() {
_isMouseInsideTooltip = true;
});
},
onExit: (_) {
setState(() {
_isMouseInsideTooltip = false;
// Slight delay to prevent flickering
Future.delayed(const Duration(milliseconds: 50), () {
if (!_isMouseInside && !_isMouseInsideTooltip) {
_hideTooltip();
}
});
});
},
child: FadeTransition(
opacity: const AlwaysStoppedAnimation(1.0),
child: Container(
constraints: BoxConstraints(
maxWidth: tooltipWidth,
maxHeight: widget.maxHeight,
),
decoration: BoxDecoration(
color: widget.backgroundColor,
borderRadius: widget.borderRadius,
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(
77,
), // Equivalent to 0.3 opacity
blurRadius: 10.0,
spreadRadius: 0.0,
),
],
),
child: ClipRRect(
borderRadius: widget.borderRadius,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Container(
color: const Color(0xFF3D4A59),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title ?? 'Description',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14.0,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Scroll to top button
InkWell(
onTap: () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
0.0,
duration: const Duration(
milliseconds: 300,
),
curve: Curves.easeOut,
);
}
},
child: const Padding(
padding: EdgeInsets.all(2.0),
child: Icon(
Icons.arrow_upward,
color: Colors.white,
size: 16.0,
),
),
),
const SizedBox(width: 4.0),
// Close button
InkWell(
onTap: () {
_hideTooltip();
},
child: const Padding(
padding: EdgeInsets.all(2.0),
child: Icon(
Icons.close,
color: Colors.white,
size: 16.0,
),
),
),
],
),
],
),
),
// Content
Flexible(
child: SingleChildScrollView(
controller: _scrollController,
child: Padding(
padding: widget.padding,
child: Html(
data: FormatConverter.toHtml(widget.content),
style: {
"body": Style(
color: widget.textColor,
margin: Margins.zero,
padding: HtmlPaddings.zero,
fontSize: FontSize(14.0),
),
"a": Style(
color: Colors.lightBlue,
textDecoration: TextDecoration.underline,
),
"blockquote": Style(
backgroundColor: Colors.grey.withAlpha(
26,
), // Approx 0.1 opacity
border: Border(
left: BorderSide(
color: Colors.grey.withAlpha(
128,
), // Approx 0.5 opacity
width: 4.0,
),
),
padding: HtmlPaddings.all(8.0),
margin: Margins.only(left: 0, right: 0),
),
"code": Style(
backgroundColor: Colors.grey.withAlpha(
51,
), // Approx 0.2 opacity
padding: HtmlPaddings.all(2.0),
fontFamily: 'monospace',
),
"pre": Style(
backgroundColor: Colors.grey.withAlpha(
51,
), // Approx 0.2 opacity
padding: HtmlPaddings.all(8.0),
fontFamily: 'monospace',
margin: Margins.only(
bottom: 8.0,
top: 8.0,
),
),
"table": Style(
border: Border.all(color: Colors.grey),
backgroundColor: Colors.transparent,
),
"td": Style(
border: Border.all(
color: Colors.grey.withAlpha(128),
), // Approx 0.5 opacity
padding: HtmlPaddings.all(4.0),
),
"th": Style(
border: Border.all(
color: Colors.grey.withAlpha(128),
), // Approx 0.5 opacity
padding: HtmlPaddings.all(4.0),
backgroundColor: Colors.grey.withAlpha(
51,
), // Approx 0.2 opacity
),
},
onAnchorTap: (url, _, __) {
_launchUrl(url);
},
),
),
),
),
],
),
),
),
),
),
),
),
);
},
);
// Use a check for mounted before inserting the overlay
if (mounted) {
Overlay.of(context).insert(_overlayEntry!);
_isTooltipVisible = true;
}
}
void _hideTooltip() {
_overlayEntry?.remove();
_overlayEntry = null;
_isTooltipVisible = false;
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: MouseRegion(
onEnter: (_) {
setState(() {
_isMouseInside = true;
// Show tooltip after a brief delay to prevent accidental triggers
Future.delayed(const Duration(milliseconds: 50), () {
if (mounted && _isMouseInside && !_isTooltipVisible) {
_showTooltip(context);
}
});
});
},
onExit: (_) {
setState(() {
_isMouseInside = false;
// Slight delay to prevent flickering
Future.delayed(const Duration(milliseconds: 50), () {
if (mounted && !_isMouseInside && !_isMouseInsideTooltip) {
_hideTooltip();
}
});
});
},
child: GestureDetector(
onTap: () {
// Toggle tooltip for touch devices
if (_isTooltipVisible) {
_hideTooltip();
} else {
_showTooltip(context);
}
},
child: widget.child,
),
),
);
}
}

308
lib/format_converter.dart Normal file
View File

@@ -0,0 +1,308 @@
import 'dart:math' as math;
/// Utility class to convert mixed format content (BBCode, Markdown, and HTML) to HTML
class FormatConverter {
/// Converts mixed format text (BBCode, Markdown, HTML) to pure HTML
static String toHtml(String content) {
if (content.isEmpty) return '';
// First, normalize line endings and escape any literal backslashes that aren't already escaped
String result = content.replaceAll('\r\n', '\n');
// Handle BBCode format
result = _convertBBCodeToHtml(result);
// Handle Markdown format
result = _convertMarkdownToHtml(result);
// Sanitize HTML
result = _sanitizeHtml(result);
// Wrap the final content in a container with styles
result =
'<div style="line-height: 1.5; word-wrap: break-word;">$result</div>';
return result;
}
/// Converts BBCode to HTML
static String _convertBBCodeToHtml(String bbcode) {
String result = bbcode;
// Fix unclosed tags - RimWorld descriptions often have unclosed BBCode tags
final List<String> tagTypes = [
'b',
'i',
'color',
'size',
'url',
'code',
'quote',
'list',
'table',
'tr',
'td',
];
for (final tag in tagTypes) {
final openCount = '[${tag}'.allMatches(result).length;
final closeCount = '[/$tag]'.allMatches(result).length;
if (openCount > closeCount) {
result = result + '[/$tag]' * (openCount - closeCount);
}
}
// URLs
// [url=http://example.com]text[/url] -> <a href="http://example.com">text</a>
result = RegExp(
r'\[url=([^\]]+)\](.*?)\[/url\]',
dotAll: true,
).allMatches(result).fold(result, (prev, match) {
final url = match.group(1);
final text = match.group(2);
return prev.replaceFirst(
match.group(0)!,
'<a href="$url" target="_blank">$text</a>',
);
});
// Simple URL [url]http://example.com[/url] -> <a href="http://example.com">http://example.com</a>
result = result.replaceAllMapped(
RegExp(r'\[url\](.*?)\[/url\]', dotAll: true),
(match) =>
'<a href="${match.group(1)}" target="_blank">${match.group(1)}</a>',
);
// Bold
result = result
.replaceAll('[b]', '<strong>')
.replaceAll('[/b]', '</strong>');
// Italic
result = result.replaceAll('[i]', '<em>').replaceAll('[/i]', '</em>');
// Headers
result = result.replaceAllMapped(
RegExp(r'\[h1\](.*?)\[/h1\]', dotAll: true),
(match) =>
'<h1 style="margin-top: 16px; margin-bottom: 8px;">${match.group(1)?.trim()}</h1>',
);
result = result.replaceAllMapped(
RegExp(r'\[h2\](.*?)\[/h2\]', dotAll: true),
(match) =>
'<h2 style="margin-top: 12px; margin-bottom: 6px;">${match.group(1)?.trim()}</h2>',
);
result = result.replaceAllMapped(
RegExp(r'\[h3\](.*?)\[/h3\]', dotAll: true),
(match) =>
'<h3 style="margin-top: 10px; margin-bottom: 4px;">${match.group(1)?.trim()}</h3>',
);
// Lists
result = result
.replaceAll(
'[list]',
'<ul style="padding-left: 20px; margin-top: 8px; margin-bottom: 8px;">',
)
.replaceAll('[/list]', '</ul>');
// List items
result = result.replaceAllMapped(
RegExp(r'\[\*\](.*?)(?=\[\*\]|\[/list\]|$)', dotAll: true),
(match) {
final content = match.group(1)?.trim() ?? '';
return '<li style="margin-bottom: 4px;">$content</li>';
},
);
// Color
result = result.replaceAllMapped(
RegExp(r'\[color=([^\]]+)\](.*?)\[/color\]', dotAll: true),
(match) {
final color = match.group(1) ?? '';
final content = match.group(2) ?? '';
if (content.trim().isEmpty) return '';
return '<span style="color:$color">$content</span>';
},
);
// Images
result = result.replaceAllMapped(
RegExp(r'\[img\](.*?)\[/img\]', dotAll: true),
(match) =>
'<img src="${match.group(1)}" alt="Image" style="max-width: 100%;" />',
);
// Image with size
result = result.replaceAllMapped(
RegExp(r'\[img[^\]]*width=(\d+)[^\]]*\](.*?)\[/img\]', dotAll: true),
(match) {
final width = match.group(1) ?? '';
final url = match.group(2) ?? '';
return '<img src="$url" alt="Image" width="$width" style="max-width: 100%;" />';
},
);
// Tables
result = result
.replaceAll(
'[table]',
'<table border="1" style="border-collapse: collapse; width: 100%; margin: 10px 0;">',
)
.replaceAll('[/table]', '</table>');
result = result.replaceAll('[tr]', '<tr>').replaceAll('[/tr]', '</tr>');
result = result
.replaceAll('[td]', '<td style="padding: 8px;">')
.replaceAll('[/td]', '</td>');
// Size
result = result.replaceAllMapped(
RegExp(r'\[size=([^\]]+)\](.*?)\[/size\]', dotAll: true),
(match) {
final size = match.group(1) ?? '';
final content = match.group(2) ?? '';
return '<span style="font-size:${size}px">$content</span>';
},
);
// Code
result = result
.replaceAll(
'[code]',
'<pre style="background-color: rgba(0,0,0,0.1); padding: 8px; border-radius: 4px; overflow-x: auto;"><code>',
)
.replaceAll('[/code]', '</code></pre>');
// Quote
result = result.replaceAllMapped(
RegExp(r'\[quote\](.*?)\[/quote\]', dotAll: true),
(match) {
final content = match.group(1)?.trim() ?? '';
if (content.isEmpty) return '';
return '<blockquote style="border-left: 4px solid rgba(128,128,128,0.5); padding-left: 10px; margin: 10px 0; color: rgba(255,255,255,0.8);">$content</blockquote>';
},
);
// Handle any remaining custom BBCode tags
result = result.replaceAllMapped(
RegExp(r'\[([a-zA-Z0-9_]+)(?:=[^\]]+)?\](.*?)\[/\1\]', dotAll: true),
(match) => match.group(2) ?? '',
);
// Handle RimWorld-specific patterns
// [h1] without closing tag is common
result = result.replaceAllMapped(
RegExp(r'\[h1\]([^\[]+)'),
(match) =>
'<h1 style="margin-top: 16px; margin-bottom: 8px;">${match.group(1)?.trim()}</h1>',
);
return result;
}
/// Converts Markdown to HTML
static String _convertMarkdownToHtml(String markdown) {
String result = markdown;
// Headers
// Convert # Header to <h1>Header</h1>
result = result.replaceAllMapped(
RegExp(r'^#\s+(.*?)$', multiLine: true),
(match) =>
'<h1 style="margin-top: 16px; margin-bottom: 8px;">${match.group(1)?.trim()}</h1>',
);
// Convert ## Header to <h2>Header</h2>
result = result.replaceAllMapped(
RegExp(r'^##\s+(.*?)$', multiLine: true),
(match) =>
'<h2 style="margin-top: 12px; margin-bottom: 6px;">${match.group(1)?.trim()}</h2>',
);
// Convert ### Header to <h3>Header</h3>
result = result.replaceAllMapped(
RegExp(r'^###\s+(.*?)$', multiLine: true),
(match) =>
'<h3 style="margin-top: 10px; margin-bottom: 4px;">${match.group(1)?.trim()}</h3>',
);
// Bold - **text** to <strong>text</strong>
result = result.replaceAllMapped(
RegExp(r'\*\*(.*?)\*\*'),
(match) => '<strong>${match.group(1)}</strong>',
);
// Italic - *text* or _text_ to <em>text</em>
result = result.replaceAllMapped(
RegExp(r'\*(.*?)\*|_(.*?)_'),
(match) => '<em>${match.group(1) ?? match.group(2)}</em>',
);
// Inline code - `code` to <code>code</code>
result = result.replaceAllMapped(
RegExp(r'`(.*?)`'),
(match) =>
'<code style="background-color: rgba(0,0,0,0.1); padding: 2px 4px; border-radius: 3px;">${match.group(1)}</code>',
);
// Links - [text](url) to <a href="url">text</a>
result = result.replaceAllMapped(
RegExp(r'\[(.*?)\]\((.*?)\)'),
(match) =>
'<a href="${match.group(2)}" target="_blank">${match.group(1)}</a>',
);
// Images - ![alt](url) to <img src="url" alt="alt" />
result = result.replaceAllMapped(
RegExp(r'!\[(.*?)\]\((.*?)\)'),
(match) =>
'<img src="${match.group(2)}" alt="${match.group(1)}" style="max-width: 100%;" />',
);
// Lists - Convert Markdown bullet lists to HTML lists
// This is a simple implementation and might not handle all cases
result = result.replaceAllMapped(
RegExp(r'^(\s*)\*\s+(.*?)$', multiLine: true),
(match) => '<li style="margin-bottom: 4px;">${match.group(2)}</li>',
);
// Wrap adjacent list items in <ul> tags (simple approach)
result = result.replaceAll('</li>\n<li>', '</li><li>');
result = result.replaceAll(
'<li>',
'<ul style="padding-left: 20px; margin-top: 8px; margin-bottom: 8px;"><li>',
);
result = result.replaceAll('</li>', '</li></ul>');
// Remove duplicated </ul><ul> tags
result = result.replaceAll(
'</ul><ul style="padding-left: 20px; margin-top: 8px; margin-bottom: 8px;">',
'',
);
// Paragraphs - Convert newlines to <br>, but skipping where tags already exist
result = result.replaceAllMapped(
RegExp(r'(?<!>)\n(?!<)'),
(match) => '<br />',
);
return result;
}
/// Performs basic sanitization and fixes for the HTML
static String _sanitizeHtml(String html) {
// Remove potentially dangerous elements and attributes
final String result = html
// Remove any script tags
.replaceAll(RegExp(r'<script.*?>.*?</script>', dotAll: true), '')
// Remove on* event handlers
.replaceAll(RegExp(r'\son\w+=".*?"'), '')
// Ensure newlines are converted to <br /> if not already handled
.replaceAll(RegExp(r'(?<!>)\n(?!<)'), '<br />');
// Fix double paragraph or break issues
return result
.replaceAll('<br /><br />', '<br />')
.replaceAll('<br></br>', '<br />')
.replaceAll('<p></p>', '');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,10 +22,10 @@ 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
@@ -37,39 +37,50 @@ class Mod {
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,
this.enabled = false, this.enabled = false,
}); });
static Mod fromDirectory(String path, {bool skipFileCount = false}) { int get tier {
final logger = Logger.instance; if (isBaseGame) return 0;
final stopwatch = Stopwatch()..start(); if (isExpansion) return 1;
return 2;
}
logger.info('Attempting to load mod from directory: $path'); @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();
// logger.info('Attempting to load mod from directory: $path');
final aboutFile = File('$path/About/About.xml'); final aboutFile = File('$path/About/About.xml');
if (!aboutFile.existsSync()) { if (!aboutFile.existsSync()) {
logger.error('About.xml file does not exist in $aboutFile'); // logger.error('About.xml file does not exist in $aboutFile');
throw Exception('About.xml file does not exist in $aboutFile'); throw Exception('About.xml file does not exist in $aboutFile');
} }
logger.info('Parsing About.xml file...'); // logger.info('Parsing About.xml file...');
final aboutXml = XmlDocument.parse(aboutFile.readAsStringSync()); final aboutXml = XmlDocument.parse(aboutFile.readAsStringSync());
final xmlTime = stopwatch.elapsedMilliseconds; // final xmlTime = stopwatch.elapsedMilliseconds;
late final XmlElement metadata; late final XmlElement metadata;
try { try {
metadata = findCaseInsensitiveDoc(aboutXml, 'ModMetaData'); metadata = findCaseInsensitiveDoc(aboutXml, 'ModMetaData');
logger.info('Successfully found ModMetaData in About.xml'); // logger.info('Successfully found ModMetaData in About.xml');
} catch (e) { } catch (e) {
logger.error( // logger.error(
'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e', // 'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e',
); // );
throw Exception( throw Exception(
'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e', 'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e',
); );
@@ -78,11 +89,11 @@ class Mod {
late final String name; late final String name;
try { try {
name = metadata.findElements('name').first.innerText; name = metadata.findElements('name').first.innerText;
logger.info('Mod name found: $name'); // logger.info('Mod name found: $name');
} catch (e) { } catch (e) {
logger.error( // logger.error(
'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e', // 'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e',
); // );
throw Exception( throw Exception(
'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e', 'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e',
); );
@@ -91,11 +102,11 @@ class Mod {
late final String id; late final String id;
try { try {
id = metadata.findElements('packageId').first.innerText.toLowerCase(); id = metadata.findElements('packageId').first.innerText.toLowerCase();
logger.info('Mod ID found: $id'); // logger.info('Mod ID found: $id');
} catch (e) { } catch (e) {
logger.error( // logger.error(
'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e', // 'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e',
); // );
throw Exception( throw Exception(
'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e', 'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e',
); );
@@ -110,11 +121,11 @@ class Mod {
.findElements('li') .findElements('li')
.map((e) => e.innerText) .map((e) => e.innerText)
.toList(); .toList();
logger.info('Supported versions found: ${versions.join(", ")}'); // logger.info('Supported versions found: ${versions.join(", ")}');
} catch (e) { } catch (e) {
logger.error( // logger.error(
'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e', // 'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e',
); // );
throw Exception( throw Exception(
'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e', 'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e',
); );
@@ -123,16 +134,16 @@ class Mod {
String description = ''; String description = '';
try { try {
description = metadata.findElements('description').first.innerText; description = metadata.findElements('description').first.innerText;
logger.info('Mod description found: $description'); // logger.info('Mod description found: $description');
} catch (e) { } catch (e) {
logger.warning( // logger.warning(
'Description element is missing in ModMetaData ($aboutFile).', // 'Description element is missing in ModMetaData ($aboutFile).',
); // );
} }
List<String> hardDependencies = []; List<String> dependencies = [];
try { try {
hardDependencies = dependencies =
metadata metadata
.findElements('modDependenciesByVersion') .findElements('modDependenciesByVersion')
.first .first
@@ -145,11 +156,11 @@ 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).',
); // );
} }
List<String> loadAfter = []; List<String> loadAfter = [];
@@ -161,12 +172,32 @@ class Mod {
.findElements('li') .findElements('li')
.map((e) => e.innerText.toLowerCase()) .map((e) => e.innerText.toLowerCase())
.toList(); .toList();
logger.info('Load after dependencies found: ${loadAfter.join(", ")}'); // logger.info(
// 'Load after dependencies found: ${loadAfter.isNotEmpty ? loadAfter.join(", ") : "none"}',
// );
} catch (e) { } catch (e) {
logger.warning( // logger.warning(
'Load after element is missing in ModMetaData ($aboutFile).', // 'Load after element is missing or empty in ModMetaData ($aboutFile). Original error: $e',
); // );
} }
List<String> loadAfterForce = [];
try {
loadAfterForce =
metadata
.findElements('forceLoadAfter')
.first
.findElements('li')
.map((e) => e.innerText.toLowerCase())
.toList();
// logger.info(
// 'Force load after dependencies found: ${loadAfterForce.isNotEmpty ? loadAfterForce.join(", ") : "none"}',
// );
} catch (e) {
// logger.warning(
// 'Force load after element is missing or empty in ModMetaData ($aboutFile). Original error: $e',
// );
}
dependencies.addAll(loadAfterForce);
List<String> loadBefore = []; List<String> loadBefore = [];
try { try {
@@ -177,67 +208,110 @@ class Mod {
.findElements('li') .findElements('li')
.map((e) => e.innerText.toLowerCase()) .map((e) => e.innerText.toLowerCase())
.toList(); .toList();
logger.info('Load before dependencies found: ${loadBefore.join(", ")}'); // logger.info(
// 'Load before dependencies found: ${loadBefore.isNotEmpty ? loadBefore.join(", ") : "none"}',
// );
} catch (e) { } catch (e) {
logger.warning( // logger.warning(
'Load before element is missing in ModMetaData ($aboutFile).', // 'Load before element is missing or empty in ModMetaData ($aboutFile). Original error: $e ',
); // );
} }
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).',
); // );
} }
final metadataTime = stopwatch.elapsedMilliseconds - xmlTime; // final metadataTime = stopwatch.elapsedMilliseconds - xmlTime;
int size = 0; int size = 0;
if (!skipFileCount) { if (!skipFileCount) {
size = // Find all directories matching version pattern (like "1.0", "1.4", etc.)
final versionDirs =
Directory(path) Directory(path)
.listSync(recursive: true) .listSync(recursive: false)
.whereType<Directory>()
.where( .where(
(entity) => (dir) => RegExp(
!entity.path r'^\d+\.\d+$',
).hasMatch(dir.path.split(Platform.pathSeparator).last),
)
.toList();
// Find the latest version directory (if any)
Directory? latestVersionDir;
if (versionDirs.isNotEmpty) {
// Sort by version number
versionDirs.sort((a, b) {
final List<int> vA =
a.path
.split(Platform.pathSeparator) .split(Platform.pathSeparator)
.last .last
.startsWith('.'), .split('.')
) .map(int.parse)
.length; .toList();
logger.info('File count in mod directory: $size'); final List<int> vB =
b.path
.split(Platform.pathSeparator)
.last
.split('.')
.map(int.parse)
.toList();
return vA[0] != vB[0]
? vA[0] - vB[0]
: vA[1] - vB[1]; // Compare major, then minor version
});
latestVersionDir = versionDirs.last;
// logger.info(
// 'Latest version directory found: ${latestVersionDir.path.split(Platform.pathSeparator).last}',
// );
} }
// Check if this is RimWorld base game or expansion // Count all files, excluding older version directories
bool isBaseGame = id == 'ludeon.rimworld'; size =
bool isExpansion = !isBaseGame && id.startsWith('ludeon.rimworld.'); Directory(path).listSync(recursive: true).where((entity) {
if (entity is! File ||
// If this is an expansion, ensure it depends on the base game entity.path
if (isExpansion && !loadAfter.contains('ludeon.rimworld')) { .split(Platform.pathSeparator)
loadAfter.add('ludeon.rimworld'); .any((part) => part.startsWith('.'))) {
logger.info( return false;
'Added base game dependency for expansion mod: ludeon.rimworld',
);
} }
final fileCountTime = // Skip files in version directories except for the latest
stopwatch.elapsedMilliseconds - metadataTime - xmlTime; for (final verDir in versionDirs) {
final totalTime = stopwatch.elapsedMilliseconds; if (verDir != latestVersionDir &&
entity.path.startsWith(verDir.path)) {
return false;
}
}
return true;
}).length;
// logger.info(
// 'File count in mod directory (with only latest version): $size',
// );
}
// final fileCountTime =
// stopwatch.elapsedMilliseconds - metadataTime - xmlTime;
// final totalTime = stopwatch.elapsedMilliseconds;
// Log detailed timing information // Log detailed timing information
logger.info( // logger.info(
'Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms', // 'Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms',
); // );
return Mod( return Mod(
name: name, name: name,
@@ -245,13 +319,14 @@ 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, // No mods loaded from workshop are ever base or expansion games
isExpansion: isExpansion, isBaseGame: false,
isExpansion: false,
); );
} }
@@ -261,10 +336,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 +351,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

@@ -1,26 +1,143 @@
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';
class LoadOrder {
List<Mod> order = [];
final List<String> errors = [];
List<String> get loadOrder {
return order.map((mod) => mod.id).toList();
}
LoadOrder([List<Mod>? order]) {
this.order = order ?? [];
}
bool get hasErrors => errors.isNotEmpty;
}
var specialMods = {
'ludeon.rimworld': Mod(
id: 'ludeon.rimworld',
name: 'RimWorld',
path: '',
versions: [],
description: 'RimWorld base game',
dependencies: [],
loadAfter: [],
loadBefore: [],
incompatibilities: [],
isBaseGame: true,
size: 0,
isExpansion: false,
enabled: true,
),
'ludeon.rimworld.royalty': Mod(
id: 'ludeon.rimworld.royalty',
name: 'Royalty',
path: '',
versions: [],
description: 'RimWorld expansion - Royalty',
dependencies: ['ludeon.rimworld'],
loadAfter: [],
loadBefore: [
'ludeon.rimworld.anomaly',
'ludeon.rimworld.biotech',
'ludeon.rimworld.ideology',
],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
'ludeon.rimworld.ideology': Mod(
id: 'ludeon.rimworld.ideology',
name: 'Ideology',
path: '',
versions: [],
description: 'RimWorld expansion - Ideology',
dependencies: ['ludeon.rimworld'],
loadAfter: ['ludeon.rimworld.royalty'],
loadBefore: ['ludeon.rimworld.anomaly', 'ludeon.rimworld.biotech'],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
'ludeon.rimworld.biotech': Mod(
id: 'ludeon.rimworld.biotech',
name: 'Biotech',
path: '',
versions: [],
description: 'RimWorld expansion - Biotech',
dependencies: ['ludeon.rimworld'],
loadAfter: ['ludeon.rimworld.ideology', 'ludeon.rimworld.royalty'],
loadBefore: ['ludeon.rimworld.anomaly'],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
'ludeon.rimworld.anomaly': Mod(
id: 'ludeon.rimworld.anomaly',
name: 'Anomaly',
path: '',
versions: [],
description: 'RimWorld expansion - Anomaly',
dependencies: ['ludeon.rimworld'],
loadAfter: [
'ludeon.rimworld.biotech',
'ludeon.rimworld.ideology',
'ludeon.rimworld.royalty',
],
loadBefore: [],
incompatibilities: [],
isBaseGame: false,
size: 0,
isExpansion: true,
enabled: true,
),
};
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, Mod> 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* { ModList copyWith({
final logger = Logger.instance; String? configPath,
final stopwatch = Stopwatch()..start(); 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({bool skipExistingSizes = false}) async* {
// final logger = Logger.instance;
// final stopwatch = Stopwatch()..start();
final directory = Directory(modsPath); final directory = Directory(modsPath);
if (!directory.existsSync()) { if (!directory.existsSync()) {
logger.error('Error: Mods root directory does not exist: $modsPath'); // logger.error('Error: Mods root directory does not exist: $modsPath');
return; return;
} }
@@ -28,28 +145,28 @@ class ModList {
final List<String> modDirectories = final List<String> modDirectories =
entities.whereType<Directory>().map((dir) => dir.path).toList(); entities.whereType<Directory>().map((dir) => dir.path).toList();
logger.info( // logger.info(
'Found ${modDirectories.length} mod directories (${stopwatch.elapsedMilliseconds}ms)', // 'Found ${modDirectories.length} mod directories (${stopwatch.elapsedMilliseconds}ms)',
); // );
for (final modDir in modDirectories) { for (final modDir in modDirectories) {
try { try {
final modStart = stopwatch.elapsedMilliseconds; // final modStart = stopwatch.elapsedMilliseconds;
// Check if this directory contains a valid mod // Check if this directory contains a valid mod
final aboutFile = File('$modDir/About/About.xml'); final aboutFile = File('$modDir/About/About.xml');
if (!aboutFile.existsSync()) { if (!aboutFile.existsSync()) {
logger.warning('No About.xml found in directory: $modDir'); // logger.warning('No About.xml found in directory: $modDir');
continue; continue;
} }
final mod = Mod.fromDirectory(modDir); final mod = Mod.fromDirectory(modDir, skipFileCount: skipExistingSizes);
logger.info('Loaded mod from directory: ${mod.name} (ID: ${mod.id})'); // logger.info('Loaded mod from directory: ${mod.name} (ID: ${mod.id})');
if (mods.containsKey(mod.id)) { if (mods.containsKey(mod.id)) {
logger.warning( // logger.warning(
'Mod $mod.id already exists in mods list, overwriting', // 'Mod ${mod.id} already exists in mods list, overwriting',
); // );
final existingMod = mods[mod.id]!; final existingMod = mods[mod.id]!;
mods[mod.id] = Mod( mods[mod.id] = Mod(
name: mod.name, name: mod.name,
@@ -57,28 +174,29 @@ 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,
isExpansion: existingMod.isExpansion, isExpansion: existingMod.isExpansion,
); );
logger.info('Updated existing mod: ${mod.name} (ID: ${mod.id})'); // logger.info('Updated existing mod: ${mod.name} (ID: ${mod.id})');
} else { } else {
mods[mod.id] = mod; mods[mod.id] = mod;
logger.info('Added new mod: ${mod.name} (ID: ${mod.id})'); // logger.info('Added new mod: ${mod.name} (ID: ${mod.id})');
} }
final modTime = stopwatch.elapsedMilliseconds - modStart; // final modTime = stopwatch.elapsedMilliseconds - modStart;
logger.info( // logger.info(
'Loaded mod from directory: ${mod.name} (ID: ${mod.id}) in $modTime ms', // 'Loaded mod from directory: ${mod.name} (ID: ${mod.id}) in $modTime ms',
); // );
yield mod;
} catch (e) { } catch (e) {
logger.error('Error loading mod from directory: $modDir'); // logger.error('Error loading mod from directory: $modDir');
logger.error('Error: $e'); // logger.error('Error: $e');
} }
} }
} }
@@ -121,48 +239,36 @@ class ModList {
for (final modElement in modElements) { for (final modElement in modElements) {
final modId = modElement.innerText.toLowerCase(); final modId = modElement.innerText.toLowerCase();
// Check if this is a special Ludeon mod if (specialMods.containsKey(modId)) {
final isBaseGame = modId == 'ludeon.rimworld'; logger.info('Loading special mod: $modId');
final isExpansion = mods[modId] = specialMods[modId]!.copyWith();
!isBaseGame && setEnabled(modId, true);
modId.startsWith('ludeon.rimworld.') && logger.info('Enabled special mod: $modId');
knownExpansionIds.contains(modId); yield mods[modId]!;
continue;
}
final existingMod = mods[modId]; final existingMod = mods[modId];
final mod = Mod( final mod = Mod(
name: name: existingMod?.name ?? modId,
existingMod?.name ??
(isBaseGame
? "RimWorld"
: isExpansion
? "RimWorld ${_expansionNameFromId(modId)}"
: modId),
id: existingMod?.id ?? modId, id: existingMod?.id ?? modId,
path: existingMod?.path ?? '', path: existingMod?.path ?? '',
versions: existingMod?.versions ?? [], versions: existingMod?.versions ?? [],
description: description: existingMod?.description ?? '',
existingMod?.description ?? dependencies: existingMod?.dependencies ?? [],
(isBaseGame loadAfter: existingMod?.loadAfter ?? [],
? "RimWorld base game"
: isExpansion
? "RimWorld expansion"
: ""),
hardDependencies: existingMod?.hardDependencies ?? [],
loadAfter:
existingMod?.loadAfter ??
(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: false,
isExpansion: isExpansion, isExpansion: false,
); );
if (mods.containsKey(modId)) { if (mods.containsKey(modId)) {
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, true);
yield mod; yield mod;
} }
@@ -172,13 +278,520 @@ class ModList {
throw Exception('Failed to load config file: $e'); throw Exception('Failed to load config file: $e');
} }
} }
void saveToConfig(LoadOrder loadOrder) {
final file = File(configPath);
final logger = Logger.instance;
try {
// Create XML builder
final builder = XmlBuilder();
// Add XML declaration
builder.declaration(encoding: 'utf-8');
// Add root element
builder.element(
'ModsConfigData',
nest: () {
// Add version element
builder.element('version', nest: '1.5.4297 rev994');
// Add active mods element
builder.element(
'activeMods',
nest: () {
// Add each mod as a list item
for (final mod in loadOrder.order) {
builder.element('li', nest: mod.id);
logger.info('Adding mod to config: ${mod.name} (${mod.id})');
}
},
);
// Add known expansions element
final expansions = mods.values.where((m) => m.isExpansion).toList();
if (expansions.isNotEmpty) {
builder.element(
'knownExpansions',
nest: () {
for (final mod in expansions) {
builder.element('li', nest: mod.id);
logger.info(
'Adding expansion to config: ${mod.name} (${mod.id})',
);
}
},
);
}
},
);
// Build the XML document
final xmlDocument = builder.buildDocument();
// Convert to string with 2-space indentation
final prettyXml = xmlDocument.toXmlString(
pretty: true,
indent: ' ', // 2 spaces
newLine: '\n',
);
// Write the formatted XML document to file
file.writeAsStringSync(prettyXml);
logger.info('Successfully saved mod configuration to: $configPath');
} catch (e) {
logger.error('Error saving configuration file: $e');
throw Exception('Failed to save config file: $e');
}
} }
String _expansionNameFromId(String id) { void setEnabled(String modId, bool enabled) {
final parts = id.split('.'); if (mods.containsKey(modId)) {
if (parts.length < 3) return id; final mod = mods[modId]!;
mod.enabled = enabled;
final expansionPart = parts[2]; if (enabled) {
return expansionPart.substring(0, 1).toUpperCase() + activeMods[modId] = mod;
expansionPart.substring(1); } 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);
}
}
void enableMods(List<String> modIds) {
for (final modId in modIds) {
setEnabled(modId, true);
}
}
void disableMods(List<String> modIds) {
for (final modId in modIds) {
setEnabled(modId, false);
}
}
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}');
if (specialMods.containsKey(mod.id)) {
logger.info('Special mod: ${mod.id}');
// Replace our fake base game mod with the chad one
// This is a bit of a hack, but it works
activeMods[mod.id] = specialMods[mod.id]!.copyWith();
mods[mod.id] = specialMods[mod.id]!.copyWith();
}
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(', ')}',
);
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);
});
// Initialize queue with mods having inDegree 0
for (final mod in loadOrder.order) {
if (inDegree[mod.id] == 0) {
pq.add(mod);
}
}
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
// A mod depends on a mod that depends on another mod
// So we move our first mod A to after B
// But then we move B after C and A is no longer guranteed to be after B
// So we update it too just in case
// To make sure we have A B C
// Now it opens us to a stack overflow...
LoadOrder shuffleMod(
Mod mod,
LoadOrder loadOrder,
Map<String, List<Mod>> relations, [
Map<String, bool>? seen,
]) {
final logger = Logger.instance;
logger.info('Starting shuffleMod for mod: ${mod.id}');
// Prevent infinite loops
seen ??= <String, bool>{};
if (seen[mod.id] == true) {
logger.info('Mod ${mod.id} has already been seen, skipping.');
return loadOrder;
}
seen[mod.id] = true;
logger.info('Marking mod ${mod.id} as seen.');
for (final dependency in mod.dependencies) {
logger.info('Checking dependency: $dependency for mod ${mod.id}');
final depMod = mods[dependency];
if (depMod == null) {
loadOrder.errors.add(
'Missing dependency: ${mod.id} requires mod with ID $dependency',
);
logger.warning(
'Missing dependency: ${mod.id} requires mod with ID $dependency',
);
continue;
}
if (loadOrder.order.indexOf(mod) < loadOrder.order.indexOf(depMod)) {
logger.info('Reordering: ${mod.id} should come after ${depMod.id}');
loadOrder.order.removeAt(loadOrder.order.indexOf(mod));
loadOrder.order.insert(loadOrder.order.indexOf(depMod) + 1, mod);
relations[mod.id] = [...relations[mod.id] ?? [], depMod];
}
}
for (final loadAfter in mod.loadAfter) {
logger.info('Checking loadAfter: $loadAfter for mod ${mod.id}');
final loadAfterMod = mods[loadAfter];
if (loadAfterMod != null &&
loadOrder.order.indexOf(mod) <
loadOrder.order.indexOf(loadAfterMod)) {
final loadAfterIndex = loadOrder.order.indexOf(loadAfterMod);
// Mod is not loaded, we don't care about it
if (loadAfterIndex == -1) {
logger.warning(
'Missing loadAfter: ${mod.id} requires mod with ID $loadAfter',
);
continue;
}
logger.info(
'Reordering: ${mod.id} should come after ${loadAfterMod.id}',
);
loadOrder.order.removeAt(loadOrder.order.indexOf(mod));
loadOrder.order.insert(loadOrder.order.indexOf(loadAfterMod) + 1, mod);
relations[mod.id] = [...relations[mod.id] ?? [], loadAfterMod];
}
}
for (final loadBefore in mod.loadBefore) {
logger.info('Checking loadBefore: $loadBefore for mod ${mod.id}');
final loadBeforeMod = mods[loadBefore];
if (loadBeforeMod != null &&
loadOrder.order.indexOf(mod) >
loadOrder.order.indexOf(loadBeforeMod)) {
final loadBeforeIndex = loadOrder.order.indexOf(loadBeforeMod);
// Mod is not loaded, we don't care about it
if (loadBeforeIndex == -1) {
logger.warning(
'Missing loadBefore: ${mod.id} requires mod with ID $loadBefore',
);
continue;
}
logger.info(
'Reordering: ${mod.id} should come before ${loadBeforeMod.id}',
);
loadOrder.order.removeAt(loadOrder.order.indexOf(mod));
loadOrder.order.insert(loadOrder.order.indexOf(loadBeforeMod), mod);
relations[mod.id] = [...relations[mod.id] ?? [], loadBeforeMod];
}
}
for (final relatedMod in relations[mod.id] ?? []) {
logger.info('Recursively shuffling related mod: ${relatedMod.id}');
loadOrder = shuffleMod(relatedMod, loadOrder, relations, seen);
}
logger.info('Completed shuffleMod for mod: ${mod.id}');
return loadOrder;
}
List<List<String>> checkIncompatibilities(List<String> modIds) {
final incompatibilities = <List<String>>[];
for (final modId in modIds) {
final mod = mods[modId]!;
for (final incomp in mod.incompatibilities) {
if (modIds.contains(incomp)) {
incompatibilities.add([mod.id, incomp]);
}
}
}
return incompatibilities;
}
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),
);
}
return loadOrder;
}
LoadOrder loadRequired([LoadOrder? loadOrder]) {
loadOrder ??= LoadOrder();
final toEnable = <String>[];
final logger = Logger.instance;
// First, identify all base game and expansion mods
final baseGameIds = <String>{};
final expansionIds = <String>{};
for (final entry in mods.entries) {
if (entry.value.isBaseGame) {
baseGameIds.add(entry.key);
} else if (entry.value.isExpansion) {
expansionIds.add(entry.key);
}
}
logger.info("Base game mods: ${baseGameIds.join(', ')}");
logger.info("Expansion mods: ${expansionIds.join(', ')}");
// Load dependencies for all active mods
for (final modid in activeMods.keys) {
loadDependencies(modid, loadOrder, toEnable);
}
// Enable all required dependencies
for (final modid in toEnable) {
setEnabled(modid, true);
}
// Generate the load order
final newLoadOrder = generateLoadOrder(loadOrder);
// Filter out any error messages related to incompatibilities between base game and expansions
if (newLoadOrder.hasErrors) {
final filteredErrors = <String>[];
for (final error in newLoadOrder.errors) {
// Check if the error is about incompatibility
if (error.contains('Incompatibility detected:')) {
// Extract the mod IDs from the error message
final parts = error.split(' is incompatible with ');
if (parts.length == 2) {
final firstModId = parts[0].replaceAll('Incompatibility detected: ', '');
final secondModId = parts[1];
// Check if either mod is a base game or expansion
final isBaseGameOrExpansion =
baseGameIds.contains(firstModId) || baseGameIds.contains(secondModId) ||
expansionIds.contains(firstModId) || expansionIds.contains(secondModId);
// Only keep the error if it's not between base game/expansions
if (!isBaseGameOrExpansion) {
filteredErrors.add(error);
} else {
logger.info("Ignoring incompatibility between base game or expansion mods: $error");
}
} else {
// If we can't parse the error, keep it
filteredErrors.add(error);
}
} else {
// Keep non-incompatibility errors
filteredErrors.add(error);
}
}
// Replace the errors with the filtered list
newLoadOrder.errors.clear();
newLoadOrder.errors.addAll(filteredErrors);
}
return newLoadOrder;
}
LoadOrder loadRequiredBaseGame([LoadOrder? loadOrder]) {
loadOrder ??= LoadOrder();
final baseGameMods =
mods.values.where((mod) => mod.isBaseGame || mod.isExpansion).toList();
// You would probably want to load these too if you had them
final specialMods =
mods.values
.where(
(mod) =>
mod.id.contains("harmony") ||
mod.id.contains("prepatcher") ||
mod.id.contains("betterlog"),
)
.toList();
enableMods(baseGameMods.map((mod) => mod.id).toList());
enableMods(specialMods.map((mod) => mod.id).toList());
return loadRequired(loadOrder);
}
} }

View File

@@ -0,0 +1,139 @@
import 'package:rimworld_modman/mod_list.dart';
/// A class that helps find the minimum set of mods that exhibit a bug.
///
/// Provides two main algorithms:
/// - Binary search / bisect: Divides mods into smaller subsets to find problematic ones quickly.
/// - Linear search / batching: Tests mods in small groups to systematically identify issues.
///
/// These approaches help RimWorld mod users identify which mods are causing problems
/// when many mods are installed.
class Move {
final int startIndex;
final int endIndex;
Move({required this.startIndex, required this.endIndex});
}
class ModListTroubleshooter {
final ModList originalModList;
ModList currentModList;
// These indices should ALWAYS represent the CURRENT selection of mods
int _startIndex = 0;
int _endIndex = 0;
ModListTroubleshooter(ModList modList)
: originalModList = modList,
currentModList = modList.copyWith(),
_startIndex = 0,
_endIndex = modList.activeMods.length;
Move binaryForwardMove() {
final midIndex = (_startIndex + _endIndex) ~/ 2;
return Move(startIndex: midIndex, endIndex: _endIndex);
}
Move binaryBackwardMove() {
final midIndex = ((_startIndex + _endIndex) / 2).ceil();
return Move(startIndex: _startIndex, endIndex: midIndex);
}
ModList binaryForward() {
final move = binaryForwardMove();
final subset = originalModList.activeMods.keys.toList().sublist(
move.startIndex,
move.endIndex,
);
currentModList.disableAll();
currentModList.enableMods(subset);
_startIndex = move.startIndex;
_endIndex = move.endIndex;
return currentModList;
}
ModList binaryBackward() {
final move = binaryBackwardMove();
final subset = originalModList.activeMods.keys.toList().sublist(
move.startIndex,
move.endIndex,
);
currentModList.disableAll();
currentModList.enableMods(subset);
_startIndex = move.startIndex;
_endIndex = move.endIndex;
return currentModList;
}
// If the current selection is not equal to our proposed step size
// We do not MOVE but instead just return the correct amount of mods from the start
Move linearForwardMove({int stepSize = 20}) {
var start = _startIndex;
var end = _endIndex;
// If we are not "in step"
if (end - start == stepSize) {
// Move the indices forward by the step size, step forward
start += stepSize;
end += stepSize;
} else {
end = start + stepSize;
}
if (end > originalModList.activeMods.length) {
// If we are at the end of the list, move the start index such that we return
// At most the step size amount of mods
end = originalModList.activeMods.length;
start = (end - stepSize).clamp(0, end);
}
return Move(startIndex: start, endIndex: end);
}
Move linearBackwardMove({int stepSize = 20}) {
var start = _startIndex;
var end = _endIndex;
if (end - start == stepSize) {
start -= stepSize;
end -= stepSize;
} else {
start = end - stepSize;
}
if (start < 0) {
start = 0;
end = stepSize.clamp(0, originalModList.activeMods.length);
}
return Move(startIndex: start, endIndex: end);
}
ModList linearForward({int stepSize = 20}) {
final move = linearForwardMove(stepSize: stepSize);
final subset = originalModList.activeMods.keys.toList().sublist(
move.startIndex,
move.endIndex,
);
currentModList.disableAll();
currentModList.enableMods(subset);
_startIndex = move.startIndex;
_endIndex = move.endIndex;
return currentModList;
}
ModList linearBackward({int stepSize = 20}) {
final move = linearBackwardMove(stepSize: stepSize);
final subset = originalModList.activeMods.keys.toList().sublist(
move.startIndex,
move.endIndex,
);
currentModList.disableAll();
currentModList.enableMods(subset);
_startIndex = move.startIndex;
_endIndex = move.endIndex;
return currentModList;
}
void reset() {
currentModList = originalModList.copyWith();
}
}

View File

@@ -0,0 +1,829 @@
import 'package:flutter/material.dart';
import 'package:rimworld_modman/mod_list.dart';
import 'package:rimworld_modman/mod_list_troubleshooter.dart';
import 'main.dart';
/// A widget that provides a user interface for the mod troubleshooter functionality.
///
/// This allows users to:
/// - Toggle between binary and linear search modes
/// - Navigate forward and backward through mod sets
/// - Adjust step size for linear navigation
/// - Mark mods as checked/good or problematic
/// - Find specific mods causing issues in their load order
class ModTroubleshooterWidget extends StatefulWidget {
const ModTroubleshooterWidget({super.key});
@override
State<ModTroubleshooterWidget> createState() =>
_ModTroubleshooterWidgetState();
}
class _ModTroubleshooterWidgetState extends State<ModTroubleshooterWidget> {
late ModListTroubleshooter _troubleshooter;
bool _isInitialized = false;
bool _isBinaryMode = false;
int _stepSize = 10;
// Set of mod IDs that have been checked and confirmed to be good
final Set<String> _checkedMods = {};
// Set of mod IDs that are suspected to cause issues
final Set<String> _problemMods = {};
// The currently selected mod IDs (for highlighting)
LoadOrder _loadOrder = LoadOrder();
// The next potential set of mods (from move calculation)
Move? _nextForwardMove;
Move? _nextBackwardMove;
// Controller for step size input
late TextEditingController _stepSizeController;
@override
void initState() {
super.initState();
_stepSizeController = TextEditingController(text: _stepSize.toString());
}
@override
void dispose() {
_stepSizeController.dispose();
super.dispose();
}
void _initialize() {
if (_isInitialized) return;
// Initialize the troubleshooter with the global mod manager
_troubleshooter = ModListTroubleshooter(modManager);
// Set initial active mods for highlighting
if (modManager.activeMods.isNotEmpty) {
// Initially select all active mods
_loadOrder = LoadOrder(modManager.activeMods.values.toList());
}
// Calculate initial moves
_updateNextMoves();
setState(() {
_isInitialized = true;
});
}
void _updateNextMoves() {
if (_isBinaryMode) {
_nextForwardMove = _troubleshooter.binaryForwardMove();
_nextBackwardMove = _troubleshooter.binaryBackwardMove();
} else {
_nextForwardMove = _troubleshooter.linearForwardMove(stepSize: _stepSize);
_nextBackwardMove = _troubleshooter.linearBackwardMove(
stepSize: _stepSize,
);
}
}
void _navigateForward() {
ModList result;
if (_isBinaryMode) {
result = _troubleshooter.binaryForward();
} else {
result = _troubleshooter.linearForward(stepSize: _stepSize);
}
// Load all required dependencies for the selected mods
final loadOrder = result.loadRequiredBaseGame();
// Use the mods from the load order result
setState(() {
_loadOrder = loadOrder;
_updateNextMoves();
});
}
void _navigateBackward() {
ModList result;
if (_isBinaryMode) {
result = _troubleshooter.binaryBackward();
} else {
result = _troubleshooter.linearBackward(stepSize: _stepSize);
}
// Load all required dependencies for the selected mods
final loadOrder = result.loadRequiredBaseGame();
// Use the mods from the load order result
setState(() {
_loadOrder = loadOrder;
_updateNextMoves();
});
}
void _markAsGood(String modId) {
setState(() {
_checkedMods.add(modId);
_problemMods.remove(modId);
});
}
void _markAsProblem(String modId) {
setState(() {
_problemMods.add(modId);
_checkedMods.remove(modId);
});
}
void _clearMarks(String modId) {
setState(() {
_checkedMods.remove(modId);
_problemMods.remove(modId);
});
}
void _resetTroubleshooter() {
setState(() {
_checkedMods.clear();
_problemMods.clear();
_isInitialized = false;
});
_initialize();
}
void _saveTroubleshootingConfig() {
// Only save if we have a valid selection
if (_loadOrder.order.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No mods selected to save'),
duration: Duration(seconds: 2),
),
);
return;
}
modManager.saveToConfig(_loadOrder);
// Save the configuration (we don't have direct access to save method, so show a message)
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${_loadOrder.order.length} mods have been successfully saved to the configuration.',
),
backgroundColor: Colors.green,
duration: const Duration(seconds: 4),
),
);
}
void _markSelectedAsGood() {
if (_loadOrder.order.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No mods selected to mark'),
duration: Duration(seconds: 2),
),
);
return;
}
setState(() {
for (final mod in _loadOrder.order) {
_checkedMods.add(mod.id);
_problemMods.remove(mod.id);
}
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Marked ${_loadOrder.order.length} mods as good'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
void _markSelectedAsProblem() {
if (_loadOrder.order.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No mods selected to mark'),
duration: Duration(seconds: 2),
),
);
return;
}
setState(() {
for (final mod in _loadOrder.order) {
_problemMods.add(mod.id);
_checkedMods.remove(mod.id);
}
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Marked ${_loadOrder.order.length} mods as problematic'),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
);
}
@override
Widget build(BuildContext context) {
// Make sure we're initialized
if (!_isInitialized) {
_initialize();
}
if (!_isInitialized || modManager.mods.isEmpty) {
return _buildEmptyState();
}
return Column(
children: [_buildControlPanel(), Expanded(child: _buildModList())],
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.build,
size: AppThemeExtension.of(context).iconSizeLarge * 2,
),
const SizedBox(height: 16),
Text(
'Troubleshooting',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Text(
'Load mods first to use the troubleshooting tools.',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
// No direct access to the ModManagerHomePage state, so just show a message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please go to the Mods tab to load mods first'),
duration: Duration(seconds: 3),
),
);
},
child: const Text('Go to Mod Manager'),
),
],
),
);
}
Widget _buildControlPanel() {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Padding(
padding: AppThemeExtension.of(context).paddingSmall,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Compact instruction
Expanded(
child: Text(
_loadOrder.order.isNotEmpty
? 'Testing ${_loadOrder.order.length} mods. Tap highlighted mods to navigate. Mark results below:'
: 'Click highlighted mods to begin testing. Blue→forward, purple←backward.',
style: TextStyle(
fontSize: AppThemeExtension.of(context).textSizeRegular,
fontStyle: FontStyle.italic,
),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
// Binary/Linear mode toggle
Text('Mode:', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(width: 8),
ToggleButtons(
isSelected: [!_isBinaryMode, _isBinaryMode],
onPressed: (index) {
setState(() {
_isBinaryMode = index == 1;
_updateNextMoves();
});
},
children: const [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Text('Linear'),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Text('Binary'),
),
],
),
// Step size input field (only for linear mode)
if (!_isBinaryMode) ...[
const SizedBox(width: 16),
Text('Step:', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(width: 4),
SizedBox(
width: 60,
child: TextField(
keyboardType: TextInputType.number,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 6,
vertical: 6,
),
),
controller: _stepSizeController,
onChanged: (value) {
final parsedValue = int.tryParse(value);
if (parsedValue != null && parsedValue > 0) {
setState(() {
_stepSize = parsedValue;
_updateNextMoves();
});
}
},
),
),
],
const Spacer(),
// Buttons to mark selected mods
if (_loadOrder.order.isNotEmpty) ...[
OutlinedButton.icon(
icon: Icon(
Icons.error,
color: Colors.red.shade300,
size: 16,
),
label: const Text('Problem'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 0,
),
),
onPressed: _markSelectedAsProblem,
),
const SizedBox(width: 4),
OutlinedButton.icon(
icon: Icon(
Icons.check_circle,
color: Colors.green.shade300,
size: 16,
),
label: const Text('Good'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 0,
),
),
onPressed: _markSelectedAsGood,
),
const SizedBox(width: 4),
],
// Reset button
OutlinedButton.icon(
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reset'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 0,
),
),
onPressed: _resetTroubleshooter,
),
if (_loadOrder.order.isNotEmpty) ...[
const SizedBox(width: 4),
// Save config button
OutlinedButton.icon(
icon: const Icon(Icons.save, size: 16),
label: const Text('Save'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 0,
),
),
onPressed: _saveTroubleshootingConfig,
),
],
],
),
],
),
),
);
}
Widget _buildModList() {
// Get the original mod order from mod manager
final fullModList = modManager.activeMods.keys.toList();
return Card(
margin: AppThemeExtension.of(context).paddingRegular,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
color: Theme.of(context).primaryColor,
padding: AppThemeExtension.of(context).paddingRegular,
child: Column(
children: [
Text(
'Active Mods (${fullModList.length})',
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
if (_nextForwardMove != null || _nextBackwardMove != null)
Text(
'Click ↓blue areas to move forward, ↑purple to move backward',
style: TextStyle(
fontSize: AppThemeExtension.of(context).textSizeSmall,
fontStyle: FontStyle.italic,
color: Colors.grey.shade300,
),
textAlign: TextAlign.center,
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: fullModList.length,
itemBuilder: (context, index) {
final modId = fullModList[index];
final mod = modManager.mods[modId];
if (mod == null) return const SizedBox.shrink();
// Determine if this mod is in the selection range for highlighted navigation
final bool isSelected = _loadOrder.order.any(
(m) => m.id == modId,
);
// Check if this mod would be included in the next Forward/Backward move
bool isInNextForward = false;
bool isInNextBackward = false;
if (_nextForwardMove != null &&
index >= _nextForwardMove!.startIndex &&
index < _nextForwardMove!.endIndex) {
isInNextForward = true;
}
if (_nextBackwardMove != null &&
index >= _nextBackwardMove!.startIndex &&
index < _nextBackwardMove!.endIndex) {
isInNextBackward = true;
}
// Determine mod status for coloring
final bool isChecked = _checkedMods.contains(modId);
final bool isProblem = _problemMods.contains(modId);
return GestureDetector(
onTap: () {
// Navigation takes precedence if this mod is in a navigation range
if (isInNextForward) {
_navigateForward();
} else if (isInNextBackward) {
_navigateBackward();
}
// Otherwise toggle the status of this mod
else if (isChecked) {
_markAsProblem(modId);
} else if (isProblem) {
_clearMarks(modId);
} else {
_markAsGood(modId);
}
},
child: Card(
margin: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 4.0,
),
color: _getModCardColor(
isSelected: isSelected,
isChecked: isChecked,
isProblem: isProblem,
isInNextForward: isInNextForward,
isInNextBackward: isInNextBackward,
),
child: ListTile(
leading: Text(
'${index + 1}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isSelected ? Colors.white : Colors.grey,
),
),
title: Row(
children: [
if (isSelected)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 0,
),
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(
color: const Color(
0x28303F9F,
), // Blue with alpha 40
borderRadius: BorderRadius.circular(4),
),
child: Text(
'TESTING',
style: TextStyle(
color: Colors.blue.shade200,
fontSize:
AppThemeExtension.of(
context,
).textSizeSmall,
fontWeight: FontWeight.bold,
),
),
),
if (isChecked)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 0,
),
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(
color: const Color(
0x1E2E7D32,
), // Green with alpha 30
borderRadius: BorderRadius.circular(4),
),
child: Text(
'GOOD',
style: TextStyle(
color: Colors.green.shade200,
fontSize:
AppThemeExtension.of(
context,
).textSizeSmall,
fontWeight: FontWeight.bold,
),
),
),
if (isProblem)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 0,
),
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(
color: const Color(
0x1EC62828,
), // Red with alpha 30
borderRadius: BorderRadius.circular(4),
),
child: Text(
'PROBLEM',
style: TextStyle(
color: Colors.red.shade200,
fontSize:
AppThemeExtension.of(
context,
).textSizeSmall,
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: Text(
mod.name,
style: TextStyle(
fontWeight:
isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
],
),
subtitle: Text(
modId,
style: TextStyle(
fontSize: AppThemeExtension.of(context).textSizeSmall,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Display mod characteristics
if (mod.isBaseGame)
Tooltip(
message: 'Base Game',
child: Icon(
Icons.home,
color:
AppThemeExtension.of(context).baseGameColor,
size:
AppThemeExtension.of(context).iconSizeSmall,
),
),
if (mod.isExpansion)
Tooltip(
message: 'Expansion',
child: Icon(
Icons.star,
color:
AppThemeExtension.of(
context,
).expansionColor,
size:
AppThemeExtension.of(context).iconSizeSmall,
),
),
if (mod.dependencies.isNotEmpty)
Tooltip(
message:
'Dependencies:\n${mod.dependencies.join('\n')}',
child: Icon(
Icons.link,
color: AppThemeExtension.of(context).linkColor,
size:
AppThemeExtension.of(context).iconSizeSmall,
),
),
// Display status icon
if (isChecked)
Tooltip(
message: 'Marked as working correctly',
child: Icon(
Icons.check_circle,
color: Colors.green.shade300,
),
)
else if (isProblem)
Tooltip(
message: 'Marked as problematic',
child: Icon(
Icons.error,
color: Colors.red.shade300,
),
),
const SizedBox(width: 4),
// Show navigation indicators
if (isInNextForward)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(
0x0A2196F3,
), // Blue with alpha 10
borderRadius: BorderRadius.circular(4),
),
child: Tooltip(
message:
'Click to move Forward (test this mod)',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.arrow_forward,
color: Colors.blue.shade300,
size:
AppThemeExtension.of(
context,
).iconSizeSmall,
),
const SizedBox(width: 2),
Text(
'Forward',
style: TextStyle(
color: Colors.blue.shade300,
fontSize:
AppThemeExtension.of(
context,
).textSizeSmall,
),
),
],
),
),
),
if (isInNextBackward)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(
0x0A9C27B0,
), // Purple with alpha 10
borderRadius: BorderRadius.circular(4),
),
child: Tooltip(
message:
'Click to move Backward (test this mod)',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.arrow_back,
color: Colors.purple.shade300,
size:
AppThemeExtension.of(
context,
).iconSizeSmall,
),
const SizedBox(width: 2),
Text(
'Back',
style: TextStyle(
color: Colors.purple.shade300,
fontSize:
AppThemeExtension.of(
context,
).textSizeSmall,
),
),
],
),
),
),
],
),
),
),
);
},
),
),
],
),
);
}
Color _getModCardColor({
required bool isSelected,
required bool isChecked,
required bool isProblem,
required bool isInNextForward,
required bool isInNextBackward,
}) {
// Priority: 1. Selected, 2. Navigation areas, 3. Status
if (isSelected) {
return const Color(0x80303F9F);
} else if (isInNextForward && isInNextBackward) {
return const Color(0x50673AB7);
} else if (isInNextForward) {
return const Color(0x402196F3);
} else if (isInNextBackward) {
return const Color(0x409C27B0);
} else if (isChecked) {
return const Color(0x802E7D32);
} else if (isProblem) {
return const Color(0x80C62828);
}
return Colors.transparent;
}
}

View File

@@ -1,691 +0,0 @@
import 'dart:io';
import 'dart:async';
import 'package:rimworld_modman/logger.dart';
import 'package:rimworld_modman/mod.dart';
import 'package:xml/xml.dart';
const root = r'C:/Users/Administrator/Seafile/Games-RimWorld';
const modsRoot = '$root/294100';
const configRoot = '$root/AppData/RimWorld by Ludeon Studios/Config';
const configPath = '$configRoot/ModsConfig.xml';
const logsPath = '$root/ModManager';
class ModList {
final String path;
Map<String, Mod> mods = {};
bool modsLoaded = false;
String loadingStatus = '';
int totalModsFound = 0;
int loadedModsCount = 0;
ModList({required this.path});
Future<void> loadWithConfig({bool skipFileCount = false}) async {
final logger = Logger.instance;
// Clear existing state if reloading
if (modsLoaded) {
logger.info('Clearing existing mods state for reload.');
mods.clear();
}
modsLoaded = false;
loadedModsCount = 0;
loadingStatus = 'Loading active mods from config...';
final stopwatch = Stopwatch()..start();
logger.info('Loading configuration from config file: $configPath');
try {
// First, load the config file to get the list of active mods
final configFile = ConfigFile(path: configPath);
await configFile.load();
logger.info('Config file loaded successfully.');
// Create a Set of active mod IDs for quick lookups
final activeModIds = configFile.mods.map((m) => m.id).toSet();
logger.info('Active mod IDs created: ${activeModIds.join(', ')}');
// Special handling for Ludeon mods that might not exist as directories
for (final configMod in configFile.mods) {
if (configMod.id.startsWith('ludeon.')) {
final isBaseGame = configMod.id == 'ludeon.rimworld';
final isExpansion =
configMod.id.startsWith('ludeon.rimworld.') && !isBaseGame;
// Create a placeholder mod for the Ludeon mods that might not have directories
final mod = Mod(
name:
isBaseGame
? "RimWorld"
: isExpansion
? "RimWorld ${_expansionNameFromId(configMod.id)}"
: configMod.id,
id: configMod.id,
path: '',
versions: [],
description:
isBaseGame
? "RimWorld base game"
: isExpansion
? "RimWorld expansion"
: "",
hardDependencies: [],
loadAfter: isExpansion ? ['ludeon.rimworld'] : [],
loadBefore: [],
incompatabilities: [],
enabled: true,
size: 0,
isBaseGame: isBaseGame,
isExpansion: isExpansion,
);
mods[configMod.id] = mod;
loadedModsCount++;
logger.info('Added mod from config: ${mod.name} (ID: ${mod.id})');
}
}
// Now scan the directory for mod metadata
loadingStatus = 'Scanning mod directories...';
final directory = Directory(path);
if (!directory.existsSync()) {
loadingStatus = 'Error: Mods root directory does not exist: $path';
logger.error(loadingStatus);
return;
}
final List<FileSystemEntity> entities = directory.listSync();
final List<String> modDirectories =
entities.whereType<Directory>().map((dir) => dir.path).toList();
totalModsFound = modDirectories.length;
loadingStatus = 'Found $totalModsFound mod directories. Loading...';
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, skipFileCount: skipFileCount);
logger.info('Loaded mod from directory: ${mod.name} (ID: ${mod.id})');
// If we already have this mod from the config (like Ludeon mods), update its data
if (mods.containsKey(mod.id)) {
final existingMod = mods[mod.id]!;
mods[mod.id] = Mod(
name: mod.name,
id: mod.id,
path: mod.path,
versions: mod.versions,
description: mod.description,
hardDependencies: mod.hardDependencies,
loadAfter: mod.loadAfter,
loadBefore: mod.loadBefore,
incompatabilities: mod.incompatabilities,
enabled: activeModIds.contains(
mod.id,
), // Set enabled based on config
size: mod.size,
isBaseGame: existingMod.isBaseGame,
isExpansion: existingMod.isExpansion,
);
logger.info('Updated existing mod: ${mod.name} (ID: ${mod.id})');
} else {
// Otherwise add as a new mod
mods[mod.id] = Mod(
name: mod.name,
id: mod.id,
path: mod.path,
versions: mod.versions,
description: mod.description,
hardDependencies: mod.hardDependencies,
loadAfter: mod.loadAfter,
loadBefore: mod.loadBefore,
incompatabilities: mod.incompatabilities,
enabled: activeModIds.contains(
mod.id,
), // Set enabled based on config
size: mod.size,
isBaseGame: mod.isBaseGame,
isExpansion: mod.isExpansion,
);
loadedModsCount++;
logger.info('Added new mod: ${mod.name} (ID: ${mod.id})');
}
final modTime = stopwatch.elapsedMilliseconds - modStart;
loadingStatus = 'Loaded $loadedModsCount/$totalModsFound mods...';
if (loadedModsCount % 50 == 0 || loadedModsCount == totalModsFound) {
logger.info(
'Progress: Loaded $loadedModsCount mods (${modTime}ms)',
);
}
} catch (e) {
logger.error('Error loading mod from directory: $modDir');
logger.error('Error: $e');
}
}
modsLoaded = true;
final totalTime = stopwatch.elapsedMilliseconds;
loadingStatus =
'Completed! Loaded $loadedModsCount mods in ${totalTime}ms.';
logger.info(
'Loading complete! Loaded ${mods.length} mods in ${totalTime}ms',
);
} catch (e) {
loadingStatus = 'Error loading mods: $e';
logger.error(loadingStatus);
}
}
// Helper function to get a nice expansion name from ID
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);
}
// Build a directed graph of mod dependencies
Map<String, Set<String>> buildDependencyGraph() {
// Graph where graph[A] contains B if A depends on B (B must load before A)
final Map<String, Set<String>> graph = {};
// Initialize the graph with empty dependency sets for all mods
for (final mod in mods.values) {
graph[mod.id] = <String>{};
}
// Add hard dependencies to the graph
for (final mod in mods.values) {
for (final dependency in mod.hardDependencies) {
// Only add if the dependency exists in our loaded mods
if (mods.containsKey(dependency)) {
graph[mod.id]!.add(dependency);
}
}
}
// Handle base game and expansions:
// 1. Add the base game as a dependency of all mods except those who have loadBefore for it
// 2. Add expansions as dependencies of mods that load after them
// First identify the base game and expansions
final baseGameId =
mods.values.where((m) => m.isBaseGame).map((m) => m.id).firstOrNull;
if (baseGameId != null) {
for (final mod in mods.values) {
// Skip the base game itself and mods that explicitly load before it
if (mod.id != baseGameId && !mod.loadBefore.contains(baseGameId)) {
graph[mod.id]!.add(baseGameId);
}
}
}
return graph;
}
// Build a graph for soft dependencies
Map<String, Set<String>> buildSoftDependencyGraph() {
final Map<String, Set<String>> graph = {};
// Initialize the graph with empty sets
for (final mod in mods.values) {
graph[mod.id] = <String>{};
}
// Add soft dependencies (loadAfter)
for (final mod in mods.values) {
for (final dependency in mod.loadAfter) {
// Only add if the dependency exists in our loaded mods
if (mods.containsKey(dependency)) {
graph[mod.id]!.add(dependency);
}
}
}
// Handle loadBefore - invert the relationship for the graph
// If A loadBefore B, then B softDepends on A
for (final mod in mods.values) {
for (final loadBeforeId in mod.loadBefore) {
if (mods.containsKey(loadBeforeId)) {
graph[loadBeforeId]!.add(mod.id);
}
}
}
return graph;
}
// Detect cycles in the dependency graph (which would make a valid loading order impossible)
List<String>? detectCycle(Map<String, Set<String>> graph) {
// Track visited nodes and the current path
Set<String> visited = {};
Set<String> currentPath = {};
List<String> cycleNodes = [];
bool dfs(String node, List<String> path) {
if (currentPath.contains(node)) {
// Found a cycle
int cycleStart = path.indexOf(node);
cycleNodes = path.sublist(cycleStart);
cycleNodes.add(node); // Close the cycle
return true;
}
if (visited.contains(node)) {
return false;
}
visited.add(node);
currentPath.add(node);
path.add(node);
for (final dependency in graph[node] ?? {}) {
if (dfs(dependency, path)) {
return true;
}
}
currentPath.remove(node);
return false;
}
for (final node in graph.keys) {
if (!visited.contains(node)) {
if (dfs(node, [])) {
return cycleNodes;
}
}
}
return null; // No cycle found
}
// Perform a topological sort using Kahn's algorithm with size prioritization
List<String> topologicalSort(Map<String, Set<String>> graph) {
// Create a copy of the graph to work with
final Map<String, Set<String>> graphCopy = {};
for (final entry in graph.entries) {
graphCopy[entry.key] = Set<String>.from(entry.value);
}
// Calculate in-degree of each node (number of edges coming in)
Map<String, int> inDegree = {};
for (final node in graphCopy.keys) {
inDegree[node] = 0;
}
for (final dependencies in graphCopy.values) {
for (final dep in dependencies) {
inDegree[dep] = (inDegree[dep] ?? 0) + 1;
}
}
// Separate nodes by "layers" (nodes that can be processed at the same time)
List<List<String>> layers = [];
// Process until all nodes are assigned to layers
while (inDegree.isNotEmpty) {
// Find all nodes with in-degree 0 in this iteration
List<String> currentLayer = [];
inDegree.forEach((node, degree) {
if (degree == 0) {
currentLayer.add(node);
}
});
if (currentLayer.isEmpty && inDegree.isNotEmpty) {
// We have a cycle - add all remaining nodes to a final layer
currentLayer = inDegree.keys.toList();
print(
"Warning: Cycle detected in dependency graph. Adding all remaining nodes to final layer.",
);
}
// Sort this layer by mod size (descending)
currentLayer.sort((a, b) {
final modA = mods[a];
final modB = mods[b];
if (modA == null || modB == null) return 0;
return modB.size.compareTo(modA.size); // Larger mods first
});
// Add the layer to our layers list
layers.add(currentLayer);
// Remove processed nodes from inDegree
for (final node in currentLayer) {
inDegree.remove(node);
// Update in-degrees for remaining nodes
for (final entry in graphCopy.entries) {
if (entry.value.contains(node)) {
if (inDegree.containsKey(entry.key)) {
inDegree[entry.key] = inDegree[entry.key]! - 1;
}
}
}
}
}
// Flatten the layers to get the final order (first layer first)
List<String> result = [];
for (final layer in layers) {
result.addAll(layer);
}
// Final sanity check to make sure all nodes are included
if (result.length != graph.keys.length) {
// Add any missing nodes
for (final node in graph.keys) {
if (!result.contains(node)) {
result.add(node);
}
}
}
return result;
}
// Adjust the order to respect soft dependencies where possible
List<String> adjustForSoftDependencies(
List<String> hardOrder,
Map<String, Set<String>> softGraph,
) {
// Create a map of positions in the hard dependency order
Map<String, int> positions = {};
for (int i = 0; i < hardOrder.length; i++) {
positions[hardOrder[i]] = i;
}
// For each mod, try to move its soft dependencies earlier in the order
bool changed = true;
while (changed) {
changed = false;
for (final modId in hardOrder) {
final softDeps = softGraph[modId] ?? {};
for (final softDep in softDeps) {
// If the soft dependency is loaded after the mod, try to move it earlier
if (positions.containsKey(softDep) &&
positions[softDep]! > positions[modId]!) {
// Find where we can move the soft dependency to
int targetPos = positions[modId]!;
// Move the soft dependency just before the mod
hardOrder.removeAt(positions[softDep]!);
hardOrder.insert(targetPos, softDep);
// Update positions
for (int i = 0; i < hardOrder.length; i++) {
positions[hardOrder[i]] = i;
}
changed = true;
break;
}
}
if (changed) break;
}
}
return hardOrder;
}
// Check for incompatibilities in the current mod list
List<List<String>> findIncompatibilities() {
List<List<String>> incompatiblePairs = [];
for (final mod in mods.values) {
for (final incompatibility in mod.incompatabilities) {
if (mods.containsKey(incompatibility)) {
incompatiblePairs.add([mod.id, incompatibility]);
}
}
}
return incompatiblePairs;
}
// Sort mods based on dependencies and return the sorted list
List<String> sortMods() {
final logger = Logger.instance;
logger.info("Building dependency graph...");
final hardGraph = buildDependencyGraph();
// Check for cycles in hard dependencies
final cycle = detectCycle(hardGraph);
if (cycle != null) {
logger.warning(
"Cycle in hard dependencies detected: ${cycle.join(" -> ")}",
);
logger.info("Will attempt to break cycle to produce a valid load order");
}
logger.info(
"Performing topological sort for hard dependencies (prioritizing larger mods)...",
);
final hardOrder = topologicalSort(hardGraph);
logger.info("Adjusting for soft dependencies...");
final softGraph = buildSoftDependencyGraph();
final finalOrder = adjustForSoftDependencies(hardOrder, softGraph);
// Check for incompatibilities
final incompatibilities = findIncompatibilities();
if (incompatibilities.isNotEmpty) {
logger.warning("Incompatible mods detected:");
for (final pair in incompatibilities) {
logger.warning(" - ${mods[pair[0]]?.name} and ${mods[pair[1]]?.name}");
}
}
logger.info(
"Sorting complete. Final mod order contains ${finalOrder.length} mods.",
);
return finalOrder;
}
// Get a list of mods in the proper load order
List<Mod> getModsInLoadOrder() {
final orderedIds = sortMods();
return orderedIds.map((id) => mods[id]!).toList();
}
}
// Add a method to ConfigFile to fix the mod order
class ConfigFile {
final String path;
List<Mod> mods;
ConfigFile({required this.path, this.mods = const []});
Future<void> load() async {
final logger = Logger.instance;
final file = File(path);
logger.info('Loading configuration from: $path');
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
mods = [];
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);
// We'll populate with dummy mods for now, they'll be replaced later
mods.add(
Mod(
name:
isBaseGame
? "RimWorld"
: isExpansion
? "RimWorld ${_expansionNameFromId(modId)}"
: modId,
id: modId,
path: '',
versions: [],
description:
isBaseGame
? "RimWorld base game"
: isExpansion
? "RimWorld expansion"
: "",
hardDependencies: [],
loadAfter: isExpansion ? ['ludeon.rimworld'] : [],
loadBefore: [],
incompatabilities: [],
enabled: true,
size: 0,
isBaseGame: isBaseGame,
isExpansion: isExpansion,
),
);
}
logger.info('Loaded ${mods.length} mods from config file.');
} catch (e) {
logger.error('Error loading configuration file: $e');
throw Exception('Failed to load config file: $e');
}
}
// Helper function to get a nice expansion name from ID
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);
}
// Save the current mod order back to the config file
void save() {
final logger = Logger.instance;
final file = File(path);
logger.info('Saving configuration to: $path');
// Create a backup just in case
final backupPath = '$path.bak';
file.copySync(backupPath);
logger.info('Created backup at: $backupPath');
try {
// Load the existing XML
final xmlString = file.readAsStringSync();
final xmlDocument = XmlDocument.parse(xmlString);
// Get the ModsConfigData element
final modConfigData = xmlDocument.findElements("ModsConfigData").first;
// Get the activeMods element
final modsElement = modConfigData.findElements("activeMods").first;
// Clear existing mod entries
modsElement.children.clear();
// Add mods in the new order
for (final mod in mods) {
final liElement = XmlElement(XmlName('li'));
liElement.innerText = mod.id;
modsElement.children.add(liElement);
}
// Write the updated XML back to the file
file.writeAsStringSync(xmlDocument.toXmlString(pretty: true));
logger.info('Configuration saved successfully with ${mods.length} mods.');
} catch (e) {
logger.error('Error saving configuration: $e');
logger.info('Original configuration preserved at: $backupPath');
}
}
// Fix the load order of mods according to dependencies
void fixLoadOrder(ModList modList) {
final logger = Logger.instance;
logger.info("Fixing mod load order...");
// Get the ordered mod IDs from the mod list
final orderedIds = modList.sortMods();
// Reorder the current mods list according to the dependency-sorted order
// We only modify mods that exist in both the configFile and the modList
List<Mod> orderedMods = [];
Set<String> addedIds = {};
// First add mods in the sorted order
for (final id in orderedIds) {
final modIndex = mods.indexWhere((m) => m.id == id);
if (modIndex >= 0) {
orderedMods.add(mods[modIndex]);
addedIds.add(id);
}
}
// Then add any mods that weren't in the sorted list
for (final mod in mods) {
if (!addedIds.contains(mod.id)) {
orderedMods.add(mod);
}
}
// Replace the current mods list with the ordered one
mods = orderedMods;
logger.info(
"Load order fixed. ${mods.length} mods are now in dependency-sorted order.",
);
}
}

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
} }

View File

@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -5,6 +5,8 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View File

@@ -89,6 +89,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.6" version: "3.0.6"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -118,6 +126,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_html:
dependency: "direct main"
description:
name: flutter_html
sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -126,11 +142,24 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_markdown:
dependency: "direct main"
description:
name: flutter_markdown
sha256: "04c4722cc36ec5af38acc38ece70d22d3c2123c61305d555750a091517bbe504"
url: "https://pub.dev"
source: hosted
version: "0.6.23"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
frontend_server_client: frontend_server_client:
dependency: transitive dependency: transitive
description: description:
@@ -147,6 +176,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
html:
dependency: transitive
description:
name: html
sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec"
url: "https://pub.dev"
source: hosted
version: "0.15.5"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@@ -219,6 +256,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.1" version: "5.1.1"
list_counter:
dependency: transitive
description:
name: list_counter
sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237
url: "https://pub.dev"
source: hosted
version: "1.0.2"
logging: logging:
dependency: transitive dependency: transitive
description: description:
@@ -227,6 +272,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
url: "https://pub.dev"
source: hosted
version: "7.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -291,6 +344,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool: pool:
dependency: transitive dependency: transitive
description: description:
@@ -432,6 +493,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
url: "https://pub.dev"
source: hosted
version: "6.3.1"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4"
url: "https://pub.dev"
source: hosted
version: "6.3.15"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626"
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -506,4 +631,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.7.2 <4.0.0" dart: ">=3.7.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.27.0"

View File

@@ -37,6 +37,9 @@ dependencies:
xml: ^6.5.0 xml: ^6.5.0
intl: ^0.20.2 intl: ^0.20.2
path: ^1.9.1 path: ^1.9.1
flutter_markdown: ^0.6.20
flutter_html: ^3.0.0-beta.2
url_launcher: ^6.3.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

51
release.sh Normal file
View File

@@ -0,0 +1,51 @@
#!/bin/bash
echo "Figuring out the tag..."
TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
if [ -z "$TAG" ]; then
# Get the latest tag
LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
# Increment the patch version
IFS='.' read -r -a VERSION_PARTS <<< "$LATEST_TAG"
VERSION_PARTS[2]=$((VERSION_PARTS[2]+1))
TAG="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
# Create a new tag
git tag $TAG
git push origin $TAG
fi
echo "Tag: $TAG"
echo "Building the thing..."
flutter build windows --release
echo "Creating a release..."
TOKEN="$GITEA_API_KEY"
GITEA="https://git.site.quack-lab.dev"
REPO="dave/flutter-rimworld-modman"
ZIP="rimworld-modman-${TAG}.zip"
# Create a release
RELEASE_RESPONSE=$(curl -s -X POST \
-H "Authorization: token $TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "'"$TAG"'",
"name": "'"$TAG"'",
"draft": false,
"prerelease": false
}' \
$GITEA/api/v1/repos/$REPO/releases)
# Extract the release ID
echo $RELEASE_RESPONSE
RELEASE_ID=$(echo $RELEASE_RESPONSE | awk -F'"id":' '{print $2+0; exit}')
echo "Release ID: $RELEASE_ID"
echo "Uploading the things..."
WINRELEASE="./build/windows/x64/runner/Release/"
7z a $WINRELEASE/$ZIP $WINRELEASE/*
curl -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@$WINRELEASE/$ZIP" \
"$GITEA/api/v1/repos/$REPO/releases/${RELEASE_ID}/assets?name=$ZIP"
rm $WINRELEASE/$ZIP

View File

@@ -0,0 +1,744 @@
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.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
'ludeon.rimworld.anomaly',
'dubwise.rimatomics',
'jecrell.doorsexpanded',
'dubwise.rimefeller',
'neronix17.toolbox',
'automatic.bionicicons',
'lwm.deepstorage',
'dubwise.dubsmintminimap',
'dubwise.dubsmintmenus',
'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.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
'ludeon.rimworld.anomaly',
];
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',
'ludeon.rimworld',
'bs.betterlog',
'ludeon.rimworld.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
'ludeon.rimworld.anomaly',
'rah.rbse',
'mlie.usethisinstead',
'dubwise.rimatomics',
'jecrell.doorsexpanded',
'balistafreak.stopdropandroll',
'fluffy.animaltab',
'gt.sam.glittertech',
'dubwise.rimefeller',
'darthcy.misc.morebetterdeepdrill',
'haplo.miscellaneous.training',
'linkolas.stabilize',
'dubwise.dubsperformanceanalyzer.steam',
'memegoddess.searchanddestroy',
'gogatio.mechanoidupgrades',
'issaczhuang.muzzleflash',
'smashphil.vehicleframework',
'cabbage.rimcities',
'vis.staticquality',
'automatic.bionicicons',
'vanillaexpanded.vanillatraitsexpanded',
'tk421storm.ragdoll',
'andromeda.nicehealthtab',
];
expect(order.loadOrder, equals(expected));
});
}

View File

@@ -8,72 +8,938 @@ 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 = {
'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'];
void main() {
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 = {
expect(harmonyIndex, lessThan(rimworldIndex)); '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 expected = ['harmony', 'ludeon.rimworld'];
expect(order.errors, isEmpty);
expect(order.loadOrder, equals(expected));
}); });
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 expected = ['harmony', 'ludeon.rimworld', 'prepatcher'];
expect(order.errors, isEmpty);
expect(order.loadOrder, equals(expected));
});
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 = {
expect(rimworldIndex, lessThan(anomalyIndex)); 'ludeon.rimworld': makeDummy().copyWith(
name: 'RimWorld',
id: 'ludeon.rimworld',
),
'ludeon.rimworld.anomaly': makeDummy().copyWith(
name: 'RimWorld Anomaly',
id: 'ludeon.rimworld.anomaly',
dependencies: ['ludeon.rimworld'],
),
};
list.enableAll();
final order = list.generateLoadOrder();
final expected = ['ludeon.rimworld', 'ludeon.rimworld.anomaly'];
expect(order.errors, isEmpty);
expect(order.loadOrder, equals(expected));
}); });
test('Disabled dummy mod should not be loaded', () { test('Disabled dummy mod should not be loaded', () {
final disabledIndex = sortedMods.indexOf('disabledDummy'); final list = ModList();
expect(disabledIndex, isNegative); list.mods = {
'disabledDummy': makeDummy().copyWith(
name: 'Disabled Dummy',
id: 'disabledDummy',
),
};
list.disableAll();
final order = list.generateLoadOrder();
final expected = [];
expect(order.errors, isEmpty);
expect(order.loadOrder, equals(expected));
}); });
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 expected = ['yuuuge', 'smol'];
expect(order.errors, isEmpty);
expect(order.loadOrder, equals(expected));
});
test('Incompatible mods should return errors', () {
final list = ModList();
list.mods = {
'incompatible': makeDummy().copyWith(
name: 'Incompatible',
id: 'incompatible',
incompatibilities: ['harmony'],
),
'harmony': makeDummy().copyWith(name: 'Harmony', id: 'harmony'),
};
list.enableAll();
final result = list.generateLoadOrder();
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('incompatible')), 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));
});
test('Expansions should load in the correct order', () {
final list = ModList();
// Intentionally left barren because that's how we get it out of the box
// It is up to generateLoadOrder to fill in the details
list.mods = {
'ludeon.rimworld': makeDummy().copyWith(id: 'ludeon.rimworld'),
'ludeon.rimworld.anomaly': makeDummy().copyWith(
id: 'ludeon.rimworld.anomaly',
),
'ludeon.rimworld.ideology': makeDummy().copyWith(
id: 'ludeon.rimworld.ideology',
),
'ludeon.rimworld.biotech': makeDummy().copyWith(
id: 'ludeon.rimworld.biotech',
),
'ludeon.rimworld.royalty': makeDummy().copyWith(
id: 'ludeon.rimworld.royalty',
),
};
list.enableAll();
final result = list.generateLoadOrder();
final expected = [
'ludeon.rimworld',
'ludeon.rimworld.royalty',
'ludeon.rimworld.ideology',
'ludeon.rimworld.biotech',
'ludeon.rimworld.anomaly',
];
expect(result.errors, isEmpty);
expect(result.loadOrder, equals(expected));
});
});
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();
final expected = ['harmony', 'prepatcher'];
expect(order.errors, isEmpty);
expect(order.loadOrder, equals(expected));
});
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();
final expected = ['harmony', 'prepatcher'];
expect(order.errors, isEmpty);
expect(order.loadOrder, equals(expected));
});
test('Incompatible mods should return errors', () {
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);
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 = ['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);
expect(result.loadOrder, equals(expected));
});
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();
final expected = ['modC', 'modB', 'modA'];
expect(order.errors, isEmpty);
expect(order.loadOrder, equals(expected));
});
});
group('Test cyclic dependencies', () {
test('Cyclic dependencies should return errors', () {
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);
final result = list.loadRequired();
// We try to not disable mods...... But cyclic dependencies are just hell
// Can not handle it
final expected = [];
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('modA')), isTrue);
expect(result.errors.any((e) => e.contains('modB')), isTrue);
expect(result.errors.any((e) => e.contains('modC')), isTrue);
expect(result.loadOrder, equals(expected));
});
});
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 expected = ['modB', 'modA', 'modC'];
expect(order.errors, isEmpty);
expect(order.loadOrder, equals(expected));
});
});
//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(
// list.activeMods.keys.toList(),
// );
// 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.loadOrder.indexOf('ludeon.rimworld');
final expansionIndex = order.loadOrder.indexOf('ludeon.rimworld.anomaly');
expect(order.errors, isEmpty);
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();
final expected = ['modD', 'modC', 'modB', 'modA'];
expect(result.errors, isEmpty);
expect(result.loadOrder, equals(expected));
});
});
group('Test missing dependencies', () {
test('Should detect missing dependencies and return errors', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['modB', 'nonExistentMod'],
),
'modB': makeDummy().copyWith(name: 'Mod B', id: 'modB'),
};
list.enableAll();
// This should throw an exception because the dependency doesn't exist
final result = list.generateLoadOrder();
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('nonExistentMod')), isTrue);
});
test('Should handle multiple missing dependencies correctly', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['missing1'],
),
'modB': makeDummy().copyWith(
name: 'Mod B',
id: 'modB',
dependencies: ['missing2'],
),
};
list.enableAll();
final result = list.generateLoadOrder();
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);
expect(result.loadOrder, equals(expected));
});
});
group('Test missing loadBefore/loadAfter relationships', () {
test('Should handle missing loadBefore relationships', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
loadBefore: ['nonExistentMod'],
),
};
list.enableAll();
// Should not throw exception for soft constraints
// But might generate a warning that we could check for
final order = list.generateLoadOrder();
final expected = ['modA'];
expect(order.errors, isEmpty);
expect(order.loadOrder, equals(expected));
});
test('Should handle missing loadAfter relationships', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
loadAfter: ['nonExistentMod'],
),
};
list.enableAll();
// Should not throw exception for soft constraints
final order = list.generateLoadOrder();
final expected = ['modA'];
expect(order.errors, isEmpty);
expect(order.loadOrder, equals(expected));
});
});
group('Test BuildLoadOrder error handling', () {
test('Should return errors for incompatibilities', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
incompatibilities: ['modB'],
),
'modB': makeDummy().copyWith(name: 'Mod B', id: 'modB'),
};
list.enableAll();
final result = list.generateLoadOrder();
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);
expect(result.errors.any((e) => e.contains('modB')), isTrue);
expect(result.loadOrder, equals(expected));
});
test('Should handle a combination of errors simultaneously', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['missingDep'],
incompatibilities: ['modB'],
),
'modB': makeDummy().copyWith(name: 'Mod B', id: 'modB'),
};
list.enableAll();
final result = list.generateLoadOrder();
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);
expect(result.loadOrder, equals(expected));
});
});
group('Test dependency resolution with constraints', () {
test(
'Should resolve dependencies while respecting load order constraints',
() {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['modB'],
loadAfter: ['modC'],
),
'modB': makeDummy().copyWith(name: 'Mod B', id: 'modB'),
'modC': makeDummy().copyWith(name: 'Mod C', id: 'modC'),
};
list.enableAll();
final order = list.generateLoadOrder();
expect(order.errors, isEmpty);
// modB should load before modA due to dependency
expect(
order.loadOrder.indexOf('modB'),
lessThan(order.loadOrder.indexOf('modA')),
);
// modC should load before modA due to loadAfter constraint
expect(
order.loadOrder.indexOf('modC'),
lessThan(order.loadOrder.indexOf('modA')),
);
},
);
test('Should detect and report conflicting constraints', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
loadBefore: ['modB'],
),
'modB': makeDummy().copyWith(
name: 'Mod B',
id: 'modB',
loadBefore: ['modA'],
),
};
list.enableAll();
// These constraints create a circular dependency which should cause an error
try {
list.generateLoadOrder();
fail('Expected an exception to be thrown due to circular constraints');
} catch (e) {
// Verify error is about circular dependencies or conflicting constraints
expect(
e.toString().toLowerCase().contains('conflict') ||
e.toString().toLowerCase().contains('circular'),
isTrue,
);
}
});
});
group('Test BuildLoadOrder with result object', () {
test('Should return successful load order with no errors', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(name: 'Mod A', id: 'modA'),
'modB': makeDummy().copyWith(name: 'Mod B', id: 'modB'),
};
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modB', 'modA'];
expect(result.errors, isEmpty);
expect(result.loadOrder, equals(expected));
});
test('Should return errors for missing dependencies', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['nonExistentMod'],
),
};
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modA'];
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('nonExistentMod')), isTrue);
expect(result.loadOrder, equals(expected));
});
test('Should return both valid load order and errors', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(name: 'Mod A', id: 'modA'),
'modB': makeDummy().copyWith(
name: 'Mod B',
id: 'modB',
dependencies: ['nonExistentMod'],
),
};
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modB', 'modA'];
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('nonExistentMod')), isTrue);
expect(result.loadOrder, equals(expected));
});
});
group('Debug missing dependencies', () {
test(
'Should provide detailed information about missing dependencies but still load mods',
() {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['missingDep1', 'missingDep2'],
),
'modB': makeDummy().copyWith(
name: 'Mod B',
id: 'modB',
dependencies: ['modA', 'missingDep3'],
),
};
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modA', 'modB'];
// Verify all missing dependencies are reported
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('missingDep1')), isTrue);
expect(result.errors.any((e) => e.contains('missingDep2')), isTrue);
expect(result.errors.any((e) => e.contains('missingDep3')), isTrue);
// Verify errors include the mod that requires the missing dependency
expect(result.errors.any((e) => e.contains('modA')), isTrue);
expect(result.errors.any((e) => e.contains('modB')), isTrue);
// But mods should still be loaded anyway (the "It's fucked but anyway" philosophy)
expect(result.loadOrder, equals(expected));
},
);
});
group('Debug missing loadBefore/loadAfter relationships', () {
test('Should handle and report missing loadBefore targets', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
loadBefore: ['missingMod1', 'missingMod2'],
),
};
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modA'];
// Should still generate a valid load order despite missing soft constraints
expect(result.loadOrder, equals(expected));
// System should track or report the missing loadBefore targets
// This may be implementation-specific - modify if needed based on how your system handles this
// May need to implement a warnings list in the BuildLoadOrderResult
expect(result.errors, isEmpty); // Soft constraints shouldn't cause errors
});
test('Should handle and report missing loadAfter targets', () {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
loadAfter: ['missingMod1', 'existingMod'],
),
'existingMod': makeDummy().copyWith(
name: 'Existing Mod',
id: 'existingMod',
),
};
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['existingMod', 'modA'];
// Should still generatdeequals(mopected)
expect(result.loadOrder.contains('existingMod'), isTrue);
// The existing loadAfter relationship should be honored
expect(result.loadOrder, equals(expected));
// System should track or report the missing loadAfter targets
expect(result.errors, isEmpty); // Soft constraints shouldn't cause errors
});
});
group('Debug multiple constraint issues simultaneously', () {
test(
'Should detect and report both missing dependencies and loadBefore/loadAfter issues but still load mods',
() {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['missingDep'],
loadBefore: ['missingMod'],
loadAfter: ['anotherMissingMod'],
),
};
list.enableAll();
final expected = ['modA'];
final result = list.generateLoadOrder();
// Should report the missing dependency
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('missingDep')), isTrue);
// Missing soft constraints shouldn't cause errors but should be handled gracefully
expect(result.errors.any((e) => e.contains('missingMod')), isFalse);
expect(
result.errors.any((e) => e.contains('anotherMissingMod')),
isFalse,
);
expect(result.loadOrder, equals(expected));
},
);
test(
'Should provide clear debugging information for complex dependency chains with issues while loading all possible mods',
() {
final list = ModList();
list.mods = {
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['modB', 'modC'],
),
'modB': makeDummy().copyWith(
name: 'Mod B',
id: 'modB',
dependencies: ['missingDep1'],
),
'modC': makeDummy().copyWith(
name: 'Mod C',
id: 'modC',
dependencies: ['missingDep2'],
loadAfter: ['nonExistentMod'],
),
};
list.enableAll();
final result = list.generateLoadOrder();
final expected = ['modC', 'modB', 'modA'];
// Should report all missing dependencies
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('missingDep1')), isTrue);
expect(result.errors.any((e) => e.contains('missingDep2')), isTrue);
// Should indicate which mods are affected by these missing dependencies
expect(result.errors.any((e) => e.contains('modB')), isTrue);
expect(result.errors.any((e) => e.contains('modC')), isTrue);
// But all mods should still be loaded in the best possible order
expect(result.loadOrder, equals(expected));
},
);
test(
'Should try to satisfy as many dependencies as possible in "it\'s fucked but anyway" mode',
() {
final list = ModList();
list.mods = {
'harmony': makeDummy().copyWith(name: 'Harmony', id: 'harmony'),
'missingFramework': makeDummy().copyWith(
name: 'Missing Framework',
id: 'missingFramework',
dependencies: ['nonExistentDep'],
),
'modA': makeDummy().copyWith(
name: 'Mod A',
id: 'modA',
dependencies: ['harmony', 'missingFramework', 'anotherMissingDep'],
),
};
list.enableAll();
final result = list.generateLoadOrder();
// Should report missing dependencies
expect(result.errors, isNotEmpty);
expect(result.errors.any((e) => e.contains('nonExistentDep')), isTrue);
expect(
result.errors.any((e) => e.contains('anotherMissingDep')),
isTrue,
);
// All mods should still be included in load order despite missing dependencies
expect(result.loadOrder.contains('harmony'), isTrue);
expect(result.loadOrder.contains('missingFramework'), isTrue);
expect(result.loadOrder.contains('modA'), isTrue);
// Existing dependencies should still be respected in the ordering
expect(
result.loadOrder.indexOf('harmony'),
lessThan(result.loadOrder.indexOf('modA')),
);
expect(
result.loadOrder.indexOf('missingFramework'),
lessThan(result.loadOrder.indexOf('modA')),
);
},
);
}); });
} }

View File

@@ -0,0 +1,731 @@
// Here's the plan:
// This class will take an instance of ModList and manipulate it in various ways
// What we want to achieve is two things:
// A) a binary search / bisect algorithm to find the minimum set of mods
// that exhibit a bug
// B) a linear search / batching algorithm for the same purpose
// Why both? I think B will be most useful most often but A theoretically
// should be faster
// Why I think A might not always be faster is because it takes us a very long
// time to load a lot of mods
// So say it takes us 30 minutes to load 300 mods
// Via bisect we would be loading 30 + 15 + 7.5 + ... = some 50 minutes
// Via linear search we would be loading say 30 mods at a time
// Which would be 3 minutes per batch for 10 batches
// ie. 30 minutes
// Reality is a little bit more complicated than that but that is the theory
// Now - how should this class do what I detailed it to do
// Keep the original ModList and copy it for every iteration
// Whether that be an iteration of bisect or a batch of linear search
// For every new batch make sure all its dependencies are loaded (ModList.loadRequired())
// Then try run game and proceed to next batch (or don't)
// Progressively our ModList will shrink (or not, regardless)
// And we should keep a registry of tested (say Good) mods and ones we haven't gotten to yet
// Maybe even make sure each batch contains N untested mods
// And that we don't test the same mod twice (unless it's a library)
import 'package:flutter_test/flutter_test.dart';
import 'package:rimworld_modman/mod.dart';
import 'package:rimworld_modman/mod_list.dart';
import 'package:rimworld_modman/mod_list_troubleshooter.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() {
group('Bisect Tests', () {
late ModList modList = ModList();
setUp(() {
modList = ModList();
// Add some base mods
for (int i = 0; i < 20; i++) {
final modId = 'test.mod$i';
final mod = makeDummy().copyWith(name: 'Test Mod $i', id: modId);
modList.mods[modId] = mod;
}
// Add some mods with dependencies
for (int i = 20; i < 30; i++) {
final modId = 'test.mod$i';
final mod = makeDummy().copyWith(
name: 'Test Mod $i',
id: modId,
dependencies: ['test.mod${i - 20}'], // Depend on earlier mods
);
modList.mods[modId] = mod;
}
modList.enableAll();
});
test(
'Should end up with half the mods every forward iteration until 1',
() {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.binaryForward();
// Half of our initial 30
expect(result.activeMods.length, equals(15));
expect(result.activeMods.keys.first, equals('test.mod15'));
result = troubleshooter.binaryForward();
// Half of our previous result
expect(result.activeMods.length, equals(8));
expect(result.activeMods.keys.first, equals('test.mod22'));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(4));
expect(result.activeMods.keys.first, equals('test.mod26'));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(2));
expect(result.activeMods.keys.first, equals('test.mod28'));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(1));
expect(result.activeMods.keys.first, equals('test.mod29'));
},
);
test(
'Should end up with half the mods every backward iteration until 1',
() {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.binaryBackward();
// Half of our initial 30
expect(result.activeMods.length, equals(15));
expect(result.activeMods.keys.last, equals('test.mod14'));
result = troubleshooter.binaryBackward();
// Half of our previous result
expect(result.activeMods.length, equals(8));
expect(result.activeMods.keys.last, equals('test.mod7'));
result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(4));
expect(result.activeMods.keys.last, equals('test.mod3'));
result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(2));
expect(result.activeMods.keys.last, equals('test.mod1'));
result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(1));
expect(result.activeMods.keys.last, equals('test.mod0'));
},
);
test('Should end up with half the mods every iteration until 1', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.binaryBackward();
// Half of our initial 30
expect(result.activeMods.length, equals(15));
expect(result.activeMods.keys.last, equals('test.mod14'));
result = troubleshooter.binaryForward();
// Half of our previous result
expect(result.activeMods.length, equals(8));
expect(result.activeMods.keys.first, equals('test.mod7'));
result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(4));
expect(result.activeMods.keys.last, equals('test.mod10'));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(2));
expect(result.activeMods.keys.first, equals('test.mod9'));
result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(1));
expect(result.activeMods.keys.last, equals('test.mod9'));
});
test('Should handle abuse gracefully', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(15));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(8));
result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(4));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(2));
result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(1));
expect(result.activeMods.keys.first, equals('test.mod9'));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(1));
expect(result.activeMods.keys.first, equals('test.mod9'));
result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(1));
expect(result.activeMods.keys.first, equals('test.mod9'));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(1));
expect(result.activeMods.keys.first, equals('test.mod9'));
result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(1));
expect(result.activeMods.keys.first, equals('test.mod9'));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(1));
expect(result.activeMods.keys.first, equals('test.mod9'));
});
});
group('Linear Tests', () {
late ModList modList = ModList();
setUp(() {
modList = ModList();
// Add some base mods
for (int i = 0; i < 20; i++) {
final modId = 'test.mod$i';
final mod = makeDummy().copyWith(name: 'Test Mod $i', id: modId);
modList.mods[modId] = mod;
}
// Add some mods with dependencies
for (int i = 20; i < 30; i++) {
final modId = 'test.mod$i';
final mod = makeDummy().copyWith(
name: 'Test Mod $i',
id: modId,
dependencies: ['test.mod${i - 20}'], // Depend on earlier mods
);
modList.mods[modId] = mod;
}
modList.enableAll();
});
test('Should end up with 10 mods every forward iteration', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod9'));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod10'));
expect(result.activeMods.keys.last, equals('test.mod19'));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod20'));
expect(result.activeMods.keys.last, equals('test.mod29'));
});
test('Should end up with 10 mods every backward iteration', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod20'));
expect(result.activeMods.keys.last, equals('test.mod29'));
result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod10'));
expect(result.activeMods.keys.last, equals('test.mod19'));
result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod9'));
});
test('Should end up with 10 mods every iteration', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod20'));
expect(result.activeMods.keys.last, equals('test.mod29'));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod20'));
expect(result.activeMods.keys.last, equals('test.mod29'));
result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod10'));
expect(result.activeMods.keys.last, equals('test.mod19'));
});
test('Should handle abuse gracefully', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
});
test('Should handle different step sizes', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
});
test('Cannot return more items than there are', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.linearBackward(stepSize: 10000);
expect(result.activeMods.length, equals(30));
result = troubleshooter.linearForward(stepSize: 10000);
expect(result.activeMods.length, equals(30));
});
});
group('Navigation tests', () {
late ModList modList = ModList();
setUp(() {
modList = ModList();
// Add some base mods
for (int i = 0; i < 20; i++) {
final modId = 'test.mod$i';
final mod = makeDummy().copyWith(name: 'Test Mod $i', id: modId);
modList.mods[modId] = mod;
}
// Add some mods with dependencies
for (int i = 20; i < 30; i++) {
final modId = 'test.mod$i';
final mod = makeDummy().copyWith(
name: 'Test Mod $i',
id: modId,
dependencies: ['test.mod${i - 20}'], // Depend on earlier mods
);
modList.mods[modId] = mod;
}
modList.enableAll();
});
test('Mixed navigation should work', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod9'));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(5));
expect(result.activeMods.keys.first, equals('test.mod5'));
expect(result.activeMods.keys.last, equals('test.mod9'));
result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod9'));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(5));
expect(result.activeMods.keys.first, equals('test.mod5'));
expect(result.activeMods.keys.last, equals('test.mod9'));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod5'));
expect(result.activeMods.keys.last, equals('test.mod14'));
});
test('Complex navigation sequence should work correctly', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(15));
expect(result.activeMods.keys.first, equals('test.mod15'));
result = troubleshooter.linearBackward(stepSize: 5);
expect(result.activeMods.length, equals(5));
expect(result.activeMods.keys.first, equals('test.mod25'));
expect(result.activeMods.keys.last, equals('test.mod29'));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(3));
expect(result.activeMods.keys.first, equals('test.mod27'));
result = troubleshooter.linearForward(stepSize: 2);
expect(result.activeMods.length, equals(2));
expect(result.activeMods.keys.first, equals('test.mod27'));
expect(result.activeMods.keys.last, equals('test.mod28'));
result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(1));
expect(result.activeMods.keys.first, equals('test.mod27'));
});
test('Varying step sizes in linear navigation', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.linearForward(stepSize: 15);
expect(result.activeMods.length, equals(15));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod14'));
result = troubleshooter.linearForward(stepSize: 5);
expect(result.activeMods.length, equals(5));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod4'));
result = troubleshooter.linearForward(stepSize: 2);
expect(result.activeMods.length, equals(2));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod1'));
result = troubleshooter.linearBackward(stepSize: 3);
expect(result.activeMods.length, equals(3));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod2'));
result = troubleshooter.linearBackward(stepSize: 7);
expect(result.activeMods.length, equals(7));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod6'));
});
test('Edge case - switching approaches at the boundary', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod20'));
expect(result.activeMods.keys.last, equals('test.mod29'));
result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod10'));
expect(result.activeMods.keys.last, equals('test.mod19'));
result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod9'));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(5));
expect(result.activeMods.keys.first, equals('test.mod5'));
expect(result.activeMods.keys.last, equals('test.mod9'));
result = troubleshooter.linearBackward(stepSize: 2);
expect(result.activeMods.length, equals(2));
expect(result.activeMods.keys.first, equals('test.mod8'));
expect(result.activeMods.keys.last, equals('test.mod9'));
});
test('Testing reset/restart behavior', () {
final troubleshooter = ModListTroubleshooter(modList);
// Do some navigation first
var result = troubleshooter.linearForward(stepSize: 5);
expect(result.activeMods.length, equals(5));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(3));
// Create a new troubleshooter with the same mod list (simulating reset)
final newTroubleshooter = ModListTroubleshooter(modList);
// First operation should work as if we're starting fresh
result = newTroubleshooter.binaryForward();
expect(result.activeMods.length, equals(15));
expect(result.activeMods.keys.first, equals('test.mod15'));
// Original troubleshooter should still be in its own state
result = troubleshooter.linearForward(stepSize: 1);
expect(result.activeMods.length, equals(1));
});
test('Alternate between multiple approaches repeatedly', () {
final troubleshooter = ModListTroubleshooter(modList);
// Alternate between binary and linear several times
var result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(15));
result = troubleshooter.linearBackward(stepSize: 5);
expect(result.activeMods.length, equals(5));
result = troubleshooter.binaryForward();
expect(result.activeMods.length, equals(3));
result = troubleshooter.linearBackward(stepSize: 1);
expect(result.activeMods.length, equals(1));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
result = troubleshooter.binaryBackward();
expect(result.activeMods.length, equals(5));
// Final set of mods should be consistent with the operations performed
expect(result.activeMods.length, equals(5));
});
// These tests specifically examine the nuances of linear navigation
test('Linear navigation window adjustment - forward', () {
final troubleshooter = ModListTroubleshooter(modList);
// First linearForward with a specific step size
var result = troubleshooter.linearForward(stepSize: 8);
expect(result.activeMods.length, equals(8));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod7'));
// Second call should move forward since current selection size matches step size
result = troubleshooter.linearForward(stepSize: 8);
expect(result.activeMods.length, equals(8));
expect(result.activeMods.keys.first, equals('test.mod8'));
expect(result.activeMods.keys.last, equals('test.mod15'));
// Change step size - should adapt the window size without moving position
result = troubleshooter.linearForward(stepSize: 5);
expect(result.activeMods.length, equals(5));
expect(result.activeMods.keys.first, equals('test.mod8'));
expect(result.activeMods.keys.last, equals('test.mod12'));
// Move forward with new step size
result = troubleshooter.linearForward(stepSize: 5);
expect(result.activeMods.length, equals(5));
expect(result.activeMods.keys.first, equals('test.mod13'));
expect(result.activeMods.keys.last, equals('test.mod17'));
});
test('Linear navigation window adjustment - backward', () {
final troubleshooter = ModListTroubleshooter(modList);
// Move to the end first
troubleshooter.linearBackward(stepSize: 30);
// First linearBackward with a specific step size
var result = troubleshooter.linearBackward(stepSize: 8);
expect(result.activeMods.length, equals(8));
expect(result.activeMods.keys.first, equals('test.mod22'));
expect(result.activeMods.keys.last, equals('test.mod29'));
// Second call should move backward since current selection size matches step size
result = troubleshooter.linearBackward(stepSize: 8);
expect(result.activeMods.length, equals(8));
expect(result.activeMods.keys.first, equals('test.mod14'));
expect(result.activeMods.keys.last, equals('test.mod21'));
// Change step size - should adapt the window size without moving position
result = troubleshooter.linearBackward(stepSize: 5);
expect(result.activeMods.length, equals(5));
expect(result.activeMods.keys.first, equals('test.mod17'));
expect(result.activeMods.keys.last, equals('test.mod21'));
// Move backward with new step size
result = troubleshooter.linearBackward(stepSize: 5);
expect(result.activeMods.length, equals(5));
expect(result.activeMods.keys.first, equals('test.mod12'));
expect(result.activeMods.keys.last, equals('test.mod16'));
});
test('Linear navigation boundary handling - forward', () {
final troubleshooter = ModListTroubleshooter(modList);
var result = troubleshooter.linearForward(stepSize: 25);
expect(result.activeMods.length, equals(25));
expect(result.activeMods.keys.first, equals('test.mod0'));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod9'));
result = troubleshooter.linearForward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod10'));
expect(result.activeMods.keys.last, equals('test.mod19'));
result = troubleshooter.linearForward(stepSize: 3);
expect(result.activeMods.length, equals(3));
expect(result.activeMods.keys.first, equals('test.mod10'));
expect(result.activeMods.keys.last, equals('test.mod12'));
});
test('Linear navigation boundary handling - backward', () {
final troubleshooter = ModListTroubleshooter(modList);
troubleshooter.linearForward(stepSize: 30);
var result = troubleshooter.linearBackward(stepSize: 25);
expect(result.activeMods.length, equals(25));
expect(result.activeMods.keys.first, equals('test.mod5'));
expect(result.activeMods.keys.last, equals('test.mod29'));
result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod20'));
expect(result.activeMods.keys.last, equals('test.mod29'));
result = troubleshooter.linearBackward(stepSize: 10);
expect(result.activeMods.length, equals(10));
expect(result.activeMods.keys.first, equals('test.mod10'));
expect(result.activeMods.keys.last, equals('test.mod19'));
result = troubleshooter.linearBackward(stepSize: 3);
expect(result.activeMods.length, equals(3));
expect(result.activeMods.keys.first, equals('test.mod17'));
expect(result.activeMods.keys.last, equals('test.mod19'));
});
// Test to verify we always get the requested number of mods at boundaries
test(
'Linear navigation always returns exactly stepSize mods when possible',
() {
final troubleshooter = ModListTroubleshooter(modList);
troubleshooter.linearForward(stepSize: 23);
var result = troubleshooter.linearForward(stepSize: 7);
expect(result.activeMods.length, equals(7));
result = troubleshooter.linearForward(stepSize: 7);
expect(result.activeMods.length, equals(7));
result = troubleshooter.linearBackward(stepSize: 23);
expect(result.activeMods.length, equals(23));
result = troubleshooter.linearBackward(stepSize: 8);
expect(result.activeMods.length, equals(8));
result = troubleshooter.linearBackward(stepSize: 8);
expect(result.activeMods.length, equals(8));
},
);
test('Linear navigation with oversized steps', () {
final troubleshooter = ModListTroubleshooter(modList);
// Step size larger than total mods
var result = troubleshooter.linearForward(stepSize: 50);
expect(result.activeMods.length, equals(30)); // All 30 mods
expect(result.activeMods.keys.first, equals('test.mod0'));
expect(result.activeMods.keys.last, equals('test.mod29'));
// Forward with oversized step should still return all mods
result = troubleshooter.linearForward(stepSize: 50);
expect(result.activeMods.length, equals(30)); // Still all 30 mods
// Now with backward
result = troubleshooter.linearBackward(stepSize: 50);
expect(result.activeMods.length, equals(30)); // All 30 mods
// Another backward with oversized step
result = troubleshooter.linearBackward(stepSize: 50);
expect(result.activeMods.length, equals(30)); // Still all 30 mods
});
});
group('Loading dependencies', () {
late ModList modList = ModList();
setUp(() {
modList = ModList();
for (int i = 0; i < 20; i++) {
final modId = 'test.mod$i';
var mod = makeDummy().copyWith(name: 'Test Mod $i', id: modId);
if (i % 3 == 0) {
mod = mod.copyWith(dependencies: ['test.mod${i + 1}']);
}
modList.mods[modId] = mod;
}
// Dependencies are:
// 0 -> 1
// 3 -> 4
// 6 -> 7
// 9 -> 10
// 12 -> 13
// 15 -> 16
// 18 -> 19
modList.enableAll();
});
// Not that it has any reason to since they're completely detached...
test('Should not fuck up troubleshooter', () {
final troubleshooter = ModListTroubleshooter(modList);
final expectedFirst = [
'test.mod10',
'test.mod9',
'test.mod8',
'test.mod2',
'test.mod4',
'test.mod3',
'test.mod5',
'test.mod7',
'test.mod6',
'test.mod1',
'test.mod0',
];
var result = troubleshooter.linearForward(stepSize: 10);
var loadOrder = result.loadRequired();
expect(loadOrder.loadOrder.length, equals(11));
expect(loadOrder.loadOrder, equals(expectedFirst));
final expectedSecond = [
'test.mod19',
'test.mod18',
'test.mod17',
'test.mod11',
'test.mod13',
'test.mod12',
'test.mod14',
'test.mod16',
'test.mod15',
'test.mod10',
];
result = troubleshooter.linearForward(stepSize: 10);
loadOrder = result.loadRequired();
expect(loadOrder.loadOrder.length, equals(10));
expect(loadOrder.loadOrder, equals(expectedSecond));
});
});
}

View File

@@ -5,26 +5,27 @@
// gestures. You can also use WidgetTester to find child widgets in the widget // gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct. // tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart'; //import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; //import 'package:flutter_test/flutter_test.dart';
//
import 'package:rimworld_modman/main.dart'; //import 'package:rimworld_modman/main.dart';
//
void main() { //void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { // testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame. // // Build our app and trigger a frame.
await tester.pumpWidget(const MyApp()); // await tester.pumpWidget(const MyApp());
//
// Verify that our counter starts at 0. // // Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget); // expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing); // expect(find.text('1'), findsNothing);
//
// Tap the '+' icon and trigger a frame. // // Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add)); // await tester.tap(find.byIcon(Icons.add));
await tester.pump(); // await tester.pump();
//
// Verify that our counter has incremented. // // Verify that our counter has incremented.
expect(find.text('0'), findsNothing); // expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget); // expect(find.text('1'), findsOneWidget);
}); // });
} //}
//

View File

@@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View File

@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -25,8 +25,9 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
FlutterWindow window(project); FlutterWindow window(project);
Win32Window::Point origin(10, 10); Win32Window::Size size(1920, 1080);
Win32Window::Size size(1280, 720); Win32Window::Point origin((GetSystemMetrics(SM_CXSCREEN) - size.width) / 2,
(GetSystemMetrics(SM_CYSCREEN) - size.height) / 2);
if (!window.Create(L"rimworld_modman", origin, size)) { if (!window.Create(L"rimworld_modman", origin, size)) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }