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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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