Skip to content

Commit 65fbaec

Browse files
feat(combat): status-instance target selection + remove-selected op (#2 slice 1)
The effect target domain was combatant-only: status ops could name a definition ('remove Poison') or a whole polarity ('remove all buffs') but never a single status INSTANCE. Add that missing selector, the status-domain analog of card- instance targeting: - StatusSelectionSpec (polarity filter x First[index]/Random) + StatusSelection.Resolve, a reusable, serializable picker shared by future ops (reduce/copy/steal). - RemoveStatusInstanceEffectRequest/Handler: removes one instance by StatusInstanceId (vs RemoveStatus which clears a whole definition). - RemoveSelectedStatusNode<T> + executor (registered) consuming the spec per target. Expresses 'remove a random buff', 'remove the enemy's first debuff'. Random advances the combat RNG (replay-stable). Core 1355->1361. Follow-ups: CombatJson round-trip, reduce/copy/steal ops, resource-pool domain.
1 parent d0fa44d commit 65fbaec

7 files changed

Lines changed: 307 additions & 0 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,15 @@ public interface IRemoveStatusesByPolarityNodeCore : INativeEffectOperationNode
268268
Type INativeEffectOperationNode.ProducedEffectRequestType => typeof(RemoveStatusesByPolarityEffectRequest);
269269
}
270270

271+
// Removes ONE selected status instance per target (#3): the target selector picks the combatant(s); the
272+
// StatusSelectionSpec picks which of that combatant's statuses (a random buff, the first debuff, …).
273+
public interface IRemoveSelectedStatusNodeCore : INativeEffectOperationNode
274+
{
275+
ICombatantTargetSelector TargetSelector { get; }
276+
StatusSelectionSpec Selection { get; }
277+
Type INativeEffectOperationNode.ProducedEffectRequestType => typeof(RemoveStatusInstanceEffectRequest);
278+
}
279+
271280
public interface IModifyStatusStacksNodeCore : INativeEffectOperationNode
272281
{
273282
ICombatantTargetSelector TargetSelector { get; }

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,27 @@ ResultKey is { } key
397397
: null;
398398
}
399399

400+
// Removes a single SELECTED status instance from each target (#3): "remove a random buff", "remove the enemy's
401+
// first debuff". The instance is chosen at execution time by StatusSelection against the live state.
402+
public sealed class RemoveSelectedStatusNode<TContext> : IRemoveSelectedStatusNodeCore, IEffectNode<TContext>
403+
where TContext : class
404+
{
405+
public ICombatantTargetSelector TargetSelector { get; }
406+
public IEnumerable<ICombatantTargetSelector> GetTargetSelectors() => [TargetSelector];
407+
public StatusSelectionSpec Selection { get; }
408+
409+
public IReadOnlyList<IEffectNode<TContext>> Children => [];
410+
411+
public RemoveSelectedStatusNode(ICombatantTargetSelector targetSelector, StatusSelectionSpec selection)
412+
{
413+
ArgumentNullException.ThrowIfNull(targetSelector);
414+
ArgumentNullException.ThrowIfNull(selection);
415+
416+
TargetSelector = targetSelector;
417+
Selection = selection;
418+
}
419+
}
420+
400421
public sealed class RemoveStatusesByPolarityNode<TContext> : IRemoveStatusesByPolarityNodeCore, IEffectNode<TContext>
401422
where TContext : class
402423
{

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ private static EffectNodeExecutorRegistry CreateDefault()
6363
r.RegisterOpenGeneric(typeof(RefillResourceNode<>), new RefillResourceNodeExecutor());
6464
r.RegisterOpenGeneric(typeof(ApplyStatusNode<>), new ApplyStatusNodeExecutor());
6565
r.RegisterOpenGeneric(typeof(RemoveStatusNode<>), new RemoveStatusNodeExecutor());
66+
r.RegisterOpenGeneric(typeof(RemoveSelectedStatusNode<>), new RemoveSelectedStatusNodeExecutor());
6667
r.RegisterOpenGeneric(typeof(RemoveStatusesByPolarityNode<>), new RemoveStatusesByPolarityNodeExecutor());
6768
r.RegisterOpenGeneric(typeof(ModifyStatusStacksNode<>), new ModifyStatusStacksNodeExecutor());
6869
r.RegisterOpenGeneric(typeof(ModifyStatusDurationNode<>), new ModifyStatusDurationNodeExecutor());
@@ -906,6 +907,25 @@ public void Execute(IEffectNode node, IEffectExecutionContextCore ctx, CombatSta
906907
}
907908
}
908909

