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
Modifierinterface — id, target, stat, mode (flat|percent|set), value, duration, remaining, creator, source, stacks, maxStacks, stackType (independent|refresh).StackOptionsinterface —stackTypeandmaxStacks.Modifierssingleton with internal state:_mods— flat array of every activeModifier._modsByTarget—Map<entityId, Modifier[]>index for fast per-entity lookup._nextId— monotonic modifier id counter (starts at 1)._dirty—Set<entityId>of entities whose stats need recalc.
- Public API:
add,remove,removeByCreator,removeByTarget,removeBySource,tick,recalc,dump,markDirty,clear.
READS FROM
- Caller-supplied
entityobject (usesentity.eidfor indexing; reads/writes arbitrary stat fields by key). - Caller-supplied
baseStats: Record<string, number>snapshot passed intorecalc(the authoritative reset target before modifiers apply). - Frame
dtpassed intotick.
PUSHES TO
- Mutates
entity[stat]directly duringrecalc— writes the recomputed final value back onto the entity. - Mutates its own internal collections (
_mods,_modsByTarget,_dirty,_nextId). - Returns modifier ids from
addso callers can laterremoveby id.
DOES NOT
- Does not own base stats. Callers must pass the base-stats snapshot into
recalcevery time; the module never caches base values. - Does not subscribe to entity lifecycle events. Callers are responsible for invoking
removeByTarget/removeByCreatorwhen entities die. - Does not recalc automatically inside
add/remove/tick— those only flip the dirty flag. Stats stay stale until the owner callsrecalc. - 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 separateModifierrows unlessstackType === '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 byadd,remove,removeByCreator,removeBySource,tick(on expiry), source-keyed refresh-stack updates, and the publicmarkDirty(targetId)entry point.recalcis 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_modsand the per-target bucket; marks target dirty.removeByCreator(creatorId)— sweeps every modifier whosecreatormatches; 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 whosesourcetag matches; marks target dirty.tick(dt)— decrementsremainingon 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 tobaseStats, 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_nextIdto 1 (used on run reset).
Pattern notes
- Source-keyed replacement (refresh stacking). When
stackOpts.stackType === 'refresh',addsearches the target’s bucket for an existing modifier with the same(stat, creator, source)triple. If found, it resetsremaining = durationand incrementsstacksup tomaxStacks(or 999 ifmaxStacksis 0/unset), then returns the existing id. No new row is created.independentstacking (the default) always appends a new row. - Dual indexing. Every modifier is stored twice: once in the flat
_modsarray, once in the_modsByTargetbucket for its target. All mutation paths keep both in sync —recalcreads only from the bucket, butremoveByCreator,removeBySource, andtickscan_modsand then splice the bucket.dumpis the one read path that scans the flat array. - Aggregation formula. Per stat:
flatcontributions sum asvalue × stacks;percentcontributions sum the same way;setmode is last-write-wins (the final loop iteration’ssetvalue sticks). Whensetis non-null for a stat, it overrides everything else and skips the flat/percent math entirely. - Multiplier clamp (drag protection).
percentcontributions add together additively, so a stat with several stacking negative-percent modifiers can drive the sum below-1.0and 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.10per 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
undefinedon the entity,recalcskips it (after handlingsetmode). 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 insiderecalc. The owner of each entity must callrecalcon a known cadence (typically once per frame aftertick). - Two-step expiry.
tickonly decrements timed modifiers and removes those that hit zero. Permanent modifiers (duration === 0) are never decremented or expired; they stay until explicit removal orclear. - Reset semantics.
clearresets_nextIdto 1. Any modifier id held by outside code across aclearboundary will collide with new ids — callers must drop their id references on run reset.