Skip to content

Commit eb504ee

Browse files
committed
feat(card-type-text): implement BaseLib-compatible card type text modifiers and localization support
1 parent 89ab601 commit eb504ee

6 files changed

Lines changed: 262 additions & 0 deletions

File tree

docs/pages/guide/content-authoring-toolkit.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,69 @@ base game class already reads `LocString` from its table.
346346

347347
:::
348348

349+
## Card Type Text{lang="en"}
350+
351+
::: en
352+
353+
The API mirrors BaseLib's type-text contracts and composition behavior. Implement `ICustomTypeTextCard` on a card when
354+
it modifies its own type plaque:
355+
356+
```csharp
357+
IEnumerable<LocString> ICustomTypeTextCard.GetTypeModifiers()
358+
{
359+
return
360+
[
361+
new LocString("my_mod_ui", "card_type.cursed"),
362+
];
363+
}
364+
```
365+
366+
Implement `ICardTypeTextModifier.GetTypeModifiers(CardModel)` on a card capability, relic, power, or other model-owned
367+
effect. RitsuLib discovers model and capability implementations through the normal run/combat hook-listener order.
368+
Only genuinely process-wide effects should use `RitsuLibFramework.RegisterCardTypeTextModifier(...)`.
369+
370+
Composition is intentionally identical to BaseLib:
371+
372+
- A localized string containing `{Type}` wraps the selected base text.
373+
- A localized string without `{Type}` replaces the base text; the last replacement wins.
374+
- All replacements are selected before wrappers are applied. Wrappers retain their original source order.
375+
376+
For example, `"Cursed {Type}"` turns `Attack` into `Cursed Attack`. When BaseLib is installed, its modifiers compose
377+
first, RitsuLib modifiers compose second, and the resulting `LocString` is formatted only once.
378+
379+
:::
380+
381+
## 卡牌类型文本{lang="zh-CN"}
382+
383+
::: zh-CN
384+
385+
该 API 镜像 BaseLib 的类型文本契约与组合行为。卡牌修改自身类型牌匾时,实现 `ICustomTypeTextCard`
386+
387+
```csharp
388+
IEnumerable<LocString> ICustomTypeTextCard.GetTypeModifiers()
389+
{
390+
return
391+
[
392+
new LocString("my_mod_ui", "card_type.cursed"),
393+
];
394+
}
395+
```
396+
397+
卡牌 capability、遗物、能力或其他模型所属效果实现
398+
`ICardTypeTextModifier.GetTypeModifiers(CardModel)`。RitsuLib 会按常规跑局/战斗 hook listener 顺序发现模型和
399+
capability 实现。只有真正的进程级效果才使用 `RitsuLibFramework.RegisterCardTypeTextModifier(...)`
400+
401+
组合行为有意保持与 BaseLib 完全一致:
402+
403+
- 本地化文本包含 `{Type}` 时,包裹选定的基础文本。
404+
- 不包含 `{Type}` 时替换基础文本;最后一个 replacement 胜出。
405+
- 先选出全部 replacement,再应用 wrapper;wrapper 保持原始来源顺序。
406+
407+
例如本地化值 `"诅咒{Type}"` 会把 `攻击` 变成 `诅咒攻击`。安装 BaseLib 时,先组合 BaseLib 修改器,再组合
408+
RitsuLib 修改器,最终得到的 `LocString` 只格式化一次。
409+
410+
:::
411+
349412
## Entry Ids{lang="en"}
350413

351414
::: en

