Skip to content

[Bug] 升级重演时移除的 Default Capability,读档后是否应该保持移除 #99

Description

@MagicalAstrogy

模块范围

Public API(公开 API)

严重程度

Minor(轻微)

发生了什么?

实际场景来自一张多人游戏卡牌:

  • 基础卡有一个默认 Capability。
  • 这个 Capability 会让卡牌在战斗中少花 1 点能量。
  • 卡牌升级后会失去这个 Capability。
  • Host 一直使用现场升级后的卡牌对象,所以 Host 上没有这个 Capability。
  • Client 会从序列化数据重新创建这张卡。Client 上的升级代码确实执行了移除操作,随后 RitsuLib 又补回了默认 Capability。
  • 最后,Host 看到的费用是 1,Client 看到的费用是 0,双方状态开始分叉。

为了排除原 Mod 的其他逻辑,我在 TestTheSpireTemplateRitsu 中做了一个独立的最小复现。复现只保留默认 Capability、升级、序列化和费用修改。

复现步骤

  1. 一张卡通过 ConfigureDefaultModelCapabilities 获得一个默认 Capability。
  2. 这张卡升级时,在 OnUpgrade 里调用 RemoveCapability<T>() 移除它。
  3. STS2 读档时重新创建卡牌,并重演这次升级。
  4. 升级代码再次移除了 Capability。
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/apiPublic API, contracts, or compatibility surface.priority/p3Low priority or backlog.severity/minorMinor defect, polish issue, or low-impact bug.status/confirmedConfirmed and actionable.type/bugDefect or incorrect behavior in RitsuLib.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions