Skip to content

Commit b5c88d8

Browse files
committed
add support for unified mod data overrides on the wiki
1 parent 0888f71 commit b5c88d8

10 files changed

Lines changed: 479 additions & 111 deletions

File tree

docs/release-notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* Fixed map tile rotations/flips not working for farmhands in split-screen mode.
1010

1111
* For the web UI:
12+
* Added support for unified [mod data overrides](https://stardewvalleywiki.com/Modding:Mod_compatibility#Mod_data_overrides) defined on the wiki.
1213
* The mod compatibility list now shows separate beta stats when 'show advanced info' is enabled.
1314

1415
## 3.12.7
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using NUnit.Framework;
4+
using StardewModdingAPI;
5+
using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
6+
7+
namespace SMAPI.Tests.WikiClient
8+
{
9+
/// <summary>Unit tests for <see cref="ChangeDescriptor"/>.</summary>
10+
[TestFixture]
11+
internal class ChangeDescriptorTests
12+
{
13+
/*********
14+
** Unit tests
15+
*********/
16+
/****
17+
** Constructor
18+
****/
19+
[Test(Description = "Assert that Parse sets the expected values for valid and invalid descriptors.")]
20+
public void Parse_SetsExpectedValues_Raw()
21+
{
22+
// arrange
23+
string rawDescriptor = "-Nexus:2400, -B, XX → YY, Nexus:451,+A, XXX → YYY, invalidA →, → invalidB";
24+
string[] expectedAdd = new[] { "Nexus:451", "A" };
25+
string[] expectedRemove = new[] { "Nexus:2400", "B" };
26+
IDictionary<string, string> expectedReplace = new Dictionary<string, string>
27+
{
28+
["XX"] = "YY",
29+
["XXX"] = "YYY"
30+
};
31+
string[] expectedErrors = new[]
32+
{
33+
"Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.",
34+
"Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value."
35+
};
36+
37+
// act
38+
ChangeDescriptor parsed = ChangeDescriptor.Parse(rawDescriptor, out string[] errors);
39+
40+
// assert
41+
Assert.That(parsed.Add, Is.EquivalentTo(expectedAdd), $"{nameof(parsed.Add)} doesn't match the expected value.");
42+
Assert.That(parsed.Remove, Is.EquivalentTo(expectedRemove), $"{nameof(parsed.Replace)} doesn't match the expected value.");
43+
Assert.That(parsed.Replace, Is.EquivalentTo(expectedReplace), $"{nameof(parsed.Replace)} doesn't match the expected value.");
44+
Assert.That(errors, Is.EquivalentTo(expectedErrors), $"{nameof(errors)} doesn't match the expected value.");
45+
}
46+
47+
[Test(Description = "Assert that Parse sets the expected values for descriptors when a format callback is specified.")]
48+
public void Parse_SetsExpectedValues_Formatted()
49+
{
50+
// arrange
51+
string rawDescriptor = "-1.0.1, -2.0-beta, 1.00 → 1.0, 1.0.0,+2.0-beta.15, 2.0 → 2.0-beta, invalidA →, → invalidB";
52+
string[] expectedAdd = new[] { "1.0.0", "2.0.0-beta.15" };
53+
string[] expectedRemove = new[] { "1.0.1", "2.0.0-beta" };
54+
IDictionary<string, string> expectedReplace = new Dictionary<string, string>
55+
{
56+
["1.00"] = "1.0.0",
57+
["2.0.0"] = "2.0.0-beta"
58+
};
59+
string[] expectedErrors = new[]
60+
{
61+
"Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.",
62+
"Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value."
63+
};
64+
65+
// act
66+
ChangeDescriptor parsed = ChangeDescriptor.Parse(
67+
rawDescriptor,
68+
out string[] errors,
69+
formatValue: raw => SemanticVersion.TryParse(raw, out ISemanticVersion version)
70+
? version.ToString()
71+
: raw
72+
);
73+
74+
// assert
75+
Assert.That(parsed.Add, Is.EquivalentTo(expectedAdd), $"{nameof(parsed.Add)} doesn't match the expected value.");
76+
Assert.That(parsed.Remove, Is.EquivalentTo(expectedRemove), $"{nameof(parsed.Replace)} doesn't match the expected value.");
77+
Assert.That(parsed.Replace, Is.EquivalentTo(expectedReplace), $"{nameof(parsed.Replace)} doesn't match the expected value.");
78+
Assert.That(errors, Is.EquivalentTo(expectedErrors), $"{nameof(errors)} doesn't match the expected value.");
79+
}
80+
81+
[Test(Description = "Assert that Apply returns the expected value for the given descriptor.")]
82+
83+
// null input
84+
[TestCase(null, "", ExpectedResult = null)]
85+
[TestCase(null, "+Nexus:2400", ExpectedResult = "Nexus:2400")]
86+
[TestCase(null, "-Nexus:2400", ExpectedResult = null)]
87+
88+
// blank input
89+
[TestCase("", null, ExpectedResult = "")]
90+
[TestCase("", "", ExpectedResult = "")]
91+
92+
// add value
93+
[TestCase("", "+Nexus:2400", ExpectedResult = "Nexus:2400")]
94+
[TestCase("Nexus:2400", "+Nexus:2400", ExpectedResult = "Nexus:2400")]
95+
[TestCase("Nexus:2400", "Nexus:2400", ExpectedResult = "Nexus:2400")]
96+
[TestCase("Nexus:2400", "+Nexus:2401", ExpectedResult = "Nexus:2400, Nexus:2401")]
97+
[TestCase("Nexus:2400", "Nexus:2401", ExpectedResult = "Nexus:2400, Nexus:2401")]
98+
99+
// remove value
100+
[TestCase("", "-Nexus:2400", ExpectedResult = "")]
101+
[TestCase("Nexus:2400", "-Nexus:2400", ExpectedResult = "")]
102+
[TestCase("Nexus:2400", "-Nexus:2401", ExpectedResult = "Nexus:2400")]
103+
104+
// replace value
105+
[TestCase("", "Nexus:2400 → Nexus:2401", ExpectedResult = "")]
106+
[TestCase("Nexus:2400", "Nexus:2400 → Nexus:2401", ExpectedResult = "Nexus:2401")]
107+
[TestCase("Nexus:1", "Nexus: 2400 → Nexus: 2401", ExpectedResult = "Nexus:1")]
108+
109+
// complex strings
110+
[TestCase("", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:A, Nexus:B")]
111+
[TestCase("Nexus:2400", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:A, Nexus:B")]
112+
[TestCase("Nexus:2400, Nexus:2401, Nexus:B,Chucklefish:14", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:2401, Nexus:B, Nexus:A")]
113+
public string Apply_Raw(string input, string descriptor)
114+
{
115+
var parsed = ChangeDescriptor.Parse(descriptor, out string[] errors);
116+
117+
Assert.IsEmpty(errors, "Parsing the descriptor failed.");
118+
119+
return parsed.ApplyToCopy(input);
120+
}
121+
122+
[Test(Description = "Assert that ToString returns the expected normalized descriptors.")]
123+
[TestCase(null, ExpectedResult = "")]
124+
[TestCase("", ExpectedResult = "")]
125+
[TestCase("+ Nexus:2400", ExpectedResult = "+Nexus:2400")]
126+
[TestCase(" Nexus:2400 ", ExpectedResult = "+Nexus:2400")]
127+
[TestCase("-Nexus:2400", ExpectedResult = "-Nexus:2400")]
128+
[TestCase(" Nexus:2400 →Nexus:2401 ", ExpectedResult = "Nexus:2400 → Nexus:2401")]
129+
[TestCase("+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "+Nexus:A, +Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A → Nexus:B")]
130+
public string ToString(string descriptor)
131+
{
132+
var parsed = ChangeDescriptor.Parse(descriptor, out string[] errors);
133+
134+
Assert.IsEmpty(errors, "Parsing the descriptor failed.");
135+
136+
return parsed.ToString();
137+
}
138+
}
139+
}

src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Collections.Generic;
21
using System.Linq;
32
using Newtonsoft.Json;
43
using Newtonsoft.Json.Converters;
@@ -87,11 +86,14 @@ public class ModExtendedMetadataModel
8786
/****
8887
** Version mappings
8988
****/
90-
/// <summary>Maps local versions to a semantic version for update checks.</summary>
91-
public IDictionary<string, string> MapLocalVersions { get; set; }
89+
/// <summary>A serialized change descriptor to apply to the local version during update checks (see <see cref="ChangeDescriptor"/>).</summary>
90+
public string ChangeLocalVersions { get; set; }
9291

93-
/// <summary>Maps remote versions to a semantic version for update checks.</summary>
94-
public IDictionary<string, string> MapRemoteVersions { get; set; }
92+
/// <summary>A serialized change descriptor to apply to the remote version during update checks (see <see cref="ChangeDescriptor"/>).</summary>
93+
public string ChangeRemoteVersions { get; set; }
94+
95+
/// <summary>A serialized change descriptor to apply to the update keys during update checks (see <see cref="ChangeDescriptor"/>).</summary>
96+
public string ChangeUpdateKeys { get; set; }
9597

9698

9799
/*********
@@ -137,8 +139,9 @@ public ModExtendedMetadataModel(WikiModEntry wiki, ModDataRecord db, ModEntryVer
137139
this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary;
138140
this.BetaBrokeIn = wiki.BetaCompatibility?.BrokeIn;
139141

140-
this.MapLocalVersions = wiki.MapLocalVersions;
141-
this.MapRemoteVersions = wiki.MapRemoteVersions;
142+
this.ChangeLocalVersions = wiki.Overrides?.ChangeLocalVersions?.ToString();
143+
this.ChangeRemoteVersions = wiki.Overrides?.ChangeRemoteVersions?.ToString();
144+
this.ChangeUpdateKeys = wiki.Overrides?.ChangeUpdateKeys?.ToString();
142145
}
143146

144147
// internal DB data
@@ -148,16 +151,5 @@ public ModExtendedMetadataModel(WikiModEntry wiki, ModDataRecord db, ModEntryVer
148151
this.Name ??= db.DisplayName;
149152
}
150153
}
151-
152-
/// <summary>Get update keys based on the metadata.</summary>
153-
public IEnumerable<string> GetUpdateKeys()
154-
{
155-
if (this.NexusID.HasValue)
156-
yield return $"Nexus:{this.NexusID}";
157-
if (this.ChucklefishID.HasValue)
158-
yield return $"Chucklefish:{this.ChucklefishID}";
159-
if (this.GitHubRepo != null)
160-
yield return $"GitHub:{this.GitHubRepo}";
161-
}
162154
}
163155
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.ObjectModel;
4+
using System.Linq;
5+
using System.Text;
6+
7+
namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
8+
{
9+
/// <summary>A set of changes which can be applied to a mod data field.</summary>
10+
public class ChangeDescriptor
11+
{
12+
/*********
13+
** Accessors
14+
*********/
15+
/// <summary>The values to add to the field.</summary>
16+
public ISet<string> Add { get; }
17+
18+
/// <summary>The values to remove from the field.</summary>
19+
public ISet<string> Remove { get; }
20+
21+
/// <summary>The values to replace in the field, if matched.</summary>
22+
public IReadOnlyDictionary<string, string> Replace { get; }
23+
24+
/// <summary>Whether the change descriptor would make any changes.</summary>
25+
public bool HasChanges { get; }
26+
27+
/// <summary>Format a raw value into a normalized form.</summary>
28+
public Func<string, string> FormatValue { get; }
29+
30+
31+
/*********
32+
** Public methods
33+
*********/
34+
/// <summary>Construct an instance.</summary>
35+
/// <param name="add">The values to add to the field.</param>
36+
/// <param name="remove">The values to remove from the field.</param>
37+
/// <param name="replace">The values to replace in the field, if matched.</param>
38+
/// <param name="formatValue">Format a raw value into a normalized form.</param>
39+
public ChangeDescriptor(ISet<string> add, ISet<string> remove, IReadOnlyDictionary<string, string> replace, Func<string, string> formatValue)
40+
{
41+
this.Add = add;
42+
this.Remove = remove;
43+
this.Replace = replace;
44+
this.HasChanges = add.Any() || remove.Any() || replace.Any();
45+
this.FormatValue = formatValue;
46+
}
47+
48+
/// <summary>Apply the change descriptors to a comma-delimited field.</summary>
49+
/// <param name="rawField">The raw field text.</param>
50+
/// <returns>Returns the modified field.</returns>
51+
public string ApplyToCopy(string rawField)
52+
{
53+
// get list
54+
List<string> values = !string.IsNullOrWhiteSpace(rawField)
55+
? new List<string>(rawField.Split(','))
56+
: new List<string>();
57+
58+
// apply changes
59+
this.Apply(values);
60+
61+
// format
62+
if (rawField == null && !values.Any())
63+
return null;
64+
return string.Join(", ", values);
65+
}
66+
67+
/// <summary>Apply the change descriptors to the given field values.</summary>
68+
/// <param name="values">The field values.</param>
69+
/// <returns>Returns the modified field values.</returns>
70+
public void Apply(List<string> values)
71+
{
72+
// replace/remove values
73+
if (this.Replace.Any() || this.Remove.Any())
74+
{
75+
for (int i = values.Count - 1; i >= 0; i--)
76+
{
77+
string value = this.FormatValue(values[i]?.Trim() ?? string.Empty);
78+
79+
if (this.Remove.Contains(value))
80+
values.RemoveAt(i);
81+
82+
else if (this.Replace.TryGetValue(value, out string newValue))
83+
values[i] = newValue;
84+
}
85+
}
86+
87+
// add values
88+
if (this.Add.Any())
89+
{
90+
HashSet<string> curValues = new(values.Select(p => p?.Trim() ?? string.Empty), StringComparer.OrdinalIgnoreCase);
91+
foreach (string add in this.Add)
92+
{
93+
if (!curValues.Contains(add))
94+
{
95+
values.Add(add);
96+
curValues.Add(add);
97+
}
98+
}
99+
}
100+
}
101+
102+
/// <inheritdoc />
103+
public override string ToString()
104+
{
105+
if (!this.HasChanges)
106+
return string.Empty;
107+
108+
List<string> descriptors = new List<string>(this.Add.Count + this.Remove.Count + this.Replace.Count);
109+
foreach (string add in this.Add)
110+
descriptors.Add($"+{add}");
111+
foreach (string remove in this.Remove)
112+
descriptors.Add($"-{remove}");
113+
foreach (var pair in this.Replace)
114+
descriptors.Add($"{pair.Key}{pair.Value}");
115+
116+
return string.Join(", ", descriptors);
117+
}
118+
119+
/// <summary>Parse a raw change descriptor string into a <see cref="ChangeDescriptor"/> model.</summary>
120+
/// <param name="descriptor">The raw change descriptor.</param>
121+
/// <param name="errors">The human-readable error message describing any invalid values that were ignored.</param>
122+
/// <param name="formatValue">Format a raw value into a normalized form if needed.</param>
123+
public static ChangeDescriptor Parse(string descriptor, out string[] errors, Func<string, string> formatValue = null)
124+
{
125+
// init
126+
formatValue ??= p => p;
127+
var add = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
128+
var remove = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
129+
var replace = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
130+
131+
// parse each change in the descriptor
132+
if (!string.IsNullOrWhiteSpace(descriptor))
133+
{
134+
List<string> rawErrors = new();
135+
foreach (string rawEntry in descriptor.Split(','))
136+
{
137+
// normalzie entry
138+
string entry = rawEntry.Trim();
139+
if (entry == string.Empty)
140+
continue;
141+
142+
// parse as replace (old value → new value)
143+
if (entry.Contains('→'))
144+
{
145+
string[] parts = entry.Split(new[] { '→' }, 2);
146+
string oldValue = formatValue(parts[0].Trim());
147+
string newValue = formatValue(parts[1].Trim());
148+
149+
if (oldValue == string.Empty)
150+
{
151+
rawErrors.Add($"Failed parsing '{rawEntry}': can't map from a blank old value. Use the '+value' format to add a value.");
152+
continue;
153+
}
154+
155+
if (newValue == string.Empty)
156+
{
157+
rawErrors.Add($"Failed parsing '{rawEntry}': can't map to a blank value. Use the '-value' format to remove a value.");
158+
continue;
159+
}
160+
161+
replace[oldValue] = newValue;
162+
}
163+
164+
// else as remove
165+
else if (entry.StartsWith("-"))
166+
{
167+
entry = formatValue(entry.Substring(1).Trim());
168+
remove.Add(entry);
169+
}
170+
171+
// else as add
172+
else
173+
{
174+
if (entry.StartsWith("+"))
175+
entry = formatValue(entry.Substring(1).Trim());
176+
add.Add(entry);
177+
}
178+
}
179+
180+
errors = rawErrors.ToArray();
181+
}
182+
else
183+
errors = Array.Empty<string>();
184+
185+
// build model
186+
return new ChangeDescriptor(
187+
add: add,
188+
remove: remove,
189+
replace: new ReadOnlyDictionary<string, string>(replace),
190+
formatValue: formatValue
191+
);
192+
}
193+
}
194+
}

0 commit comments

Comments
 (0)