Compare commits
58 Commits
8f8f727603
...
master
Author | SHA1 | Date | |
---|---|---|---|
4c6e3b5ed5 | |||
a662dffc7c | |||
753859fd3e | |||
43a5f63759 | |||
07d81eca71 | |||
2e6bfb84de | |||
0384e8012e | |||
1bb8ed9084 | |||
573ad05514 | |||
9a8b7fd2d3 | |||
d00c20397f | |||
40d251f400 | |||
09b7fe539e | |||
5f20368fe2 | |||
9eb71e94c1 | |||
f90371109c | |||
7f4b944101 | |||
8f466420f2 | |||
160488849f | |||
6826b272aa | |||
1c6af27c7e | |||
71ad392fb6 | |||
a4ee202971 | |||
a37b67873e | |||
164e95fa54 | |||
02cfe01ae0 | |||
1dabc804b4 | |||
a6cfd3e16e | |||
efe74b404e | |||
e3cd0c13a4 | |||
1e4b4db220 | |||
43a7efa1aa | |||
4c768a7fd4 | |||
69635ec8a0 | |||
9daae41e1c | |||
512bd644ab | |||
72b6f3486d | |||
179bebf188 | |||
878244ead0 | |||
07264d1f75 | |||
294219cef3 | |||
a022576f7b | |||
fb8d3195db | |||
86a7c16194 | |||
c27ae80b5e | |||
872a59b27c | |||
2df23dde06 | |||
dbfe627877 | |||
70198ff293 | |||
9192e68bd3 | |||
0a9d97074f | |||
51d9526aa3 | |||
5deaf35b95 | |||
d913d8ca60 | |||
4d2b676c8b | |||
2a7b3f8345 | |||
d41413dbcd | |||
958de3e4a1 |
344
lib/components/html_tooltip.dart
Normal file
344
lib/components/html_tooltip.dart
Normal file
@@ -0,0 +1,344 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../format_converter.dart';
|
||||
|
||||
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
308
lib/format_converter.dart
Normal 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 -  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>', '');
|
||||
}
|
||||
}
|
1258
lib/main.dart
1258
lib/main.dart
File diff suppressed because it is too large
Load Diff
245
lib/mod.dart
245
lib/mod.dart
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:rimworld_modman/logger.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
XmlElement findCaseInsensitive(XmlElement element, String name) {
|
||||
@@ -31,10 +30,6 @@ class Mod {
|
||||
final bool isBaseGame; // Is this the base RimWorld game
|
||||
final bool isExpansion; // Is this a RimWorld expansion
|
||||
|
||||
bool visited = false;
|
||||
bool mark = false;
|
||||
int position = -1;
|
||||
|
||||
Mod({
|
||||
required this.name,
|
||||
required this.id,
|
||||
@@ -51,29 +46,40 @@ class Mod {
|
||||
this.enabled = false,
|
||||
});
|
||||
|
||||
static Mod fromDirectory(String path, {bool skipFileCount = false}) {
|
||||
final logger = Logger.instance;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
int get tier {
|
||||
if (isBaseGame) return 0;
|
||||
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');
|
||||
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');
|
||||
}
|
||||
|
||||
logger.info('Parsing About.xml file...');
|
||||
// logger.info('Parsing About.xml file...');
|
||||
final aboutXml = XmlDocument.parse(aboutFile.readAsStringSync());
|
||||
final xmlTime = stopwatch.elapsedMilliseconds;
|
||||
// final xmlTime = stopwatch.elapsedMilliseconds;
|
||||
|
||||
late final XmlElement metadata;
|
||||
try {
|
||||
metadata = findCaseInsensitiveDoc(aboutXml, 'ModMetaData');
|
||||
logger.info('Successfully found ModMetaData in About.xml');
|
||||
// logger.info('Successfully found ModMetaData in About.xml');
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e',
|
||||
);
|
||||
// logger.error(
|
||||
// 'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e',
|
||||
// );
|
||||
throw Exception(
|
||||
'Error: ModMetaData element is missing in About.xml ($aboutFile). Original error: $e',
|
||||
);
|
||||
@@ -82,11 +88,11 @@ class Mod {
|
||||
late final String name;
|
||||
try {
|
||||
name = metadata.findElements('name').first.innerText;
|
||||
logger.info('Mod name found: $name');
|
||||
// logger.info('Mod name found: $name');
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e',
|
||||
);
|
||||
// logger.error(
|
||||
// 'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e',
|
||||
// );
|
||||
throw Exception(
|
||||
'Error: name element is missing in ModMetaData ($aboutFile). Original error: $e',
|
||||
);
|
||||
@@ -95,11 +101,11 @@ class Mod {
|
||||
late final String id;
|
||||
try {
|
||||
id = metadata.findElements('packageId').first.innerText.toLowerCase();
|
||||
logger.info('Mod ID found: $id');
|
||||
// logger.info('Mod ID found: $id');
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e',
|
||||
);
|
||||
// logger.error(
|
||||
// 'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e',
|
||||
// );
|
||||
throw Exception(
|
||||
'Error: packageId element is missing in ModMetaData ($aboutFile). Original error: $e',
|
||||
);
|
||||
@@ -114,11 +120,11 @@ class Mod {
|
||||
.findElements('li')
|
||||
.map((e) => e.innerText)
|
||||
.toList();
|
||||
logger.info('Supported versions found: ${versions.join(", ")}');
|
||||
// logger.info('Supported versions found: ${versions.join(", ")}');
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e',
|
||||
);
|
||||
// logger.error(
|
||||
// 'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e',
|
||||
// );
|
||||
throw Exception(
|
||||
'Error: supportedVersions or li elements are missing in ModMetaData ($aboutFile). Original error: $e',
|
||||
);
|
||||
@@ -127,11 +133,11 @@ class Mod {
|
||||
String description = '';
|
||||
try {
|
||||
description = metadata.findElements('description').first.innerText;
|
||||
logger.info('Mod description found: $description');
|
||||
// logger.info('Mod description found: $description');
|
||||
} catch (e) {
|
||||
logger.warning(
|
||||
'Description element is missing in ModMetaData ($aboutFile).',
|
||||
);
|
||||
// logger.warning(
|
||||
// 'Description element is missing in ModMetaData ($aboutFile).',
|
||||
// );
|
||||
}
|
||||
|
||||
List<String> dependencies = [];
|
||||
@@ -149,11 +155,28 @@ class Mod {
|
||||
e.findElements("packageId").first.innerText.toLowerCase(),
|
||||
)
|
||||
.toList();
|
||||
logger.info('Dependencies found: ${dependencies.join(", ")}');
|
||||
// logger.info('Dependencies found: ${dependencies.join(", ")}');
|
||||
} catch (e) {
|
||||
logger.warning(
|
||||
'Dependencies element is missing in ModMetaData ($aboutFile).',
|
||||
// logger.warning(
|
||||
// 'Dependencies element is missing in ModMetaData ($aboutFile).',
|
||||
// );
|
||||
}
|
||||
try {
|
||||
dependencies.addAll(
|
||||
metadata
|
||||
.findElements('modDependencies')
|
||||
.first
|
||||
.findElements('li')
|
||||
.map(
|
||||
(e) => e.findElements("packageId").first.innerText.toLowerCase(),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
// logger.info('Additional dependencies found: ${dependencies.join(", ")}');
|
||||
} catch (e) {
|
||||
// logger.warning(
|
||||
// 'modDependencies element is missing in ModMetaData ($aboutFile). Original error: $e',
|
||||
// );
|
||||
}
|
||||
|
||||
List<String> loadAfter = [];
|
||||
@@ -165,12 +188,32 @@ class Mod {
|
||||
.findElements('li')
|
||||
.map((e) => e.innerText.toLowerCase())
|
||||
.toList();
|
||||
logger.info('Load after dependencies found: ${loadAfter.join(", ")}');
|
||||
// logger.info(
|
||||
// 'Load after dependencies found: ${loadAfter.isNotEmpty ? loadAfter.join(", ") : "none"}',
|
||||
// );
|
||||
} catch (e) {
|
||||
logger.warning(
|
||||
'Load after element is missing in ModMetaData ($aboutFile).',
|
||||
);
|
||||
// logger.warning(
|
||||
// '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 = [];
|
||||
try {
|
||||
@@ -181,11 +224,13 @@ class Mod {
|
||||
.findElements('li')
|
||||
.map((e) => e.innerText.toLowerCase())
|
||||
.toList();
|
||||
logger.info('Load before dependencies found: ${loadBefore.join(", ")}');
|
||||
// logger.info(
|
||||
// 'Load before dependencies found: ${loadBefore.isNotEmpty ? loadBefore.join(", ") : "none"}',
|
||||
// );
|
||||
} catch (e) {
|
||||
logger.warning(
|
||||
'Load before element is missing in ModMetaData ($aboutFile).',
|
||||
);
|
||||
// logger.warning(
|
||||
// 'Load before element is missing or empty in ModMetaData ($aboutFile). Original error: $e ',
|
||||
// );
|
||||
}
|
||||
|
||||
List<String> incompatibilities = [];
|
||||
@@ -197,52 +242,97 @@ class Mod {
|
||||
.findElements('li')
|
||||
.map((e) => e.innerText.toLowerCase())
|
||||
.toList();
|
||||
logger.info('Incompatibilities found: ${incompatibilities.join(", ")}');
|
||||
// logger.info('Incompatibilities found: ${incompatibilities.join(", ")}');
|
||||
} catch (e) {
|
||||
logger.warning(
|
||||
'Incompatibilities element is missing in ModMetaData ($aboutFile).',
|
||||
);
|
||||
// logger.warning(
|
||||
// 'Incompatibilities element is missing in ModMetaData ($aboutFile).',
|
||||
// );
|
||||
}
|
||||
|
||||
final metadataTime = stopwatch.elapsedMilliseconds - xmlTime;
|
||||
// final metadataTime = stopwatch.elapsedMilliseconds - xmlTime;
|
||||
|
||||
int size = 0;
|
||||
if (!skipFileCount) {
|
||||
size =
|
||||
// Find all directories matching version pattern (like "1.0", "1.4", etc.)
|
||||
final versionDirs =
|
||||
Directory(path)
|
||||
.listSync(recursive: true)
|
||||
.listSync(recursive: false)
|
||||
.whereType<Directory>()
|
||||
.where(
|
||||
(entity) =>
|
||||
!entity.path
|
||||
.split(Platform.pathSeparator)
|
||||
.last
|
||||
.startsWith('.'),
|
||||
(dir) => RegExp(
|
||||
r'^\d+\.\d+$',
|
||||
).hasMatch(dir.path.split(Platform.pathSeparator).last),
|
||||
)
|
||||
.length;
|
||||
logger.info('File count in mod directory: $size');
|
||||
.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)
|
||||
.last
|
||||
.split('.')
|
||||
.map(int.parse)
|
||||
.toList();
|
||||
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}',
|
||||
// );
|
||||
}
|
||||
|
||||
// Count all files, excluding older version directories
|
||||
size =
|
||||
Directory(path).listSync(recursive: true).where((entity) {
|
||||
if (entity is! File ||
|
||||
entity.path
|
||||
.split(Platform.pathSeparator)
|
||||
.any((part) => part.startsWith('.'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip files in version directories except for the latest
|
||||
for (final verDir in versionDirs) {
|
||||
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',
|
||||
// );
|
||||
}
|
||||
|
||||
// Check if this is RimWorld base game or expansion
|
||||
bool isBaseGame = id == 'ludeon.rimworld';
|
||||
bool isExpansion = !isBaseGame && id.startsWith('ludeon.rimworld.');
|
||||
|
||||
// If this is an expansion, ensure it depends on the base game
|
||||
if (isExpansion && !loadAfter.contains('ludeon.rimworld')) {
|
||||
loadAfter.add('ludeon.rimworld');
|
||||
logger.info(
|
||||
'Added base game dependency for expansion mod: ludeon.rimworld',
|
||||
);
|
||||
}
|
||||
|
||||
final fileCountTime =
|
||||
stopwatch.elapsedMilliseconds - metadataTime - xmlTime;
|
||||
final totalTime = stopwatch.elapsedMilliseconds;
|
||||
// final fileCountTime =
|
||||
// stopwatch.elapsedMilliseconds - metadataTime - xmlTime;
|
||||
// final totalTime = stopwatch.elapsedMilliseconds;
|
||||
|
||||
// Log detailed timing information
|
||||
logger.info(
|
||||
'Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms',
|
||||
);
|
||||
// logger.info(
|
||||
// 'Mod $name timing: XML=${xmlTime}ms, Metadata=${metadataTime}ms, FileCount=${fileCountTime}ms, Total=${totalTime}ms',
|
||||
// );
|
||||
|
||||
dependencies = dependencies.toSet().toList();
|
||||
loadAfter = loadAfter.toSet().toList();
|
||||
loadBefore = loadBefore.toSet().toList();
|
||||
incompatibilities = incompatibilities.toSet().toList();
|
||||
return Mod(
|
||||
name: name,
|
||||
id: id,
|
||||
@@ -254,8 +344,9 @@ class Mod {
|
||||
loadBefore: loadBefore,
|
||||
incompatibilities: incompatibilities,
|
||||
size: size,
|
||||
isBaseGame: isBaseGame,
|
||||
isExpansion: isExpansion,
|
||||
// No mods loaded from workshop are ever base or expansion games
|
||||
isBaseGame: false,
|
||||
isExpansion: false,
|
||||
);
|
||||
}
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
139
lib/mod_list_troubleshooter.dart
Normal file
139
lib/mod_list_troubleshooter.dart
Normal 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();
|
||||
}
|
||||
}
|
829
lib/mod_troubleshooter_widget.dart
Normal file
829
lib/mod_troubleshooter_widget.dart
Normal 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;
|
||||
}
|
||||
}
|
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
@@ -6,6 +6,10 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
@@ -5,6 +5,8 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
175
pubspec.lock
175
pubspec.lock
@@ -5,18 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57
|
||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "80.0.0"
|
||||
version: "85.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e"
|
||||
sha256: abf63d42450c7ad6d8188887d16eeba2f1ff92ea8d8dc673213e99fb3c02b194
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.0"
|
||||
version: "7.5.7"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -29,10 +29,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.0"
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -49,6 +49,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_config
|
||||
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -77,10 +85,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43
|
||||
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.11.1"
|
||||
version: "1.15.0"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,6 +97,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -101,10 +117,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
version: "1.3.3"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -118,6 +134,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -126,11 +150,24 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -147,6 +184,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -191,10 +236,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
|
||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.8"
|
||||
version: "10.0.9"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -219,6 +264,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -227,6 +280,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
markdown:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: markdown
|
||||
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -291,6 +352,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -432,6 +501,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.16"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
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: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -444,18 +577,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
|
||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.3.1"
|
||||
version: "15.0.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104"
|
||||
sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
version: "1.1.2"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -468,18 +601,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83"
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.6"
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5"
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
version: "3.0.3"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -506,4 +639,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.7.2 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.27.0"
|
||||
|
@@ -37,6 +37,9 @@ dependencies:
|
||||
xml: ^6.5.0
|
||||
intl: ^0.20.2
|
||||
path: ^1.9.1
|
||||
flutter_markdown: ^0.6.20
|
||||
flutter_html: ^3.0.0-beta.2
|
||||
url_launcher: ^6.3.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
51
release.sh
Normal file
51
release.sh
Normal 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
|
744
test/mod_list_regressive_test.dart
Normal file
744
test/mod_list_regressive_test.dart
Normal 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));
|
||||
});
|
||||
}
|
@@ -31,7 +31,11 @@ void main() {
|
||||
test('Harmony should load before RimWorld', () {
|
||||
final list = ModList();
|
||||
list.mods = {
|
||||
'harmony': makeDummy().copyWith(name: 'Harmony', id: 'harmony'),
|
||||
'harmony': makeDummy().copyWith(
|
||||
name: 'Harmony',
|
||||
id: 'harmony',
|
||||
loadBefore: ['ludeon.rimworld'],
|
||||
),
|
||||
'ludeon.rimworld': makeDummy().copyWith(
|
||||
name: 'RimWorld',
|
||||
id: 'ludeon.rimworld',
|
||||
@@ -39,10 +43,9 @@ void main() {
|
||||
};
|
||||
list.enableAll();
|
||||
final order = list.generateLoadOrder();
|
||||
|
||||
final harmonyIndex = order.indexOf('harmony');
|
||||
final rimworldIndex = order.indexOf('ludeon.rimworld');
|
||||
expect(harmonyIndex, lessThan(rimworldIndex));
|
||||
final expected = ['harmony', 'ludeon.rimworld'];
|
||||
expect(order.errors, isEmpty);
|
||||
expect(order.loadOrder, equals(expected));
|
||||
});
|
||||
|
||||
test('Prepatcher should load after Harmony and RimWorld', () {
|
||||
@@ -66,12 +69,9 @@ void main() {
|
||||
};
|
||||
list.enableAll();
|
||||
final order = list.generateLoadOrder();
|
||||
|
||||
final prepatcherIndex = order.indexOf('prepatcher');
|
||||
final harmonyIndex = order.indexOf('harmony');
|
||||
final rimworldIndex = order.indexOf('ludeon.rimworld');
|
||||
expect(prepatcherIndex, greaterThan(harmonyIndex));
|
||||
expect(prepatcherIndex, greaterThan(rimworldIndex));
|
||||
final expected = ['harmony', 'ludeon.rimworld', 'prepatcher'];
|
||||
expect(order.errors, isEmpty);
|
||||
expect(order.loadOrder, equals(expected));
|
||||
});
|
||||
|
||||
test('RimWorld should load before Anomaly', () {
|
||||
@@ -84,14 +84,14 @@ void main() {
|
||||
'ludeon.rimworld.anomaly': makeDummy().copyWith(
|
||||
name: 'RimWorld Anomaly',
|
||||
id: 'ludeon.rimworld.anomaly',
|
||||
dependencies: ['ludeon.rimworld'],
|
||||
),
|
||||
};
|
||||
list.enableAll();
|
||||
final order = list.generateLoadOrder();
|
||||
|
||||
final rimworldIndex = order.indexOf('ludeon.rimworld');
|
||||
final anomalyIndex = order.indexOf('ludeon.rimworld.anomaly');
|
||||
expect(rimworldIndex, lessThan(anomalyIndex));
|
||||
final expected = ['ludeon.rimworld', 'ludeon.rimworld.anomaly'];
|
||||
expect(order.errors, isEmpty);
|
||||
expect(order.loadOrder, equals(expected));
|
||||
});
|
||||
|
||||
test('Disabled dummy mod should not be loaded', () {
|
||||
@@ -104,9 +104,9 @@ void main() {
|
||||
};
|
||||
list.disableAll();
|
||||
final order = list.generateLoadOrder();
|
||||
|
||||
final disabledIndex = order.indexOf('disabledDummy');
|
||||
expect(disabledIndex, isNegative);
|
||||
final expected = [];
|
||||
expect(order.errors, isEmpty);
|
||||
expect(order.loadOrder, equals(expected));
|
||||
});
|
||||
|
||||
test('Larger mods should load before smaller ones', () {
|
||||
@@ -121,13 +121,12 @@ void main() {
|
||||
};
|
||||
list.enableAll();
|
||||
final order = list.generateLoadOrder();
|
||||
|
||||
final smolIndex = order.indexOf('smol');
|
||||
final yuuugeIndex = order.indexOf('yuuuge');
|
||||
expect(yuuugeIndex, lessThan(smolIndex));
|
||||
final expected = ['yuuuge', 'smol'];
|
||||
expect(order.errors, isEmpty);
|
||||
expect(order.loadOrder, equals(expected));
|
||||
});
|
||||
|
||||
test('Incompatible mods should throw exception', () {
|
||||
test('Incompatible mods should return errors', () {
|
||||
final list = ModList();
|
||||
list.mods = {
|
||||
'incompatible': makeDummy().copyWith(
|
||||
@@ -138,7 +137,82 @@ void main() {
|
||||
'harmony': makeDummy().copyWith(name: 'Harmony', id: 'harmony'),
|
||||
};
|
||||
list.enableAll();
|
||||
expect(() => list.generateLoadOrder(), throwsException);
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,7 +230,10 @@ void main() {
|
||||
list.disableAll();
|
||||
list.setEnabled('prepatcher', true);
|
||||
final order = list.loadRequired();
|
||||
expect(order.indexOf('harmony'), isNot(-1));
|
||||
|
||||
final expected = ['harmony', 'prepatcher'];
|
||||
expect(order.errors, isEmpty);
|
||||
expect(order.loadOrder, equals(expected));
|
||||
});
|
||||
|
||||
test('Only required mods should be enabled', () {
|
||||
@@ -173,11 +250,13 @@ void main() {
|
||||
list.disableAll();
|
||||
list.setEnabled('prepatcher', true);
|
||||
final order = list.loadRequired();
|
||||
expect(order.indexOf('harmony'), isNot(-1));
|
||||
expect(order.indexOf('dummy'), -1);
|
||||
|
||||
final expected = ['harmony', 'prepatcher'];
|
||||
expect(order.errors, isEmpty);
|
||||
expect(order.loadOrder, equals(expected));
|
||||
});
|
||||
|
||||
test('Incompatible mods should throw exception', () {
|
||||
test('Incompatible mods should return errors', () {
|
||||
final list = ModList();
|
||||
list.mods = {
|
||||
'incompatible': makeDummy().copyWith(
|
||||
@@ -195,7 +274,14 @@ void main() {
|
||||
list.disableAll();
|
||||
list.setEnabled('incompatible', true);
|
||||
list.setEnabled('prepatcher', true);
|
||||
expect(() => list.loadRequired(), throwsException);
|
||||
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();
|
||||
@@ -215,14 +301,15 @@ void main() {
|
||||
list.disableAll();
|
||||
list.setEnabled('modA', true);
|
||||
final order = list.loadRequired();
|
||||
expect(order.indexOf('modA'), isNot(-1));
|
||||
expect(order.indexOf('modB'), isNot(-1));
|
||||
expect(order.indexOf('modC'), isNot(-1));
|
||||
|
||||
final expected = ['modC', 'modB', 'modA'];
|
||||
expect(order.errors, isEmpty);
|
||||
expect(order.loadOrder, equals(expected));
|
||||
});
|
||||
});
|
||||
|
||||
group('Test cyclic dependencies', () {
|
||||
test('Cyclic dependencies should throw exception', () {
|
||||
test('Cyclic dependencies should return errors', () {
|
||||
final list = ModList();
|
||||
list.mods = {
|
||||
'modA': makeDummy().copyWith(
|
||||
@@ -243,7 +330,16 @@ void main() {
|
||||
};
|
||||
list.disableAll();
|
||||
list.setEnabled('modA', true);
|
||||
expect(() => list.loadRequired(), throwsException);
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,50 +360,49 @@ void main() {
|
||||
list.enableAll();
|
||||
final order = list.generateLoadOrder();
|
||||
|
||||
final aIndex = order.indexOf('modA');
|
||||
final bIndex = order.indexOf('modB');
|
||||
final cIndex = order.indexOf('modC');
|
||||
|
||||
expect(aIndex, greaterThan(bIndex));
|
||||
expect(aIndex, lessThan(cIndex));
|
||||
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();
|
||||
expect(conflicts.length, equals(2));
|
||||
//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,
|
||||
);
|
||||
});
|
||||
});
|
||||
// // 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', () {
|
||||
@@ -349,8 +444,9 @@ void main() {
|
||||
final order = list.generateLoadOrder();
|
||||
|
||||
// Base game should load before any expansions
|
||||
final baseGameIndex = order.indexOf('ludeon.rimworld');
|
||||
final expansionIndex = order.indexOf('ludeon.rimworld.anomaly');
|
||||
final baseGameIndex = order.loadOrder.indexOf('ludeon.rimworld');
|
||||
final expansionIndex = order.loadOrder.indexOf('ludeon.rimworld.anomaly');
|
||||
expect(order.errors, isEmpty);
|
||||
expect(baseGameIndex, lessThan(expansionIndex));
|
||||
});
|
||||
});
|
||||
@@ -381,16 +477,469 @@ void main() {
|
||||
|
||||
final result = list.loadRequired();
|
||||
|
||||
// All mods in the chain should be enabled
|
||||
expect(result.contains('modA'), isTrue);
|
||||
expect(result.contains('modB'), isTrue);
|
||||
expect(result.contains('modC'), isTrue);
|
||||
expect(result.contains('modD'), isTrue);
|
||||
|
||||
// The order should be D -> C -> B -> A
|
||||
expect(result.indexOf('modD'), lessThan(result.indexOf('modC')));
|
||||
expect(result.indexOf('modC'), lessThan(result.indexOf('modB')));
|
||||
expect(result.indexOf('modB'), lessThan(result.indexOf('modA')));
|
||||
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')),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
731
test/mod_list_troubleshooter_test.dart
Normal file
731
test/mod_list_troubleshooter_test.dart
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
@@ -5,26 +5,27 @@
|
||||
// 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.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:rimworld_modman/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
//import 'package:flutter/material.dart';
|
||||
//import 'package:flutter_test/flutter_test.dart';
|
||||
//
|
||||
//import 'package:rimworld_modman/main.dart';
|
||||
//
|
||||
//void main() {
|
||||
// testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// // Build our app and trigger a frame.
|
||||
// await tester.pumpWidget(const MyApp());
|
||||
//
|
||||
// // Verify that our counter starts at 0.
|
||||
// expect(find.text('0'), findsOneWidget);
|
||||
// expect(find.text('1'), findsNothing);
|
||||
//
|
||||
// // Tap the '+' icon and trigger a frame.
|
||||
// await tester.tap(find.byIcon(Icons.add));
|
||||
// await tester.pump();
|
||||
//
|
||||
// // Verify that our counter has incremented.
|
||||
// expect(find.text('0'), findsNothing);
|
||||
// expect(find.text('1'), findsOneWidget);
|
||||
// });
|
||||
//}
|
||||
//
|
@@ -6,6 +6,9 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
@@ -25,8 +25,9 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
|
||||
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
Win32Window::Size size(1920, 1080);
|
||||
Win32Window::Point origin((GetSystemMetrics(SM_CXSCREEN) - size.width) / 2,
|
||||
(GetSystemMetrics(SM_CYSCREEN) - size.height) / 2);
|
||||
if (!window.Create(L"rimworld_modman", origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
Reference in New Issue
Block a user