diff --git a/BaseLib/images/ui/temp_hp.png b/BaseLib/images/ui/temp_hp.png
new file mode 100644
index 00000000..d5180b5b
Binary files /dev/null and b/BaseLib/images/ui/temp_hp.png differ
diff --git a/BaseLib/localization/deu/card_keywords.json b/BaseLib/localization/deu/card_keywords.json
index 6821b11f..85f89aff 100644
--- a/BaseLib/localization/deu/card_keywords.json
+++ b/BaseLib/localization/deu/card_keywords.json
@@ -1,4 +1,7 @@
{
"BASELIB-PURGE.title": "Tilgen",
+ "BASELIB-PURGE.description": "Wird dauerhaft aus dem Kampf und deinem Deck entfernt.",
+ "BASELIB-TEMP_HP.title": "Temporäre LP",
+ "BASELIB-TEMP_HP.description": "Absorbiert Schaden, bevor LP verloren gehen – auch Schaden, der [gold]Block[/gold] ignoriert. Verschwindet am Ende des Kampfes."
"BASELIB-PURGE.description": "Wird dauerhaft aus dem Kampf und deinem [gold]Deck[/gold] entfernt."
}
\ No newline at end of file
diff --git a/BaseLib/localization/deu/powers.json b/BaseLib/localization/deu/powers.json
index 6934139b..350c6b59 100644
--- a/BaseLib/localization/deu/powers.json
+++ b/BaseLib/localization/deu/powers.json
@@ -5,5 +5,8 @@
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.description": "Erhöht einen [gold]Effekt[/gold] für eine gewisse Zeit.",
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.smartDescription": "Erhalte [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] bis zum Ende {UntilEndOfOtherSideTurn:cond:dieses Zuges|des gegnerischen Zuges}.{Repeat:cond:>0?\\nHält [blue]{Repeat:diff()}[/blue] {Repeat:plural:weiteren Zug|weitere Züge} an.|}",
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.description": "Verringert einen [gold]Effekt[/gold] für eine gewisse Zeit.",
- "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Verliere [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] bis zum Ende {UntilEndOfOtherSideTurn:cond:dieses Zuges|des gegnerischen Zuges}.{Repeat:cond:>0?\\nHält [blue]{Repeat:diff()}[/blue] {Repeat:plural:weiteren Zug|weitere Züge} an.|}"
+ "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Verliere [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] bis zum Ende {UntilEndOfOtherSideTurn:cond:dieses Zuges|des gegnerischen Zuges}.{Repeat:cond:>0?\\nHält [blue]{Repeat:diff()}[/blue] {Repeat:plural:weiteren Zug|weitere Züge} an.|}",
+ "BASELIB-TEMP_HP_POWER.title": "Temporäre LP",
+ "BASELIB-TEMP_HP_POWER.description": "Absorbiert Schaden, bevor LP verloren gehen – auch Schaden, der [gold]Block[/gold] ignoriert. Verschwindet am Ende des Kampfes.",
+ "BASELIB-TEMP_HP_POWER.smartDescription": "Absorbiert Schaden, bevor LP verloren gehen – auch Schaden, der [gold]Block[/gold] ignoriert. Verschwindet am Ende des Kampfes."
}
\ No newline at end of file
diff --git a/BaseLib/localization/deu/static_hover_tips.json b/BaseLib/localization/deu/static_hover_tips.json
index 9adcdb06..9d79c047 100644
--- a/BaseLib/localization/deu/static_hover_tips.json
+++ b/BaseLib/localization/deu/static_hover_tips.json
@@ -6,6 +6,9 @@
"BASELIB-EXHAUSTIVE.description": "Diese Karte wird nach [blue]X[/blue] Verwendungen [gold]Erschöpft[/gold].",
"BASELIB-EXHAUSTIVE.smartDescription": "Diese Karte wird nach {Exhaustive:cond:>1?[blue]{Exhaustive}[/blue] |einer }{Exhaustive:plural:Verwendung|Verwendungen} [gold]Erschöpft[/gold].",
"BASELIB-REFUND.title": "Erstattung",
+ "BASELIB-REFUND.description": "Wird Energie für diese Karte ausgegeben, werden bis zu [blue]{Refund}[/blue] dieser Energie zurückerstattet.",
+ "BASELIB-TEMP_HP.title": "Temporäre LP",
+ "BASELIB-TEMP_HP.description": "Absorbiert Schaden, bevor LP verloren gehen – auch Schaden, der [gold]Block[/gold] ignoriert. Verschwindet am Ende des Kampfes.",
"BASELIB-REFUND.description": "Wird Energie für diese Karte ausgegeben, werden bis zu [blue]X[/blue] dieser Energie zurückerstattet.",
"BASELIB-REFUND.smartDescription": "Wird Energie für diese Karte ausgegeben, werden bis zu [blue]{Refund}[/blue] dieser Energie zurückerstattet.",
"BASELIB-SCRY.title": "Spähen",
diff --git a/BaseLib/localization/eng/card_keywords.json b/BaseLib/localization/eng/card_keywords.json
index edf6c2d4..0e70eb7a 100644
--- a/BaseLib/localization/eng/card_keywords.json
+++ b/BaseLib/localization/eng/card_keywords.json
@@ -1,4 +1,6 @@
{
"BASELIB-PURGE.title": "Purge",
- "BASELIB-PURGE.description": "Removed from combat and your [gold]Deck[/gold] permanently."
+ "BASELIB-PURGE.description": "Removed from combat and your [gold]Deck[/gold] permanently.",
+ "BASELIB-TEMP_HP.title": "Temporary HP",
+ "BASELIB-TEMP_HP.description": "Absorbs damage before HP is lost, even damage that ignores [gold]Block[/gold]. Disappears at the end of combat."
}
diff --git a/BaseLib/localization/eng/powers.json b/BaseLib/localization/eng/powers.json
index a6f43505..c101b12b 100644
--- a/BaseLib/localization/eng/powers.json
+++ b/BaseLib/localization/eng/powers.json
@@ -5,5 +5,8 @@
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.description": "Increase a [gold]Power[/gold] for some time.",
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.smartDescription": "Gain [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] until the end of your {UntilEndOfOtherSideTurn:cond:|enemies }turn.{Repeat:cond:>0?\\nLasts for [blue]{Repeat:diff()}[/blue] additional {Repeat:plural:turn|turns}.|}",
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.description": "Decrease a [gold]Power[/gold] for some time.",
- "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Lose [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] until the end of your {UntilEndOfOtherSideTurn:cond:|enemies }turn.{Repeat:cond:>0?\\nLasts for [blue]{Repeat:diff()}[/blue] additional {Repeat:plural:turn|turns}.|}"
+ "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Lose [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] until the end of your {UntilEndOfOtherSideTurn:cond:|enemies }turn.{Repeat:cond:>0?\\nLasts for [blue]{Repeat:diff()}[/blue] additional {Repeat:plural:turn|turns}.|}",
+ "BASELIB-TEMP_HP_POWER.title": "Temporary HP",
+ "BASELIB-TEMP_HP_POWER.description": "Absorbs damage before HP is lost, even damage that ignores [gold]Block[/gold]. Disappears at the end of combat.",
+ "BASELIB-TEMP_HP_POWER.smartDescription": "Absorbs damage before HP is lost, even damage that ignores [gold]Block[/gold]. Disappears at the end of combat."
}
diff --git a/BaseLib/localization/eng/static_hover_tips.json b/BaseLib/localization/eng/static_hover_tips.json
index fe1ea886..dca0973d 100644
--- a/BaseLib/localization/eng/static_hover_tips.json
+++ b/BaseLib/localization/eng/static_hover_tips.json
@@ -6,6 +6,9 @@
"BASELIB-EXHAUSTIVE.description": "This card [gold]Exhausts[/gold] after [blue]X[/blue] uses.",
"BASELIB-EXHAUSTIVE.smartDescription": "This card [gold]Exhausts[/gold] after {Exhaustive:cond:>1?[blue]{Exhaustive}[/blue] |}{Exhaustive:plural:use|uses}.",
"BASELIB-REFUND.title": "Refund",
+ "BASELIB-REFUND.description": "When energy is spent on this card, up to [blue]{Refund}[/blue] of that energy is refunded.",
+ "BASELIB-TEMP_HP.title": "Temporary HP",
+ "BASELIB-TEMP_HP.description": "Absorbs damage before HP is lost, even damage that ignores [gold]Block[/gold]. Disappears at the end of combat.",
"BASELIB-REFUND.description": "When energy is spent on this card, up to [blue]X[/blue] of that energy is refunded.",
"BASELIB-REFUND.smartDescription": "When energy is spent on this card, up to [blue]{Refund}[/blue] of that energy is refunded.",
"BASELIB-SCRY.title": "Scry",
diff --git a/BaseLib/localization/rus/card_keywords.json b/BaseLib/localization/rus/card_keywords.json
index df29f1d7..acffa231 100644
--- a/BaseLib/localization/rus/card_keywords.json
+++ b/BaseLib/localization/rus/card_keywords.json
@@ -1,4 +1,6 @@
{
+ "BASELIB-TEMP_HP.title": "Временное здоровье",
+ "BASELIB-TEMP_HP.description": "Поглощает урон до потери здоровья, даже урон, игнорирующий [gold]блок[/gold]. Исчезает в конце боя.",
"BASELIB-PURGE.title": "Исчищается",
"BASELIB-PURGE.description": "Эта карта исчезает из игры и удаляется из [gold]колоды[/gold]."
}
diff --git a/BaseLib/localization/rus/powers.json b/BaseLib/localization/rus/powers.json
index 19792e5a..9c04af6f 100644
--- a/BaseLib/localization/rus/powers.json
+++ b/BaseLib/localization/rus/powers.json
@@ -5,5 +5,8 @@
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.description": "Временно усиливает эффект [gold]таланта[/gold].",
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.smartDescription": "Дает [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] до конца хода{UntilEndOfOtherSideTurn:cond:| врага}.{Repeat:cond:>0?\\nДействует еще [blue]{Repeat:diff()}[/blue] доп. {Repeat:plural(ru):ход|хода|ходов}.|}",
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.description": "Временно ослабляет эффект [gold]таланта[/gold].",
- "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Вы теряете [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] до конца хода {UntilEndOfOtherSideTurn:cond:| врага}.{Repeat:cond:>0?\\nДействует еще [blue]{Repeat:diff()}[/blue] доп. {Repeat:plural(ru):ход|хода|ходов}.|}"
+ "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Вы теряете [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] до конца хода {UntilEndOfOtherSideTurn:cond:| врага}.{Repeat:cond:>0?\\nДействует еще [blue]{Repeat:diff()}[/blue] доп. {Repeat:plural(ru):ход|хода|ходов}.|}",
+ "BASELIB-TEMP_HP_POWER.title": "Временное здоровье",
+ "BASELIB-TEMP_HP_POWER.description": "Поглощает урон до потери здоровья, даже урон, игнорирующий [gold]блок[/gold]. Исчезает в конце боя.",
+ "BASELIB-TEMP_HP_POWER.smartDescription": "Поглощает урон до потери здоровья, даже урон, игнорирующий [gold]блок[/gold]. Исчезает в конце боя."
}
diff --git a/BaseLib/localization/rus/static_hover_tips.json b/BaseLib/localization/rus/static_hover_tips.json
index f19e581d..57dcee2f 100644
--- a/BaseLib/localization/rus/static_hover_tips.json
+++ b/BaseLib/localization/rus/static_hover_tips.json
@@ -6,6 +6,9 @@
"BASELIB-EXHAUSTIVE.description": "Эта карта [gold]сжигается[/gold] после того, как будет разыграна еще [blue]X[/blue] раз.",
"BASELIB-EXHAUSTIVE.smartDescription": "Эта карта [gold]сжигается[/gold] после того, как будет разыграна еще {Exhaustive:cond:>1?[blue]{Exhaustive}[/blue] |}{Exhaustive:plural(ru):раз|раза|раз}.",
"BASELIB-REFUND.title": "Возврат",
+ "BASELIB-REFUND.description": "Когда вы тратите энергию при разыгрывании этой карты, возвращает до [blue]{Refund}[/blue] {energyPrefix:energyIcons(1)} назад.",
+ "BASELIB-TEMP_HP.title": "Временное здоровье",
+ "BASELIB-TEMP_HP.description": "Поглощает урон до потери здоровья, даже урон, игнорирующий [gold]блок[/gold]. Исчезает в конце боя.",
"BASELIB-REFUND.description": "Когда вы тратите энергию при разыгрывании этой карты, возвращает до [blue]X[/blue] энергии назад.",
"BASELIB-REFUND.smartDescription": "Когда вы тратите энергию при разыгрывании этой карты, возвращает до [blue]{Refund}[/blue] {energyPrefix:energyIcons(1)} назад.",
"BASELIB-SCRY.title": "Предсказывание карт",
diff --git a/BaseLib/localization/zhs/card_keywords.json b/BaseLib/localization/zhs/card_keywords.json
index 53893dfd..ee8e0710 100644
--- a/BaseLib/localization/zhs/card_keywords.json
+++ b/BaseLib/localization/zhs/card_keywords.json
@@ -1,4 +1,6 @@
{
"BASELIB-PURGE.title": "即逝",
- "BASELIB-PURGE.description": "从战斗和你的[gold]牌组[/gold]中永久移除。"
+ "BASELIB-PURGE.description": "从战斗和你的[gold]牌组[/gold]中永久移除。",
+ "BASELIB-TEMP_HP.title": "临时生命",
+ "BASELIB-TEMP_HP.description": "在损失生命值之前吸收伤害,即使是无视[gold]格挡[/gold]的伤害。战斗结束时消失。"
}
diff --git a/BaseLib/localization/zhs/powers.json b/BaseLib/localization/zhs/powers.json
index 61aef702..fc4ae9bd 100644
--- a/BaseLib/localization/zhs/powers.json
+++ b/BaseLib/localization/zhs/powers.json
@@ -5,5 +5,8 @@
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.description": "在一段时间内提升[gold]能力[/gold]。",
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.smartDescription": "获得[blue]{Amount:abs()}[/blue]层[gold]{TemporaryPowerTitle}[/gold],持续到你的{UntilEndOfOtherSideTurn:cond:|敌人}回合结束。{Repeat:cond:>0?\\n额外持续[blue]{Repeat:diff()}[/blue]个回合。|}",
"BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.description": "在一段时间内降低[gold]能力[/gold]。",
- "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "失去[blue]{Amount:abs()}[/blue]层[gold]{TemporaryPowerTitle}[/gold],持续到你的{UntilEndOfOtherSideTurn:cond:|敌人}回合结束。{Repeat:cond:>0?\\n额外持续[blue]{Repeat:diff()}[/blue]个回合。|}"
+ "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "失去[blue]{Amount:abs()}[/blue]层[gold]{TemporaryPowerTitle}[/gold],持续到你的{UntilEndOfOtherSideTurn:cond:|敌人}回合结束。{Repeat:cond:>0?\\n额外持续[blue]{Repeat:diff()}[/blue]个回合。|}",
+ "BASELIB-TEMP_HP_POWER.title": "临时生命",
+ "BASELIB-TEMP_HP_POWER.description": "在损失生命值之前吸收伤害,即使是无视[gold]格挡[/gold]的伤害。战斗结束时消失。",
+ "BASELIB-TEMP_HP_POWER.smartDescription": "在损失生命值之前吸收伤害,即使是无视[gold]格挡[/gold]的伤害。战斗结束时消失。"
}
diff --git a/BaseLib/localization/zhs/static_hover_tips.json b/BaseLib/localization/zhs/static_hover_tips.json
index b93fe749..495f16e5 100644
--- a/BaseLib/localization/zhs/static_hover_tips.json
+++ b/BaseLib/localization/zhs/static_hover_tips.json
@@ -6,9 +6,12 @@
"BASELIB-EXHAUSTIVE.description": "这张牌在被使用 [blue]X[/blue] 次后会[gold]消耗[/gold]。",
"BASELIB-EXHAUSTIVE.smartDescription": "这张牌在被使用 {Exhaustive:cond:>1?[blue]{Exhaustive}[/blue] |}次后会[gold]消耗[/gold]。",
"BASELIB-REFUND.title": "回能",
+ "BASELIB-REFUND.description": "当此牌花费能量时,返还最多 [blue]{Refund}[/blue] 点能量。",
+ "BASELIB-TEMP_HP.title": "临时生命",
+ "BASELIB-TEMP_HP.description": "在损失生命值之前吸收伤害,即使是无视[gold]格挡[/gold]的伤害。战斗结束时消失。",
"BASELIB-REFUND.description": "当此牌花费能量时,返还最多 [blue]X[/blue] 点能量。",
"BASELIB-REFUND.smartDescription": "当此牌花费能量时,返还最多 [blue]{Refund}[/blue] 点能量。",
"BASELIB-SCRY.title": "预见",
"BASELIB-SCRY.description": "检视你[gold]抽牌堆[/gold]顶部的 [blue]X[/blue] 张牌。你可以选择丢弃其中的任意张牌。",
"BASELIB-SCRY.smartDescription": "检视你[gold]抽牌堆[/gold]顶部的{Scry:cond:>1?[blue]{Scry:diff()}[/blue]张牌|一张牌}。你可以{Scry:cond:>1?选择丢弃其中的任意张牌|选择丢弃这张牌}。"
-}
\ No newline at end of file
+}
diff --git a/Cards/BaseLibKeywords.cs b/Cards/BaseLibKeywords.cs
index aed332c9..efbec7a7 100644
--- a/Cards/BaseLibKeywords.cs
+++ b/Cards/BaseLibKeywords.cs
@@ -10,4 +10,10 @@ public class BaseLibKeywords
/// A card that removes itself from the deck when played.
///
[CustomEnum] [KeywordProperties(AutoKeywordPosition.After)] public static CardKeyword Purge;
+
+ /// See TempHpPower and TempHpCmd
+ ///
+ /// Temporary HP absorbs damage before HP and disappears at the end of combat.
+ ///
+ [CustomEnum("Temp_HP")] [KeywordProperties(AutoKeywordPosition.After)] public static CardKeyword TempHp;
}
\ No newline at end of file
diff --git a/Commands/TempHpCmd.cs b/Commands/TempHpCmd.cs
new file mode 100644
index 00000000..2e0f59a7
--- /dev/null
+++ b/Commands/TempHpCmd.cs
@@ -0,0 +1,119 @@
+using BaseLib.Patches.Content;
+using BaseLib.Powers;
+using MegaCrit.Sts2.Core.Combat;
+using MegaCrit.Sts2.Core.Commands;
+using MegaCrit.Sts2.Core.Entities.Creatures;
+using MegaCrit.Sts2.Core.GameActions.Multiplayer;
+using MegaCrit.Sts2.Core.Helpers;
+using MegaCrit.Sts2.Core.HoverTips;
+using MegaCrit.Sts2.Core.Models;
+using MegaCrit.Sts2.Core.Nodes;
+using MegaCrit.Sts2.Core.Nodes.Vfx;
+using MegaCrit.Sts2.Core.ValueProps;
+using MegaCrit.Sts2.addons.mega_text;
+
+namespace BaseLib.Commands;
+
+///
+/// Public API for the Temporary HP mechanic (port of StSLib's Temporary HP).
+/// Temporary HP absorbs any HP loss that goes through the damage pipeline (attacks, Poison, self-damage, ...).
+/// Order: Block → Osty redirect → Temporary HP → late-pass buffs (ModifyHpLostAfterOstyLate, e.g.
+/// Buffer-style prevention) → real HP. Disappears at the end of combat.
+/// See for the underlying hidden power and
+/// for the per-hit modification hook.
+///
+public static class TempHpCmd
+{
+ ///
+ /// Reserved damage-props bit: damage carrying this flag bypasses Temporary HP entirely
+ /// (the StSLib "ignoresTempHP" equivalent). Vanilla only uses bits 1-4; BaseLib reserves bit 28.
+ ///
+ public const ValueProp IgnoresTempHp = (ValueProp)(1 << 28);
+
+ /// Hover tip shown when a creature has Temporary HP. Usable on cards via WithTip(TempHpCmd.TempHp).
+ [CustomEnum] public static StaticHoverTip TempHp;
+
+ /// Fired after a creature gains Temporary HP: (creature, amount gained, new total).
+ public static event Action? Gained;
+
+ ///
+ /// Fired after a creature loses Temporary HP: (creature, amount lost, new total, damage that caused it).
+ /// The DamageResult is null for non-damage losses (RemoveAll, foreign PowerCmd decrements, death flush).
+ ///
+ public static event Action? Lost;
+
+ ///
+ /// Grant Temporary HP to a creature. Stacks with existing Temporary HP.
+ /// Multiplayer-safe when called from synchronized contexts (card OnPlay, hooks, networked console commands),
+ /// exactly like any power application.
+ ///
+ /// The creature's new Temporary HP total.
+ public static async Task Add(PlayerChoiceContext choiceContext, Creature target, int amount,
+ Creature? applier = null, CardModel? cardSource = null, bool showVfx = true)
+ {
+ if (amount <= 0)
+ return Get(target);
+ var power = await PowerCmd.Apply(choiceContext, target, amount, applier, cardSource, silent: true);
+ if (power != null && showVfx)
+ PlayGainFx(target);
+ return power?.Amount ?? Get(target);
+ }
+
+ /// Remove all Temporary HP from a creature (the StSLib RemoveAllTemporaryHPAction equivalent).
+ public static async Task RemoveAll(Creature target)
+ {
+ var power = target.GetPower();
+ if (power == null)
+ return;
+ var had = power.Amount;
+ await PowerCmd.Remove(power);
+ if (had > 0)
+ InvokeLost(target, had, 0, null);
+ }
+
+ /// The creature's current Temporary HP (0 if none).
+ public static int Get(Creature target)
+ {
+ return target.GetPower()?.Amount ?? 0;
+ }
+
+ internal static void InvokeGained(Creature creature, int gained, int total)
+ {
+ Gained?.Invoke(creature, gained, total);
+ }
+
+ internal static void InvokeLost(Creature creature, int lost, int total, DamageResult? source)
+ {
+ Lost?.Invoke(creature, lost, total, source);
+ }
+
+ private static void PlayGainFx(Creature target)
+ {
+ if (!CombatManager.Instance.IsInProgress)
+ return;
+ SfxCmd.Play("event:/sfx/heal");
+ VfxCmd.PlayOnCreatureCenter(target, "vfx/vfx_cross_heal");
+ }
+
+ ///
+ /// Gold floating number for absorbed damage — the only feedback on a fully absorbed hit, since vanilla
+ /// shows no damage number and no hurt anim when the final HP loss is 0.
+ ///
+ internal static void PlayAbsorbFx(Creature target, int absorbed)
+ {
+ if (absorbed <= 0 || !CombatManager.Instance.IsInProgress)
+ return;
+ var vfx = NDamageNumVfx.Create(target, absorbed);
+ if (vfx == null)
+ return;
+ vfx.Modulate = StsColors.gold; // the _Ready tween animates modulate gold -> cream, mimicking vanilla's red -> cream
+ var label = vfx.GetNodeOrNull("Label");
+ label?.AddThemeColorOverride("font_color", StsColors.gold);
+ label?.AddThemeColorOverride("font_outline_color", StsColors.rewardLabelGoldOutline);
+ var container = target.GetVfxContainer();
+ if (container != null)
+ container.AddChildSafely(vfx);
+ else
+ NRun.Instance?.GlobalUi.AddChildSafely(vfx);
+ }
+}
diff --git a/ConsoleCommands/TempHpConsoleCmd.cs b/ConsoleCommands/TempHpConsoleCmd.cs
new file mode 100644
index 00000000..ee2196bf
--- /dev/null
+++ b/ConsoleCommands/TempHpConsoleCmd.cs
@@ -0,0 +1,67 @@
+using BaseLib.Commands;
+using MegaCrit.Sts2.Core.Combat;
+using MegaCrit.Sts2.Core.DevConsole;
+using MegaCrit.Sts2.Core.DevConsole.ConsoleCommands;
+using MegaCrit.Sts2.Core.Entities.Creatures;
+using MegaCrit.Sts2.Core.Entities.Players;
+using MegaCrit.Sts2.Core.GameActions.Multiplayer;
+using MegaCrit.Sts2.Core.Runs;
+
+namespace BaseLib.ConsoleCommands;
+
+///
+/// Debug console command for the Temporary HP mechanic. Modeled on the vanilla "heal" command.
+///
+public class TempHpConsoleCmd : AbstractConsoleCmd
+{
+ ///
+ public override string CmdName => "temphp";
+
+ ///
+ public override string Args => " [index:int]";
+
+ ///
+ public override string Description => "Grant the player (or the ally at index) Temporary HP. A negative amount removes all of it.";
+
+ ///
+ public override bool IsNetworked => true;
+
+ ///
+ public override CmdResult Process(Player? issuingPlayer, string[] args)
+ {
+ if (args.Length < 1)
+ return new CmdResult(success: false, "An amount is required");
+ if (!int.TryParse(args[0], out var amount))
+ return new CmdResult(success: false, "First argument (the Temporary HP amount) must be an int.");
+ if (!RunManager.Instance.IsInProgress)
+ return new CmdResult(success: false, "A run does not appear to be in progress");
+ if (!CombatManager.Instance.IsInProgress)
+ return new CmdResult(success: false, "Temporary HP can only be granted during combat.");
+ Creature creature;
+ if (args.Length > 1)
+ {
+ if (!int.TryParse(args[1], out var index))
+ return new CmdResult(success: false, "Arg 2 must be the target index (int), got '" + args[1] + "'.");
+ var combatState = CombatManager.Instance.DebugOnlyGetState();
+ if (combatState == null)
+ return new CmdResult(success: false, "No combat state available.");
+ IReadOnlyList allies = combatState.Allies;
+ if (index < 0 || index >= allies.Count)
+ return new CmdResult(success: false, $"Invalid target index {index}. Valid range: 0-{allies.Count - 1}");
+ creature = allies[index];
+ }
+ else
+ {
+ if (issuingPlayer == null)
+ return new CmdResult(success: false, "No issuing player; pass an ally index instead.");
+ creature = issuingPlayer.Creature;
+ }
+ if (amount < 0)
+ {
+ Task removeTask = TempHpCmd.RemoveAll(creature);
+ return new CmdResult(removeTask, success: true, $"Removed all Temporary HP from {creature}.");
+ }
+ Task addTask = TempHpCmd.Add(new ThrowingPlayerChoiceContext(), creature, amount);
+ return new CmdResult(addTask, success: true, $"Granted '{amount}' Temporary HP to {creature}.");
+ }
+}
diff --git a/Extensions/CreatureTempHpExtensions.cs b/Extensions/CreatureTempHpExtensions.cs
new file mode 100644
index 00000000..31c086ab
--- /dev/null
+++ b/Extensions/CreatureTempHpExtensions.cs
@@ -0,0 +1,29 @@
+using BaseLib.Commands;
+using MegaCrit.Sts2.Core.Entities.Creatures;
+using MegaCrit.Sts2.Core.GameActions.Multiplayer;
+using MegaCrit.Sts2.Core.Models;
+
+namespace BaseLib.Extensions;
+
+/// Creature extensions for the Temporary HP mechanic. See .
+public static class CreatureTempHpExtensions
+{
+ /// The creature's current Temporary HP (0 if none).
+ public static int GetTempHp(this Creature creature)
+ {
+ return TempHpCmd.Get(creature);
+ }
+
+ /// Whether the creature currently has any Temporary HP.
+ public static bool HasTempHp(this Creature creature)
+ {
+ return TempHpCmd.Get(creature) > 0;
+ }
+
+ /// Grant Temporary HP. See .
+ public static Task AddTempHp(this Creature creature, PlayerChoiceContext choiceContext, int amount,
+ Creature? applier = null, CardModel? cardSource = null)
+ {
+ return TempHpCmd.Add(choiceContext, creature, amount, applier, cardSource);
+ }
+}
diff --git a/Hooks/ITempHpLossModifier.cs b/Hooks/ITempHpLossModifier.cs
new file mode 100644
index 00000000..88c268ef
--- /dev/null
+++ b/Hooks/ITempHpLossModifier.cs
@@ -0,0 +1,28 @@
+using MegaCrit.Sts2.Core.Entities.Creatures;
+using MegaCrit.Sts2.Core.Models;
+using MegaCrit.Sts2.Core.ValueProps;
+
+namespace BaseLib.Hooks;
+
+///
+/// Implement on a PowerModel or RelicModel to modify how much of a hit is applied to its owner's Temporary HP
+/// (the StSLib OnLoseTempHpPower/OnLoseTempHpRelic equivalent).
+/// Dispatched to the damaged creature's own powers and its player's relics, in that order, and only when the
+/// creature actually has Temporary HP remaining.
+/// Called synchronously inside the HP-loss pipeline: implementations MUST be pure — no awaits, no state
+/// mutation, no VFX.
+///
+public interface ITempHpLossModifier
+{
+ ///
+ /// Return the (possibly modified) damage amount that will be offered to Temporary HP.
+ /// Return 0 to make this hit skip Temporary HP entirely.
+ ///
+ /// Damage amount about to be absorbed (post-Block, post-Osty-redirect).
+ /// The creature whose Temporary HP is absorbing.
+ /// Properties of the damage.
+ /// Creature who dealt the damage, if any.
+ /// Card that dealt the damage, if any.
+ decimal ModifyTempHpLoss(decimal damageToTempHp, Creature target, ValueProp props,
+ Creature? dealer, CardModel? cardSource) => damageToTempHp;
+}
diff --git a/Patches/UI/TempHpDisplayPatch.cs b/Patches/UI/TempHpDisplayPatch.cs
new file mode 100644
index 00000000..9573f326
--- /dev/null
+++ b/Patches/UI/TempHpDisplayPatch.cs
@@ -0,0 +1,195 @@
+using BaseLib.Commands;
+using BaseLib.Utils;
+using Godot;
+using HarmonyLib;
+using MegaCrit.Sts2.Core.Assets;
+using MegaCrit.Sts2.Core.Entities.Creatures;
+using MegaCrit.Sts2.Core.Helpers;
+using MegaCrit.Sts2.Core.HoverTips;
+using MegaCrit.Sts2.Core.Nodes.Combat;
+using MegaCrit.Sts2.Core.Nodes.HoverTips;
+using MegaCrit.Sts2.addons.mega_text;
+
+namespace BaseLib.Patches.UI;
+
+///
+/// Health-bar display for Temporary HP (StSLib-style): a gold outline around the whole bar plus a gold
+/// shield chip with the amount, mirroring the Block chip on the opposite (right) end of the bar.
+/// All nodes are duplicated from the vanilla scene lazily on first use, so no custom assets are required;
+/// vanilla nodes are never modified. Refreshing rides on the existing RefreshValues pipeline — power changes
+/// already trigger it via CombatStateTracker (combat bars) and NMultiplayerPlayerState (MP side panel).
+///
+[HarmonyPatch]
+public static class TempHpDisplayPatch
+{
+ private static readonly SpireField UiStates = new(() => null);
+
+ private static readonly Color GoldOutlineColor = StsColors.rewardLabelGoldOutline;
+
+ /// StSLib's heart icon (a white heart, tinted gold like StSLib does), shipped in BaseLib.pck.
+ private const string HeartTexturePath = "BaseLib/images/ui/temp_hp.png";
+
+ private static Texture2D? _heartTexture;
+ private static bool _heartLoadAttempted;
+
+ [HarmonyPatch(typeof(NHealthBar), "RefreshBlockUi")]
+ [HarmonyPostfix]
+ private static void RefreshBlockUiPostfix(NHealthBar __instance)
+ {
+ RefreshTempHpUi(__instance);
+ }
+
+ [HarmonyPatch(typeof(NHealthBar), "SetHpBarContainerSizeWithOffsetsImmediately")]
+ [HarmonyPostfix]
+ private static void SetHpBarContainerSizeWithOffsetsImmediatelyPostfix(NHealthBar __instance)
+ {
+ RepositionAfterLayoutChange(__instance);
+ }
+
+ [HarmonyPatch(typeof(NHealthBar), nameof(NHealthBar.UpdateLayoutForCreatureBounds))]
+ [HarmonyPostfix]
+ private static void UpdateLayoutForCreatureBoundsPostfix(NHealthBar __instance)
+ {
+ RepositionAfterLayoutChange(__instance);
+ }
+
+ ///
+ /// The hidden power contributes no hover tips of its own (invisible powers never do), so the
+ /// "Temporary HP" keyword tip is appended at the creature level and shows when hovering the bar.
+ ///
+ [HarmonyPatch(typeof(Creature), nameof(Creature.HoverTips), MethodType.Getter)]
+ [HarmonyPostfix]
+ private static void HoverTipsPostfix(Creature __instance, ref IEnumerable __result)
+ {
+ if (TempHpCmd.Get(__instance) <= 0)
+ return;
+ __result = __result.Append(HoverTipFactory.Static(TempHpCmd.TempHp));
+ }
+
+ private static void RefreshTempHpUi(NHealthBar healthBar)
+ {
+ var creature = healthBar._creature;
+ if (creature == null)
+ return;
+
+ var tempHp = TempHpCmd.Get(creature);
+ var state = UiStates[healthBar];
+ if (tempHp <= 0 || creature.CurrentHp <= 0 || creature.HpDisplay.IsInfinite())
+ {
+ if (state != null)
+ {
+ state.Outline.Visible = false;
+ state.Chip.Visible = false;
+ }
+ return;
+ }
+
+ state ??= EnsureUiState(healthBar);
+ if (state == null)
+ return;
+
+ state.Outline.Visible = true;
+ state.Chip.Visible = true;
+ state.Label.SetTextAutoSize(tempHp.ToString());
+ PositionChip(healthBar, state);
+ }
+
+ private static void RepositionAfterLayoutChange(NHealthBar healthBar)
+ {
+ var state = UiStates[healthBar];
+ if (state is { Chip.Visible: true })
+ PositionChip(healthBar, state);
+ }
+
+ ///
+ /// Mirror of the vanilla Block chip rule: Block is centered on the bar's left edge
+ /// (see NHealthBar.UpdateLayoutForCreatureBounds), so the Temporary HP chip is centered on the right edge.
+ /// Computed from HpBarContainer in local space, which works for both combat bars (creature-bounds layout)
+ /// and the multiplayer side panel (reference-width layout).
+ ///
+ private static void PositionChip(NHealthBar healthBar, TempHpUiState state)
+ {
+ var barContainer = healthBar.HpBarContainer;
+ if (barContainer == null)
+ return;
+ state.Chip.Position = new Vector2(
+ barContainer.Position.X + barContainer.Size.X - state.Chip.Size.X * 0.5f,
+ healthBar._originalBlockPosition.Y);
+ }
+
+ private static TempHpUiState? EnsureUiState(NHealthBar healthBar)
+ {
+ if (UiStates[healthBar] is { } existing)
+ return existing;
+
+ var blockOutline = healthBar._blockOutline;
+ var blockContainer = healthBar._blockContainer;
+ if (blockOutline == null || blockContainer == null)
+ return null; // _Ready hasn't run yet
+ if (blockOutline.GetParent() is not Control barContainer ||
+ blockContainer.GetParent() is not Control chipParent)
+ return null;
+
+ // Gold outline around the whole bar, layered right above the blue Block outline.
+ var outline = (Control)blockOutline.Duplicate();
+ outline.Name = "BaseLibTempHpOutline";
+ outline.Visible = false;
+ outline.Modulate = Colors.White;
+ outline.SelfModulate = StsColors.gold;
+ outline.MouseFilter = Control.MouseFilterEnum.Ignore;
+ barContainer.AddChild(outline);
+ barContainer.MoveChild(outline, blockOutline.GetIndex() + 1);
+
+ // Gold shield chip with the amount, duplicated from the Block chip.
+ var chip = (Control)blockContainer.Duplicate();
+ chip.Name = "BaseLibTempHpContainer";
+ chip.Visible = false;
+ var icon = chip.GetNodeOrNull("BlockIcon");
+ var label = chip.GetNodeOrNull("BlockLabel");
+ if (icon == null || label == null)
+ {
+ outline.QueueFreeSafely();
+ chip.QueueFreeSafely();
+ return null;
+ }
+ if (TryLoadHeartTexture() is { } heartTexture)
+ icon.Texture = heartTexture; // fallback: keep the duplicated block.png if the pck texture is missing
+ icon.SelfModulate = StsColors.gold;
+ label.AddThemeColorOverride(ThemeConstants.Label.FontColor, StsColors.gold);
+ label.AddThemeColorOverride(ThemeConstants.Label.FontOutlineColor, GoldOutlineColor);
+ chip.MouseFilter = Control.MouseFilterEnum.Stop;
+ // CreateAndShow does NOT position the tip set itself — vanilla callers set the position explicitly
+ // (see NMultiplayerPlayerState's network-problem tip); without this it lands at the screen origin.
+ chip.MouseEntered += () => NHoverTipSet.CreateAndShow(chip, HoverTipFactory.Static(TempHpCmd.TempHp))
+ ?.SetGlobalPosition(chip.GlobalPosition + Vector2.Down * 80f);
+ chip.MouseExited += () => NHoverTipSet.Remove(chip);
+ chipParent.AddChild(chip);
+
+ var state = new TempHpUiState(outline, chip, label);
+ UiStates[healthBar] = state;
+ return state;
+ }
+
+ private static Texture2D? TryLoadHeartTexture()
+ {
+ if (_heartLoadAttempted)
+ return _heartTexture;
+ _heartLoadAttempted = true;
+ try
+ {
+ _heartTexture = PreloadManager.Cache.GetTexture2D(HeartTexturePath);
+ }
+ catch (Exception e)
+ {
+ BaseLibMain.Logger.Warn($"Failed to load {HeartTexturePath}, falling back to the block icon: {e.Message}");
+ }
+ return _heartTexture;
+ }
+
+ private sealed class TempHpUiState(Control outline, Control chip, MegaLabel label)
+ {
+ public Control Outline { get; } = outline;
+ public Control Chip { get; } = chip;
+ public MegaLabel Label { get; } = label;
+ }
+}
diff --git a/Powers/TempHpPower.cs b/Powers/TempHpPower.cs
new file mode 100644
index 00000000..490d7d15
--- /dev/null
+++ b/Powers/TempHpPower.cs
@@ -0,0 +1,145 @@
+using BaseLib.Abstracts;
+using BaseLib.Commands;
+using BaseLib.Hooks;
+using MegaCrit.Sts2.Core.Combat;
+using MegaCrit.Sts2.Core.Commands;
+using MegaCrit.Sts2.Core.Entities.Creatures;
+using MegaCrit.Sts2.Core.Entities.Powers;
+using MegaCrit.Sts2.Core.GameActions.Multiplayer;
+using MegaCrit.Sts2.Core.Models;
+using MegaCrit.Sts2.Core.ValueProps;
+
+namespace BaseLib.Powers;
+
+///
+/// Temporary HP (port of StSLib's mechanic): a hidden per-creature pool that absorbs any HP loss going through
+/// the damage pipeline. Resolution order: Block → Osty redirect → Temporary HP (early AfterOsty pass) →
+/// late-pass buffs (ModifyHpLostAfterOstyLate, e.g. Buffer-style prevention) → real HP. Because absorption
+/// happens after the redirect, it is the FINAL receiver's pool that absorbs (redirected damage consumes Osty's
+/// Temporary HP; overkill trampling back consumes the original target's).
+/// Removed automatically at end of combat and on death, like any power.
+/// Grant/query through or the Creature extensions; do not apply this power directly
+/// unless you want to skip the gain FX.
+///
+public sealed class TempHpPower : CustomPowerModel
+{
+ ///
+ public override PowerType Type => PowerType.Buff;
+
+ ///
+ public override PowerStackType StackType => PowerStackType.Counter;
+
+ /// Hidden from the power row; rendered on the health bar instead (see TempHpDisplayPatch).
+ /// Invisibility also exempts it from Artifact, which only blocks visible debuffs.
+ protected override bool IsVisibleInternal => false;
+
+ /// Absorption is computed in the pure ModifyHpLost phase but applied in AfterDamageReceived, which runs after
+ /// every HP mutation of the same Damage() call has completed. Only nonzero within a single damage resolution;
+ /// never serialized (power serialization is (ModelId, Amount) only).
+ private int _pendingAbsorb;
+
+ ///
+ protected override void AfterCloned()
+ {
+ base.AfterCloned();
+ _pendingAbsorb = 0;
+ }
+
+ ///
+ /// Early AfterOsty pass: after Block and after the Osty redirect (target here is the final damage receiver),
+ /// but before all late-pass HP-loss hooks — Buffer-style preventers implementing ModifyHpLostAfterOstyLate
+ /// are guaranteed to run after Temporary HP. Relative order versus other EARLY-pass effects on the same
+ /// creature (e.g. vanilla Intangible) follows power application order. Must stay pure — the pool is only
+ /// debited later, in .
+ ///
+ public override decimal ModifyHpLostAfterOsty(Creature target, decimal amount, ValueProp props, Creature? dealer, CardModel? cardSource)
+ {
+ if (!CombatManager.Instance.IsInProgress)
+ return amount;
+ if (target != Owner || amount < 1m)
+ return amount;
+ if (props.HasFlag(TempHpCmd.IgnoresTempHp))
+ return amount;
+ // The same creature can enter this pass twice within one Damage() call (multi-target redirects, and the
+ // overkill-trample-back AfterOsty call for the original target), hence the remaining-pool cap.
+ var remaining = Amount - _pendingAbsorb;
+ if (remaining <= 0)
+ return amount;
+
+ var toTempHp = amount;
+ foreach (var power in Owner.Powers)
+ {
+ if (power is ITempHpLossModifier modifier)
+ toTempHp = modifier.ModifyTempHpLoss(toTempHp, Owner, props, dealer, cardSource);
+ }
+ var relics = Owner.Player?.Relics;
+ if (relics != null)
+ {
+ foreach (var relic in relics)
+ {
+ if (relic is ITempHpLossModifier modifier)
+ toTempHp = modifier.ModifyTempHpLoss(toTempHp, Owner, props, dealer, cardSource);
+ }
+ }
+
+ var absorbed = (int)Math.Min(decimal.Truncate(toTempHp), remaining);
+ if (absorbed <= 0)
+ return amount;
+ _pendingAbsorb += absorbed;
+ return amount - absorbed;
+ }
+
+ ///
+ /// Applies the absorption computed during the ModifyHpLost phase of the same Damage() call.
+ /// Dispatched once per DamageResult after ALL HP mutations of the call have completed; the results loop
+ /// fires it with the result whose Receiver is this pool's owner (redirect and overkill-trample cases each
+ /// produce their own result), so filtering on Receiver == Owner commits exactly once.
+ ///
+ public override async Task AfterDamageReceived(PlayerChoiceContext choiceContext, Creature target, DamageResult result, ValueProp props, Creature? dealer, CardModel? cardSource)
+ {
+ if (result.Receiver != Owner || _pendingAbsorb == 0)
+ return;
+ await ApplyPendingAbsorb(silent: false, playFx: true, result);
+ }
+
+ ///
+ /// A killed target skips AfterDamageReceived entirely, but death can still be prevented (Fairy in a Bottle
+ /// etc.). Flushing here — before the death-prevention decision — keeps the pool consistent either way.
+ ///
+ public override Task BeforeDeath(Creature creature)
+ {
+ if (creature == Owner && _pendingAbsorb > 0)
+ return ApplyPendingAbsorb(silent: true, playFx: false, null);
+ return Task.CompletedTask;
+ }
+
+ private async Task ApplyPendingAbsorb(bool silent, bool playFx, DamageResult? source)
+ {
+ var lost = Math.Min(_pendingAbsorb, Amount);
+ _pendingAbsorb = 0;
+ if (lost <= 0)
+ return;
+ // SetAmount instead of PowerCmd.ModifyAmount: the debit must equal the absorbed damage exactly, without
+ // being re-routed through the ModifyPowerAmountGiven/Received hook bus.
+ SetAmount(Amount - lost, silent);
+ TempHpCmd.InvokeLost(Owner, lost, Amount, source);
+ if (playFx)
+ TempHpCmd.PlayAbsorbFx(Owner, lost);
+ if (Amount <= 0)
+ await PowerCmd.Remove(this); // hook dispatch iterates a snapshot list, so removal mid-dispatch is safe
+ }
+
+ /// Forwards gains (and foreign PowerCmd changes) to the events.
+ public override Task AfterPowerAmountChanged(PlayerChoiceContext choiceContext, PowerModel power, decimal amount, Creature? applier, CardModel? cardSource)
+ {
+ if (power != this)
+ return Task.CompletedTask;
+ // Covers PowerCmd.Apply (fresh application and stacking) and foreign PowerCmd.ModifyAmount calls.
+ // Our own SetAmount debit does not re-enter this hook, so absorption is never double-reported.
+ if (amount > 0m)
+ TempHpCmd.InvokeGained(Owner, (int)amount, Amount);
+ else if (amount < 0m)
+ TempHpCmd.InvokeLost(Owner, (int)(-amount), Amount, null);
+ return Task.CompletedTask;
+ }
+}