Skip to content

Commit 26fbf78

Browse files
authored
Merge pull request #2 from studioph/1-questmod-support
Add radiant quest mod support
2 parents 637159c + cb6cd11 commit 26fbf78

File tree

17 files changed

+941
-129
lines changed

17 files changed

+941
-129
lines changed

.editorconfig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ dotnet_diagnostic.CS4014.severity = error
99

1010
# CS1998: Async function does not contain await
1111
dotnet_diagnostic.CS1998.severity = silent
12+
13+
dotnet_diagnostic.CA1416.severity = none

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Synthesis patcher for [Skyrim Realistic Conquering](https://www.nexusmods.com/sk
44

55
## Usage
66
- Add to your Synthesis pipeline using the patcher browser
7+
- In addition to the base game, the patcher will automatically detect and patch the following radiant quest mods:
8+
- Missives
9+
- Notice Board
10+
- Bounty Hunter
711
- If you have multiple Synthesis groups, run this patcher in the same group as other patchers that also modify quests to ensure changes are merged properly.
812
- The patcher will log which quests and aliases it forwarded the condition to. These can be viewed in the Synthesis log files or in the UI itself.
913

@@ -19,6 +23,17 @@ Please include the following to help me help you:
1923
## Examples:
2024
**Note** that xEdit tends to show conflicts with lists like conditions, even if they contain the same items. What's important is that the condition is present in the winning record as marked in the screenshots. In a future update I might add logic to try and place the condition in the same spot in the list, but there's no guarantee that will remove the visual conflicts in xedit.
2125

22-
![Example showing SRC being overwritten by Dawnguard Tweaks and Enhancements and the patcher forwarding the condition](/examples/example.jpg)
26+
<details>
27+
<summary> Base game</summary>
2328

24-
![Example showing SRC being overwritten by At Your Own Pace and the patcher forwarding the condition](/examples/example2.jpg)
29+
![Dawnguard Tweaks and Enhancements](/examples/example1.jpg)
30+
![At Your Own Pace](/examples/example2.jpg)
31+
</details>
32+
33+
<details>
34+
<summary>Radiant quest mods</summary>
35+
36+
![Missives](/examples/missives.jpg)
37+
![Notice Board](/examples/noticeboard.jpg)
38+
![BountyHunter](/examples/bountyhunter.jpg)
39+
</details>

SRCPatcher/Patcher.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using Mutagen.Bethesda.Plugins;
2+
using Mutagen.Bethesda.Skyrim;
3+
using Noggog;
4+
using Synthesis.Util;
5+
using Synthesis.Util.Quest;
6+
7+
namespace SRCPatcher
8+
{
9+
public class QuestAliasConditionAdder(
10+
IConditionGetter condition,
11+
Func<IQuestAliasGetter, bool> aliasFilter
12+
)
13+
: QuestAliasConditionPatcher(condition),
14+
IConditionalTransformPatcher<IQuest, IQuestGetter, IEnumerable<uint>>
15+
{
16+
private static readonly ModKey Skyrim = ModKey.FromNameAndExtension("Skyrim.esm");
17+
18+
private readonly Func<IQuestAliasGetter, bool> _aliasFilter = aliasFilter;
19+
20+
public IEnumerable<uint> Apply(IQuestGetter quest) =>
21+
quest
22+
.Aliases.Where(_aliasFilter)
23+
.Where(alias => !alias.HasCondition(Condition))
24+
.Select(alias => alias.ID);
25+
26+
// Can't really narrow down quests without performing the exact logic, so may as well not waste time doing it twice
27+
public bool Filter(IQuestGetter quest) => quest.FormKey.ModKey != Skyrim; // Base game quests are handled by forwarding patcher
28+
29+
public bool ShouldPatch(IEnumerable<uint> aliasIDs) => aliasIDs.Any();
30+
}
31+
}

SRCPatcher/Plugins.cs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
using Mutagen.Bethesda;
2+
using Mutagen.Bethesda.Plugins;
3+
using Mutagen.Bethesda.Skyrim;
4+
using Mutagen.Bethesda.Synthesis;
5+
using Noggog;
6+
using Synthesis.Util;
7+
using Synthesis.Util.Quest;
8+
9+
namespace SRCPatcher
10+
{
11+
/// <summary>
12+
/// Base class for plugins for ERS, which dynamically add the ERS condition to quest aliases as needed.
13+
///
14+
/// Each specific plugin contains its own rules to determine whether a quest alias (and by extension the quest) should be patched.
15+
/// </summary>
16+
/// <param name="mod">The mod to patch</param>
17+
/// <param name="aliasFilter">Function to determine whether a quest alias should have the ERS condition added to it</param>
18+
/// <param name="pipeline">Patcher pipeline to patch records</param>
19+
internal abstract class ERSPlugin(
20+
ISkyrimModGetter mod,
21+
Func<IQuestAliasGetter, bool> aliasFilter,
22+
ConditionalTransformPatcherPipeline<ISkyrimMod, ISkyrimModGetter> pipeline
23+
) : IPatcherPlugin<ISkyrimMod, ISkyrimModGetter>
24+
{
25+
protected readonly ISkyrimModGetter _mod = mod;
26+
27+
private readonly ConditionalTransformPatcherPipeline<
28+
ISkyrimMod,
29+
ISkyrimModGetter
30+
> _pipeline = pipeline;
31+
32+
/// <summary>
33+
/// The patcher instance to use.
34+
/// </summary>
35+
private readonly QuestAliasConditionAdder _patcher = new(SRC_ERS.Condition, aliasFilter);
36+
37+
public void Run(IPatcherState<ISkyrimMod, ISkyrimModGetter> state)
38+
{
39+
var newQuests = _mod
40+
.Quests.Select(quest =>
41+
quest
42+
.ToLinkGetter()
43+
.ResolveContext<ISkyrimMod, ISkyrimModGetter, IQuest, IQuestGetter>(
44+
state.LinkCache
45+
)
46+
)
47+
.NotNull();
48+
49+
_pipeline.Run(_patcher, newQuests);
50+
}
51+
}
52+
53+
/// <summary>
54+
/// ERS Plugin for Missives
55+
/// </summary>
56+
internal sealed class MissivesPlugin(
57+
ISkyrimModGetter mod,
58+
ConditionalTransformPatcherPipeline<ISkyrimMod, ISkyrimModGetter> pipeline
59+
) : ERSPlugin(mod, ShouldHaveCondition, pipeline), IPluginData
60+
{
61+
private static readonly ModKey Missives = ModKey.FromNameAndExtension("Missives.esp");
62+
63+
/// <summary>
64+
/// The following rules result in the same selection as the existing ERS patch:
65+
///
66+
/// - Location alias type
67+
/// - Has at least 1 condition
68+
/// - The alias name is not "OtherHold" (it would probably be fine to leave this out - the extra patched aliases are unlikely to break anything, but aiming for parity here)
69+
/// </summary>
70+
/// <param name="alias">The quest alias to evaluate</param>
71+
/// <returns>True if the quest alias should have the ERS condition added to it</returns>
72+
private static bool ShouldHaveCondition(IQuestAliasGetter alias) =>
73+
alias.Type == QuestAlias.TypeEnum.Location
74+
&& alias.Conditions.Any()
75+
&& (!alias.Name?.Equals("OtherHold") ?? true);
76+
77+
public static PluginData Data => new(nameof(MissivesPlugin), Missives);
78+
}
79+
80+
/// <summary>
81+
/// ERS Plugin for Notice Board
82+
/// </summary>
83+
internal sealed class NoticeBoardPlugin(
84+
ISkyrimModGetter mod,
85+
ConditionalTransformPatcherPipeline<ISkyrimMod, ISkyrimModGetter> pipeline
86+
) : ERSPlugin(mod, ShouldHaveCondition, pipeline), IPluginData
87+
{
88+
private static readonly ModKey NoticeBoard = ModKey.FromNameAndExtension(
89+
"notice board.esp"
90+
);
91+
92+
/// <summary>
93+
/// Notice Board's own location blacklist. Used for alias evaluation.
94+
/// </summary>
95+
private static readonly IFormLinkGetter<IFormListGetter> _avoidLocations = FormKey
96+
.Factory($"02ACAB:{NoticeBoard}")
97+
.ToLinkGetter<IFormListGetter>();
98+
99+
private static bool HasAvoidFormList(IConditionGetter cond) =>
100+
cond.Data.Function == Condition.Function.GetInCurrentLocFormList
101+
&& ((IGetInCurrentLocFormListConditionDataGetter)cond.Data).FormList.Link.Equals(
102+
_avoidLocations
103+
);
104+
105+
/// <summary>
106+
/// The following rules result in the same selection as the existing ERS patch:
107+
///
108+
/// - Location alias type
109+
/// - Alias has the "avoid locations" formlist condition (this is a more complex check than the other mods, but other criteria proved to be unreliable)
110+
/// </summary>
111+
/// <param name="alias">The quest alias to evaluate</param>
112+
/// <returns>True if the quest alias should have the ERS condition added to it</returns>
113+
private static bool ShouldHaveCondition(IQuestAliasGetter alias) =>
114+
alias.Type == QuestAlias.TypeEnum.Location && alias.HasCondition(HasAvoidFormList);
115+
116+
public static PluginData Data => new(nameof(NoticeBoardPlugin), NoticeBoard);
117+
}
118+
119+
/// <summary>
120+
/// ERS Plugin for Bounty Hunter
121+
///
122+
/// Note that the few base game quests that BH touches are already handled by the main forwarding patcher
123+
/// </summary>
124+
internal sealed class BountyHunterPlugin(
125+
ISkyrimModGetter mod,
126+
ConditionalTransformPatcherPipeline<ISkyrimMod, ISkyrimModGetter> pipeline
127+
) : ERSPlugin(mod, ShouldHaveCondition, pipeline), IPluginData
128+
{
129+
private static readonly ModKey BountyHunter = ModKey.FromNameAndExtension(
130+
"BountyHunter.esp"
131+
);
132+
133+
/// <summary>
134+
/// The following rules result in the same selection as the existing ERS patch:
135+
/// - Location alias type
136+
/// - Has at least 1 condition
137+
/// </summary>
138+
/// <param name="alias">The quest alias to evaluate</param>
139+
/// <returns>True if the quest alias should have the ERS condition added to it</returns>
140+
private static bool ShouldHaveCondition(IQuestAliasGetter alias) =>
141+
alias.Type == QuestAlias.TypeEnum.Location && alias.Conditions.Any();
142+
143+
public static PluginData Data => new(nameof(BountyHunterPlugin), BountyHunter);
144+
}
145+
}

SRCPatcher/Program.cs

Lines changed: 88 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,116 @@
1-
using CommandLine;
21
using Mutagen.Bethesda;
32
using Mutagen.Bethesda.Plugins;
43
using Mutagen.Bethesda.Skyrim;
54
using Mutagen.Bethesda.Synthesis;
65
using Noggog;
7-
using Synthesis.Utils.Quests;
6+
using Synthesis.Util;
7+
using Synthesis.Util.Quest;
88

99
namespace SRCPatcher
1010
{
1111
public class Program
1212
{
13-
private static readonly ModKey SRC_ERS = ModKey.FromNameAndExtension("SRC_ERS.esp");
13+
/// <summary>
14+
/// Patcher pipeline for plugin instances. Lazy so that it doesn't get created if no plugins are loaded
15+
/// </summary>
16+
private static Lazy<SkyrimConditionalPipeline> _pluginPipeline = null!;
17+
18+
/// <summary>
19+
/// Loader instance to register plugins with
20+
/// </summary>
21+
private static readonly PluginLoader<ISkyrimMod, ISkyrimModGetter> _loader = new();
22+
23+
/// <summary>
24+
/// Registers the optional ERS patcher plugins with the loader
25+
/// </summary>
26+
static Program()
27+
{
28+
_loader.Register<MissivesPlugin>(mod => new MissivesPlugin(mod, _pluginPipeline.Value));
29+
_loader.Register<NoticeBoardPlugin>(mod => new NoticeBoardPlugin(
30+
mod,
31+
_pluginPipeline.Value
32+
));
33+
_loader.Register<BountyHunterPlugin>(mod => new BountyHunterPlugin(
34+
mod,
35+
_pluginPipeline.Value
36+
));
37+
}
1438

1539
public static async Task<int> Main(string[] args)
1640
{
1741
return await SynthesisPipeline
1842
.Instance.AddPatch<ISkyrimMod, ISkyrimModGetter>(RunPatch)
1943
.SetTypicalOpen(GameRelease.SkyrimSE, "SRCPatcher.esp")
20-
.AddRunnabilityCheck(state =>
21-
{
22-
state.LoadOrder.AssertListsMod(SRC_ERS, $"Missing {SRC_ERS}");
23-
})
2444
.Run(args);
2545
}
2646

2747
public static void RunPatch(IPatcherState<ISkyrimMod, ISkyrimModGetter> state)
2848
{
29-
var srcErsEsp = state.LoadOrder.GetIfEnabled(SRC_ERS);
30-
if (srcErsEsp.Mod is null)
31-
{
32-
return;
33-
}
49+
// Add factory for lazy pipeline creation
50+
// Can't be done before here since it relies on patch mod
51+
_pluginPipeline = new Lazy<SkyrimConditionalPipeline>(
52+
() => new SkyrimConditionalPipeline(state.PatchMod)
53+
);
54+
55+
// Load ERS mod and any optional plugins in user's load order
56+
var ersMod = state.LoadOrder.GetIfEnabledAndExists(SRC_ERS.ModKey);
57+
var loadedPlugins = _loader.Scan(state.LoadOrder);
58+
var patcher = new QuestAliasConditionForwarder(SRC_ERS.Condition);
59+
var pipeline = new SkyrimForwardPipeline(state.PatchMod);
3460

35-
var affectedQuests = srcErsEsp.Mod.Quests;
36-
var srcFormList = srcErsEsp
37-
.Mod.FormLists.Where(formList => formList.EditorID is not null)
38-
.Single(formList => formList.EditorID!.Equals("SRC_ERSList"));
39-
40-
var condition = QuestAliasConditionUtil.FindAliasCondition(
41-
affectedQuests.First(),
42-
condition =>
43-
condition.Data.Function == Condition.Function.GetInCurrentLocFormList
44-
&& condition
45-
.Data.Cast<IGetInCurrentLocFormListConditionDataGetter>()
46-
.FormList.Link.Equals(srcFormList.ToLinkGetter())
61+
// Patch base ERS mod
62+
var forwardContexts = ersMod.Quests.Select(quest =>
63+
quest.WithContext<ISkyrimMod, ISkyrimModGetter, IQuest, IQuestGetter>(
64+
state.LinkCache
65+
)
4766
);
48-
if (condition is null)
67+
pipeline.Run(patcher, forwardContexts);
68+
69+
// Run any optional patcher plugins based on user's load order
70+
foreach (var plugin in loadedPlugins)
4971
{
50-
Console.WriteLine($"Unable to find SRC ERS condition in quest aliases, aborting");
51-
return;
72+
plugin.Run(state);
5273
}
53-
var patcher = new QuestAliasConditionUtil(condition);
54-
patcher.PatchAll(affectedQuests, state);
74+
75+
uint totalPatched = _pluginPipeline.IsValueCreated
76+
? _pluginPipeline.Value.PatchedCount + pipeline.PatchedCount
77+
: pipeline.PatchedCount;
78+
79+
Console.WriteLine($"Patched {totalPatched} total records");
80+
}
81+
}
82+
83+
/// <summary>
84+
/// Contains random information about the ERS mod that can be defined ahead of time
85+
/// </summary>
86+
public static class SRC_ERS
87+
{
88+
public static readonly ModKey ModKey = ModKey.FromNameAndExtension("SRC_ERS.esp");
89+
90+
/// <summary>
91+
/// The SRC formlist that locations get added to when cleared.
92+
/// </summary>
93+
public static readonly IFormLinkGetter<IFormListGetter> FormList = FormKey
94+
.Factory($"000800:{ModKey}")
95+
.ToLinkGetter<IFormListGetter>();
96+
97+
/// <summary>
98+
/// The ERS formlist condition for Location quest aliases. This is what ensures cleared locations are excluded from quests.
99+
/// </summary>
100+
public static readonly IConditionGetter Condition = BuildCondition();
101+
102+
/// <summary>
103+
/// Creates a GetInCurrentLocFormList condition object referencing the SRC formlist.
104+
/// The condition isn't complex so it can be statically created up-front to avoid searching for it in the ERS mod at runtime.
105+
/// </summary>
106+
/// <returns>A condition object that references the SRC formlist</returns>
107+
private static IConditionGetter BuildCondition()
108+
{
109+
IConditionFloat condition = new ConditionFloat();
110+
IGetInCurrentLocFormListConditionData data = new GetInCurrentLocFormListConditionData();
111+
data.FormList.Link.SetTo(FormList);
112+
condition.Data = (ConditionData)data;
113+
return condition;
55114
}
56115
}
57116
}

0 commit comments

Comments
 (0)