src/Cards/CardTypeTextHook.cs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using MegaCrit.Sts2.Core.Localization;
2+
using MegaCrit.Sts2.Core.Models;
3+
using STS2RitsuLib.Models.Capabilities;
4+
5+
namespace STS2RitsuLib.Cards
6+
{
7+
/// <summary>
8+
/// Dispatches BaseLib-compatible card type text modifiers from cards, model capabilities, run/combat hook
9+
/// listeners, and registered global modifiers.
10+
/// 从卡牌、模型能力、跑局/战斗 hook listener 和已注册的全局修改器分发与 BaseLib 兼容的卡牌类型文本修改。
11+
/// </summary>
12+
public static class CardTypeTextHook
13+
{
14+
private const string TypeArgumentName = "Type";
15+
private static readonly ModelHookListenerRegistry<ICardTypeTextModifier> GlobalModifiers = new();
16+
17+
/// <summary>
18+
/// Registers a process-wide modifier. Model-owned effects should usually implement
19+
/// <see cref="ICardTypeTextModifier" /> directly.
20+
/// 注册一个进程级修改器。模型所属效果通常应直接实现 <see cref="ICardTypeTextModifier" />。
21+
/// </summary>
22+
public static void RegisterGlobalModifier(ICardTypeTextModifier modifier)
23+
{
24+
GlobalModifiers.Register(modifier);
25+
}
26+
27+
internal static LocString Apply(LocString originalPlaqueText, CardModel card)
28+
{
29+
var modifiers = GetTypeModifiers(card);
30+
var modifiersByWrapMode = modifiers.ToLookup(ReferencesTypeArgument);
31+
32+
foreach (var modifier in modifiersByWrapMode[false])
33+
originalPlaqueText = modifier;
34+
35+
var previousTypeText = originalPlaqueText;
36+
foreach (var modifier in modifiersByWrapMode[true])
37+
{
38+
modifier.Add(TypeArgumentName, previousTypeText);
39+
previousTypeText = modifier;
40+
}
41+
42+
return previousTypeText;
43+
}
44+
45+
private static IEnumerable<LocString> GetTypeModifiers(CardModel card)
46+
{
47+
if (card is ICustomTypeTextCard customTypeTextCard)
48+
foreach (var modifier in customTypeTextCard.GetTypeModifiers())
49+
yield return modifier;
50+
51+
foreach (var capability in ModelCapabilityHost.GetCapabilities<ICardTypeTextModifier>(card))
52+
foreach (var modifier in capability.GetTypeModifiers(card))
53+
yield return modifier;
54+
55+
foreach (var source in IterateHookModifiers(card))
56+
foreach (var modifier in source.GetTypeModifiers(card))
57+
yield return modifier;
58+
}
59+
60+
private static IEnumerable<ICardTypeTextModifier> IterateHookModifiers(CardModel card)
61+
{
62+
HashSet<ICardTypeTextModifier> seen = new(ReferenceEqualityComparer.Instance);
63+
foreach (var capability in ModelCapabilityHost.GetCapabilities<ICardTypeTextModifier>(card))
64+
seen.Add(capability);
65+
66+
if (card.RunState is { } runState)
67+
{
68+
foreach (var entry in ModelHookListenerDispatcher.FromRun(
69+
runState,
70+
card.CombatState,
71+
GlobalModifiers))
72+
if (seen.Add(entry.Listener))
73+
yield return entry.Listener;
74+
yield break;
75+
}
76+
77+
if (card.CombatState is { } combatState)
78+
{
79+
foreach (var entry in ModelHookListenerDispatcher.FromCombat(combatState, GlobalModifiers))
80+
if (seen.Add(entry.Listener))
81+
yield return entry.Listener;
82+
yield break;
83+
}
84+
85+
foreach (var modifier in GlobalModifiers.Snapshot())
86+
if (seen.Add(modifier))
87+
yield return modifier;
88+
}
89+
90+
private static bool ReferencesTypeArgument(LocString modifier)
91+
{
92+
return modifier.GetRawText().Contains("{" + TypeArgumentName + "}", StringComparison.Ordinal);
93+
}
94+
}
95+
}

src/Models/Capabilities/CardModelCapabilityContributors.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,37 @@ public interface ICardTitleContributor
289289
IEnumerable<CardTitleFragment> GetTitleFragments(CardTitleContext context);
290290
}
291291

292+
/// <summary>
293+
/// Optional card interface for visually modifying its own type text. Returned strings use the same composition
294+
/// contract as BaseLib: entries containing <c>{Type}</c> wrap the selected base text, while entries without it
295+
/// replace the base text.
296+
/// 可选卡牌接口:修改自身显示的类型文本。返回文本使用与 BaseLib 相同的组合契约:包含 <c>{Type}</c> 的条目
297+
/// 包裹选定的基础文本,不包含它的条目替换基础文本。
298+
/// </summary>
299+
public interface ICustomTypeTextCard
300+
{
301+
/// <summary>
302+
/// Returns localized type text modifiers in application order.
303+
/// 按应用顺序返回本地化类型文本修改器。
304+
/// </summary>
305+
IEnumerable<LocString> GetTypeModifiers();
306+
}
307+
308+
/// <summary>
309+
/// Optional model or model-capability hook for visually modifying cards' type text. The method signature and
310+
/// composition contract match BaseLib's <c>ICardTypeTextModifier</c>.
311+
/// 可选模型或模型能力 hook:修改卡牌显示的类型文本。方法签名与组合契约和 BaseLib 的
312+
/// <c>ICardTypeTextModifier</c> 一致。
313+
/// </summary>
314+
public interface ICardTypeTextModifier
315+
{
316+
/// <summary>
317+
/// Returns localized type text modifiers for <paramref name="card" /> in application order.
318+
/// 按应用顺序返回 <paramref name="card" /> 的本地化类型文本修改器。
319+
/// </summary>
320+
IEnumerable<LocString> GetTypeModifiers(CardModel card);
321+
}
322+
292323
/// <summary>
293324
/// Optional model capability that contributes hand glow predicates.
294325
/// 可选能力:贡献手牌发光判定。

