Skip to content

Commit 699bd0f

Browse files
feat(combat): opt-in damage elements + resistance/weakness
A hit may carry an optional ElementId; a combatant resists or is weak to an element via a status whose PassiveModifierSpec is RestrictElement-gated (ScalePercent 50 = half, 200 = double). Additive: untyped damage and existing element-agnostic specs are unchanged. - ElementId; DealDamageEffectRequest.Element threaded into DamageAmountModificationContext. - PassiveModifierSpec.RestrictElement gate in DeclarativePassiveModifierEngine; DeclarativePassiveDamageModifier passes context.Element. - DealDamageNode.Element (+ IDealDamageNodeCore) → executor → request; CombatJson round-trips. Rides the existing declarative passive pipeline, so resistances are just statuses: snapshot/restore/hash and mid-combat apply/remove come for free (tested). Core 1365->1369.
1 parent ee6482c commit 699bd0f

9 files changed

Lines changed: 154 additions & 7 deletions

File tree

src/RogueDeck.Core/Combat/CombatIds.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ public readonly record struct CounterId(string value)
6060
public override string ToString() => value;
6161
}
6262

63+
// Optional damage element (fire, ice, lightning, …). Untyped damage carries none. A combatant resists or is
64+
// weak to an element via a status whose PassiveModifierSpec restricts to it (RestrictElement).
65+
public readonly record struct ElementId(string value)
66+
{
67+
public override string ToString() => value;
68+
}
69+
6370
public readonly record struct PackageId(string value)
6471
{
6572
public override string ToString() => value;

src/RogueDeck.Core/Combat/Effects/DamageAmountModifiers.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ public sealed record DamageAmountModificationContext(
1818
CombatantState? SourceCombatant,
1919
CardDefinitionId? SourceCardId,
2020
DamageKind Kind,
21-
int RequestedAmount);
21+
int RequestedAmount,
22+
// Optional damage element, threaded from the request so element-restricted passive specs (resistance/
23+
// weakness) can gate on it. Null = untyped.
24+
ElementId? Element = null);
2225

2326
public interface IDamageAmountModifier
2427
{

src/RogueDeck.Core/Combat/Effects/DamageEffects.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ public sealed record DealDamageEffectRequest(
2929
// True when this hit is one share of a redistributed (split) hit. A share carries its final amount,
3030
// so it skips the amount-modifier pipeline and is not split again — which stops symmetric links
3131
// (e.g. Symbiosis) from cascading. Block, HP and damage events still apply per share.
32-
bool IsRedistributedShare = false
32+
bool IsRedistributedShare = false,
33+
// Optional damage element (fire/ice/…). Null = untyped, unchanged. When set, a target status whose
34+
// PassiveModifierSpec restricts to this element scales the hit (resistance/weakness).
35+
ElementId? Element = null
3336
) : IEffectRequest;
3437

3538
public sealed class DealDamageEffectHandler : EffectRequestHandler<DealDamageEffectRequest>
@@ -275,7 +278,8 @@ private static int ApplyDamageAmountModifiers(
275278
SourceCombatant: source,
276279
SourceCardId: dealDamage.SourceCardId,
277280
Kind: dealDamage.Kind,
278-
RequestedAmount: dealDamage.Amount);
281+
RequestedAmount: dealDamage.Amount,
282+
Element: dealDamage.Element);
279283

280284
var currentAmount = dealDamage.Amount;
281285
var modifiers = registry.GetDamageAmountModifiers();

src/RogueDeck.Core/Combat/Effects/PassiveModifiers.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ public sealed record PassiveModifierSpec(
8080
StatusDefinitionId? AppliesToStatusId = null,
8181
// When set, the effective magnitude is computed from live state instead of the constant Magnitude
8282
// (e.g. Bloodlust scaling by missing HP). Evaluated against the combatant the spec is read from.
83-
IPassiveModifierMagnitude? MagnitudeExpression = null);
83+
IPassiveModifierMagnitude? MagnitudeExpression = null,
84+
// Damage pipelines only: when set, the spec applies only to damage of this element (fire/ice/…). This
85+
// is how a status expresses elemental resistance (ScalePercent 50 for Fire) or weakness (ScalePercent
86+
// 200). Null (default) = element-agnostic, so existing specs apply to all damage incl. untyped.
87+
ElementId? RestrictElement = null);
8488

