modifiers.ts

PURPOSE

Centralized stat-modification stack. Every transient or permanent change to an entity stat (HP cap, thrust, drag, fire rate, etc.) goes through this module. Owns the canonical formula final = (base + Σ flat×stacks) × (1 + Σ percent×stacks), with set mode overriding everything (last-set wins). Ported from the legacy 06-systems.js Modifiers block.

OWNS

  • Modifier interface — id, target, stat, mode (flat | percent | set), value, duration, remaining, creator, source, stacks, maxStacks, stackType (independent | refresh).
  • StackOptions interface — stackType and maxStacks.
  • Modifiers singleton with internal state:
    • _mods — flat array of every active Modifier.
    • _modsByTargetMap<entityId, Modifier[]> index for fast per-entity lookup.
    • _nextId — monotonic modifier id counter (starts at 1).
    • _dirtySet<entityId> of entities whose stats need recalc.
  • Public API: add, remove, removeByCreator, removeByTarget, removeBySource, tick, recalc, dump, markDirty, clear.

READS FROM

  • Caller-supplied entity object (uses entity.eid for indexing; reads/writes arbitrary stat fields by key).
  • Caller-supplied baseStats: Record<string, number> snapshot passed into recalc (the authoritative reset target before modifiers apply).
  • Frame dt passed into tick.

PUSHES TO

  • Mutates entity[stat] directly during recalc — writes the recomputed final value back onto the entity.
  • Mutates its own internal collections (_mods, _modsByTarget, _dirty, _nextId).
  • Returns modifier ids from add so callers can later remove by id.

DOES NOT

  • Does not own base stats. Callers must pass the base-stats snapshot into recalc every time; the module never caches base values.
  • Does not subscribe to entity lifecycle events. Callers are responsible for invoking removeByTarget / removeByCreator when entities die.
  • Does not recalc automatically inside add / remove / tick — those only flip the dirty flag. Stats stay stale until the owner calls recalc.
  • Does not interpolate or smooth — recalc is an immediate replacement.
  • Does not detect or merge independent-mode duplicates; same (stat, creator, source) triple stacks as separate Modifier rows unless stackType === 'refresh'.
  • Does not clamp the result to entity-specific bounds (e.g. min HP, max speed) — only the multiplier itself is floored at zero.

Signals

  • _dirty.add(targetId) is the only internal signal. It is set by add, remove, removeByCreator, removeBySource, tick (on expiry), source-keyed refresh-stack updates, and the public markDirty(targetId) entry point.
  • recalc is the consumer of _dirty: it early-returns when the target isn’t dirty, otherwise clears the flag and rebuilds the stats.
  • No event bus. No callbacks. The dirty set is the entire signaling surface.

Entry points

  • add(target, stat, mode, value, duration=0, creator=0, source='', stackOpts?) — appends a modifier (or refreshes an existing source-keyed one) and marks the target dirty. Returns the modifier id.
  • remove(modId) — deletes one modifier by id from both _mods and the per-target bucket; marks target dirty.
  • removeByCreator(creatorId) — sweeps every modifier whose creator matches; marks each affected target dirty.
  • removeByTarget(targetId) — drops every modifier on that target and deletes its bucket entry. Does not mark dirty (the target is presumed gone).
  • removeBySource(targetId, source) — drops every modifier on the target whose source tag matches; marks target dirty.
  • tick(dt) — decrements remaining on timed modifiers; on expiry, removes them and marks the target dirty. Permanent modifiers (duration === 0) are skipped.
  • recalc(entity, baseStats) — short-circuits if target isn’t dirty; otherwise resets the entity to baseStats, aggregates flat / percent / set contributions per stat, then writes the final value back onto the entity.
  • dump(targetId) — debug; returns a fresh array of every active modifier targeting that entity.
  • markDirty(targetId) — force-recalc next frame.
  • clear() — wipe all state and reset _nextId to 1 (used on run reset).

Pattern notes

  • Source-keyed replacement (refresh stacking). When stackOpts.stackType === 'refresh', add searches the target’s bucket for an existing modifier with the same (stat, creator, source) triple. If found, it resets remaining = duration and increments stacks up to maxStacks (or 999 if maxStacks is 0/unset), then returns the existing id. No new row is created. independent stacking (the default) always appends a new row.
  • Dual indexing. Every modifier is stored twice: once in the flat _mods array, once in the _modsByTarget bucket for its target. All mutation paths keep both in sync — recalc reads only from the bucket, but removeByCreator, removeBySource, and tick scan _mods and then splice the bucket. dump is the one read path that scans the flat array.
  • Aggregation formula. Per stat: flat contributions sum as value × stacks; percent contributions sum the same way; set mode is last-write-wins (the final loop iteration’s set value sticks). When set is non-null for a stat, it overrides everything else and skips the flat/percent math entirely.
  • Multiplier clamp (drag protection). percent contributions add together additively, so a stat with several stacking negative-percent modifiers can drive the sum below -1.0 and would otherwise produce a negative multiplier that flips the stat’s sign. The code floors the multiplier at zero: mult = Math.max(0, 1 + vals.pct). The in-source comment cites the canonical case — drag at -0.10 per Speed pick × 12 picks = -1.20, which would invert drag without the clamp. A fully-negated stat sits at zero, never negative. Positive sums are unaffected.
  • Skip unknown stats. If a stat key in the bystat aggregator is undefined on the entity, recalc skips it (after handling set mode). Set mode can introduce new keys onto the entity; flat/percent cannot.
  • Lazy recalc. Mutating modifiers only flips dirty; the actual entity[stat] = … writes happen exclusively inside recalc. The owner of each entity must call recalc on a known cadence (typically once per frame after tick).
  • Two-step expiry. tick only decrements timed modifiers and removes those that hit zero. Permanent modifiers (duration === 0) are never decremented or expired; they stay until explicit removal or clear.
  • Reset semantics. clear resets _nextId to 1. Any modifier id held by outside code across a clear boundary will collide with new ids — callers must drop their id references on run reset.