src/Models/Capabilities/Patches/CardModelCapabilityPatches.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
using MegaCrit.Sts2.Core.Helpers.Models;
1212
using MegaCrit.Sts2.Core.Hooks;
1313
using MegaCrit.Sts2.Core.HoverTips;
14+
using MegaCrit.Sts2.Core.Localization;
1415
using MegaCrit.Sts2.Core.Localization.DynamicVars;
1516
using MegaCrit.Sts2.Core.Models;
17+
using MegaCrit.Sts2.Core.Nodes.Cards;
1618
using MegaCrit.Sts2.Core.Nodes.CommonUi;
1719
using MegaCrit.Sts2.Core.Random;
1820
using MegaCrit.Sts2.Core.Saves.Runs;
@@ -104,6 +106,67 @@ private static string GetUpgradeSuffix(CardModel card)
104106
}
105107
}
106108

109+
/// <summary>
110+
/// Applies BaseLib-compatible type text modifiers before the plaque LocString is formatted.
111+
/// 在类型牌匾 LocString 格式化前应用与 BaseLib 兼容的类型文本修改器。
112+
/// </summary>
113+
internal sealed class TypeTextPatch : IPatchMethod
114+
{
115+
public static string PatchId => "ritsulib_card_capability_type_text";
116+
117+
public static string Description => "Apply BaseLib-compatible card type text modifiers";
118+
119+
public static bool IsCritical => false;
120+
121+
public static ModPatchTarget[] GetTargets()
122+
{
123+
return [new(typeof(NCard), "UpdateTypePlaque")];
124+
}
125+
126+
[HarmonyAfter(Const.BaseLibHarmonyId)]
127+
public static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions)
128+
{
129+
var code = instructions.ToList();
130+
var applyMethod = AccessTools.Method(
131+
typeof(CardTypeTextHook),
132+
nameof(CardTypeTextHook.Apply));
133+
if (applyMethod == null || code.Any(instruction => instruction.Calls(applyMethod)))
134+
return code;
135+
136+
var toLocStringMethod = AccessTools.Method(
137+
typeof(CardTypeExtensions),
138+
nameof(CardTypeExtensions.ToLocString));
139+
var getFormattedTextMethod = AccessTools.Method(
140+
typeof(LocString),
141+
nameof(LocString.GetFormattedText));
142+
var modelGetter = AccessTools.PropertyGetter(typeof(NCard), nameof(NCard.Model));
143+
if (toLocStringMethod == null || getFormattedTextMethod == null || modelGetter == null)
144+
return code;
145+
146+
var toLocStringIndex = code.FindIndex(instruction => instruction.Calls(toLocStringMethod));
147+
var getFormattedTextIndex = toLocStringIndex < 0
148+
? -1
149+
: code.FindIndex(
150+
toLocStringIndex + 1,
151+
instruction => instruction.Calls(getFormattedTextMethod));
152+
if (getFormattedTextIndex < 0)
153+
{
154+
RitsuLibFramework.Logger.Warn(
155+
"[ModelCapabilities] Card type text patch did not find the expected LocString formatting site.");
156+
return code;
157+
}
158+
159+
code.InsertRange(
160+
getFormattedTextIndex,
161+
[
162+
CodeInstruction.LoadArgument(0),
163+
new(OpCodes.Call, modelGetter),
164+
new(OpCodes.Call, applyMethod),
165+
]);
166+
return code;
167+
}
168+
}
169+
107170
internal sealed class CardTypePatch : IPatchMethod
108171
{
109172
public static string PatchId => "ritsulib_card_capability_type";

src/RitsuLibFramework.PatcherSetup.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ private static void RegisterLifecyclePatches()
405405
patcher.RegisterPatch<OrbModelCapabilityPatches.AfterOrbEvokedHookPatch>();
406406
patcher.RegisterPatch<CardModelCapabilityPatches.UpdateDynamicVarPreviewPatch>();
407407
patcher.RegisterPatch<CardModelCapabilityPatches.TitlePatch>();
408+
patcher.RegisterPatch<CardModelCapabilityPatches.TypeTextPatch>();
408409
patcher.RegisterPatch<CardModelCapabilityPatches.CardTypePatch>();
409410
patcher.RegisterPatch<CardModelCapabilityPatches.CardRarityPatch>();
410411
patcher.RegisterPatch<CardModelCapabilityPatches.TargetTypePatch>();

src/RitsuLibFramework.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,15 @@ public static void RegisterCardOnPlayHookListener(ICardOnPlayHookListener listen
875875
CardOnPlayHook.RegisterGlobalListener(listener);
876876
}
877877

878+
/// <summary>
879+
/// Registers a process-wide BaseLib-compatible card type text modifier through the framework.
880+
/// 通过框架注册进程级、与 BaseLib 兼容的卡牌类型文本修改器。
881+
/// </summary>
882+
public static void RegisterCardTypeTextModifier(ICardTypeTextModifier modifier)
883+
{
884+
CardTypeTextHook.RegisterGlobalModifier(modifier);
885+
}
886+
878887
/// <summary>
879888
/// Resolves the current max-hand-size value for <paramref name="player" />.
880889
/// 解析 <paramref name="player" /> 当前的最大手牌数值。

0 commit comments

Comments
 (0)