8589
// Shared fold used by every generic declarative modifier.
8690
internal static class DeclarativePassiveModifierEngine
@@ -92,7 +96,8 @@ public static int Apply(
9296
PassiveModifierPipeline pipeline,
9397
DamageKind? damageKind,
9498
int amount,
95-
StatusDefinitionId? appliesToStatusId = null)
99+
StatusDefinitionId? appliesToStatusId = null,
100+
ElementId? damageElement = null)
96101
{
97102
// Legacy bespoke modifiers all no-op at non-positive amounts; preserve that.
98103
if (amount <= 0)
@@ -118,6 +123,10 @@ public static int Apply(
118123
// Status-application gate: a spec scoped to a specific status only augments that one.
119124
if (appliesToStatusId is { } applied && spec.AppliesToStatusId is { } specStatus && specStatus != applied)
120125
continue;
126+
// Element gate: an element-restricted spec applies only to damage of that element (so untyped
127+
// damage, or another element, is never scaled by a resistance/weakness meant for this one).
128+
if (spec.RestrictElement is { } specElement && specElement != damageElement)
129+
continue;
121130
applicable.Add((spec.Priority, group.Key.value, i, spec, totalStacks));
122131
}
123132
}
@@ -184,7 +193,8 @@ public int ModifyDamageAmount(DamageAmountModificationContext context, int curre
184193
return currentAmount;
185194

186195
return DeclarativePassiveModifierEngine.Apply(
187-
context.Combat, context.Registry, combatant, _pipeline, context.Kind, currentAmount);
196+
context.Combat, context.Registry, combatant, _pipeline, context.Kind, currentAmount,
197+
damageElement: context.Element);
188198
}
189199
}
190200

src/RogueDeck.Core/Combat/Effects/Programs/EffectProgramContracts.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ public interface IDealDamageNodeCore : INativeEffectOperationNode
177177
int EvaluateAmount(IEffectExecutionContextCore ctx, CombatState combat);
178178
EffectResultKey<OrderedTargetOutcomes<DamageOutcome>>? ResultKey { get; }
179179
bool IgnoresBlock => false;
180+
ElementId? Element => null;
180181
Type INativeEffectOperationNode.ProducedEffectRequestType => typeof(DealDamageEffectRequest);
181182
}
182183

src/RogueDeck.Core/Combat/Effects/Programs/EffectProgramNativeNodes.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ public sealed class DealDamageNode<TContext> : IDealDamageNodeCore, IEffectNode<
88
public ICombatExpression<TContext, int> Amount { get; }
99
public EffectResultKey<OrderedTargetOutcomes<DamageOutcome>>? ResultKey { get; }
1010
public bool IgnoresBlock { get; }
11+
public ElementId? Element { get; }
1112

1213
public IReadOnlyList<IEffectNode<TContext>> Children => [];
1314

1415
public DealDamageNode(
1516
ICombatantTargetSelector targetSelector,
1617
ICombatExpression<TContext, int> amount,
1718
EffectResultKey<OrderedTargetOutcomes<DamageOutcome>>? resultKey = null,
18-
bool ignoresBlock = false)
19+
bool ignoresBlock = false,
20+
ElementId? element = null)
1921
{
2022
ArgumentNullException.ThrowIfNull(targetSelector);
2123
ArgumentNullException.ThrowIfNull(amount);
@@ -24,6 +26,7 @@ public DealDamageNode(
2426
Amount = amount;
2527
ResultKey = resultKey;
2628
IgnoresBlock = ignoresBlock;
29+
Element = element;
2730
}
2831

2932
public ProducedResult? GetProducedResult() =>

src/RogueDeck.Core/Combat/Effects/Programs/EffectProgramNodeExecutors.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ public void Execute(IEffectNode node, IEffectExecutionContextCore ctx, CombatSta
588588
SourceCombatantId: ctx.BuildContext.Source.SourceCombatantId,
589589
SourceCardId: ctx.BuildContext.Source.SourceCardId,
590590
IgnoresBlock: typed.IgnoresBlock,
591+
Element: typed.Element,
591592
OutcomeSlot: slots?[i]));
592593

