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
### 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
Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on
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
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
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. */
@variable('.') active: boolean = false;
constructor(
targetStat: string,
delta: number,
targetField?: 'value' | 'max' | 'min',
duration?: number,
condition?: string,
scope?: 'equip' | 'onHit',
) {
constructor(opts: {
targetStat: string;
delta: number;
targetField?: 'value' | 'max' | 'min';
duration?: number;
condition?: string;
scope?: 'equip' | 'onHit';
stacking?: 'stack' | 'unique' | 'replace';
tag?: string;
}) {
super({
targetStat,
targetField: targetField ?? 'value',
delta,
duration: duration ?? null,
remaining: duration ?? null,
condition: condition ?? null,
scope: scope ?? 'equip',
targetStat: opts.targetStat,
targetField: opts.targetField ?? 'value',
delta: opts.delta,
duration: opts.duration ?? null,
remaining: opts.duration ?? null,
condition: opts.condition ?? null,
scope: opts.scope ?? 'equip',
stacking: opts.stacking ?? 'stack',
tag: opts.tag ?? null,
});
}
override onAdd(): void {
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);
if (stat) {
stat.applyModifier(this.state.delta, this.state.targetField);
@ -46,7 +71,7 @@ export class Effect extends Component<{
}
override onRemove(): void {
if (this.state.scope === 'onHit') return;
if (!this.active) return;
const stat = this.entity.get(Stat, this.state.targetStat);
if (stat) {
stat.removeModifier(this.state.delta, this.state.targetField);
@ -67,7 +92,6 @@ export class Effect extends Component<{
@action
clear(): void {
this.state.remaining = 0;
this.active = false;
this.emit('expired');
}

View File

@ -55,7 +55,15 @@ export class CombatSystem extends System {
const s = component.state;
target.add(
`__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)) {
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]);
}
}

View File

@ -147,7 +147,7 @@ describe('CombatSystem — on-hit effects', () => {
const w = world();
const sword = w.createEntity('sword');
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');
const target = w.createEntity('target');
target.add('health', new Health({ value: 100, min: 0 }));
@ -162,7 +162,7 @@ describe('CombatSystem — on-hit effects', () => {
const sword = w.createEntity('sword');
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
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);
});
@ -170,7 +170,7 @@ describe('CombatSystem — on-hit effects', () => {
const w = world();
const sword = w.createEntity('sword');
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');
const target = w.createEntity('target');
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', () => {
it("emits 'hit' on target with attack info", () => {
const w = world();

View File

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