feat: implement DoT/debuff runtime system (spec §6, AC-12, AC-13)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s

- Add ActiveEffect, EffectType types to game.ts; activeEffects + effectiveArmor on EnemyState
- Add SpellOnHitEffect + onHitEffect field to SpellDefinition
- Wire onHitEffect to fire (burn), death (curse), lightning (armor_corrode), frost (freeze), soul (bypassArmor burn)
- Add applyOnHitEffect() — applies on-hit effect on successful spell hit (spec §6.2)
- Add processDoTPhase() — ticks all active effects after weapon/golem attacks (spec §6.3)
- Add bypassArmor/bypassBarrier support in applyEnemyDefenses() (AC-13)
- Export standalone applyEnemyDefenses from combat-tick.ts for DoT pipeline
- Split DoT runtime into separate dot-runtime.ts (135 lines) to keep combat-actions.ts under 400 lines
- Update all enemy generation sites with activeEffects/effectiveArmor defaults
- Fix test helpers for new required fields

All 921 tests pass (45 test files)
This commit is contained in:
2026-06-03 18:38:01 +02:00
parent a2cdf6d21c
commit b506f0bcc3
30 changed files with 3272 additions and 71 deletions
+32 -1
View File
@@ -5,8 +5,10 @@
import { SPELLS_DEF, HOURS_PER_TICK } from '../constants';
import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore, CombatState } from './combat-state.types';
import type { SpellState } from '../types';
import type { SpellState, EnemyState } from '../types';
import { applyOnHitEffect, processDoTPhase } from './dot-runtime';
import type { ActiveGolem } from '../types';
import type { SpellOnHitEffect } from '../types/spells';
import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import { processGolemMaintenance, processGolemAttacks } from './golem-combat-actions';
@@ -65,6 +67,14 @@ export function processCombatTick(
signedPacts: number[],
golemancyState: { activeGolems: ActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: (
dmg: number,
enemy: EnemyState | null,
roomType: string,
addLog: (msg: string) => void,
bypassArmor?: boolean,
bypassBarrier?: boolean,
) => number,
): CombatTickResult {
const state = get();
const logMessages: string[] = [];
@@ -177,6 +187,9 @@ export function processCombatTick(
castProgress -= 1;
safetyCounter++;
// Apply on-hit effect (DoT/debuff) to enemy (spec §6.2)
applyOnHitEffect(get, set, spellId, logMessages);
// Check if room/floor is cleared
if (floorHP <= 0) {
const guardian = getGuardianForFloor(currentFloor);
@@ -302,6 +315,24 @@ export function processCombatTick(
currentFloor = postGolemState.currentFloor;
}
// ─── DoT/Debuff tick processing (spec §6.3) ──────────────────────────
// Process after all weapon/golem attacks
if (floorHP > 0) {
const doTDamage = processDoTPhase(get, set, applyEnemyDefenses, logMessages);
floorHP = Math.max(0, floorHP - doTDamage);
// Check if DoT cleared the room
if (floorHP <= 0) {
const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian);
get().advanceRoomOrFloor();
const newState = get();
currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP;
}
}
const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor);
return {
+9 -1
View File
@@ -1,7 +1,7 @@
// ─── Combat State Types ────────────────────────────────────────────────────────
// Shared types for combat store and combat actions to avoid circular dependency
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem } from '../types';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState } from '../types';
// ─── Combat State (data only) ─────────────────────────────────────────────────
@@ -145,6 +145,14 @@ export interface CombatActions {
signedPacts: number[],
golemancyState: { activeGolems: ActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: (
dmg: number,
enemy: EnemyState | null,
roomType: string,
addLog: (msg: string) => void,
bypassArmor?: boolean,
bypassBarrier?: boolean,
) => number,
) => {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
+10 -1
View File
@@ -4,7 +4,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem } from '../types';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, ActiveGolem, EnemyState } from '../types';
import { getFloorMaxHP } from '../utils';
import { generateFloorState } from '../utils/room-utils';
import { addActivityLogEntry } from '../utils/activity-log';
@@ -302,6 +302,14 @@ export const useCombatStore = create<CombatStore>()(
signedPacts: number[],
golemancyState: { activeGolems: ActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: (
dmg: number,
enemy: EnemyState | null,
roomType: string,
addLog: (msg: string) => void,
bypassArmor?: boolean,
bypassBarrier?: boolean,
) => number,
) => {
return processCombatTick(
get,
@@ -315,6 +323,7 @@ export const useCombatStore = create<CombatStore>()(
signedPacts,
golemancyState,
golemApplyDamageToRoom,
applyEnemyDefenses,
);
},
+135
View File
@@ -0,0 +1,135 @@
// ─── DoT/Debuff Runtime (spec §6) ─────────────────────────────────────────────
// Handles on-hit effect application and DoT tick processing for combat.
import { SPELLS_DEF } from '../constants';
import type { CombatStore, CombatState } from './combat-state.types';
import type { ActiveEffect, EnemyState } from '../types';
import type { SpellOnHitEffect } from '../types/spells';
const DOT_EFFECT_TYPES = new Set(['burn', 'poison', 'bleed']);
/**
* Apply an on-hit effect from a spell to the current room's primary enemy.
* Called after a successful spell hit in processCombatTick.
* spec §6.2
*/
export function applyOnHitEffect(
get: () => CombatStore,
set: (state: Partial<CombatState>) => void,
spellId: string,
logMessages: string[],
): void {
const spellDef = SPELLS_DEF[spellId];
if (!spellDef?.onHitEffect) return;
const onHit = spellDef.onHitEffect as SpellOnHitEffect;
if (onHit.applyChance !== undefined && Math.random() > onHit.applyChance) return;
const state = get();
const room = state.currentRoom;
if (!room || room.enemies.length === 0) return;
// Apply to the first living enemy
const targetIdx = room.enemies.findIndex(e => e.hp > 0);
if (targetIdx === -1) return;
const target = room.enemies[targetIdx];
const effect: ActiveEffect = {
type: onHit.type,
remainingDuration: onHit.duration,
magnitude: onHit.magnitude,
source: 'spell',
bypassArmor: onHit.bypassArmor,
bypassBarrier: onHit.bypassBarrier,
};
const updatedEnemies = [...room.enemies];
updatedEnemies[targetIdx] = {
...target,
activeEffects: [...target.activeEffects, effect],
};
set({ currentRoom: { ...room, enemies: updatedEnemies } });
logMessages.push(`${target.name} afflicted with ${onHit.type}`);
}
/**
* Process one tick of all active effects on all enemies in the current room.
* Called after all weapon/golem attacks in processCombatTick.
* spec §6.3
* Returns total DoT damage dealt (to subtract from floorHP).
*/
export function processDoTPhase(
get: () => CombatStore,
set: (state: Partial<CombatState>) => void,
applyEnemyDefenses: (
dmg: number,
enemy: EnemyState | null,
roomType: string,
addLog: (msg: string) => void,
bypassArmor?: boolean,
bypassBarrier?: boolean,
) => number,
logMessages: string[],
): number {
const state = get();
const room = state.currentRoom;
if (!room || room.enemies.length === 0) return 0;
let totalDoTDamage = 0;
const updatedEnemies: EnemyState[] = [];
for (const enemy of room.enemies) {
if (enemy.hp <= 0) {
updatedEnemies.push(enemy);
continue;
}
let enemyHp = enemy.hp;
let enemyArmor = enemy.effectiveArmor;
const remainingEffects: ActiveEffect[] = [];
for (const effect of enemy.activeEffects) {
if (DOT_EFFECT_TYPES.has(effect.type)) {
// DoT: burn, poison, bleed (spec §6.3)
const dmg = effect.magnitude;
let dotFinalDmg: number;
if (effect.bypassArmor) {
// AC-13: bypass armor — apply directly to HP, skip all defenses
dotFinalDmg = dmg;
} else if (effect.bypassBarrier) {
// Bypass barrier: skip barrier but still apply armor
dotFinalDmg = applyEnemyDefenses(dmg, enemy, room.roomType, () => {}, false, true);
} else {
// Normal: apply full defense pipeline (dodge → barrier → armor)
dotFinalDmg = applyEnemyDefenses(dmg, enemy, room.roomType, () => {});
}
enemyHp = Math.max(0, enemyHp - dotFinalDmg);
totalDoTDamage += dotFinalDmg;
} else if (effect.type === 'curse') {
// Curse: amplifies incoming damage (tracked on enemy, applied next tick)
// No immediate HP effect — curse multiplier is stored on the enemy
} else if (effect.type === 'armor_corrode') {
// Armor corrode: reduce effective armor
enemyArmor = Math.max(0, enemyArmor - effect.magnitude);
}
// freeze, slow, blind: soft CC — no HP effect, just tracked
// Decrement duration
const newDuration = effect.remainingDuration - 1;
if (newDuration > 0) {
remainingEffects.push({ ...effect, remainingDuration: newDuration });
}
}
updatedEnemies.push({
...enemy,
hp: enemyHp,
effectiveArmor: enemyArmor,
activeEffects: remainingEffects,
});
}
set({ currentRoom: { ...room, enemies: updatedEnemies } });
return totalDoTDamage;
}
+4
View File
@@ -28,6 +28,7 @@ import { buildGolemCombatPipeline } from './pipelines/golem-combat';
import type { TickContext, TickWrites } from './tick-pipeline';
import type { GameCoordinatorState } from './gameStore.types';
import type { EnemyState } from '../types';
import { applyEnemyDefenses as applyEnemyDefensesFromPipeline } from './pipelines/combat-tick';
export interface GameCoordinatorStore extends GameCoordinatorState {
tick: () => void;
@@ -309,6 +310,9 @@ export const useGameStore = create<GameCoordinatorStore>()(
ctx.prestige.signedPacts,
{ activeGolems: golemPipeline.activeGolems },
golemPipeline.golemApplyDamageToRoom,
(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) => applyEnemyDefensesFromPipeline(
dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier,
),
);
rawMana = combatResult.rawMana;
elements = combatResult.elements;
+53 -42
View File
@@ -37,13 +37,62 @@ interface BuildCombatCallbacksParams {
effects: ComputedEffects;
maxMana: number;
addLog: (msg: string) => void;
useCombatStore: { setState: (s: Record<string, unknown>) => void };
useCombatStore: { setState: (s: Record<string, unknown>) => void; getState: () => Record<string, unknown> };
usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void } };
}
/** Speed-room bonus added to agile dodge chance (spec §4.5) */
const SPEED_ROOM_DODGE_BONUS = 0.20;
// ─── Standalone Enemy Defenses (for DoT/debuff pipeline) ─────────────────────
/**
* Apply regular enemy defenses: dodge → barrier → armor (spec §5.2).
* Returns modified damage, or 0 on dodge.
* Exported for use by the DoT/debuff tick processing system (spec §6.3).
*
* @param bypassArmor — if true, skip armor reduction entirely (spec §6.4, AC-13)
* @param bypassBarrier — if true, skip barrier absorption (spec §6.4)
*/
export function applyEnemyDefenses(
dmg: number,
enemy: EnemyState | null,
roomType: string,
addLog: (msg: string) => void,
bypassArmor?: boolean,
bypassBarrier?: boolean,
): number {
if (!enemy) return dmg;
// 1. Dodge check (spec §5.2, §4.5)
let effectiveDodge = enemy.dodgeChance;
if (roomType === 'speed') {
const hasAgile = enemy.name.toLowerCase().includes('agile');
if (hasAgile) {
effectiveDodge = Math.min(0.75, enemy.dodgeChance + SPEED_ROOM_DODGE_BONUS);
}
}
if (effectiveDodge > 0 && Math.random() < effectiveDodge) {
addLog('Attack dodged!');
return 0;
}
// 2. Barrier absorption (percentage, spec §5.2) — skipped if bypassBarrier
if (!bypassBarrier && enemy.barrier && enemy.barrier > 0) {
dmg *= (1 - enemy.barrier);
}
// 3. Armor reduction — skipped if bypassArmor (spec §6.4, AC-13)
if (!bypassArmor) {
const armorValue = (enemy as EnemyState & { effectiveArmor?: number }).effectiveArmor ?? enemy.armor;
if (armorValue && armorValue > 0) {
dmg *= (1 - armorValue);
}
}
return dmg;
}
export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
const { ctx, effects, maxMana, useCombatStore, usePrestigeStore } = params;
@@ -85,46 +134,8 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
return { ...enemy, barrier: recharged };
};
/**
* Apply regular enemy defenses: dodge → barrier → armor (spec §5.2).
* Returns modified damage, or 0 on dodge.
* This is the single defense pipeline used for ALL enemy hits (not just guardians).
*/
const applyEnemyDefenses = (
dmg: number,
enemy: EnemyState | null,
roomType: string,
addLog: (msg: string) => void,
): number => {
if (!enemy) return dmg;
// 1. Dodge check (spec §5.2, §4.5)
let effectiveDodge = enemy.dodgeChance;
if (roomType === 'speed') {
// Agile + speed room: additive dodge bonus, capped at 0.75
const hasAgile = enemy.name.toLowerCase().includes('agile');
if (hasAgile) {
effectiveDodge = Math.min(0.75, enemy.dodgeChance + SPEED_ROOM_DODGE_BONUS);
}
}
if (effectiveDodge > 0 && Math.random() < effectiveDodge) {
addLog('Attack dodged!');
return 0;
}
// 2. Barrier absorption (percentage, spec §5.2)
if (enemy.barrier && enemy.barrier > 0) {
dmg *= (1 - enemy.barrier);
}
// 3. Armor reduction — use effectiveArmor (after corrode) if available, else base armor (spec §5.2)
const armorValue = (enemy as EnemyState & { effectiveArmor?: number }).effectiveArmor ?? enemy.armor;
if (armorValue && armorValue > 0) {
dmg *= (1 - armorValue);
}
return dmg;
};
// Local reference to the module-level applyEnemyDefenses
const defApply = applyEnemyDefenses;
/**
* Create the onDamageDealt callback for this tick.
@@ -150,7 +161,7 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
}
// Apply regular enemy defenses for ALL enemies (spec §5.2)
dmg = applyEnemyDefenses(dmg, defCtx.enemy, defCtx.roomType, addLog);
dmg = defApply(dmg, defCtx.enemy, defCtx.roomType, addLog);
// Guardian-specific defensive pipeline (shield → barrier → health regen, spec §5.3)
const guardian = getGuardianForFloor(ctx.combat.currentFloor);