1
0
Fork 0

Add stacking rules to effect

This commit is contained in:
Pabloader 2026-04-29 18:12:40 +00:00
parent d7a3830740
commit 561ffb1d7e
7 changed files with 148 additions and 52 deletions

View File

@ -11,10 +11,6 @@ definition registry (neutral/friendly/hostile thresholds) pairs with this.
## Deferred Combat Features ## Deferred Combat Features
### Effect stacking rules
Add `stack` (default) / `unique` / `replace` modes with a tag discriminator to
`Effect`. Prevents e.g. multiple poison stacks when the design calls for one.
### Damage modifiers ### Damage modifiers
Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on
the attacker/source that CombatSystem folds into the final damage value. the attacker/source that CombatSystem folds into the final damage value.

View File

@ -13,31 +13,56 @@ export class Effect extends Component<{
remaining: number | null; // countdown in seconds; null for condition-based/permanent remaining: number | null; // countdown in seconds; null for condition-based/permanent
condition: string | null; // keep effect while true; remove when it becomes false condition: string | null; // keep effect while true; remove when it becomes false
scope: 'equip' | 'onHit'; // 'equip' = live modifier on owner; 'onHit' = template applied to attack target scope: 'equip' | 'onHit'; // 'equip' = live modifier on owner; 'onHit' = template applied to attack target
stacking: 'stack' | 'unique' | 'replace';
tag: string | null; // discriminator for stacking; null = no stacking enforcement
}> { }> {
/** True while the effect's delta is applied to the target stat. */ /** True while the effect's delta is applied to the target stat. */
@variable('.') active: boolean = false; @variable('.') active: boolean = false;
constructor( constructor(opts: {
targetStat: string, targetStat: string;
delta: number, delta: number;
targetField?: 'value' | 'max' | 'min', targetField?: 'value' | 'max' | 'min';
duration?: number, duration?: number;
condition?: string, condition?: string;
scope?: 'equip' | 'onHit', scope?: 'equip' | 'onHit';
) { stacking?: 'stack' | 'unique' | 'replace';
tag?: string;
}) {
super({ super({
targetStat, targetStat: opts.targetStat,
targetField: targetField ?? 'value', targetField: opts.targetField ?? 'value',
delta, delta: opts.delta,
duration: duration ?? null, duration: opts.duration ?? null,
remaining: duration ?? null, remaining: opts.duration ?? null,
condition: condition ?? null, condition: opts.condition ?? null,
scope: scope ?? 'equip', scope: opts.scope ?? 'equip',
stacking: opts.stacking ?? 'stack',
tag: opts.tag ?? null,
}); });
} }
override onAdd(): void { override onAdd(): void {
if (this.state.scope === 'onHit') return; if (this.state.scope === 'onHit') return;
const { stacking, tag } = this.state;
if (tag != null && stacking !== 'stack') {
const siblings = this.entity.getAll(Effect).filter(e => e !== this && e.state.tag === tag);
if (stacking === 'unique' && siblings.length > 0) {
// An effect with this tag is already active — discard the incoming one.
this.entity.remove(this.key);
return;
}
if (stacking === 'replace') {
for (const old of siblings) {
this.entity.remove(old.key);
}
}
}
const stat = this.entity.get(Stat, this.state.targetStat); const stat = this.entity.get(Stat, this.state.targetStat);
if (stat) { if (stat) {
stat.applyModifier(this.state.delta, this.state.targetField); stat.applyModifier(this.state.delta, this.state.targetField);
@ -46,7 +71,7 @@ export class Effect extends Component<{
} }
override onRemove(): void { override onRemove(): void {
if (this.state.scope === 'onHit') return; if (!this.active) return;
const stat = this.entity.get(Stat, this.state.targetStat); const stat = this.entity.get(Stat, this.state.targetStat);
if (stat) { if (stat) {
stat.removeModifier(this.state.delta, this.state.targetField); stat.removeModifier(this.state.delta, this.state.targetField);
@ -67,7 +92,6 @@ export class Effect extends Component<{
@action @action
clear(): void { clear(): void {
this.state.remaining = 0; this.state.remaining = 0;
this.active = false;
this.emit('expired'); this.emit('expired');
} }

View File

@ -55,7 +55,15 @@ export class CombatSystem extends System {
const s = component.state; const s = component.state;
target.add( target.add(
`__hit_${source.id}_${key}_${hitEffectCounter++}`, `__hit_${source.id}_${key}_${hitEffectCounter++}`,
new Effect(s.targetStat, s.delta, s.targetField, s.duration ?? undefined, s.condition ?? undefined), new Effect({
targetStat: s.targetStat,
delta: s.delta,
targetField: s.targetField,
duration: s.duration ?? undefined,
condition: s.condition ?? undefined,
stacking: s.stacking,
tag: s.tag ?? undefined,
}),
); );
} }

View File

@ -7,7 +7,7 @@ export class EffectSystem extends System {
for (const [entity, key, effect] of world.query(Effect)) { for (const [entity, key, effect] of world.query(Effect)) {
effect.update(dt, entity.context); effect.update(dt, entity.context);
if (!effect.active && effect.state.scope !== 'onHit') { if (effect.state.remaining !== null && effect.state.remaining <= 0) {
expired.push([entity, key]); expired.push([entity, key]);
} }
} }

View File

@ -147,7 +147,7 @@ describe('CombatSystem — on-hit effects', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
sword.add('burn', new Effect('health', -5, 'value', 10, undefined, 'onHit')); sword.add('burn', new Effect({ targetStat: 'health', delta: -5, targetField: 'value', duration: 10, scope: 'onHit' }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add('health', new Health({ value: 100, min: 0 }));
@ -162,7 +162,7 @@ describe('CombatSystem — on-hit effects', () => {
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' })); sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
sword.add('str', new Stat({ value: 50 })); sword.add('str', new Stat({ value: 50 }));
sword.add('drain', new Effect('str', -99, 'value', undefined, undefined, 'onHit')); sword.add('drain', new Effect({ targetStat: 'str', delta: -99, targetField: 'value', scope: 'onHit' }));
expect(sword.get(Stat, 'str')!.value).toBe(50); expect(sword.get(Stat, 'str')!.value).toBe(50);
}); });
@ -170,7 +170,7 @@ describe('CombatSystem — on-hit effects', () => {
const w = world(); const w = world();
const sword = w.createEntity('sword'); const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' })); sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
sword.add('burn', new Effect('health', -10, 'value', 2, undefined, 'onHit')); sword.add('burn', new Effect({ targetStat: 'health', delta: -10, targetField: 'value', duration: 2, scope: 'onHit' }));
w.createEntity('attacker'); w.createEntity('attacker');
const target = w.createEntity('target'); const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 })); target.add('health', new Health({ value: 100, min: 0 }));
@ -183,6 +183,74 @@ describe('CombatSystem — on-hit effects', () => {
}); });
}); });
describe('Effect stacking', () => {
it('stack: allows multiple effects with the same tag', () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' }));
expect(target.getAll(Effect).length).toBe(2);
expect(target.get(Health)!.value).toBe(90);
});
it('unique: second effect with same tag is discarded', () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
expect(target.getAll(Effect).length).toBe(1);
expect(target.get(Health)!.value).toBe(95);
});
it('unique: effects with different tags both apply', () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -3, stacking: 'unique', tag: 'burn' }));
expect(target.getAll(Effect).length).toBe(2);
expect(target.get(Health)!.value).toBe(92);
});
it('replace: removes existing effect and applies new one', () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'replace', tag: 'poison' }));
expect(target.get(Health)!.value).toBe(95);
target.add('e2', new Effect({ targetStat: 'health', delta: -10, stacking: 'replace', tag: 'poison' }));
expect(target.getAll(Effect).length).toBe(1);
expect(target.get(Health)!.value).toBe(90); // old -5 reversed, new -10 applied
});
it('no tag: unique mode does not enforce limits', () => {
const w = world();
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' }));
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' }));
expect(target.getAll(Effect).length).toBe(2);
expect(target.get(Health)!.value).toBe(90);
});
it('unique onHit effect is discarded when same tag already on target', () => {
const w = world();
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 0, damageType: 'physical' }));
sword.add('burn', new Effect({ targetStat: 'health', delta: -5, duration: 10, scope: 'onHit', stacking: 'unique', tag: 'burn' }));
w.createEntity('attacker');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
target.add('pre', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'burn' }));
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
w.update(1);
expect(target.getAll(Effect).length).toBe(1); // onHit copy discarded
expect(target.get(Health)!.value).toBe(95);
});
});
describe('CombatSystem — events', () => { describe('CombatSystem — events', () => {
it("emits 'hit' on target with attack info", () => { it("emits 'hit' on target with attack info", () => {
const w = world(); const w = world();

View File

@ -20,13 +20,13 @@ function withStat(value = 10, min?: number, max?: number) {
describe('Effect — onAdd / onRemove', () => { describe('Effect — onAdd / onRemove', () => {
it('applies delta to stat on add', () => { it('applies delta to stat on add', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 5)); e.add('fx', new Effect({ targetStat: 'str', delta: 5 }));
expect(stat.value).toBe(15); expect(stat.value).toBe(15);
}); });
it('reverts delta on remove', () => { it('reverts delta on remove', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 5)); e.add('fx', new Effect({ targetStat: 'str', delta: 5 }));
e.remove('fx'); e.remove('fx');
expect(stat.value).toBe(10); expect(stat.value).toBe(10);
}); });
@ -34,7 +34,7 @@ describe('Effect — onAdd / onRemove', () => {
it('applies delta to max field', () => { it('applies delta to max field', () => {
const { e } = withStat(10, undefined, 20); const { e } = withStat(10, undefined, 20);
const s = e.get(Stat, 'str')!; const s = e.get(Stat, 'str')!;
e.add('fx', new Effect('str', 10, 'max')); e.add('fx', new Effect({ targetStat: 'str', delta: 10, targetField: 'max' }));
expect(s.max).toBe(30); expect(s.max).toBe(30);
e.remove('fx'); e.remove('fx');
expect(s.max).toBe(20); expect(s.max).toBe(20);
@ -42,21 +42,21 @@ describe('Effect — onAdd / onRemove', () => {
it('active is true after add', () => { it('active is true after add', () => {
const { e } = withStat(10); const { e } = withStat(10);
e.add('fx', new Effect('str', 1)); e.add('fx', new Effect({ targetStat: 'str', delta: 1 }));
expect(e.get(Effect, 'fx')!.active).toBeTrue(); expect(e.get(Effect, 'fx')!.active).toBeTrue();
}); });
it('no-op if target stat is missing', () => { it('no-op if target stat is missing', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
expect(() => e.add('fx', new Effect('str', 5))).not.toThrow(); expect(() => e.add('fx', new Effect({ targetStat: 'str', delta: 5 }))).not.toThrow();
}); });
}); });
describe('Effect — duration', () => { describe('Effect — duration', () => {
it('expires after duration ticks', () => { it('expires after duration ticks', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 2)); e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 }));
expect(stat.value).toBe(15); expect(stat.value).toBe(15);
w.update(1); w.update(1);
@ -69,7 +69,7 @@ describe('Effect — duration', () => {
it('emits expired before removal', () => { it('emits expired before removal', () => {
const { w, e } = withStat(10); const { w, e } = withStat(10);
e.add('fx', new Effect('str', 1, 'value', 1)); e.add('fx', new Effect({ targetStat: 'str', delta: 1, duration: 1 }));
const events: string[] = []; const events: string[] = [];
e.on('fx.expired', () => events.push('expired')); e.on('fx.expired', () => events.push('expired'));
w.update(1); w.update(1);
@ -78,7 +78,7 @@ describe('Effect — duration', () => {
it('reset() restarts timer', () => { it('reset() restarts timer', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 2)); e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 }));
w.update(1.5); w.update(1.5);
e.get(Effect, 'fx')!.reset(); e.get(Effect, 'fx')!.reset();
w.update(1.5); // would have expired without reset w.update(1.5); // would have expired without reset
@ -88,7 +88,7 @@ describe('Effect — duration', () => {
it('reset(duration) changes duration', () => { it('reset(duration) changes duration', () => {
const { w, e } = withStat(10); const { w, e } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 1)); e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1 }));
e.get(Effect, 'fx')!.reset(10); e.get(Effect, 'fx')!.reset(10);
w.update(5); w.update(5);
expect(e.has(Effect)).toBeTrue(); expect(e.has(Effect)).toBeTrue();
@ -96,7 +96,7 @@ describe('Effect — duration', () => {
it('clear() immediately expires effect', () => { it('clear() immediately expires effect', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 100)); e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 100 }));
e.get(Effect, 'fx')!.clear(); e.get(Effect, 'fx')!.clear();
w.update(0.01); w.update(0.01);
expect(e.has(Effect)).toBeFalse(); expect(e.has(Effect)).toBeFalse();
@ -107,7 +107,7 @@ describe('Effect — duration', () => {
describe('Effect — permanent', () => { describe('Effect — permanent', () => {
it('permanent effect is never removed by EffectSystem', () => { it('permanent effect is never removed by EffectSystem', () => {
const { w, e, stat } = withStat(10); const { w, e, stat } = withStat(10);
e.add('fx', new Effect('str', 5)); e.add('fx', new Effect({ targetStat: 'str', delta: 5 }));
w.update(100); w.update(100);
expect(e.has(Effect)).toBeTrue(); expect(e.has(Effect)).toBeTrue();
expect(stat.value).toBe(15); expect(stat.value).toBe(15);
@ -117,27 +117,27 @@ describe('Effect — permanent', () => {
describe('Effect — scope: onHit', () => { describe('Effect — scope: onHit', () => {
it('onAdd is a no-op for onHit scope', () => { it('onAdd is a no-op for onHit scope', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 99, 'value', undefined, undefined, 'onHit')); e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
expect(stat.value).toBe(10); expect(stat.value).toBe(10);
}); });
it('onRemove is a no-op for onHit scope', () => { it('onRemove is a no-op for onHit scope', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('fx', new Effect('str', 99, 'value', undefined, undefined, 'onHit')); e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
e.remove('fx'); e.remove('fx');
expect(stat.value).toBe(10); expect(stat.value).toBe(10);
}); });
it('EffectSystem does not tick or remove onHit effects', () => { it('EffectSystem does not tick or remove onHit effects', () => {
const { w, e } = withStat(10); const { w, e } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', 1, undefined, 'onHit')); e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1, scope: 'onHit' }));
w.update(10); w.update(10);
expect(e.has(Effect)).toBeTrue(); expect(e.has(Effect)).toBeTrue();
}); });
it('active stays false for onHit scope', () => { it('active stays false for onHit scope', () => {
const { e } = withStat(10); const { e } = withStat(10);
e.add('fx', new Effect('str', 5, 'value', undefined, undefined, 'onHit')); e.add('fx', new Effect({ targetStat: 'str', delta: 5, scope: 'onHit' }));
expect(e.get(Effect, 'fx')!.active).toBeFalse(); expect(e.get(Effect, 'fx')!.active).toBeFalse();
}); });
}); });
@ -145,15 +145,15 @@ describe('Effect — scope: onHit', () => {
describe('Effect — multiple effects on same stat', () => { describe('Effect — multiple effects on same stat', () => {
it('multiple effects stack additively', () => { it('multiple effects stack additively', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('a', new Effect('str', 3)); e.add('a', new Effect({ targetStat: 'str', delta: 3 }));
e.add('b', new Effect('str', 7)); e.add('b', new Effect({ targetStat: 'str', delta: 7 }));
expect(stat.value).toBe(20); expect(stat.value).toBe(20);
}); });
it('removing one effect reverts only that delta', () => { it('removing one effect reverts only that delta', () => {
const { e, stat } = withStat(10); const { e, stat } = withStat(10);
e.add('a', new Effect('str', 3)); e.add('a', new Effect({ targetStat: 'str', delta: 3 }));
e.add('b', new Effect('str', 7)); e.add('b', new Effect({ targetStat: 'str', delta: 7 }));
e.remove('a'); e.remove('a');
expect(stat.value).toBe(17); expect(stat.value).toBe(17);
}); });

View File

@ -91,7 +91,7 @@ describe('Equipment — equip-scope effects', () => {
it('clones equip-scope Effect onto owner on equip', () => { it('clones equip-scope Effect onto owner on equip', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('bonus', new Effect('str', 5)); // scope: equip (default) sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 })); // scope: equip (default)
const player = makePlayer(w); const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(15); expect(player.get(Stat, 'str')!.value).toBe(15);
@ -100,7 +100,7 @@ describe('Equipment — equip-scope effects', () => {
it('does NOT clone onHit-scope Effect onto owner on equip', () => { it('does NOT clone onHit-scope Effect onto owner on equip', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit')); sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
const player = makePlayer(w); const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected
@ -110,8 +110,8 @@ describe('Equipment — equip-scope effects', () => {
it('clones multiple equip effects', () => { it('clones multiple equip effects', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('a', new Effect('str', 3)); sword.add('a', new Effect({ targetStat: 'str', delta: 3 }));
sword.add('b', new Effect('str', 7)); sword.add('b', new Effect({ targetStat: 'str', delta: 7 }));
const player = makePlayer(w); const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(20); expect(player.get(Stat, 'str')!.value).toBe(20);
@ -120,8 +120,8 @@ describe('Equipment — equip-scope effects', () => {
it('equip + onHit: only equip effects reach owner', () => { it('equip + onHit: only equip effects reach owner', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('passive', new Effect('str', 5)); sword.add('passive', new Effect({ targetStat: 'str', delta: 5 }));
sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit')); sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
const player = makePlayer(w); const player = makePlayer(w);
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' }); player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
expect(player.get(Stat, 'str')!.value).toBe(15); expect(player.get(Stat, 'str')!.value).toBe(15);
@ -133,7 +133,7 @@ describe('Equipment — unequip', () => {
it('unequip reverts cloned effects', () => { it('unequip reverts cloned effects', () => {
const w = world(); const w = world();
const sword = makeSword(w); const sword = makeSword(w);
sword.add('bonus', new Effect('str', 5)); sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 }));
const player = makePlayer(w); const player = makePlayer(w);
const eq = player.get(Equipment)!; const eq = player.get(Equipment)!;
eq.equip({ slotName: 'weapon', itemId: 'sword' }); eq.equip({ slotName: 'weapon', itemId: 'sword' });