Add stacking rules to effect
This commit is contained in:
parent
d7a3830740
commit
561ffb1d7e
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue