Skip to content

Commit 5890c09

Browse files
committed
fix(Unlocks): support chained mod character prerequisites
1 parent 0713da1 commit 5890c09

4 files changed

Lines changed: 119 additions & 42 deletions

File tree

Scaffolding/Characters/ModCharacterTemplate.cs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -450,20 +450,19 @@ protected virtual IEnumerable<StartingDeckEntry> StartingDeckEntries
450450

451451
/// <summary>
452452
/// Optional prerequisite character type for vanilla <see cref="CharacterModel.GetUnlockText" /> (the
453-
/// <c>{Prerequisite}</c> placeholder). When set to <see cref="MegaCrit.Sts2.Core.Models.Characters.Ironclad" />,
454-
/// root <see cref="Timeline.Scaffolding.CharacterUnlockEpochTemplate{TCharacter}" /> slots are obtained with
455-
/// Neow's initial expansion so they match the Silent unlock path. Other unlock rules should still be aligned
456-
/// with <see cref="Unlocks.ModUnlockRegistry" />.
453+
/// <c>{Prerequisite}</c> placeholder). Root
454+
/// <see cref="Timeline.Scaffolding.CharacterUnlockEpochTemplate{TCharacter}" /> slots whose character declares
455+
/// this property are obtained with Neow's initial expansion for
456+
/// <see cref="MegaCrit.Sts2.Core.Models.Characters.Ironclad" /> prerequisites, or after a completed run as any
457+
/// other prerequisite character.
457458
/// 用于原版 <see cref="CharacterModel.GetUnlockText" /> 的可选前置角色类型(
458-
/// <c>{Prerequisite}</c> 占位符)。当设置为
459-
/// <see cref="MegaCrit.Sts2.Core.Models.Characters.Ironclad" /> 时,根
460-
/// <see cref="Timeline.Scaffolding.CharacterUnlockEpochTemplate{TCharacter}" /> 槽位会随 Neow 初始扩展获得
461-
/// 以匹配 Silent 解锁路径。其他解锁规则仍应与 <see cref="Unlocks.ModUnlockRegistry" /> 对齐
459+
/// <c>{Prerequisite}</c> 占位符)。角色声明此属性时,其根
460+
/// <see cref="Timeline.Scaffolding.CharacterUnlockEpochTemplate{TCharacter}" /> 槽位会在前置为
461+
/// <see cref="MegaCrit.Sts2.Core.Models.Characters.Ironclad" /> 时随 Neow 初始扩展获得;其他前置角色则在完成该角色 run
462+
/// 后获得
462463
/// </summary>
463464
protected virtual Type? UnlocksAfterRunAsType => null;
464465

465-
Type? IModCharacterUnlockPrerequisite.UnlocksAfterRunAsType => UnlocksAfterRunAsType;
466-
467466
/// <summary>
468467
/// Placeholder vanilla character id used when merging partial <see cref="CharacterAssetProfile" /> data
469468
/// (see <see cref="CharacterAssetProfiles.Resolve" />).
@@ -610,6 +609,8 @@ protected virtual IEnumerable<StartingDeckEntry> StartingDeckEntries
610609
return SetupCustomMerchantAnimationStateMachine(merchantRoot, character);
611610
}
612611

612+
Type? IModCharacterUnlockPrerequisite.UnlocksAfterRunAsType => UnlocksAfterRunAsType;
613+
613614
/// <inheritdoc />
614615
public virtual bool HideFromVanillaCharacterSelect => false;
615616

Timeline/ModTimelineNeowCoExpansion.cs

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,37 @@ internal static bool IsNeowPrimaryTimelineExpansionSlots(IReadOnlyList<EpochSlot
6262
&& ids.Contains(EpochModel.GetId<Silent1Epoch>());
6363
}
6464

