Status Effects, Stacking Builds & Ground Effects
Status: IMPLEMENTED — P1-P4 + R shipped to the working tree 2026-06-19; DF scenario suite 342/342 green with all changes active; per-phase status in each pillar section. Authoritative spec; supersedes
Last updated: October 9, 2026
Darkness Falls — Status Effects, Stacking Builds & Ground Effects
Status: IMPLEMENTED — P1-P4 + R shipped to the working tree 2026-06-19; DF scenario suite 342/342 green with all changes active; per-phase status in each pillar section. Authoritative spec; supersedes the
"Status Effect Implementation Status" + "Planned: Item Powers System" notes in combat.md /
items-economy.md (those now point here).
1. Goal — build-defining, stackable, elemental status play
Players should be able to build around damage types and elements, stacking timed effects to customize a playstyle. The canonical target the owner stated:
"twelve rounds of fifty percent fire damage boost that stacks" — and other build pieces, plus negative status on enemies (debuffs) and ground effects like a patch of fire a player leaves on the ground that anyone standing on it burns and must move out of.
Three hard requirements frame the whole design:
- Stacking + timed + elemental — effects carry a duration and stack, keyed by element, so a fire build feels different from an ice build.
- Ground effects live on the SQUARE, not the character — a flame patch is a property of the tile so every occupant (the player, enemies, and party members who step onto it) is affected and must move off. (§ 6.)
- The readout MUST be SHORT. Voice render-lag scales with spoken length (
SpokenLengthEstimator, soft cap 30s / hard cap 60s per turn). Every status/ground line is rolled up: if two or more effects of the same kind happen in a round, they combine into one shorthand line / count list — never one line per effect per target. (§ 7 is the binding spec.)
2. Current reality (grounded, verified in code 2026-06-19)
DF already has a fully-built status-effect framework and a barely-wired gameplay layer.
What exists and works
- Effect type catalog —
DarknessFallsStatusEffectTypedefines 30 types (DoT: Poison, Burning, Bleeding, Acid; stat mods: Blessed/Cursed/Haste/Slow + STR/AGI/INT boost+penalty; conditions: Frozen, Stunned, Invisible, Regeneration, Shield, Vulnerable, Resistant; combat mods: Critical/Armor/Damage Boost + reductions; utility: Magic/Gold Find, Experience; generic Stat Boost/Penalty). - Effect model (
DarknessFallsStatusEffect) already carries everything the vision needs:remainingDuration/originalDuration,modificationType(FLAT/PERCENTAGE/MULTIPLIER/OVERRIDE),affectedStat,modificationAmount,damagePerTurn,healingPerTurn,sourceName, and a first-classstackingRule. - Stacking engine () correctly
honors all 5
DarknessFallsStatusStackingRulevalues: STACK, NO_STACK, REFRESH, EXTEND, REPLACE. - Per-round tick (
processStatusEffects) decrements duration, fires DoT/heal, expires at 0, and processes both the player and the enemies on the current square. - DoT + appliers —
applyPoison/Burning/Bleeding/Acid/Regeneration(DoT read directly by the tick → genuinely deal damage); Burning escalates and caps at 3× initial; Acid spawns an armor-reduction sub-effect. - Gear-ability proc layer (
DarknessFallsGearAbilityExecutor) executes Lifesteal, Bleed Strike, Stun Blow, Chain Lightning, Armor Crush, Soul Drain, Execute, plus Thorns/Regen/Damage auras in .
What is inert or missing (the gaps this design closes)
- GAP-A — stat/damage effects never bite.
(and the incoming-damage logic) is never called by any combat/damage/loot formula (verified:
zero references in DF). So everything stat-numeric is applied, narrated, and ticked down while
changing nothing: Damage Boost, Vulnerable, Resistant, Blessed/Cursed, Haste/Slow, Armor
Boost/Reduction, Critical Boost, Shield absorb, and the stat boosts/penalties. Today only Stun
(gates
canCharacterAct) and the DoTs (readdamagePerTurndirectly) have real mechanical effect. This is the single biggest blocker — Armor Crush already applies Vulnerable and Fortify already applies an armor buff; both currently do nothing. - GAP-B — no per-element damage amp.
DarknessFallsStatTypehas only genericATTACK_DAMAGE, no FIRE/ICE/LIGHTNING/POISON/CHAOS/PHYSICAL_DAMAGE, so "+50% fire damage" is not expressible, and nothing applies aDAMAGE_BOOSTeffect at all. Elemental power today is instant only (Fire/Ice/Lightning/Chaos Blast +30% this swing). - GAP-C — no ground / area effects. Verified: zero hits for hazard/groundEffect/tileEffect/
burningGround/areaDenial across the codebase. Zones are grids (
DarknessFallsZoneSquare), terrain visuals (fire/water) are cosmetic, AOE novas hit instantly and leave nothing. - GAP-D — verbose status narration. The DoT tick emits one line per effect per character
(
"You take 12 poison damage! (88 health remaining)"). With several enemies and multiple effects this is exactly the spoken-length bloat the voice framework fights — it must be rolled up (§ 7). - GAP-E — almost no producers.
applyRandomDebuffwires Poison/Burning/Curse/Slow/Vulnerable/ Stun but nothing calls it; enemies apply no debuffs to the player; no power applies a timed elemental self-buff.
3. Architecture — four pillars + one readout layer
| Pillar | What it delivers | Core change |
|---|---|---|
| P1 — Consumer wiring | Makes all existing stat/damage effects actually bite | Call effective-stat + damage multipliers in the combat pipeline; extend getBaseStat |
| P2 — Elemental stacking buffs | "+50% fire, 12 rounds, stacks" and sibling builds | Per-element stat types + outgoing-damage aggregator + producers |
| P3 — Enemy debuffs | Negative status ON enemies (and enemy→player) | Producers that apply Vulnerable/Armor-shred/Slow/DoT to enemies; enemy abilities |
| P4 — Ground effects | Square-scoped hazards that hit every occupant | New DarknessFallsGroundEffect on DarknessFallsZoneSquare + a square tick |
| R — Readout rollup | SHORT, combined, shorthand-list voice | Per-round effect accumulator + new methods |
P1 is the foundation — without it P2/P3 produce only narration. R is woven through all four.
4. Pillar P1 — Consumer wiring (the foundation)
Implementation status (2026-06-19): SHIPPED to working tree, gate GREEN — JUnit 5539 pass incl.
(11 cases), DF scenarios 1650/1650, dead-end + NLU green. Built
as the incoming-damage multiplier path (§ P1.2 / P1.3): incomingDamageMultiplier +
applyIncomingDamage (Shield drain) applied at the player→enemy at-source site
(launchAttackAgainstPrimary) and the enemy→player post-hoc site (adjustPlayerIncomingDamage in the
round loop). Fortify is re-modeled as a Resistant effect rather than threading
getEffectiveStat(ARMOR) through the 24 defense builders; the armor-keyed effective-stat reads in
§ P1.1 (Acid corrosion, generic ARMOR buffs) are DEFERRED until a producer needs them. End-to-end
producer→consumer observation (does an applied Vulnerable reach the next hits consumer) lands with the
P3 controllable producer.
The math already exists in ; it is purely unwired. Three consumers:
P1.1 — Effective stats (stat-keyed effects)
Replace raw stat reads with getEffectiveStat(character, statType) at the points that matter:
| Where | Stat read today (raw) | Becomes |
|---|---|---|
| armor step | enemy/player armor rating | getEffectiveStat(ARMOR) (so Armor Crush, Fortify, Acid corrosion bite) |
| damage base per type | attack damage | effective attack damage + per-element amp (P2) |
| crit roll | crit chance / crit multiplier | getEffectiveStat(CRITICAL_CHANCE/CRITICAL_DAMAGE) |
| hit chance | accuracy / evasion via AGI | effective AGI |
| movement / sight | movement speed, sight | effective (Haste/Slow, sight buffs) |
Extend getBaseStat — it currently returns 0 for ARMOR, ATTACK_DAMAGE, CRITICAL_CHANCE,
CRITICAL_DAMAGE, MAGIC_RESISTANCE (so a PERCENTAGE modifier computes 0×% = 0). Add real reads from
DarknessFallsCharacterStats for every combat stat a modifier targets.
P1.2 — Incoming-damage multiplier (Vulnerable / Resistant)
applyVulnerability/applyResistance set modificationAmount + PERCENTAGE but affectedStat is
null — they are damage-taken multipliers, not stat mods, so they do not flow through
getEffectiveStat. Add =
1 + Σ(VULNERABLE %) − Σ(RESISTANT %), applied to the final damage a character takes in
calculateDamage. (Vulnerable STACKs, so Armor Crush stacking ramps damage — capped, § 8.)
P1.3 — Shield absorb
applyShield stores the absorb amount in customData but nothing consumes it. In the damage-taken
path, before applying damage, drain active SHIELD effects first (reduce customData, expire at 0).
P1 ships first, behind no new content — it immediately turns the already-applied Armor Crush, Fortify, and Acid corrosion live. That is a balance change → re-tune those four values and re-run the DF scenario suite (§ 9). This is the one pillar that changes existing behavior, so it gets the most test scrutiny.
5. Pillar P2 — Elemental stacking buffs ("the fire build")
Implementation status (2026-06-19): SHIPPED to working tree, JUnit GREEN (5610 pass, 0 new;
10 new P2 cases cover the outgoing multiplier, the +75% cap, attack amplification, stack counting).
Element stat types + outgoingDamageMultiplier + amplifyAttack + capped applyElementalDamageBuff
in ; the primary attack is amplified in launchAttackAgainstPrimary.
Producers: gear KINDLING_FIRE/ICE/LIGHTNING/CHAOS (every 3rd hit → +15% element stack, max 5),
registered in the trigger-frequency map, on-hit-offensive classifier, and the rare/legendary loot
pool. amplifyAttack is a verified no-op (returns the same object) when no boost is active, so it is
behavior-neutral for every existing scenario. Deferred: the live DF scenario run (God-box backend
boot contention 2026-06-19) — to be run with P3 before any deploy; behavior-neutral by construction
(see above) and P1 already ran the identical combat path 1650/1650. Paragon/star-map/skill-gem
producer variants (D2 all-sources) are follow-on sub-phases.
Kindling stack persistence + preview off-by-one (2026-07-15, Discord feedback). The Kindling
producer now applies its stack IN PLACE on the in-hand player instance the gear-ability phase
already saves, not through a ref-keyed fetch-and-save. processGearAbilities re-saves its own
player snapshot at the end of the phase, which overwrote a ref-applied stack — so the stack never
survived the round: the "fire fury, N stacks" readout stayed at zero and amplifyAttack never
engaged (the whole elemental build was inert). Do NOT route this self-buff back through the
ref-keyed applyStatusEffect path. Separately, the gear-ability next-round preview
(getAbilitiesReadyNextAttack) read the round counter one step ahead of the trigger check
(completeCombatRound advances the counter after the check, and the preview added +1 on top), so a
proc announced "activates next round" fired a round later; the preview now reads the same counter
the next round's trigger check reads. Guarded by (in-place
accumulation, +75% cap, amplification). The defensive sibling Fortify had the identical
clobber — applyResistance wrote its Resistant effect to a ref-fetched copy that
processDefensivePowers then overwrote, so the thirty-percent-less-damage guard never took hold —
and is fixed the same way, applied in place on the phase-held player instance.
P2.1 — Per-element damage stat types (closes GAP-B)
Add to DarknessFallsStatType (aligned to the engine's damage types):
PHYSICAL_DAMAGE, FIRE_DAMAGE, ICE_DAMAGE, LIGHTNING_DAMAGE, POISON_DAMAGE, CHAOS_DAMAGE, ALL_DAMAGE.
P2.2 — Outgoing-damage aggregator
Add .outgoingDamageMultiplier(attacker, element) =
1 + (Σ active DAMAGE_BOOST/DAMAGE_REDUCTION where affectedStat ∈ {<element>_DAMAGE, ALL_DAMAGE}) / 100.
The damage pipeline multiplies each element's computed damage by this before resistance/armor. A fire
build's stacked FIRE_DAMAGE boosts amplify only the fire component of every hit.
P2.3 — Worked example: "+50% fire for 12 rounds that stacks"
Expressible verbatim in the existing model:
effectType = DAMAGE_BOOST
affectedStat = FIRE_DAMAGE
modificationType = PERCENTAGE
modificationAmount= <per-stack %> // see balance § 8 (NOT a flat 50 unbounded)
remainingDuration = <rounds>
stackingRule = STACK // independent instances → genuine stacking
sourceName = "Kindling"
With P2.2 wired, each stack adds to the fire multiplier; the tick counts each down independently;
getStatusEffectsByType already lets the readout report the live stack count.
P2.4 — Producers (where the build comes from)
New triggered powers that apply an elemental self-buff on an elemental hit. Element resolved from the
power name via the existing DarknessFallsGearAbilityExecutor.getElementFromUpgradeType helper. The
placement of these (gear procs vs paragon vs star-map vs skill gems) is RESOLVED → D2 = ALL SOURCES (§ 11): producers may be gear DarknessFallsUpgradeType on-hit/aura
powers (roll on loot), paragon/promotion signature abilities (e.g. Firestarter applies Kindling),
star-map nodes, AND skill gems. Each source is a sub-phase of P2/P3 with its own scenario coverage.
6. Pillar P4 — Ground effects (square-scoped, party-shared)
Implementation status (2026-06-19): SHIPPED to working tree, build green. New
DarknessFallsGroundEffect model + DarknessFallsGroundEffectType enum (BURNING/POISON_CLOUD/
ACID_POOL) + groundEffects list on DarknessFallsZoneSquare. :
applyGroundEffect (refresh + take-max, no unbounded stacking, § 6.4) and processGroundEffects (the
square tick — damages every occupant through the P1 incoming multiplier, emits ONE rolled-up "move
clear" line, decrements + expires). Ticked from the round loop Phase 0b (no-op when no hazard, so
scenario-neutral). Producer SCORCHED_EARTH gear power (every 6th hit → burning patch on the closest
enemy square), fully registered. JUnit covers place/refresh-max/
coexist/decay. Kryo note: the new nullable field is excluded when null (Jackson NON_NULL) and the
scenario runner evicts caches per run, so no stale-blob risk in tests; reset zone cache on prod
deploy. Elemental resistance on ground damage is DEFERRED (matches Burning DoT today). End-to-end
producer→tick observation is verified by construction (each link JUnit/read-verified); a live
play_game observation is blocked by the same can-not-force-specific-gear limitation as P1-P3.
6.1 — Data model — the effect lives on the SQUARE
New DarknessFallsGroundEffect (serializable bean), and a List<DarknessFallsGroundEffect> groundEffects field on DarknessFallsZoneSquare:
DarknessFallsGroundEffect:
groundType : enum {BURNING, ACID, ICE_SLICK, POISON_CLOUD, ...} // start with BURNING
element : damage element (fire/poison/...)
damagePerTurn : long // 0 for non-damaging (e.g. ice slick = slow only)
appliedStatusType : optional status to apply to occupants (e.g. SLOW for ice)
remainingRounds : int // decays each round
ownerRef : String // who created it (for friendly-fire / attribution policy)
sourceName : String
Because the effect is square state, every DarknessFallsCharacterStats standing on the square
(player + enemies on enemiesOnSquare; party members when they co-occupy the square) is affected —
this is exactly the owner's "must exist on squares so it affects all party members."
6.2 — The square tick
In the combat-round / movement after-action pass (), after status
processing: for the player's square (and each square holding enemies), for each active ground effect,
apply damagePerTurn + any appliedStatusType to every occupant, then decrement
remainingRounds; drop expired effects. Damage routes through the same incoming-damage multiplier
and resistance as combat (so fire resistance reduces a fire patch). Occupant death folds into the
normal defeat rollup.
6.3 — Movement interaction ("move out of it")
Standing in a ground effect is meant to be escapable: the tick fires on the square the character is on at round end, so moving off the square before the next round avoids the next tick. The readout makes the imperative explicit and SHORT (§ 7): "Move off the fire." Enemy AI gets a simple bias to step off a damaging ground effect (low priority; optional in P4).
6.4 — Decay & stacking on a square
Ground effects decay by remainingRounds. Re-applying the same groundType on a square REFRESHes
its duration and takes the max damagePerTurn (no unbounded square stacking — keeps it readable and
balanced). Multiple different types can coexist on one square (fire + acid).
6.5 — Party-sharing reality (RESOLVED → D3a instance-local, § 11)
Party members today run independent per-player zone instances (no shared combat). Decision (D3a): ground effects are instance-local square state — fully correct for the solo player and any co-occupants in that instance; satisfies "lives on the square." True cross-member visibility arrives whenever shared-combat/shared-zone state does. No shared square-state replication is built now (D3b rejected as premature — it only matters once members fight in the same instance).
7. Readout layer R — the SHORT rollup spec (binding)
Implementation status (2026-06-19): SHIPPED to working tree, build green. Verified SAFE first: a
query confirmed ZERO active DF scenario tests assert on DoT damage text, so consolidating the verbose
tick narration breaks no assertion. DarknessFallsDotRollup accumulates the whole round's DoT/HoT
tick and flushes at most TWO lines: ONE player hurt/heal line (summed damage, one health readout,
sources joined "burning and bleeding") and ONE enemy count line ("2 enemies burn and 1 enemy is
poisoned" — distinct enemies per type, not per tick). Wired through backward-compatible overloads on
processAllStatusEffects/processStatusEffects/processIndividualStatusEffect +
processDamageOverTimeEffect/processHealingOverTimeEffect (old signatures delegate with null →
unchanged for any other caller); MECHANICS UNCHANGED, narration-only. Death is still narrated per
effect (significant). New-surface readouts (P2 kindling stack, P3 ignite/envenom, P4 ground "move
clear") already emit single short lines inline. JUnit proves the
grammar. Minor follow-on: unify the status-DoT player line and the ground player line into ONE line
(currently up to two short lines when a player is both burning and standing in fire) by feeding the
ground tick the same rollup. DONE 2026-06-20: the combat round shares ONE DarknessFallsDotRollup across the status tick (Phase 0) and the ground tick (Phase 0b) and flushes once, so a player both burning and standing in fire reads ONE combined line, e.g. "You take 34 from burning, plus flames underfoot. Move clear. 178 health."
This is the part the owner emphasized. Rule: never one line per effect per target. If ≥2 effects of the same kind occur in a round, combine into ONE shorthand line / count list. Mirrors System B () and obeys the spoken-length budget.
7.1 — Per-round effect accumulator
Replace the per-effect addText calls in
/ status tick with an accumulator (parallel to the System B combat accumulator). It collects every
status + ground outcome for the round, then emits at most four lines, in priority order:
- Player hurt/heal (one line, one total, one health readout) — combine ALL incoming DoT + ground
damage to the player into a single clause with the summed number:
- "You take 34 from burning and bleeding. 212 health."
- heal variant: "You regenerate 18. 230 health." (Sources listed as a short "X, Y and Z" clause; never a per-tick number.)
- Ground imperative (one short line) — only when the player stands in a hazard:
- "Move off the fire." / "Move clear of fire and acid."
- Enemy status (one line, counts not names when many) — existence only, no per-enemy numbers:
- 1 target: "The ogre is burning and vulnerable."
- many: "Three enemies burn, one is stunned."
- Deaths — fold into the existing
playerDefeatRollup(already rolled up).
Minor buff/debuff expiries are silent unless build-defining; a build-defining stack expiry gets one terse line ("Fire fury fades.").
7.2 — Buff/stack readout (folded into the combat line, not its own turn)
A live elemental stack is reported by appending a short tail to the player's existing combat-damage rollup, not a separate sentence:
- "You hit three times for 900 damage. Fire fury, three stacks." (stack count, no parentheses/percent spelled only if short.)
7.3 — The "combine same type → shorthand list" rule (precise)
- Same type across N targets: render
{count} {plural}for N ≥ 2 ("three enemies burn"); name the single target for N == 1 ("the ogre burns"). Never list 3+ names — cap a name list at ≤3 then switch to count, mirroringdefeatRollup. - Player incoming, multiple types: ONE clause, types joined "X, Y and Z", ONE summed damage number, ONE health readout. The player only acts on total HP + what to cleanse/avoid.
- Mixed: player line first (you must act on your own state), then ground imperative, then a single enemy-status line.
7.4 — New methods (shared, voice-first, pure)
playerDotRollup(long dmg, long heal, List<String> sources, long remainingHp)groundImperative(List<String> hazardTypesUnderfoot)enemyStatusRollup(Map<StatusType,Integer> counts, String soloEnemyName)- (buff tail folded into the existing
playerDamageRollupcaller, or a tinybuffTail(stackCounts))
All obey voice-first: commas + natural language, no parentheses/brackets/slashes/abbreviations; each returns "" when there's nothing to say so the accumulator stays silent on quiet rounds.
8. Balance (defaults — OPEN-DECISION D1 marks owner sign-off)
| Knob | Default proposed | Notes |
|---|---|---|
| Elemental amp per stack | +15% element damage | NOT a flat unbounded +50% — that snowballs |
| Elemental amp stack cap | 5 stacks (+75% max) | Cap is the balance lever |
| Elemental amp duration | 6 rounds, REFRESH on reapply | Player must keep hitting to hold it |
| Vulnerable (Armor Crush) | +15%/stack, cap 3 (+45%), 3 rounds | Was 25% unbounded in code; cap it |
| Fortify armor buff | +30% armor, 2 rounds | Now actually applies (P1) |
| Ground: burning | damage = ~4% of applier max HP/round, 3 rounds, 1 square | Resisted by fire resistance |
| Ground: refresh | max dmg, refresh duration | No unbounded square stacking |
| Slow / Haste | ±40–50% speed, 6 rounds | Now applies (P1) |
D1 RESOLVED → capped curve (this table is authoritative): +15%/stack, cap 5, +75% ceiling, 6 rounds, refresh on each elemental hit.
9. Test plan (hermetic; per `.claude/rules/hermetic-scenario-tests.md`)
- JUnit (pure logic): stat aggregation (
calculateStatModificationper modType incl. extendedgetBaseStat), outgoing/incoming multipliers, stacking-rule behavior, ground-tick math, and every new method (deterministic strings — same surface as ). - scenario_tests (end-to-end, via
add_scenario_test):- apply a fire-stack buff then hit → fire damage increases (assert structurally on the buff/stack readout + damage delta).
- Armor Crush / Vulnerable on an enemy → enemy takes more (P1 proof).
- stand on a burning square → take ground damage; move off → no further tick (assert the rollup line
- HP delta + the imperative token).
- enemy debuff applied and rolled up.
- rollup proof: multiple same-type effects in one round produce ONE shorthand line (assert the
combined phrasing, short
contains_text).
- Regression discipline: P1 flips existing effects live → rebuild local, run the full
DARKNESS_FALLSscenario suite, fix every red before moving on (scenario-regression-discipline.md).
10. Data model, serialization & cache
- New code enums/values:
DarknessFallsStatType(+7 element + ALL_DAMAGE), new ground-effect enum/model, newDarknessFallsUpgradeTypeproducer powers. No new MySQL tables (effects live in cached character/zone JSON) → no data-catalog change. - Cache/serialization (CRITICAL): the Redis zone cache uses Kryo serialization and character
state is cached. Adding
groundEffectstoDarknessFallsZoneSquareand new fields changes the serialized shape → on deploy, reset the affected caches (admin_cache_reset/ zone cache) so Kryo does not choke on old blobs (AGENTS.md gotcha). must be updated to cover the new field. - Cache-preservation: consumable/effect state must survive
refreshCharacterStatsrebuilds (the same trap that dropped Blood Emerald consumable flags —preserveConsumableState). Active status effects already live onactiveStatusEffects; verify they survive recalc.
11. Resolved decisions (owner sign-off 2026-06-19)
- D1 — Stacking model → CAPPED CURVE. Elemental amp = +15% element damage per stack, max 5 stacks (+75% ceiling), 6 rounds, REFRESH on each elemental hit. Not the literal unbounded "+50% that stacks" — capped to prevent snowball. § 8 table is authoritative.
- D2 — Power source → ALL SOURCES. The stacking buffs, enemy-debuff powers, and ground-effect powers may be granted by gear on-hit/aura UpgradeType procs, paragon/promotion signature abilities, star-map nodes, AND skill gems. Maximize build expression; this is the largest balance/test surface, so each source lands in its own sub-phase with its own scenario coverage (§ 12).
- D3 — Ground-effect party sharing → INSTANCE-LOCAL (D3a). Ground effects are square state in the player's own zone instance; they hit the player plus every enemy/party member sharing that square in that instance. True cross-instance visibility arrives whenever shared combat does. No shared square-state replication is built now.
12. Phasing (design-perfect → code → playtest each phase)
| Phase | Scope | Gate |
|---|---|---|
| P1 | Consumer wiring + getBaseStat extension + incoming/shield consumers; re-tune the 3 now-live effects |
build + full DF scenario suite green; playtest Armor Crush/Fortify actually bite |
| P2 | Element stat types + outgoing aggregator + Kindling-style producers + stack readout | build + scenario; playtest a fire stack grows damage |
| P3 | Enemy debuff producers + (optional) enemy→player debuff abilities | build + scenario; playtest |
| P4 | Ground-effect model + square field + tick + producer + movement/AI + readout | build + scenario; playtest burning ground, move-off works |
| R | Woven through every phase — the accumulator + methods land with P1 and extend each phase | JUnit rollup tests; playtest spoken length stays short |