910+
internal sealed class RemoveSelectedStatusNodeExecutor : IEffectNodeExecutor
911+
{
912+
public void Execute(IEffectNode node, IEffectExecutionContextCore ctx, CombatState combat,
913+
Action<CombatState>? onComplete, Action<IEffectNode, CombatState, Action<CombatState>?> dispatch)
914+
{
915+
var typed = (IRemoveSelectedStatusNodeCore)node;
916+
917+
foreach (var target in typed.TargetSelector.ResolveTargetsTraced(ctx, combat))
918+
{
919+
var statusId = StatusSelection.Resolve(combat, target, typed.Selection);
920+
if (statusId is { } id)
921+
combat.EnqueueEffect(new RemoveStatusInstanceEffectRequest(target, id));
922+
}
923+
924+
if (onComplete is not null)
925+
combat.EnqueueContinuation(onComplete);
926+
}
927+
}
928+
909929
internal sealed class RemoveStatusNodeExecutor : IEffectNodeExecutor
910930
{
911931
public void Execute(IEffectNode node, IEffectExecutionContextCore ctx, CombatState combat,

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,48 @@ protected override void Resolve(
458458
}
459459
}
460460

461+
// Removes ONE specific status instance (addressed by its StatusInstanceId), unlike RemoveStatusEffectRequest
462+
// which removes every instance of a definition. Backs the status-instance selection ops (#3): "remove a random
463+
// buff" resolves an instance id, then this removes exactly it. A no-op (empty outcome) if the instance is gone.
464+
public sealed record RemoveStatusInstanceEffectRequest(
465+
CombatantId TargetCombatantId,
466+
StatusInstanceId StatusInstanceId,
467+
RemoveStatusOutcomeSlot? OutcomeSlot = null
468+
) : IEffectRequest;
469+
470+
public sealed class RemoveStatusInstanceEffectHandler : EffectRequestHandler<RemoveStatusInstanceEffectRequest>
471+
{
472+
protected override void Resolve(
473+
CombatState combat,
474+
CombatDefinitionRegistry registry,
475+
RemoveStatusInstanceEffectRequest request)
476+
{
477+
var target = combat.GetCombatant(request.TargetCombatantId);
478+
var status = target.Statuses.FirstOrDefault(s => s.Id == request.StatusInstanceId);
479+
480+
if (status is null)
481+
{
482+
if (request.OutcomeSlot is { } emptySlot)
483+
emptySlot.Value = new RemoveStatusOutcome(0, []);
484+
return;
485+
}
486+
487+
target.RemoveStatus(status);
488+
489+
if (request.OutcomeSlot is { } slot)
490+
slot.Value = new RemoveStatusOutcome(1, [status.Id]);
491+
492+
combat.AddLogEntry(
493+
StandardCombatLogTypes.StatusRemoved,
494+
$"Removed status instance '{status.DefinitionId}' from '{request.TargetCombatantId}'.");
495+
496+
combat.EnqueueEvent(new StatusRemovedCombatEvent(
497+
request.TargetCombatantId,
498+
[status.Id],
499+
status.DefinitionId));
500+
}
501+
}
502+
461503
public sealed record DecreaseStatusChargesEffectRequest(
462504
CombatantId TargetCombatantId,
463505
StatusInstanceId StatusInstanceId,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
namespace RogueDeck.Core.Combat;
2+
3+
// ── Status-instance selection (#3 non-combatant target domains) ────────────────
4+
//
5+
// The effect-program target domain has always been the COMBATANT: a node points at combatants and acts on
6+
// them wholesale. Status operations could only name a status by DEFINITION ("remove Poison", "remove all
7+
// buffs") — there was no way to point at a single status INSTANCE the way card targeting points at a single
8+
// card instance. This adds that missing selector: given a combatant, pick ONE of its status instances by a
9+
// polarity filter + a pick mode, so ops like "remove a RANDOM buff" or "steal the enemy's first debuff" become
10+
// expressible. It mirrors the card-instance selection family (positional / random) at the status altitude.
11+
12+
// Which statuses on a combatant are eligible to be picked.
13+
public enum StatusPolarityFilter
14+
{
15+
Any,
16+
Buff,
17+
Debuff,
18+
}
19+
20+
// How to pick among the eligible statuses.
21+
public enum StatusPick
22+
{
23+
First, // deterministic: the Index-th eligible status in owner order (a pure read)
24+
Random, // uniform via the combat RNG (deterministic by seed; advances the RNG step)
25+
}
26+
27+
// A reusable, serializable description of "which one status instance on a combatant". Shared by every op that
28+
// consumes a chosen status (remove today; reduce/copy/steal are follow-ups), so the selection logic lives once.
29+
public sealed record StatusSelectionSpec(
30+
StatusPolarityFilter Polarity = StatusPolarityFilter.Any,
31+
StatusPick Pick = StatusPick.First,
32+
int Index = 0);
33+
34+
public static class StatusSelection
35+
{
36+
private static bool Matches(StatusInstance status, StatusPolarityFilter filter) => filter switch
37+
{
38+
StatusPolarityFilter.Buff => status.Polarity == StatusPolarity.Buff,
39+
StatusPolarityFilter.Debuff => status.Polarity == StatusPolarity.Debuff,
40+
_ => true,
41+
};
42+
43+
// Resolves the spec against a combatant's live statuses. Returns null when the combatant is absent or no
44+
// status matches the filter. For StatusPick.Random this advances the combat RNG step (like the random card
45+
// selector), so a replay reproduces the pick.
46+
public static StatusInstanceId? Resolve(CombatState combat, CombatantId owner, StatusSelectionSpec spec)
47+
{
48+
if (!combat.TryGetCombatant(owner, out var combatant) || combatant is null)
49+
return null;
50+
51+
var matches = combatant.Statuses.Where(s => Matches(s, spec.Polarity)).ToList();
52+
if (matches.Count == 0)
53+
return null;
54+
55+
switch (spec.Pick)
56+
{
57+
case StatusPick.Random:
58+
var index = CombatRandom.CreateShuffledIndexes(matches.Count, combat.RandomSeed, combat.RandomStep)[0];
59+
combat.AdvanceRandomStep();
60+
return matches[index].Id;
61+
default: // First
62+
return spec.Index >= 0 && spec.Index < matches.Count ? matches[spec.Index].Id : null;
63+
}
64+
}
65+
}

src/RogueDeck.Core/Combat/StandardCombatPackage.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ public void RegisterDefinitions(CombatDefinitionRegistryBuilder registry)
7171
registry.RegisterEffectRequestHandler(new ApplyStatusEffectHandler());
7272
registry.RegisterEffectRequestHandler(new RemoveStatusEffectHandler());
7373
registry.RegisterEffectRequestHandler(new RemoveStatusesByPolarityEffectHandler());
74+
registry.RegisterEffectRequestHandler(new RemoveStatusInstanceEffectHandler());
7475
registry.RegisterEffectRequestHandler(new ModifyStatusStacksEffectHandler());
7576
registry.RegisterEffectRequestHandler(new ModifyStatusDurationEffectHandler());
7677
registry.RegisterEffectRequestHandler(new ModifyStatusChargesEffectHandler());
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using RogueDeck.Core.Combat;
2+
3+
namespace RogueDeck.Core.Tests;
4+
5+
// #3 non-combatant target domains: an effect can now point at a single status INSTANCE on a combatant (not just
6+
// a status definition or a whole polarity). RemoveSelectedStatusNode + StatusSelectionSpec express "remove a
7+
// random buff", "remove the enemy's first debuff", etc. — the status-domain analog of card-instance targeting.
8+
public class SelectedStatusTargetingTests
9+
{
10+
private static readonly CombatantId HeroId = new("hero_001");
11+
private static readonly CombatantId GoblinId = new("goblin_001");
12+
13+
private static readonly StatusDefinitionId StrengthId = new("test.buff_strength");
14+
private static readonly StatusDefinitionId DexterityId = new("test.buff_dexterity");
15+
private static readonly StatusDefinitionId PoisonId = new("test.debuff_poison");
16+
private static readonly StatusDefinitionId BurnId = new("test.debuff_burn");
17+
18+
private sealed record Ctx;
19+
20+
[Fact]
21+
public void First_pick_removes_the_selected_polarity_and_keeps_the_rest()
22+
{
23+
var registry = CreateRegistry();
24+
var combat = CombatTestFactory.CreateCombatWithHeroAndGoblin();
25+
ApplyStatus(combat, registry, GoblinId, StrengthId, 2);
26+
ApplyStatus(combat, registry, GoblinId, PoisonId, 3);
27+
28+
RunRemoveSelected(combat, registry, new StatusSelectionSpec(StatusPolarityFilter.Debuff));
29+
30+
var remaining = Assert.Single(combat.GetCombatant(GoblinId).Statuses);
31+
Assert.Equal(StrengthId, remaining.DefinitionId); // the buff survived; the debuff was removed
32+
}
33+
34+
[Fact]
35+
public void Removing_a_random_buff_removes_exactly_one_buff_and_no_debuff()
36+
{
37+
var registry = CreateRegistry();
38+
var combat = CombatTestFactory.CreateCombatWithHeroAndGoblin();
39+
ApplyStatus(combat, registry, GoblinId, StrengthId, 2);
40+
ApplyStatus(combat, registry, GoblinId, DexterityId, 2);
41+
ApplyStatus(combat, registry, GoblinId, PoisonId, 3);
42+
43+
RunRemoveSelected(combat, registry, new StatusSelectionSpec(StatusPolarityFilter.Buff, StatusPick.Random));
44+
45+
var statuses = combat.GetCombatant(GoblinId).Statuses;
46+
Assert.Equal(2, statuses.Count); // exactly one removed
47+
Assert.Equal(1, statuses.Count(s => s.Polarity == StatusPolarity.Buff)); // one buff gone
48+
Assert.Contains(statuses, s => s.DefinitionId == PoisonId); // the debuff is untouched
49+
}
50+
51+
[Fact]
52+
public void Index_selects_the_nth_matching_status()
53+
{
54+
var registry = CreateRegistry();
55+
var combat = CombatTestFactory.CreateCombatWithHeroAndGoblin();
56+
ApplyStatus(combat, registry, GoblinId, PoisonId, 3); // debuff #0
57+
ApplyStatus(combat, registry, GoblinId, BurnId, 1); // debuff #1
58+
59+
RunRemoveSelected(combat, registry, new StatusSelectionSpec(StatusPolarityFilter.Debuff, Index: 1));
60+
61+
var remaining = Assert.Single(combat.GetCombatant(GoblinId).Statuses);
62+
Assert.Equal(PoisonId, remaining.DefinitionId); // the 2nd debuff (Burn) was removed
63+
}
64+
65+
[Fact]
66+
public void No_matching_status_is_a_no_op()
67+
{
68+
var registry = CreateRegistry();
69+
var combat = CombatTestFactory.CreateCombatWithHeroAndGoblin();
70+
ApplyStatus(combat, registry, GoblinId, PoisonId, 3); // only a debuff present
71+
72+
RunRemoveSelected(combat, registry, new StatusSelectionSpec(StatusPolarityFilter.Buff));
73+
74+
Assert.Single(combat.GetCombatant(GoblinId).Statuses); // nothing removed
75+
}
76+
77+
[Fact]
78+
public void The_instance_request_removes_only_the_addressed_instance()
79+
{
80+
var registry = CreateRegistry();
81+
var combat = CombatTestFactory.CreateCombatWithHeroAndGoblin();
82+
ApplyStatus(combat, registry, GoblinId, StrengthId, 2);
83+
ApplyStatus(combat, registry, GoblinId, PoisonId, 3);
84+
85+
var poison = combat.GetCombatant(GoblinId).Statuses.Single(s => s.DefinitionId == PoisonId);
86+
combat.EnqueueEffect(new RemoveStatusInstanceEffectRequest(GoblinId, poison.Id));
87+
new CombatQueueProcessor().ResolvePendingQueues(combat, registry);
88+
89+
var remaining = Assert.Single(combat.GetCombatant(GoblinId).Statuses);
90+
Assert.Equal(StrengthId, remaining.DefinitionId);
91+
}
92+
93+
[Fact]
94+
public void Standard_package_registers_the_instance_removal_handler()
95+
{
96+
var registry = CombatTestFactory.CreateStandardRegistry();
97+
Assert.IsType<RemoveStatusInstanceEffectHandler>(
98+
registry.GetEffectRequestHandler(typeof(RemoveStatusInstanceEffectRequest)));
99+
}
100+
101+
private static void RunRemoveSelected(
102+
CombatState combat, CombatDefinitionRegistry registry, StatusSelectionSpec spec)
103+
{
104+
var ctx = MakeContext(combat);
105+
var program = new EffectProgram<Ctx>(new RemoveSelectedStatusNode<Ctx>(
106+
CombatantTargetSelectors.EventTarget, spec));
107+
EffectProgramExecutor.Execute(program, ctx, combat);
108+
new CombatQueueProcessor().ResolvePendingQueues(combat, registry);
109+
}
110+
111+
private static CombatDefinitionRegistry CreateRegistry()
112+
{
113+
var builder = CombatTestFactory.CreateStandardBuilder();
114+
RegisterStatus(builder, StrengthId, StatusPolarity.Buff);
115+
RegisterStatus(builder, DexterityId, StatusPolarity.Buff);
116+
RegisterStatus(builder, PoisonId, StatusPolarity.Debuff);
117+
RegisterStatus(builder, BurnId, StatusPolarity.Debuff);
118+
return builder.Build();
119+
}
120+
121+
private static void RegisterStatus(
122+
CombatDefinitionRegistryBuilder builder, StatusDefinitionId id, StatusPolarity polarity) =>
123+
builder.RegisterStatus(new StatusDefinition(
124+
id,
125+
new PackageId("test"),
126+
displayNameKey: $"status.{id}.name",
127+
descriptionKey: $"status.{id}.desc",
128+
polarity: polarity,
129+
usesStacks: true,
130+
showStacksInUi: true,
131+
stackingBehavior: StatusStackingBehavior.MergeWithExistingInstance));
132+
133+
private static void ApplyStatus(
134+
CombatState combat, CombatDefinitionRegistry registry, CombatantId targetId, StatusDefinitionId statusId, int stacks)
135+
{
136+
combat.EnqueueEffect(new ApplyStatusEffectRequest(
137+
TargetCombatantId: targetId, StatusDefinitionId: statusId, Stacks: stacks));
138+
new CombatQueueProcessor().ResolvePendingQueues(combat, registry);
139+
}
140+
141+
private static EffectExecutionContext<Ctx> MakeContext(CombatState combat) =>
142+
new(new Ctx(),
143+
new TriggeredEffectActionBuildContext(
144+
new CombatantTargetSelectionContext(
145+
Combat: combat,
146+
Source: combat.GetCombatant(HeroId),
147+
EventTargetId: GoblinId),
148+
new TriggeredEffectActionSource(SourceCombatantId: HeroId)));
149+
}

0 commit comments

Comments
 (0)