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
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:
@@ -75,6 +75,7 @@ function runCombatTick(rawMana: number, elements: Record<string, { current: numb
|
||||
[], // signedPacts
|
||||
{ activeGolems: [] }, // golemancyState
|
||||
golemApplyDamageToRoom,
|
||||
(dmg: number) => dmg, // applyEnemyDefenses (passthrough for tests)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +95,7 @@ function processCombatTickDirect(
|
||||
onFloorCleared, onDamageDealt, signedPacts,
|
||||
{ activeGolems: [] },
|
||||
golemApplyDamageToRoom,
|
||||
(dmg: number) => dmg, // applyEnemyDefenses (passthrough for tests)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export function resetAllStores() {
|
||||
roomResetState: {},
|
||||
clearedRooms: {},
|
||||
isDescentComplete: false,
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
||||
equipmentSpellStates: [],
|
||||
comboHitCount: 0,
|
||||
floorHitCount: 0,
|
||||
|
||||
@@ -76,6 +76,8 @@ function makeEnemy(overrides: Partial<EnemyState> = {}): EnemyState {
|
||||
armor: 0,
|
||||
dodgeChance: 0,
|
||||
element: 'fire',
|
||||
activeEffects: [],
|
||||
effectiveArmor: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -197,18 +199,17 @@ describe('Enemy Defenses (spec §5.2)', () => {
|
||||
|
||||
describe('effectiveArmor (post-corrode)', () => {
|
||||
it('should use effectiveArmor over base armor when set', () => {
|
||||
const enemy = makeEnemy({ armor: 0.4 }) as EnemyState & { effectiveArmor?: number };
|
||||
enemy.effectiveArmor = 0.2; // Armor reduced by corrode
|
||||
const enemy = makeEnemy({ armor: 0.4, effectiveArmor: 0.2 }); // Armor reduced by corrode
|
||||
|
||||
// The defense pipeline uses effectiveArmor ?? armor
|
||||
const armorValue = enemy.effectiveArmor ?? enemy.armor;
|
||||
// The defense pipeline uses effectiveArmor (after corrode)
|
||||
const armorValue = enemy.effectiveArmor;
|
||||
expect(armorValue).toBe(0.2);
|
||||
});
|
||||
|
||||
it('should fall back to base armor when effectiveArmor is not set', () => {
|
||||
const enemy = makeEnemy({ armor: 0.4 });
|
||||
it('should have effectiveArmor equal to base armor when no corrode applied', () => {
|
||||
const enemy = makeEnemy({ armor: 0.4, effectiveArmor: 0.4 });
|
||||
|
||||
const armorValue = (enemy as EnemyState & { effectiveArmor?: number }).effectiveArmor ?? enemy.armor;
|
||||
const armorValue = enemy.effectiveArmor;
|
||||
expect(armorValue).toBe(0.4);
|
||||
});
|
||||
});
|
||||
@@ -219,6 +220,7 @@ describe('Enemy Defenses (spec §5.2)', () => {
|
||||
dodgeChance: 0,
|
||||
barrier: 0.5,
|
||||
armor: 0.3,
|
||||
effectiveArmor: 0.3,
|
||||
});
|
||||
|
||||
// Simulate: 100 damage → barrier (50%) → 50 → armor (30%) → 35
|
||||
@@ -229,10 +231,11 @@ describe('Enemy Defenses (spec §5.2)', () => {
|
||||
}
|
||||
expect(dmg).toBe(50);
|
||||
|
||||
const armorValue = (enemy as EnemyState & { effectiveArmor?: number }).effectiveArmor ?? enemy.armor;
|
||||
const armorValue = enemy.effectiveArmor;
|
||||
if (armorValue && armorValue > 0) {
|
||||
dmg *= (1 - armorValue);
|
||||
}
|
||||
// 100 → barrier (50%) → 50 → armor (30%) → 35
|
||||
expect(dmg).toBe(35);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@ export const BASIC_ELEMENTAL_SPELLS: Record<string, SpellDef> = {
|
||||
castSpeed: 2,
|
||||
unlock: 100,
|
||||
studyTime: 2,
|
||||
desc: "Hurl a ball of fire at your enemy."
|
||||
desc: "Hurl a ball of fire at your enemy.",
|
||||
onHitEffect: { type: 'burn', duration: 4, magnitude: 3 },
|
||||
},
|
||||
emberShot: {
|
||||
name: "Ember Shot",
|
||||
@@ -24,7 +25,8 @@ export const BASIC_ELEMENTAL_SPELLS: Record<string, SpellDef> = {
|
||||
castSpeed: 3,
|
||||
unlock: 75,
|
||||
studyTime: 1,
|
||||
desc: "A quick shot of embers. Efficient fire damage."
|
||||
desc: "A quick shot of embers. Efficient fire damage.",
|
||||
onHitEffect: { type: 'burn', duration: 3, magnitude: 2 },
|
||||
},
|
||||
waterJet: {
|
||||
name: "Water Jet",
|
||||
@@ -145,7 +147,8 @@ export const BASIC_ELEMENTAL_SPELLS: Record<string, SpellDef> = {
|
||||
castSpeed: 2,
|
||||
unlock: 150,
|
||||
studyTime: 3,
|
||||
desc: "Siphon vital energy from your enemy."
|
||||
desc: "Siphon vital energy from your enemy.",
|
||||
onHitEffect: { type: 'curse', duration: 4, magnitude: 0.20 },
|
||||
},
|
||||
rotTouch: {
|
||||
name: "Rot Touch",
|
||||
@@ -156,6 +159,7 @@ export const BASIC_ELEMENTAL_SPELLS: Record<string, SpellDef> = {
|
||||
castSpeed: 2,
|
||||
unlock: 170,
|
||||
studyTime: 3,
|
||||
desc: "Touch of decay and rot."
|
||||
desc: "Touch of decay and rot.",
|
||||
onHitEffect: { type: 'curse', duration: 4, magnitude: 0.20 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,7 +15,8 @@ export const FROST_SPELLS: Record<string, SpellDef> = {
|
||||
unlock: 180,
|
||||
studyTime: 3,
|
||||
desc: "A chilling bite of frost. Fast casting with freeze chance.",
|
||||
effects: [{ type: 'freeze', value: 0.15 }]
|
||||
effects: [{ type: 'freeze', value: 0.15 }],
|
||||
onHitEffect: { type: 'freeze', duration: 3, magnitude: 0.15 },
|
||||
},
|
||||
iceShard: {
|
||||
name: "Ice Shard",
|
||||
@@ -27,7 +28,8 @@ export const FROST_SPELLS: Record<string, SpellDef> = {
|
||||
unlock: 250,
|
||||
studyTime: 4,
|
||||
desc: "A sharp shard of ice. Moderate speed, freeze effect.",
|
||||
effects: [{ type: 'freeze', value: 0.2 }]
|
||||
effects: [{ type: 'freeze', value: 0.2 }],
|
||||
onHitEffect: { type: 'freeze', duration: 3, magnitude: 0.2 },
|
||||
},
|
||||
|
||||
// Tier 2 - Advanced Frost
|
||||
|
||||
@@ -15,7 +15,8 @@ export const LIGHTNING_SPELLS: Record<string, SpellDef> = {
|
||||
unlock: 120,
|
||||
studyTime: 2,
|
||||
desc: "A quick spark of lightning. Very fast and hard to dodge.",
|
||||
effects: [{ type: 'armor_pierce', value: 0.2 }]
|
||||
effects: [{ type: 'armor_pierce', value: 0.2 }],
|
||||
onHitEffect: { type: 'armor_corrode', duration: 3, magnitude: 0.15 },
|
||||
},
|
||||
lightningBolt: {
|
||||
name: "Lightning Bolt",
|
||||
@@ -27,7 +28,8 @@ export const LIGHTNING_SPELLS: Record<string, SpellDef> = {
|
||||
unlock: 150,
|
||||
studyTime: 3,
|
||||
desc: "A bolt of lightning that pierces armor.",
|
||||
effects: [{ type: 'armor_pierce', value: 0.3 }]
|
||||
effects: [{ type: 'armor_pierce', value: 0.3 }],
|
||||
onHitEffect: { type: 'armor_corrode', duration: 3, magnitude: 0.15 },
|
||||
},
|
||||
|
||||
// Tier 2 - Advanced Lightning
|
||||
|
||||
@@ -15,7 +15,8 @@ export const SOUL_SPELLS: Record<string, SpellDef> = {
|
||||
unlock: 30000,
|
||||
studyTime: 36,
|
||||
desc: "Strike at the soul. Bypasses all armor and resistances.",
|
||||
effects: [{ type: 'defense_bypass', value: 1.0 }]
|
||||
effects: [{ type: 'defense_bypass', value: 1.0 }],
|
||||
onHitEffect: { type: 'burn', duration: 5, magnitude: 20, bypassArmor: true },
|
||||
},
|
||||
spiritBlast: {
|
||||
name: "Spirit Blast",
|
||||
@@ -27,6 +28,7 @@ export const SOUL_SPELLS: Record<string, SpellDef> = {
|
||||
unlock: 60000,
|
||||
studyTime: 50,
|
||||
desc: "A blast of pure soul energy. Ignores all defenses entirely.",
|
||||
effects: [{ type: 'defense_bypass', value: 1.0 }, { type: 'resist_ignore', value: 0.5 }]
|
||||
effects: [{ type: 'defense_bypass', value: 1.0 }, { type: 'resist_ignore', value: 0.5 }],
|
||||
onHitEffect: { type: 'burn', duration: 5, magnitude: 35, bypassArmor: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,7 +17,8 @@ export type ActivityEventType =
|
||||
| 'armor_proc'
|
||||
| 'spell_cast'
|
||||
| 'golem_attack'
|
||||
| 'puzzle_solved';
|
||||
| 'puzzle_solved'
|
||||
| 'debuff';
|
||||
|
||||
export interface ActivityLogEntry {
|
||||
id: string; // Unique ID for React key
|
||||
@@ -37,6 +38,25 @@ export interface ActivityLogEntry {
|
||||
|
||||
export type RoomType = 'combat' | 'puzzle' | 'swarm' | 'speed' | 'guardian' | 'recovery' | 'library' | 'treasure';
|
||||
|
||||
export type EffectType =
|
||||
| 'burn'
|
||||
| 'poison'
|
||||
| 'bleed'
|
||||
| 'freeze'
|
||||
| 'slow'
|
||||
| 'curse'
|
||||
| 'armor_corrode'
|
||||
| 'blind';
|
||||
|
||||
export interface ActiveEffect {
|
||||
type: EffectType;
|
||||
remainingDuration: number; // in ticks
|
||||
magnitude: number;
|
||||
source: 'spell' | 'golem';
|
||||
bypassArmor?: boolean;
|
||||
bypassBarrier?: boolean;
|
||||
}
|
||||
|
||||
export interface EnemyState {
|
||||
id: string;
|
||||
name: string; // Display name for the enemy
|
||||
@@ -46,6 +66,8 @@ export interface EnemyState {
|
||||
dodgeChance: number; // For speed rooms (0-1)
|
||||
barrier?: number; // Shield that absorbs damage before HP (0-1 as percentage of max HP)
|
||||
element: string;
|
||||
activeEffects: ActiveEffect[]; // DoT/debuff effects currently on this enemy
|
||||
effectiveArmor: number; // Armor after corrode effects (0-1)
|
||||
}
|
||||
|
||||
export interface FloorState {
|
||||
|
||||
@@ -9,7 +9,7 @@ export type { ElementCategory, ElementDef, ElementState, ManaType } from './elem
|
||||
export type { AttunementSlot, AttunementDef, AttunementState, GuardianBoon, GuardianDef } from './attunements';
|
||||
|
||||
// Spell types
|
||||
export type { SpellCost, SpellDef, SpellEffect, SpellState } from './spells';
|
||||
export type { SpellCost, SpellDef, SpellEffect, SpellState, SpellOnHitEffect } from './spells';
|
||||
|
||||
// Equipment types
|
||||
export type {
|
||||
@@ -48,4 +48,6 @@ export type {
|
||||
ActivityLogEntry,
|
||||
PrestigeDef,
|
||||
LootDrop,
|
||||
ActiveEffect,
|
||||
EffectType,
|
||||
} from './game';
|
||||
|
||||
@@ -7,6 +7,15 @@ export interface SpellCost {
|
||||
amount: number; // Amount of mana required
|
||||
}
|
||||
|
||||
export interface SpellOnHitEffect {
|
||||
type: 'burn' | 'poison' | 'bleed' | 'freeze' | 'slow' | 'curse' | 'armor_corrode' | 'blind';
|
||||
duration: number; // ticks
|
||||
magnitude: number;
|
||||
bypassArmor?: boolean;
|
||||
bypassBarrier?: boolean;
|
||||
applyChance?: number; // 0-1, defaults to 1.0
|
||||
}
|
||||
|
||||
export interface SpellDef {
|
||||
name: string;
|
||||
elem: string; // Element type for damage calculations
|
||||
@@ -23,6 +32,7 @@ export interface SpellDef {
|
||||
aoeTargets?: number; // Number of enemies hit by AOE
|
||||
isWeaponEnchant?: boolean; // Can be used as weapon enchantment (magic swords)
|
||||
grimoire?: boolean; // Whether this spell appears in the grimoire
|
||||
onHitEffect?: SpellOnHitEffect; // DoT/debuff applied on successful hit
|
||||
}
|
||||
|
||||
export interface SpellEffect {
|
||||
|
||||
@@ -65,6 +65,8 @@ export function generateSwarmEnemies(floor: number): EnemyState[] {
|
||||
dodgeChance: 0,
|
||||
barrier: getEnemyBarrier(floor, element),
|
||||
element,
|
||||
activeEffects: [],
|
||||
effectiveArmor: SWARM_CONFIG.armorBase + Math.floor(floor / 10) * SWARM_CONFIG.armorPerFloor,
|
||||
});
|
||||
}
|
||||
return enemies;
|
||||
|
||||
@@ -110,6 +110,8 @@ export function generateFloorState(floor: number): FloorState {
|
||||
dodgeChance: 0,
|
||||
barrier: 0,
|
||||
element: guardian.element.join('+'),
|
||||
activeEffects: [],
|
||||
effectiveArmor: guardian.armor || 0,
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -132,6 +134,8 @@ export function generateFloorState(floor: number): FloorState {
|
||||
dodgeChance: getDodgeChance(floor),
|
||||
barrier: getEnemyBarrier(floor, element),
|
||||
element,
|
||||
activeEffects: [],
|
||||
effectiveArmor: getFloorArmor(floor),
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -164,6 +168,8 @@ export function generateFloorState(floor: number): FloorState {
|
||||
dodgeChance: 0,
|
||||
barrier: getEnemyBarrier(floor, element),
|
||||
element,
|
||||
activeEffects: [],
|
||||
effectiveArmor: getFloorArmor(floor),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ export function generateSpireFloorState(floor: number, roomIndex: number, totalR
|
||||
dodgeChance: 0,
|
||||
barrier: 0,
|
||||
element: guardian.element.join('+'),
|
||||
activeEffects: [],
|
||||
effectiveArmor: guardian.armor || 0,
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -184,6 +186,8 @@ function generateCombatRoom(floor: number, element: string, baseHP: number): Flo
|
||||
dodgeChance: 0,
|
||||
barrier,
|
||||
element,
|
||||
activeEffects: [],
|
||||
effectiveArmor: armor,
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -203,6 +207,8 @@ function generateSwarmRoom(floor: number, element: string, baseHP: number): Floo
|
||||
dodgeChance: 0,
|
||||
barrier: 0,
|
||||
element,
|
||||
activeEffects: [],
|
||||
effectiveArmor: Math.floor(floor / 15) * 0.02,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,6 +230,8 @@ function generateSpeedRoom(floor: number, element: string, baseHP: number): Floo
|
||||
dodgeChance,
|
||||
barrier: getSpireEnemyBarrier(floor, element),
|
||||
element,
|
||||
activeEffects: [],
|
||||
effectiveArmor: armor,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user