593594
if (typed.ResultKey is { } key)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using RogueDeck.Core.Combat;
2+
3+
namespace RogueDeck.Core.Tests;
4+
5+
// Opt-in damage elements + resistance/weakness. A hit may carry an ElementId; a combatant resists or is weak to
6+
// it via a status whose PassiveModifierSpec is RestrictElement-gated (ScalePercent 50 = half, 200 = double). This
7+
// rides the existing declarative passive-modifier pipeline, so it gets snapshot/restore/hash for free and untyped
8+
// damage is entirely unaffected.
9+
public class ElementalResistanceTests
10+
{
11+
private static readonly CombatantId HeroId = new("hero_001");
12+
private static readonly CombatantId GoblinId = new("goblin_001");
13+
private static readonly PackageId Pkg = new("element.test");
14+
private static readonly ElementId Fire = new("fire");
15+
private static readonly ElementId Ice = new("ice");
16+
17+
private sealed record Ctx;
18+
19+
private static StatusDefinition ResistStatus(string id, int scalePercent, ElementId element) =>
20+
new(new StatusDefinitionId(id), Pkg, $"status.{id}.name", $"status.{id}.desc",
21+
usesStacks: false,
22+
passiveModifiers: new[]
23+
{
24+
new PassiveModifierSpec(
25+
PassiveModifierPipeline.DamageReceived, PassiveModifierOperation.ScalePercent,
26+
scalePercent, RestrictElement: element),
27+
});
28+
29+
[Fact]
30+
public void Fire_resistance_halves_fire_damage_but_not_untyped_or_other_elements()
31+
{
32+
var fireResist = ResistStatus("fire_resist", 50, Fire);
33+
var (combat, registry) = Fight(fireResist);
34+
ApplyStatus(combat, registry, GoblinId, fireResist.Id);
35+
var hp = combat.GetCombatant(GoblinId).Health.Current;
36+
37+
Damage(combat, registry, 8, Fire); // 8 → ×50% = 4
38+
Assert.Equal(hp - 4, combat.GetCombatant(GoblinId).Health.Current);
39+
40+
Damage(combat, registry, 3, element: null); // untyped is unaffected
41+
Assert.Equal(hp - 4 - 3, combat.GetCombatant(GoblinId).Health.Current);
42+
43+
Damage(combat, registry, 3, Ice); // a different element is unaffected
44+
Assert.Equal(hp - 4 - 3 - 3, combat.GetCombatant(GoblinId).Health.Current);
45+
}
46+
47+
[Fact]
48+
public void Fire_weakness_doubles_only_fire_damage()
49+
{
50+
var fireWeak = ResistStatus("fire_weak", 200, Fire);
51+
var (combat, registry) = Fight(fireWeak);
52+
ApplyStatus(combat, registry, GoblinId, fireWeak.Id);
53+
var hp = combat.GetCombatant(GoblinId).Health.Current;
54+
55+
Damage(combat, registry, 3, Fire); // 3 → ×200% = 6
56+
Assert.Equal(hp - 6, combat.GetCombatant(GoblinId).Health.Current);
57+
58+
Damage(combat, registry, 3, element: null); // untyped unaffected
59+
Assert.Equal(hp - 6 - 3, combat.GetCombatant(GoblinId).Health.Current);
60+
}
61+
62+
[Fact]
63+
public void DealDamageNode_carries_the_element_through_to_the_pipeline()
64+
{
65+
var fireResist = ResistStatus("fire_resist", 50, Fire);
66+
var (combat, registry) = Fight(fireResist);
67+
ApplyStatus(combat, registry, GoblinId, fireResist.Id);
68+
var hp = combat.GetCombatant(GoblinId).Health.Current;
69+
70+
var program = new EffectProgram<Ctx>(new DealDamageNode<Ctx>(
71+
CombatantTargetSelectors.EventTarget, new ConstantExpression<Ctx>(8), element: Fire));
72+
EffectProgramExecutor.Execute(program, MakeContext(combat), combat);
73+
new CombatQueueProcessor().ResolvePendingQueues(combat, registry);
74+
75+
Assert.Equal(hp - 4, combat.GetCombatant(GoblinId).Health.Current); // node-authored fire damage halved
76+
}
77+
78+
[Fact]
79+
public void Resistance_survives_save_and_restore()
80+
{
81+
var fireResist = ResistStatus("fire_resist", 50, Fire);
82+
var (combat, registry) = Fight(fireResist);
83+
ApplyStatus(combat, registry, GoblinId, fireResist.Id);
84+
85+
var restored = CombatState.Restore(combat.CreateSnapshot());
86+
var hp = restored.GetCombatant(GoblinId).Health.Current;
87+
88+
Damage(restored, registry, 8, Fire); // still halved after a save/restore round-trip
89+
Assert.Equal(hp - 4, restored.GetCombatant(GoblinId).Health.Current);
90+
}
91+
92+
private static (CombatState, CombatDefinitionRegistry) Fight(StatusDefinition resist)
93+
{
94+
var builder = CombatTestFactory.CreateStandardBuilder();
95+
builder.RegisterStatus(resist);
96+
return (CombatTestFactory.CreateCombatWithHeroAndGoblin(), builder.Build());
97+
}
98+
99+
private static void ApplyStatus(CombatState combat, CombatDefinitionRegistry registry, CombatantId target, StatusDefinitionId id)
100+
{
101+
combat.EnqueueEffect(new ApplyStatusEffectRequest(TargetCombatantId: target, StatusDefinitionId: id, Stacks: 0));
102+
new CombatQueueProcessor().ResolvePendingQueues(combat, registry);
103+
}
104+
105+
private static void Damage(CombatState combat, CombatDefinitionRegistry registry, int amount, ElementId? element)
106+
{
107+
combat.EnqueueEffect(new DealDamageEffectRequest(GoblinId, amount, SourceCombatantId: HeroId, Element: element));
108+
new CombatQueueProcessor().ResolvePendingQueues(combat, registry);
109+
}
110+
111+
private static EffectExecutionContext<Ctx> MakeContext(CombatState combat) =>
112+
new(new Ctx(),
113+
new TriggeredEffectActionBuildContext(
114+
new CombatantTargetSelectionContext(
115+
Combat: combat, Source: combat.GetCombatant(HeroId), EventTargetId: GoblinId),
116+
new TriggeredEffectActionSource(SourceCombatantId: HeroId)));
117+
}

tests/RogueDeck.Core.Tests/CombatJsonBroadNodeTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public void Status_and_card_nodes_round_trip()
5151
RoundTrips(new SetCombatantCounterNode<CardPlayContext>(Source, new CounterId("combo"), Const(1)));
5252
RoundTrips(new SetCombatantCounterNode<CardPlayContext>(Source, new CounterId("combo"), Const(5), relative: false));
5353
RoundTrips(new CombatantCounterExpression<CardPlayContext>(Source, new CounterId("combo")));
54+
RoundTrips(new DealDamageNode<CardPlayContext>(Enemy, Const(8), element: new ElementId("fire")));
5455
RoundTrips(new ModifyStatusStacksNode<CardPlayContext>(Enemy, burn, Const(2)));
5556
RoundTrips(new ModifyStatusDurationNode<CardPlayContext>(Enemy, burn, Const(1)));
5657
RoundTrips(new ModifyStatusChargesNode<CardPlayContext>(Enemy, burn, Const(1)));

0 commit comments

Comments
 (0)