import type { Class } from "@common/types"; import { Component, type EvalContext } from "../core/world"; import { evaluateCondition } from "../utils/conditions"; import { action, component, ComponentTag, getComponentMeta, getComponentName, tag, variable } from "../utils/decorators"; import { Stat } from "./stat"; abstract class BaseEffect extends Component<{ target: string; // component class ID, e.g. 'Stat' targetKey?: string; // component key, e.g. 'health' targetField: 'value' | 'max' | 'min'; delta: number; duration: number | null; // null = permanent until removed gradual: boolean; // true if delta should be applied slowly over duration permanent: boolean; // true if effect should not be removed by clear() remaining: number | null; // countdown in seconds; null for condition-based/permanent condition: string | null; // keep effect while true; remove when it becomes false stacking: 'stack' | 'unique' | 'replace'; tag: string | null; // discriminator for stacking; null = no stacking enforcement active: boolean; // true while the delta is currently applied to the target stat }> { constructor(opts: { target?: Class> | string, targetKey?: string; delta: number; targetField?: 'value' | 'max' | 'min'; duration?: number; gradual?: boolean; permanent?: boolean; condition?: string; stacking?: 'stack' | 'unique' | 'replace'; tag?: string; }) { super({ target: typeof opts.target === 'string' ? opts.target : getComponentName(opts.target ?? Stat)!, targetKey: opts.targetKey, targetField: opts.targetField ?? 'value', delta: opts.delta, duration: opts.duration ?? null, gradual: opts.gradual ?? false, permanent: opts.permanent ?? false, remaining: opts.duration ?? null, condition: opts.condition ?? null, stacking: opts.stacking ?? 'stack', tag: opts.tag ?? null, active: false, }); if (opts.gradual && opts.duration == null) { throw new Error('Effect cannot be gradual without a duration'); } if (opts.duration != null && opts.duration <= 0) { throw new Error('Effect duration must be greater than zero'); } if (opts.permanent && opts.duration != null) { throw new Error('Effect cannot be both permanent and have a duration'); } } } @tag(ComponentTag.Equippable) @component export class Effect extends BaseEffect { /** True while the delta is applied. Backed by `state` so it survives serialize/clone. */ @variable('.') get active(): boolean { return this.state.active; } set active(v: boolean) { this.state.active = v; } override onAdd(): void { const { stacking, tag } = this.state; if (this.state.permanent && this.state.condition) { if (!evaluateCondition(this.state.condition, this.entity)) { this.entity.remove(this); return; } } 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); return; } if (stacking === 'replace') { for (const old of siblings) { this.entity.remove(old); } } } if (this.state.gradual) { this.active = true; } else { this.applyDelta(this.state.delta); } if (this.state.permanent) { this.entity.remove(this); // permanent effects are removed immediately } } override onRemove(): void { if (!this.active) return; this.active = false; if (this.state.permanent || this.state.gradual) return; this.applyDelta(-this.state.delta); this.active = false; // applyDelta sets active to true again } @action reset(duration?: number): void { if (duration != null) { this.state.duration = duration; } this.state.remaining = this.state.duration; this.active = true; } /** Mark as expired. EffectSystem will remove the component, which reverses the delta. */ @action clear(): void { this.state.remaining = 0; this.emit('expired'); } applyDelta(delta: number) { const component = getComponentMeta(this.state.target); if (component) { const stat = this.entity.get(component.ctor, this.state.targetKey); if (stat instanceof Stat) { stat.applyModifier(delta, this.state.targetField); this.active = true; } } } } @component export class EffectTemplate extends BaseEffect { }