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.
This commit is contained in:
cthangover committed 2026-10-21 10:23:15 +02:00
1 parent 72b54eb564
commit 68976523f0
5 files changed
+55 -27

No files matched your search

+1
View File
@@ -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" ]
}
+1
View File
@@ -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);
+3 -11
View File
@@ -134,21 +134,13 @@ namespace Cthangover.Core.Cards.StatusEffect
/// <summary>Fires end-of-turn hooks on all active effects. Operates
/// on a defensive copy so that <c>OnTurnEnd</c> callbacks that
/// add or remove effects do not invalidate the enumerator.
/// Decrements remaining turns and removes expired effects via
/// <see cref="Remove"/> (which also fires
/// <c>OnRemove</c>).</summary>
/// Durations count down in <see cref="OnTurnStart"/> only.</summary>
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);
}
}
}
/// <summary>Pipes outgoing damage through every active effect's
+7 -16
View File
@@ -75,17 +75,17 @@ namespace Cthangover.TurnBattle
audioService?.PlaySound("battle/winground", SoundType.UI);
var bg = GetNodeOrNull<TextureRect>("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<string, int> expTracking)
{
var container = charExpContainer ?? GetNodeOrNull<Container>("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);
}
+43
View File
@@ -0,0 +1,43 @@
namespace Cthangover.Mods.TurnBattle.Tests
{
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Cthangover.Core.Mods.Testing;
/// <summary>
/// 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.
/// </summary>
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");
}
}
}