模块范围
Public API(公开 API)
严重程度
Minor(轻微)
发生了什么?
实际场景来自一张多人游戏卡牌:
- 基础卡有一个默认 Capability。
- 这个 Capability 会让卡牌在战斗中少花 1 点能量。
- 卡牌升级后会失去这个 Capability。
- Host 一直使用现场升级后的卡牌对象,所以 Host 上没有这个 Capability。
- Client 会从序列化数据重新创建这张卡。Client 上的升级代码确实执行了移除操作,随后 RitsuLib 又补回了默认 Capability。
- 最后,Host 看到的费用是 1,Client 看到的费用是 0,双方状态开始分叉。
为了排除原 Mod 的其他逻辑,我在 TestTheSpireTemplateRitsu 中做了一个独立的最小复现。复现只保留默认 Capability、升级、序列化和费用修改。
复现步骤
- 一张卡通过
ConfigureDefaultModelCapabilities 获得一个默认 Capability。
- 这张卡升级时,在
OnUpgrade 里调用 RemoveCapability<T>() 移除它。
- STS2 读档时重新创建卡牌,并重演这次升级。
- 升级代码再次移除了 Capability。
- RitsuLib 随后导入 Capability 存档,并把这个默认 Capability 加了回来。
希望确认第 5 步是否属于 RitsuLib 保证的行为。这个确认会决定 Mod 应该怎样声明会被升级移除的默认 Capability。
期望行为
不加回来,就像是升级逻辑内所执行的。
日志 / 堆栈
测试环境
- Slay the Spire 2:
0.111.0,commit 41cef1ea
- RitsuLib NuGet:
0.5.14
- RitsuLib 对应源码 commit:
8fca891d65de050b1848b9dc4e1fcc449dacf253
- 实际 RitsuLib DLL SHA-256:
f4763e15a8e9c44ac8d9fd9f40e944b854e8167082744df0795805afc547bc9a
- MinionLib:
0.6.2
- TestTheSpire:
0.1.7
- .NET:
net9.0
最小复现做了什么
DefaultCapabilityRepro 的基础费用是 1。它默认拥有 ReproPulseCapability,这个 Capability 会把战斗费用减 1。
卡牌第一次升级时会执行:
this.RemoveCapability();
RemoveKeyword(CharModKeywords.ReproPulse);
现场升级后的对象状态正确:
| 状态 |
Capability |
Keyword |
战斗费用 |
| 现场升级对象 |
无 |
无 |
1 |
随后执行:
var restored = CardModel.FromSerializable(card.ToSerializable());
恢复对象进入战斗后的状态如下:
| 状态 |
Capability |
Keyword |
战斗费用 |
| 现场升级对象 |
无 |
无 |
1 |
| 序列化后恢复的对象 |
有 |
有 |
0 |
完整复现代码
这份复现基于 TestTheSpireTemplateRitsu。模板原有的角色、卡池、测试框架和 CharModCard 保持原样。下面列出了复现需要的全部新增代码和配置改动,代码块中没有省略内容。
1. 项目依赖
主项目 CharMod.csproj:
<PackageReference Include="STS2.RitsuLib" Version="0.5.14" PrivateAssets="All" GeneratePathProperty="true"/>
<PackageReference Include="FuYnAloft.Sts2.MinionLib" Version="0.6.2" PrivateAssets="All" GeneratePathProperty="true"/>
测试项目 CharMod.Tests/CharMod.Tests.csproj:
<PackageReference Include="STS2.RitsuLib" Version="0.5.14" />
<PackageReference Include="FuYnAloft.Sts2.MinionLib" Version="0.6.2" />
<PackageReference Include="TestTheSpire" Version="0.1.7" />
Mod 清单 CharMod.json 中的依赖:
"dependencies": [
{ "id": "STS2-RitsuLib", "min_version": "0.5.14" },
{ "id": "MinionLib", "min_version": "0.6.2" }
]
2. CharModCode/Capabilities/ReproPulseCapability.cs
using CharMod.CharModCode.CardKeywords;
using MegaCrit.Sts2.Core.Entities.Cards;
using MegaCrit.Sts2.Core.Models;
using STS2RitsuLib.Models.Capabilities;
namespace CharMod.CharModCode.Capabilities;
public sealed class ReproPulseCapability : CardCapability
{
public override bool TryModifyEnergyCostInCombat(
CardModel card,
decimal originalCost,
out decimal modifiedCost)
{
if (ReferenceEquals(card, Owner))
{
modifiedCost = Math.Max(0m, originalCost - 1m);
return modifiedCost != originalCost;
}
modifiedCost = originalCost;
return false;
}
public override Task AfterCardEnteredCombat(CardModel card)
{
if (ReferenceEquals(card, Owner))
card.AddKeyword(CharModKeywords.ReproPulse);
return Task.CompletedTask;
}
}
3. CharModCode/Cards/DefaultCapabilityRepro.cs
using CharMod.CharModCode.Capabilities;
using CharMod.CharModCode.CardKeywords;
using MegaCrit.Sts2.Core.Entities.Cards;
using MinionLib.Component.Core;
using STS2RitsuLib.Models.Capabilities;
namespace CharMod.CharModCode.Cards;
public sealed class DefaultCapabilityRepro : CharModCard
{
// This declaration is intentionally unconditional. This is the case being discussed.
public override IEnumerable<CardKeyword> CanonicalKeywords => [CharModKeywords.ReproPulse];
public DefaultCapabilityRepro()
: base(1, CardType.Attack, CardRarity.Common, TargetType.Self)
{
}
protected override void OnUpgrade(ComponentContext componentContext)
{
if (CurrentUpgradeLevel != 1)
return;
this.RemoveCapability<ReproPulseCapability>();
RemoveKeyword(CharModKeywords.ReproPulse);
}
protected override void AfterDowngraded(ComponentContext componentContext)
{
this.GetOrCreateCapability<ReproPulseCapability>();
AddKeyword(CharModKeywords.ReproPulse);
}
}
4. CharModCode/CardKeywords/CharModKeywords.cs
using MegaCrit.Sts2.Core.Entities.Cards;
using STS2RitsuLib.Keywords;
namespace CharMod.CharModCode.CardKeywords;
public static class CharModKeywords
{
public static CardKeyword Enhance => ModKeywordRegistry.GetCardKeyword("CHARMOD-ENHANCE");
public static CardKeyword ReproPulse => ModKeywordRegistry.GetCardKeyword("CHARMOD-REPRO_PULSE");
}
5. CharModCode/MainFile.cs
using Godot;
using HarmonyLib;
using MegaCrit.Sts2.Core.Modding;
using STS2RitsuLib;
using STS2RitsuLib.Models.Capabilities;
using ReproPulseCapability = CharMod.CharModCode.Capabilities.ReproPulseCapability;
using CharModCharacter = CharMod.CharModCode.Character.CharMod;
using CharModCardPool = CharMod.CharModCode.Character.CharModCardPool;
using CharModPotionPool = CharMod.CharModCode.Character.CharModPotionPool;
using CharModRelicPool = CharMod.CharModCode.Character.CharModRelicPool;
using DefaultCapabilityRepro = CharMod.CharModCode.Cards.DefaultCapabilityRepro;
using SampleEnhanceStrike = CharMod.CharModCode.Cards.SampleEnhanceStrike;
namespace CharMod.CharModCode;
[ModInitializer(nameof(Initialize))]
public partial class MainFile : Node
{
public const string ModId = "CharMod";
public const string ResPath = $"res://{ModId}";
public static MegaCrit.Sts2.Core.Logging.Logger Logger { get; } =
new(ModId, MegaCrit.Sts2.Core.Logging.LogType.Generic);
public static void Initialize()
{
Harmony harmony = new(ModId);
harmony.PatchAll();
RitsuLibFramework.RegisterModelCapability<ReproPulseCapability>(ModId);
RitsuLibFramework.ConfigureDefaultModelCapabilities<DefaultCapabilityRepro>(
ModId,
"repro-pulse",
// Intentionally unconditional. This is the behavior being asked about.
static (_, capabilities) => capabilities.Add<ReproPulseCapability>());
RitsuLibFramework.CreateContentPack(ModId)
.SharedCardPool<CharModCardPool>()
.SharedRelicPool<CharModRelicPool>()
.SharedPotionPool<CharModPotionPool>()
.Character<CharModCharacter>()
.Card<CharModCardPool, DefaultCapabilityRepro>()
.Card<CharModCardPool, SampleEnhanceStrike>()
.CardKeywordOwnedByLocNamespace("ENHANCE")
.CardKeywordOwnedByLocNamespace("REPRO_PULSE")
.Apply();
}
}
6. CharMod/localization/eng/card_keywords.json
{
"CHARMOD-ENHANCE.title": "Enhance X",
"CHARMOD-ENHANCE.description": "If this card's current energy cost is less than its [blue]Enhance[/blue] value, and you have enough energy to pay that value, pay the [blue]Enhance[/blue] value instead and activate the listed extra effect.",
"CHARMOD-REPRO_PULSE.title": "Repro Pulse",
"CHARMOD-REPRO_PULSE.description": "A default capability used to reproduce upgrade and save-load state divergence."
}
7. CharMod/localization/eng/cards.json
{
"CharMod.Enhance.prefix": "{IsActive:[gold]|[blue]}Enhance {Cost}: {CapturedDescription}{IsActive:[/gold]|[/blue]}",
"DEFAULT_CAPABILITY_REPRO.title": "Default Capability Repro",
"DEFAULT_CAPABILITY_REPRO.description": "Costs 1 less while [blue]Repro Pulse[/blue] is attached.\nUpgrade: Lose [blue]Repro Pulse[/blue].",
"SAMPLE_ENHANCE_STRIKE.title": "Sample Enhance Strike",
"SAMPLE_ENHANCE_STRIKE.description": "Deal {Damage:diff()} damage.\n{CompPre}",
"SAMPLE_ENHANCE_STRIKE.enhance3": "Deal {Damage:diff()} additional damage."
}
8. CharMod.Tests/Tests/DefaultCapabilityReproTests.cs
using CharMod.CharModCode.Capabilities;
using CharMod.CharModCode.CardKeywords;
using CharMod.CharModCode.Cards;
using MegaCrit.Sts2.Core.Commands;
using MegaCrit.Sts2.Core.Entities.Cards;
using MegaCrit.Sts2.Core.Models;
using MegaCrit.Sts2.Core.Models.Cards;
using MegaCrit.Sts2.Core.Models.Monsters;
using MegaCrit.Sts2.Core.Nodes.CommonUi;
using STS2RitsuLib.Models.Capabilities;
using TestTheSpire;
using Xunit;
using TemplateCharacter = global::CharMod.CharModCode.Character.CharMod;
namespace CharMod.Tests.Cases;
public sealed class DefaultCapabilityReproTests : CombatTestSuite
{
protected override void ConfigureBattle(CombatTestBattleBuilder battle)
{
battle
.Player<TemplateCharacter>()
.AddEnemy<BigDummy>()
.WithSeed("default-capability-save-load-repro");
}
[Fact(DisplayName = "--sts2-test-filter=DefaultCapabilityRepro upgraded_save_load_does_not_restore_removed_default_capability")]
public async Task Upgraded_save_load_does_not_restore_removed_default_capability()
{
var card = Combat.CreateCard<DefaultCapabilityRepro>(Player);
Assert.NotNull(card.Capability<ReproPulseCapability>());
Assert.Contains(CharModKeywords.ReproPulse, card.Keywords);
CardCmd.Upgrade(card, CardPreviewStyle.None);
await WaitForIdle();
Assert.True(card.IsUpgraded);
Assert.DoesNotContain(CharModKeywords.ReproPulse, card.Keywords);
Assert.Null(card.Capability<ReproPulseCapability>());
var restored = Assert.IsType<DefaultCapabilityRepro>(
CardModel.FromSerializable(card.ToSerializable()));
restored.Owner = Player;
Assert.True(restored.IsUpgraded);
Assert.DoesNotContain(CharModKeywords.ReproPulse, restored.Keywords);
await CardPileCmd.AddGeneratedCardToCombat(card, PileType.Hand, Player);
await CardPileCmd.AddGeneratedCardToCombat(restored, PileType.Hand, Player);
await WaitForIdle();
await Play(await AddToHand<StrikeIronclad>(), EnemyAt(0));
// Proposed invariant: save-load should keep the result produced by upgrade replay.
// This assertion currently fails on RitsuLib 0.5.14.
Assert.Equal(CaptureState(card), CaptureState(restored));
}
private static ReproCardState CaptureState(DefaultCapabilityRepro card) => new(
card.Capability<ReproPulseCapability>() != null,
card.Keywords.Contains(CharModKeywords.ReproPulse),
card.EnergyCost.GetWithModifiers(CostModifiers.All));
private readonly record struct ReproCardState(
bool HasReproPulseCapability,
bool HasReproPulseKeyword,
int EffectiveEnergyCost);
}
RitsuLib 版本
No response
运行环境
No response
模块范围
Public API(公开 API)
严重程度
Minor(轻微)
发生了什么?
实际场景来自一张多人游戏卡牌:
为了排除原 Mod 的其他逻辑,我在
TestTheSpireTemplateRitsu中做了一个独立的最小复现。复现只保留默认 Capability、升级、序列化和费用修改。复现步骤
ConfigureDefaultModelCapabilities获得一个默认 Capability。OnUpgrade里调用RemoveCapability<T>()移除它。希望确认第 5 步是否属于 RitsuLib 保证的行为。这个确认会决定 Mod 应该怎样声明会被升级移除的默认 Capability。
期望行为
不加回来,就像是升级逻辑内所执行的。
日志 / 堆栈
测试环境
0.111.0,commit41cef1ea0.5.148fca891d65de050b1848b9dc4e1fcc449dacf253f4763e15a8e9c44ac8d9fd9f40e944b854e8167082744df0795805afc547bc9a0.6.20.1.7net9.0最小复现做了什么
DefaultCapabilityRepro的基础费用是 1。它默认拥有ReproPulseCapability,这个 Capability 会把战斗费用减 1。卡牌第一次升级时会执行:
this.RemoveCapability();
RemoveKeyword(CharModKeywords.ReproPulse);
现场升级后的对象状态正确:
随后执行:
var restored = CardModel.FromSerializable(card.ToSerializable());
恢复对象进入战斗后的状态如下:
完整复现代码
这份复现基于
TestTheSpireTemplateRitsu。模板原有的角色、卡池、测试框架和CharModCard保持原样。下面列出了复现需要的全部新增代码和配置改动,代码块中没有省略内容。1. 项目依赖
主项目
CharMod.csproj:测试项目
CharMod.Tests/CharMod.Tests.csproj:Mod 清单
CharMod.json中的依赖:2.
CharModCode/Capabilities/ReproPulseCapability.cs3.
CharModCode/Cards/DefaultCapabilityRepro.cs4.
CharModCode/CardKeywords/CharModKeywords.cs5.
CharModCode/MainFile.cs6.
CharMod/localization/eng/card_keywords.json{ "CHARMOD-ENHANCE.title": "Enhance X", "CHARMOD-ENHANCE.description": "If this card's current energy cost is less than its [blue]Enhance[/blue] value, and you have enough energy to pay that value, pay the [blue]Enhance[/blue] value instead and activate the listed extra effect.", "CHARMOD-REPRO_PULSE.title": "Repro Pulse", "CHARMOD-REPRO_PULSE.description": "A default capability used to reproduce upgrade and save-load state divergence." }7.
CharMod/localization/eng/cards.json{ "CharMod.Enhance.prefix": "{IsActive:[gold]|[blue]}Enhance {Cost}: {CapturedDescription}{IsActive:[/gold]|[/blue]}", "DEFAULT_CAPABILITY_REPRO.title": "Default Capability Repro", "DEFAULT_CAPABILITY_REPRO.description": "Costs 1 less while [blue]Repro Pulse[/blue] is attached.\nUpgrade: Lose [blue]Repro Pulse[/blue].", "SAMPLE_ENHANCE_STRIKE.title": "Sample Enhance Strike", "SAMPLE_ENHANCE_STRIKE.description": "Deal {Damage:diff()} damage.\n{CompPre}", "SAMPLE_ENHANCE_STRIKE.enhance3": "Deal {Damage:diff()} additional damage." }8.
CharMod.Tests/Tests/DefaultCapabilityReproTests.csRitsuLib 版本
No response
运行环境
No response