65+
internal static IReadOnlyList<string> GetModCharacterRootEpochIdsUnlockedAfterRunAs(
66+
ModelId prerequisiteCharacterId)
67+
{
68+
var result = new List<string>();
69+
var ironcladId = ModelDb.GetId<Ironclad>();
70+
71+
foreach (var id in EpochModel.AllEpochIds)
72+
{
73+
EpochModel model;
74+
try
75+
{
76+
model = EpochModel.Get(id);
77+
}
78+
catch
79+
{
80+
continue;
81+
}
82+
83+
if (!TryGetCharacterRootUnlockPrerequisite(model, out var registeredPrerequisiteId))
84+
continue;
85+
if (registeredPrerequisiteId == ironcladId)
86+
continue;
87+
if (registeredPrerequisiteId != prerequisiteCharacterId)
88+
continue;
89+
90+
result.Add(model.Id);
91+
}
92+
93+
return result;
94+
}
95+
6596
internal static void MergeModEpochTemplateSlotsInto(List<EpochSlotData> slotsToAdd, ProgressState? progress)
6697
{
6798
PromoteNeowCharacterRootUnlocks(progress);
@@ -233,6 +264,14 @@ private static bool ShouldShowUnobtainedModSlot(string id, ProgressState? progre
233264

234265
private static bool ShouldObtainWithNeow(EpochModel model)
235266
{
267+
return TryGetCharacterRootUnlockPrerequisite(model, out var prerequisiteId) &&
268+
prerequisiteId == ModelDb.GetId<Ironclad>();
269+
}
270+
271+
private static bool TryGetCharacterRootUnlockPrerequisite(EpochModel model, out ModelId prerequisiteId)
272+
{
273+
prerequisiteId = null!;
274+
236275
if (model is not ModEpochTemplate)
237276
return false;
238277
if (!IsModTimelineRootSlot(model.Id))
@@ -250,16 +289,20 @@ private static bool ShouldObtainWithNeow(EpochModel model)
250289
return false;
251290
}
252291

253-
if (character == null)
254-
return false;
255-
if (character is IModCharacterEpochTimelineRequirement { RequiresEpochAndTimeline: false })
256-
return false;
292+
switch (character)
293+
{
294+
case null:
295+
case IModCharacterEpochTimelineRequirement { RequiresEpochAndTimeline: false }:
296+
return false;
297+
}
298+
257299
if (character is not IModCharacterUnlockPrerequisite { UnlocksAfterRunAsType: { } prerequisiteType })
258300
return false;
259301

260302
try
261303
{
262-
return ModelDb.GetId(prerequisiteType) == ModelDb.GetId<Ironclad>();
304+
prerequisiteId = ModelDb.GetId(prerequisiteType);
305+
return true;
263306
}
264307
catch
265308
{

Unlocks/ModUnlockRegistry.cs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public sealed class ModUnlockRegistry
3333
private static readonly Dictionary<ModelId, CountedEpochUnlockRule> BossEpochRulesByCharacterId = [];
3434
private static readonly Dictionary<ModelId, string> AscensionOneEpochsByCharacterId = [];
3535
private static readonly Dictionary<ModelId, string> AscensionRevealEpochsByCharacterId = [];
36-
private static readonly Dictionary<ModelId, string> PostRunCharacterUnlockEpochsByCharacterId = [];
36+
private static readonly Dictionary<ModelId, List<string>> PostRunCharacterUnlockEpochsByCharacterId = [];
3737

3838
private static readonly HashSet<string> ModIdsIgnoringEpochRequirements =
3939
new(StringComparer.OrdinalIgnoreCase);
@@ -478,7 +478,14 @@ public void RegisterPostRunCharacterUnlockEpoch(ModelId characterId, string epoc
478478

479479
lock (SyncRoot)
480480
{
481-
PostRunCharacterUnlockEpochsByCharacterId[characterId] = epochId;
481+
if (!PostRunCharacterUnlockEpochsByCharacterId.TryGetValue(characterId, out var epochIds))
482+
{
483+
epochIds = [];
484+
PostRunCharacterUnlockEpochsByCharacterId[characterId] = epochIds;
485+
}
486+
487+
if (!epochIds.Contains(epochId, StringComparer.Ordinal))
488+
epochIds.Add(epochId);
482489
}
483490
}
484491

@@ -626,7 +633,25 @@ internal static bool TryGetPostRunCharacterUnlockEpoch(ModelId characterId, out
626633
{
627634
lock (SyncRoot)
628635
{
629-
return PostRunCharacterUnlockEpochsByCharacterId.TryGetValue(characterId, out epochId!);
636+
if (PostRunCharacterUnlockEpochsByCharacterId.TryGetValue(characterId, out var epochIds) &&
637+
epochIds.Count > 0)
638+
{
639+
epochId = epochIds[0];
640+
return true;
641+
}
642+
}
643+
644+
epochId = string.Empty;
645+
return false;
646+
}
647+
648+
internal static string[] GetPostRunCharacterUnlockEpochs(ModelId characterId)
649+
{
650+
lock (SyncRoot)
651+
{
652+
return PostRunCharacterUnlockEpochsByCharacterId.TryGetValue(characterId, out var epochIds)
653+
? [.. epochIds]
654+
: [];
630655
}
631656
}
632657

Unlocks/Patches/AscensionOneEpochCompatibilityPatch.cs

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using STS2RitsuLib.Content;
1313
using STS2RitsuLib.Patching.Models;
1414
using STS2RitsuLib.Scaffolding.Characters;
15+
using STS2RitsuLib.Timeline;
1516
using SerializableRun = MegaCrit.Sts2.Core.Saves.SerializableRun;
1617

1718
namespace STS2RitsuLib.Unlocks.Patches
@@ -96,8 +97,8 @@ public static bool Prefix(SerializablePlayer serializablePlayer, SerializableRun
9697
}
9798

9899
/// <summary>
99-
/// Replaces vanilla post-run character-unlock epoch checks for mod characters with registry-driven grants.
100-
/// mod 角色的原版跑局后角色解锁纪元检查替换为由注册表驱动的授予
100+
/// Extends vanilla post-run character-unlock epoch checks with registered and template-derived mod grants.
101+
/// 用已注册和模板推导出的 mod 授予扩展原版跑局后角色解锁纪元检查
101102
/// </summary>
102103
public class PostRunCharacterUnlockEpochCompatibilityPatch : IPatchMethod
103104
{
@@ -106,7 +107,7 @@ public class PostRunCharacterUnlockEpochCompatibilityPatch : IPatchMethod
106107

107108
/// <inheritdoc />
108109
public static string Description =>
109-
"Handle post-run character unlock epochs for mod characters via registered RitsuLib unlock rules";
110+
"Handle registered or template-derived post-run character unlock epochs without blocking vanilla chains";
110111

111112
/// <inheritdoc />
112113
public static bool IsCritical => false;
@@ -123,8 +124,8 @@ public static ModPatchTarget[] GetTargets()
123124
}
124125

125126
/// <summary>
126-
/// Obtains the registered post-run character-unlock epoch when appropriate; skips vanilla when handled.
127-
/// 在适当时获得已注册的跑局后角色解锁纪元;已处理时跳过原版逻辑
127+
/// Obtains registered or template-derived post-run character-unlock epochs when appropriate.
128+
/// 在适当时获得已注册或模板推导出的跑局后角色解锁纪元
128129
/// </summary>
129130
public static bool Prefix(SerializablePlayer serializablePlayer, SerializableRun serializableRun)
130131
{
@@ -133,41 +134,48 @@ public static bool Prefix(SerializablePlayer serializablePlayer, SerializableRun
133134

134135
ArgumentNullException.ThrowIfNull(serializablePlayer.CharacterId);
135136
var character = ModelDb.GetById<CharacterModel>(serializablePlayer.CharacterId);
136-
if (!ModContentRegistry.TryGetOwnerModId(character.GetType(), out _))
137-
return true;
137+
var isModCharacter = ModContentRegistry.TryGetOwnerModId(character.GetType(), out _);
138138

139139
if (!Sts2RunGameModeCompat.IsStandardSerializableRunForEpochUnlocks(serializableRun))
140140
return true;
141141

142-
if (!ModUnlockRegistry.TryGetPostRunCharacterUnlockEpoch(character.Id, out var epochId))
142+
var epochIds = new HashSet<string>(StringComparer.Ordinal);
143+
foreach (var epochId in ModUnlockRegistry.GetPostRunCharacterUnlockEpochs(character.Id))
144+
epochIds.Add(epochId);
145+
foreach (var epochId in ModTimelineNeowCoExpansion.GetModCharacterRootEpochIdsUnlockedAfterRunAs(
146+
character.Id))
147+
epochIds.Add(epochId);
148+
149+
if (epochIds.Count == 0)
143150
{
151+
if (!isModCharacter)
152+
return true;
153+
144154
if (character is IModCharacterEpochTimelineRequirement { RequiresEpochAndTimeline: false })
145155
return false;
146156

147157
ModUnlockMissingRuleWarnings.WarnOnce(
148158
$"postrun_char_unlock_epoch:{character.Id}",
149-
$"[Unlocks] Mod character '{character.Id}' has no registered post-run character-unlock epoch (UnlockCharacterAfterRunAs / RegisterPostRunCharacterUnlockEpoch). " +
159+
$"[Unlocks] Mod character '{character.Id}' has no registered post-run character-unlock epoch (UnlocksAfterRunAsType / UnlockCharacterAfterRunAs / RegisterPostRunCharacterUnlockEpoch). " +
150160
"Leaving vanilla post-run check in place (no-op for this character).");
151161
return true;
152162
}
153163

154-
if (SaveManager.Instance.Progress.IsEpochObtained(epochId))
155-
return false;
156-
157-
if (!EpochRuntimeCompatibility.CanUseEpochId(
158-
epochId,
159-
$"post-run character unlock epoch rule for mod character '{character.Id}'"))
160-
return false;
161-
162-
SaveManager.Instance.ObtainEpoch(epochId);
163-
NGame.Instance?.AddChildSafely(NGainEpochVfx.Create(EpochModel.Get(epochId)));
164-
if (!serializablePlayer.DiscoveredEpochs.Contains(epochId, StringComparer.Ordinal))
165-
serializablePlayer.DiscoveredEpochs.Add(epochId);
164+
foreach (var epochId in epochIds.Where(epochId => !SaveManager.Instance.Progress.IsEpochObtained(epochId))
165+
.Where(epochId => EpochRuntimeCompatibility.CanUseEpochId(
166+
epochId,
167+
$"post-run character unlock epoch rule after run as '{character.Id}'")))
168+
{
169+
SaveManager.Instance.ObtainEpoch(epochId);
170+
NGame.Instance?.AddChildSafely(NGainEpochVfx.Create(EpochModel.Get(epochId)));
171+
if (!serializablePlayer.DiscoveredEpochs.Contains(epochId, StringComparer.Ordinal))
172+
serializablePlayer.DiscoveredEpochs.Add(epochId);
166173

167-
RitsuLibFramework.Logger.Info(
168-
$"[Unlocks] Obtained post-run character unlock epoch '{epochId}' for mod character '{character.Id}'.");
174+
RitsuLibFramework.Logger.Info(
175+
$"[Unlocks] Obtained post-run character unlock epoch '{epochId}' after run as '{character.Id}'.");
176+
}
169177

170-
return false;
178+
return !isModCharacter;
171179
}
172180
}
173181

0 commit comments

Comments
 (0)