From 68976523f0bca6f325ae4d17078319bb6f66107a Mon Sep 17 00:00:00 2001 From: iisaev Date: Wed, 21 Oct 2026 10:23:15 +0200 Subject: [PATCH] Status effects expire on their data duration; loot rolls never throw A status effect added without an explicit duration started with no remaining turns and the countdown skipped it, so it never expired. OnTurnEnd counted down a second time, expired unlimited effects after one turn and removed expired stacks by id; it is now a pure hook. The victory screen rolls loot through LootEntry.Roll, so a loot table with MinCount > MaxCount no longer throws half-way through the battle end, and its success-path diagnostics are no longer logged as errors. Mod unit tests for the status effect lifetime. --- manifest.json | 1 + src/StatusEffect/StatusEffectItem.cs | 1 + src/StatusEffect/StatusEffectQueue.cs | 14 ++------- src/WingroundBehaviour.cs | 23 +++++--------- tests/StatusEffectQueueTests.cs | 43 +++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 27 deletions(-) create mode 100644 tests/StatusEffectQueueTests.cs diff --git a/manifest.json b/manifest.json index 7558b52..1f80e4e 100644 --- a/manifest.json +++ b/manifest.json @@ -8,5 +8,6 @@ "sources": [ "src/**/*.cs" ], + "test_sources": ["tests/*.cs"], "depends": [ "characters_base>=1", "ui_base>=1", "items_base>=1", "quests_base>=1", "time_base>=1" ] } diff --git a/src/StatusEffect/StatusEffectItem.cs b/src/StatusEffect/StatusEffectItem.cs index 8008bef..94bd7e0 100644 --- a/src/StatusEffect/StatusEffectItem.cs +++ b/src/StatusEffect/StatusEffectItem.cs @@ -45,6 +45,7 @@ namespace Cthangover.Core.Cards.StatusEffect Name = info.Name; Description = info.Description; Duration = info.Duration; + RemainingTurns = info.Duration; EffectType = info.Type; Icon = TextureUtils.LoadFromModGroup("characters", info.Icon); Actions = StatusEffectActionFactory.Instance.Get(info.Actions); diff --git a/src/StatusEffect/StatusEffectQueue.cs b/src/StatusEffect/StatusEffectQueue.cs index d673469..b378423 100644 --- a/src/StatusEffect/StatusEffectQueue.cs +++ b/src/StatusEffect/StatusEffectQueue.cs @@ -134,21 +134,13 @@ namespace Cthangover.Core.Cards.StatusEffect /// Fires end-of-turn hooks on all active effects. Operates /// on a defensive copy so that OnTurnEnd callbacks that /// add or remove effects do not invalidate the enumerator. - /// Decrements remaining turns and removes expired effects via - /// (which also fires - /// OnRemove). + /// Durations count down in only. public void OnTurnEnd() { + // Only the hook: the countdown belongs to OnTurnStart, a second one + // here halved every duration and expired effects without one. foreach (var statusEffect in ActiveStatusEffects) - { statusEffect.Actions?.OnTurnEnd(owner); - statusEffect.RemainingTurns--; - if (statusEffect.RemainingTurns <= 0) - { - statusEffect.Actions?.OnFinalAction(owner); - Remove(statusEffect.ID); - } - } } /// Pipes outgoing damage through every active effect's diff --git a/src/WingroundBehaviour.cs b/src/WingroundBehaviour.cs index f07ef96..3a152a2 100644 --- a/src/WingroundBehaviour.cs +++ b/src/WingroundBehaviour.cs @@ -75,17 +75,17 @@ namespace Cthangover.TurnBattle audioService?.PlaySound("battle/winground", SoundType.UI); var bg = GetNodeOrNull("TransitionBg"); - GameLogger.Log("BATTLE", $"Winground.Show: TransitionBg={(bg != null ? "found" : "NULL")}, texture={(bg?.Texture != null ? "has" : "null")}", LogLevel.Error); + GameLogger.Log("BATTLE", $"Winground.Show: TransitionBg={(bg != null ? "found" : "NULL")}, texture={(bg?.Texture != null ? "has" : "null")}", bg != null ? LogLevel.Debug : LogLevel.Error); if (bg != null && bg.Texture == null) { var tex = Textures.Resolve("winground"); - GameLogger.Log("BATTLE", $"Winground.Show: ResolveTexture('winground') = {(tex != null ? "loaded" : "NULL")}", LogLevel.Error); + GameLogger.Log("BATTLE", $"Winground.Show: ResolveTexture('winground') = {(tex != null ? "loaded" : "NULL")}", tex != null ? LogLevel.Debug : LogLevel.Error); bg.Texture = tex; } data = BattleRequest.Current; - GameLogger.Log("BATTLE_UI", $"Winground.Show: BattleData={(data != null ? "found" : "NULL")}", LogLevel.Error); + GameLogger.Log("BATTLE_UI", $"Winground.Show: BattleData={(data != null ? "found" : "NULL")}", data != null ? LogLevel.Debug : LogLevel.Error); var expTracking = BuildExpTracking(); var defeatedEnemies = BuildDefeatedEnemies(); @@ -167,7 +167,7 @@ namespace Cthangover.TurnBattle private void BuildCharExpDisplay(Dictionary expTracking) { var container = charExpContainer ?? GetNodeOrNull("Content/CharExpContainer"); - GameLogger.Log("BATTLE_UI", $"BuildCharExpDisplay: charExpContainer export={(charExpContainer != null ? "found" : "NULL")}, GetNodeOrNull fallback={(container != null ? "found" : "NULL")}", LogLevel.Error); + GameLogger.Log("BATTLE_UI", $"BuildCharExpDisplay: charExpContainer export={(charExpContainer != null ? "found" : "NULL")}, GetNodeOrNull fallback={(container != null ? "found" : "NULL")}", LogLevel.Debug); if (container == null) { @@ -180,7 +180,7 @@ namespace Cthangover.TurnBattle var charData = GameData.Instance.Runtime.CharacterData; - GameLogger.Log("BATTLE_UI", $"BuildCharExpDisplay: expTracking={(expTracking != null ? $"found ({expTracking.Count} entries)" : "NULL")}, BattleSet count={charData.BattleSet.Count}", LogLevel.Error); + GameLogger.Log("BATTLE_UI", $"BuildCharExpDisplay: expTracking={(expTracking != null ? $"found ({expTracking.Count} entries)" : "NULL")}, BattleSet count={charData.BattleSet.Count}", LogLevel.Debug); var battleSetList = charData.BattleSet.ToList(); @@ -208,7 +208,7 @@ namespace Cthangover.TurnBattle reportCard.Setup(cardTemplate?.Image, name, earnedExp); container.AddChild(reportCard); - GameLogger.Log("BATTLE_UI", $"BuildCharExpDisplay: added reportCard for '{characterType}', id='{info.ID}', name='{name}', earnedExp={earnedExp}, cardTemplate={(cardTemplate != null ? "found" : "NULL")}, icon={(cardTemplate?.Image != null ? "has" : "null")}", LogLevel.Error); + GameLogger.Log("BATTLE_UI", $"BuildCharExpDisplay: added reportCard for '{characterType}', id='{info.ID}', name='{name}', earnedExp={earnedExp}, cardTemplate={(cardTemplate != null ? "found" : "NULL")}, icon={(cardTemplate?.Image != null ? "has" : "null")}", LogLevel.Debug); } else { @@ -247,16 +247,7 @@ namespace Cthangover.TurnBattle if (string.IsNullOrWhiteSpace(entry.ItemId)) continue; - var probability = Math.Clamp(entry.Probability, 0, 100); - if (probability <= 0) - continue; - if (probability < 100 && random.Next(1, 101) > probability) - continue; - - var count = entry.MinCount == entry.MaxCount - ? entry.MinCount - : random.Next(entry.MinCount, entry.MaxCount + 1); - + var count = entry.Roll(random); if (count > 0) TryAddLootItem(loot, entry.ItemId, count); } diff --git a/tests/StatusEffectQueueTests.cs b/tests/StatusEffectQueueTests.cs new file mode 100644 index 0000000..dd8ade7 --- /dev/null +++ b/tests/StatusEffectQueueTests.cs @@ -0,0 +1,43 @@ +namespace Cthangover.Mods.TurnBattle.Tests +{ + using Cthangover.Core.Cards.StatusEffect; + using Cthangover.Core.Characters; + using Cthangover.Core.Mods.Testing; + + /// + /// Lifetime of status effects: the countdown runs once per turn start, a + /// duration from the effect's data counts like an explicit one, and the + /// turn-end hook does not shorten an effect a second time. + /// + public class StatusEffectQueueTests + { + private const string Stun = "effect/physics/stun"; + + [ModTest("status/data_duration_expires")] + static void DataDurationExpires(ModTestContext ctx) + { + var queue = StatusEffectService.Of(new Character { ID = "status_test" }); + + ModAssert.True(queue.Add(Stun), "stun applied with its data duration"); + queue.OnTurnStart(); + queue.OnTurnStart(); + ModAssert.True(queue.HasStun(), "stun still active after 2 of 3 turns"); + queue.OnTurnStart(); + ModAssert.False(queue.HasStun(), "stun expired after its 3 turns"); + } + + [ModTest("status/turn_end_does_not_shorten")] + static void TurnEndDoesNotShorten(ModTestContext ctx) + { + var queue = StatusEffectService.Of(new Character { ID = "status_test" }); + + queue.Add(Stun, 2); + queue.OnTurnEnd(); + queue.OnTurnStart(); + ModAssert.True(queue.HasStun(), "one turn of two left after a full turn"); + queue.OnTurnEnd(); + queue.OnTurnStart(); + ModAssert.False(queue.HasStun(), "expired after two turns"); + } + } +}