fix: spire combat 11 high-severity discrepancies (issue #333)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
D-01: Implement per-weapon cast progress (weaponCastProgress record) D-04: Bypass Executioner/Berserker discipline specials for golem attacks D-09: Fix lightning counter direction (lightning→water, not lightning→earth) D-10: Add full composite element counters (blackflame/radiantflames ↔ frost/water/light/dark) D-15: Fix Executioner to check per-enemy HP < 25% instead of floorHP ratio D-20: Fix dodge formula to match spec (min(0.55, floor × 0.003), starts at 0) D-22: Fix shield modifier to use flat HP pool instead of percentage barrier D-23: Wire up applyMageBarrierRecharge in the damage pipeline D-25: Move guardian regen from per-damage-event to once-per-tick D-26: Add guardian armor reduction to the guardian defensive pipeline D-31: Fix armor_corrode to be temporary (restore armor on effect expiry) D-38: Implement AoE damage distribution across enemies All 1069 tests pass. No files exceed 400 lines.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# Circular Dependencies
|
# Circular Dependencies
|
||||||
Generated: 2026-06-08T13:51:45.536Z
|
Generated: 2026-06-08T14:03:03.873Z
|
||||||
Found: 1 circular chain(s) — these MUST be fixed before modifying involved files.
|
Found: 1 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||||
|
|
||||||
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
|
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"generated": "2026-06-08T13:51:43.381Z",
|
"generated": "2026-06-08T14:03:01.818Z",
|
||||||
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
||||||
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -96,13 +96,15 @@ describe('Floor element cycle', () => {
|
|||||||
|
|
||||||
it('element opposites define expected pairs', async () => {
|
it('element opposites define expected pairs', async () => {
|
||||||
const { ELEMENT_OPPOSITES } = await import('@/lib/game/constants');
|
const { ELEMENT_OPPOSITES } = await import('@/lib/game/constants');
|
||||||
expect(ELEMENT_OPPOSITES['fire']).toBe('water');
|
// ELEMENT_OPPOSITES now uses arrays to support multiple counters per element
|
||||||
expect(ELEMENT_OPPOSITES['water']).toBe('fire');
|
expect(ELEMENT_OPPOSITES['fire']).toEqual(expect.arrayContaining(['water']));
|
||||||
expect(ELEMENT_OPPOSITES['air']).toBe('earth');
|
expect(ELEMENT_OPPOSITES['water']).toEqual(expect.arrayContaining(['fire', 'lightning']));
|
||||||
expect(ELEMENT_OPPOSITES['earth']).toBe('air');
|
expect(ELEMENT_OPPOSITES['air']).toEqual(expect.arrayContaining(['earth']));
|
||||||
expect(ELEMENT_OPPOSITES['light']).toBe('dark');
|
expect(ELEMENT_OPPOSITES['earth']).toEqual(expect.arrayContaining(['air']));
|
||||||
expect(ELEMENT_OPPOSITES['dark']).toBe('light');
|
expect(ELEMENT_OPPOSITES['light']).toEqual(expect.arrayContaining(['dark']));
|
||||||
expect(ELEMENT_OPPOSITES['lightning']).toBe('earth');
|
expect(ELEMENT_OPPOSITES['dark']).toEqual(expect.arrayContaining(['light']));
|
||||||
|
// lightning is weak to earth (earth counters lightning, spec §4.2)
|
||||||
|
expect(ELEMENT_OPPOSITES['lightning']).toEqual(expect.arrayContaining(['earth']));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function resetAllStores() {
|
|||||||
isDescentComplete: false,
|
isDescentComplete: false,
|
||||||
golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] },
|
golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
|
weaponCastProgress: {},
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
spells: makeInitialSpells(),
|
spells: makeInitialSpells(),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function resetStores() {
|
|||||||
isDescentComplete: false,
|
isDescentComplete: false,
|
||||||
golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] },
|
golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] },
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
|
weaponCastProgress: {},
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
|
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ describe('generateEnemy', () => {
|
|||||||
it('should apply shield modifier', () => {
|
it('should apply shield modifier', () => {
|
||||||
const enemy = generateEnemy(30, ['shield']);
|
const enemy = generateEnemy(30, ['shield']);
|
||||||
expect(enemy.modifiers).toContain('shield');
|
expect(enemy.modifiers).toContain('shield');
|
||||||
expect(enemy.barrier).toBeGreaterThanOrEqual(SHIELD_AMOUNT);
|
// Shield modifier now sets shieldPool (flat HP) instead of barrier (percentage)
|
||||||
|
expect(enemy.shieldPool).toBeGreaterThanOrEqual(enemy.maxHP * SHIELD_AMOUNT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have valid element', () => {
|
it('should have valid element', () => {
|
||||||
|
|||||||
@@ -147,13 +147,14 @@ describe('calcMeleeDamage', () => {
|
|||||||
expect(damage).toBe(5);
|
expect(damage).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle frost-enchanted sword vs fire enemy (frost weak to fire = 0.75x)', () => {
|
it('should handle frost-enchanted sword vs fire enemy (frost super effective vs fire = 1.5x, spec §4.2 bidirectional)', () => {
|
||||||
const swordType = { stats: { baseDamage: 15, attackSpeed: 1.0 } };
|
const swordType = { stats: { baseDamage: 15, attackSpeed: 1.0 } };
|
||||||
const swordInstance = makeSwordInstance('crystalBlade', [
|
const swordInstance = makeSwordInstance('crystalBlade', [
|
||||||
{ effectId: 'sword_frost', stacks: 1, actualCost: 40 },
|
{ effectId: 'sword_frost', stacks: 1, actualCost: 40 },
|
||||||
]);
|
]);
|
||||||
const damage = calcMeleeDamage(swordInstance, swordType, 'fire');
|
const damage = calcMeleeDamage(swordInstance, swordType, 'fire');
|
||||||
expect(damage).toBe(15 * 0.75);
|
// Frost ↔ fire is bidirectional: frost counters fire (super effective = 1.5x)
|
||||||
|
expect(damage).toBe(15 * 1.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle lightning-enchanted sword vs earth enemy (lightning weak to earth = 0.75x)', () => {
|
it('should handle lightning-enchanted sword vs earth enemy (lightning weak to earth = 0.75x)', () => {
|
||||||
@@ -189,7 +190,7 @@ describe('melee auto-attack in processCombatTick', () => {
|
|||||||
const elements = makeInitialElements(500, {});
|
const elements = makeInitialElements(500, {});
|
||||||
const sword = makeSwordInstance('ironBlade');
|
const sword = makeSwordInstance('ironBlade');
|
||||||
const equippedSwords = { [sword.instanceId]: sword };
|
const equippedSwords = { [sword.instanceId]: sword };
|
||||||
let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100, floorMaxHP: 100, maxFloorReached: 1, castProgress: 0, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {}, currentRoom: { roomType: 'combat', enemies: [] } };
|
let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100, floorMaxHP: 100, maxFloorReached: 1, castProgress: 0, weaponCastProgress: {}, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {}, currentRoom: { roomType: 'combat', enemies: [] } };
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
result = runCombatTickWithSwords(1000, elements, equippedSwords);
|
result = runCombatTickWithSwords(1000, elements, equippedSwords);
|
||||||
}
|
}
|
||||||
@@ -221,7 +222,7 @@ describe('melee auto-attack in processCombatTick', () => {
|
|||||||
const sword = makeSwordInstance('ironBlade');
|
const sword = makeSwordInstance('ironBlade');
|
||||||
sword.instanceId = 'test-sword';
|
sword.instanceId = 'test-sword';
|
||||||
const equippedSwords = { [sword.instanceId]: sword };
|
const equippedSwords = { [sword.instanceId]: sword };
|
||||||
let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100000, floorMaxHP: 100000, maxFloorReached: 1, castProgress: 0, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {}, currentRoom: { roomType: 'combat', enemies: [] } };
|
let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100000, floorMaxHP: 100000, maxFloorReached: 1, castProgress: 0, weaponCastProgress: {}, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {}, currentRoom: { roomType: 'combat', enemies: [] } };
|
||||||
// Run 25 ticks: each tick adds 0.048 progress, so 25 * 0.048 = 1.2, triggering a hit
|
// Run 25 ticks: each tick adds 0.048 progress, so 25 * 0.048 = 1.2, triggering a hit
|
||||||
for (let i = 0; i < 25; i++) {
|
for (let i = 0; i < 25; i++) {
|
||||||
result = runCombatTickWithSwords(1000, elements, equippedSwords);
|
result = runCombatTickWithSwords(1000, elements, equippedSwords);
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ function runTicksAndMeasureDamage(
|
|||||||
floorMaxHP: initialFloorHP,
|
floorMaxHP: initialFloorHP,
|
||||||
maxFloorReached: 1,
|
maxFloorReached: 1,
|
||||||
castProgress: 0,
|
castProgress: 0,
|
||||||
|
weaponCastProgress: {},
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
activeGolems: [],
|
activeGolems: [],
|
||||||
meleeSwordProgress: useCombatStore.getState().meleeSwordProgress,
|
meleeSwordProgress: useCombatStore.getState().meleeSwordProgress,
|
||||||
|
|||||||
@@ -53,16 +53,30 @@ export const ELEMENTS: Record<string, ElementDef> = {
|
|||||||
export const FLOOR_ELEM_CYCLE = ["fire", "water", "air", "earth", "light", "dark", "death"];
|
export const FLOOR_ELEM_CYCLE = ["fire", "water", "air", "earth", "light", "dark", "death"];
|
||||||
|
|
||||||
// ─── Element Opposites for Damage Calculation ────────────────────────────────
|
// ─── Element Opposites for Damage Calculation ────────────────────────────────
|
||||||
export const ELEMENT_OPPOSITES: Record<string, string> = {
|
// Element Opposites for Damage Calculation
|
||||||
fire: 'water', water: 'fire',
|
// Semantics: ELEMENT_OPPOSITES[X] = list of elements that X is weak to (counters of X)
|
||||||
air: 'earth', earth: 'air',
|
// In getElementalBonus(spellElem, floorElem):
|
||||||
light: 'dark', dark: 'light',
|
// - If spellElem is in ELEMENT_OPPOSITES[floorElem] → super effective (1.5x)
|
||||||
lightning: 'earth', // Lightning is weak to earth (grounding)
|
// - If floorElem is in ELEMENT_OPPOSITES[spellElem] → weak (0.75x)
|
||||||
frost: 'fire', // Frost is weak to fire (melting)
|
// Bidirectional pairs: if A counters B, then B counters A (both can be super effective against each other)
|
||||||
blackflame: 'light', // BlackFlame is weak to light (purification)
|
// Directional counters:
|
||||||
radiantflames: 'dark', // Radiant Flames are weak to dark (extinguishing)
|
// earth → lightning (earth counters lightning, so lightning is weak to earth)
|
||||||
miasma: 'air', // Miasma is weak to air (dispersion)
|
// lightning → water (lightning counters water, so water is weak to lightning)
|
||||||
shadowglass: 'light', // Shadow Glass is weak to light (shattering)
|
export const ELEMENT_OPPOSITES: Record<string, string[]> = {
|
||||||
|
// Base opposites (bidirectional)
|
||||||
|
fire: ['water', 'frost'], water: ['fire', 'lightning', 'blackflame', 'radiantflames'],
|
||||||
|
air: ['earth', 'miasma'], earth: ['air'],
|
||||||
|
light: ['dark', 'blackflame', 'shadowglass'], dark: ['light', 'radiantflames'],
|
||||||
|
|
||||||
|
// Directional counters
|
||||||
|
lightning: ['earth'], // lightning is weak to earth (earth counters lightning)
|
||||||
|
|
||||||
|
// Composite element counters (bidirectional per spec §4.2)
|
||||||
|
frost: ['fire', 'blackflame', 'radiantflames'],
|
||||||
|
blackflame: ['frost', 'water', 'light'],
|
||||||
|
radiantflames: ['frost', 'water', 'dark'],
|
||||||
|
miasma: ['air'],
|
||||||
|
shadowglass: ['light'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Element Icon Mapping (Lucide Icons) ──────────────────────────────────────
|
// ─── Element Icon Mapping (Lucide Icons) ──────────────────────────────────────
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function makeDefaultCombatTickResult(
|
|||||||
floorMaxHP: state.floorMaxHP,
|
floorMaxHP: state.floorMaxHP,
|
||||||
maxFloorReached: state.maxFloorReached,
|
maxFloorReached: state.maxFloorReached,
|
||||||
castProgress: state.castProgress,
|
castProgress: state.castProgress,
|
||||||
|
weaponCastProgress: state.weaponCastProgress,
|
||||||
equipmentSpellStates: state.equipmentSpellStates,
|
equipmentSpellStates: state.equipmentSpellStates,
|
||||||
activeGolems,
|
activeGolems,
|
||||||
meleeSwordProgress: state.meleeSwordProgress,
|
meleeSwordProgress: state.meleeSwordProgress,
|
||||||
@@ -55,6 +56,7 @@ export interface CombatTickResult {
|
|||||||
floorMaxHP: number;
|
floorMaxHP: number;
|
||||||
maxFloorReached: number;
|
maxFloorReached: number;
|
||||||
castProgress: number;
|
castProgress: number;
|
||||||
|
weaponCastProgress: Record<string, number>;
|
||||||
equipmentSpellStates: CombatState['equipmentSpellStates'];
|
equipmentSpellStates: CombatState['equipmentSpellStates'];
|
||||||
activeGolems: RuntimeActiveGolem[];
|
activeGolems: RuntimeActiveGolem[];
|
||||||
meleeSwordProgress: Record<string, number>;
|
meleeSwordProgress: Record<string, number>;
|
||||||
@@ -71,7 +73,7 @@ export function processCombatTick(
|
|||||||
_maxMana: number,
|
_maxMana: number,
|
||||||
attackSpeedMult: number,
|
attackSpeedMult: number,
|
||||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||||
onDamageDealt: (damage: number) => {
|
onDamageDealt: (damage: number, skipSpecials?: boolean) => {
|
||||||
rawMana: number;
|
rawMana: number;
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
modifiedDamage?: number;
|
modifiedDamage?: number;
|
||||||
@@ -123,7 +125,7 @@ export function processCombatTick(
|
|||||||
let floorHP = state.floorHP;
|
let floorHP = state.floorHP;
|
||||||
let currentFloor = state.currentFloor;
|
let currentFloor = state.currentFloor;
|
||||||
let floorMaxHP = state.floorMaxHP;
|
let floorMaxHP = state.floorMaxHP;
|
||||||
let castProgress = state.castProgress;
|
const weaponCastProgress = { ...state.weaponCastProgress };
|
||||||
let currentRoom = state.currentRoom;
|
let currentRoom = state.currentRoom;
|
||||||
const updatedEquipmentSpellStates = [...state.equipmentSpellStates];
|
const updatedEquipmentSpellStates = [...state.equipmentSpellStates];
|
||||||
|
|
||||||
@@ -135,12 +137,14 @@ export function processCombatTick(
|
|||||||
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
|
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
|
||||||
const isSpellAoe = !!spellDef.isAoe;
|
const isSpellAoe = !!spellDef.isAoe;
|
||||||
|
|
||||||
castProgress = (castProgress || 0) + progressPerTick;
|
// Per-weapon cast progress for primary spell (spec §3.1, D-01 fix)
|
||||||
|
// Fall back to legacy castProgress for backward compatibility
|
||||||
|
weaponCastProgress['primary'] = ((weaponCastProgress['primary'] ?? state.castProgress) || 0) + progressPerTick;
|
||||||
|
|
||||||
// Process complete casts for active spell
|
// Process complete casts for active spell
|
||||||
let safetyCounter = 0;
|
let safetyCounter = 0;
|
||||||
const MAX_CASTS_PER_TICK = 100;
|
const MAX_CASTS_PER_TICK = 100;
|
||||||
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) {
|
while (weaponCastProgress['primary'] >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) {
|
||||||
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
|
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
|
||||||
rawMana = afterCost.rawMana;
|
rawMana = afterCost.rawMana;
|
||||||
elements = afterCost.elements;
|
elements = afterCost.elements;
|
||||||
@@ -163,11 +167,11 @@ export function processCombatTick(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply damage per-enemy (spec §3.2)
|
// Apply damage per-enemy (spec §3.2)
|
||||||
const roomResult = applyDamageToRoom(get, set, finalDamage, isSpellAoe);
|
const roomResult = applyDamageToRoom(get, set, finalDamage, isSpellAoe, spellDef.aoeTargets);
|
||||||
floorHP = roomResult.floorHP;
|
floorHP = roomResult.floorHP;
|
||||||
floorMaxHP = roomResult.floorMaxHP;
|
floorMaxHP = roomResult.floorMaxHP;
|
||||||
currentRoom = get().currentRoom;
|
currentRoom = get().currentRoom;
|
||||||
castProgress -= 1;
|
weaponCastProgress['primary'] -= 1;
|
||||||
safetyCounter++;
|
safetyCounter++;
|
||||||
|
|
||||||
applyOnHitEffect(get, set, spellId, logMessages);
|
applyOnHitEffect(get, set, spellId, logMessages);
|
||||||
@@ -182,7 +186,7 @@ export function processCombatTick(
|
|||||||
floorMaxHP = newState.floorMaxHP;
|
floorMaxHP = newState.floorMaxHP;
|
||||||
floorHP = newState.floorHP;
|
floorHP = newState.floorHP;
|
||||||
currentRoom = newState.currentRoom;
|
currentRoom = newState.currentRoom;
|
||||||
castProgress = 0;
|
weaponCastProgress['primary'] = 0;
|
||||||
if (guardian) {
|
if (guardian) {
|
||||||
logMessages.push(`⚔️ ${guardian.name} defeated!`);
|
logMessages.push(`⚔️ ${guardian.name} defeated!`);
|
||||||
} else if (currentFloor % 5 === 0) {
|
} else if (currentFloor % 5 === 0) {
|
||||||
@@ -224,7 +228,7 @@ export function processCombatTick(
|
|||||||
if (!Number.isFinite(eFinalDamage)) break;
|
if (!Number.isFinite(eFinalDamage)) break;
|
||||||
|
|
||||||
// Apply damage per-enemy (spec §3.2)
|
// Apply damage per-enemy (spec §3.2)
|
||||||
const eRoomResult = applyDamageToRoom(get, set, eFinalDamage, isESpellAoe);
|
const eRoomResult = applyDamageToRoom(get, set, eFinalDamage, isESpellAoe, eSpellDef.aoeTargets);
|
||||||
floorHP = eRoomResult.floorHP;
|
floorHP = eRoomResult.floorHP;
|
||||||
floorMaxHP = eRoomResult.floorMaxHP;
|
floorMaxHP = eRoomResult.floorMaxHP;
|
||||||
currentRoom = get().currentRoom;
|
currentRoom = get().currentRoom;
|
||||||
@@ -360,7 +364,8 @@ export function processCombatTick(
|
|||||||
floorHP,
|
floorHP,
|
||||||
floorMaxHP,
|
floorMaxHP,
|
||||||
maxFloorReached: newMaxFloorReached,
|
maxFloorReached: newMaxFloorReached,
|
||||||
castProgress,
|
castProgress: weaponCastProgress['primary'] ?? state.castProgress,
|
||||||
|
weaponCastProgress,
|
||||||
equipmentSpellStates: updatedEquipmentSpellStates,
|
equipmentSpellStates: updatedEquipmentSpellStates,
|
||||||
activeGolems,
|
activeGolems,
|
||||||
meleeSwordProgress: updatedMeleeSwordProgress,
|
meleeSwordProgress: updatedMeleeSwordProgress,
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ export function lowestHPEnemy(enemies: EnemyState[]): EnemyState | null {
|
|||||||
* Apply damage to enemies in the current room.
|
* Apply damage to enemies in the current room.
|
||||||
*
|
*
|
||||||
* Spec §3.2:
|
* Spec §3.2:
|
||||||
* - AoE spells: each enemy takes full damage
|
* - AoE spells: damage is distributed across up to aoeTargets enemies
|
||||||
* - Single-target: target the enemy with lowest HP (focus-fire)
|
* - Single-target: target the enemy with lowest HP (focus-fire)
|
||||||
* - Recalculates floorHP as sum of all enemy HP
|
* - Recalculates floorHP as sum of all enemy HP
|
||||||
* - Triggers onRoomCleared when all enemies reach 0 HP
|
* - Triggers onRoomCleared when all enemies reach 0 HP
|
||||||
*
|
*
|
||||||
|
* @param aoeTargets — max number of enemies hit by AoE (spec §3.2, D-38 fix)
|
||||||
* @returns { floorHP, floorMaxHP, roomCleared }
|
* @returns { floorHP, floorMaxHP, roomCleared }
|
||||||
*/
|
*/
|
||||||
export function applyDamageToRoom(
|
export function applyDamageToRoom(
|
||||||
@@ -35,6 +36,7 @@ export function applyDamageToRoom(
|
|||||||
set: (state: Partial<CombatState>) => void,
|
set: (state: Partial<CombatState>) => void,
|
||||||
dmg: number,
|
dmg: number,
|
||||||
isAoe: boolean,
|
isAoe: boolean,
|
||||||
|
aoeTargets?: number,
|
||||||
): { floorHP: number; floorMaxHP: number; roomCleared: boolean } {
|
): { floorHP: number; floorMaxHP: number; roomCleared: boolean } {
|
||||||
const state = get();
|
const state = get();
|
||||||
const room = state.currentRoom;
|
const room = state.currentRoom;
|
||||||
@@ -48,11 +50,23 @@ export function applyDamageToRoom(
|
|||||||
// For single-target, find the lowest HP enemy once (focus-fire)
|
// For single-target, find the lowest HP enemy once (focus-fire)
|
||||||
const singleTarget = isAoe ? null : lowestHPEnemy(room.enemies);
|
const singleTarget = isAoe ? null : lowestHPEnemy(room.enemies);
|
||||||
|
|
||||||
|
// For AoE, select up to aoeTargets living enemies (focus-fire: lowest HP first)
|
||||||
|
let aoeTargetSet: Set<string> | null = null;
|
||||||
|
if (isAoe && aoeTargets && aoeTargets > 0) {
|
||||||
|
const livingEnemies = room.enemies
|
||||||
|
.filter(e => e.hp > 0)
|
||||||
|
.sort((a, b) => a.hp - b.hp);
|
||||||
|
const targets = livingEnemies.slice(0, aoeTargets);
|
||||||
|
aoeTargetSet = new Set(targets.map(e => e.id));
|
||||||
|
}
|
||||||
|
|
||||||
const updatedEnemies = room.enemies.map((enemy) => {
|
const updatedEnemies = room.enemies.map((enemy) => {
|
||||||
if (enemy.hp <= 0) return enemy;
|
if (enemy.hp <= 0) return enemy;
|
||||||
if (isAoe) {
|
if (isAoe) {
|
||||||
// AoE: each enemy takes full damage
|
// AoE: distribute damage across up to aoeTargets enemies (spec §3.2, D-38 fix)
|
||||||
return { ...enemy, hp: Math.max(0, enemy.hp - dmg) };
|
if (aoeTargetSet && !aoeTargetSet.has(enemy.id)) return enemy;
|
||||||
|
const dmgPerEnemy = aoeTargetSet ? dmg / aoeTargetSet.size : dmg;
|
||||||
|
return { ...enemy, hp: Math.max(0, enemy.hp - dmgPerEnemy) };
|
||||||
}
|
}
|
||||||
// Single-target: only damage the lowest HP enemy
|
// Single-target: only damage the lowest HP enemy
|
||||||
if (singleTarget && enemy.id === singleTarget.id) {
|
if (singleTarget && enemy.id === singleTarget.id) {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
|
|||||||
floorHP: newFloorHP,
|
floorHP: newFloorHP,
|
||||||
floorMaxHP: newFloorHP,
|
floorMaxHP: newFloorHP,
|
||||||
castProgress: 0,
|
castProgress: 0,
|
||||||
|
weaponCastProgress: {},
|
||||||
});
|
});
|
||||||
get().addActivityLog('floor_transition', `Descended to Floor ${newFloor}`);
|
get().addActivityLog('floor_transition', `Descended to Floor ${newFloor}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -84,6 +85,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
|
|||||||
floorHP: newFloorHP,
|
floorHP: newFloorHP,
|
||||||
floorMaxHP: newFloorHP,
|
floorMaxHP: newFloorHP,
|
||||||
castProgress: 0,
|
castProgress: 0,
|
||||||
|
weaponCastProgress: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +112,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
|
|||||||
floorHP: newFloorHP,
|
floorHP: newFloorHP,
|
||||||
floorMaxHP: newFloorHP,
|
floorMaxHP: newFloorHP,
|
||||||
castProgress: 0,
|
castProgress: 0,
|
||||||
|
weaponCastProgress: {},
|
||||||
});
|
});
|
||||||
get().addActivityLog('floor_transition', `Ascending to Floor ${newFloor}`);
|
get().addActivityLog('floor_transition', `Ascending to Floor ${newFloor}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -122,6 +125,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
|
|||||||
floorHP: newFloorHP,
|
floorHP: newFloorHP,
|
||||||
floorMaxHP: newFloorHP,
|
floorMaxHP: newFloorHP,
|
||||||
castProgress: 0,
|
castProgress: 0,
|
||||||
|
weaponCastProgress: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +220,7 @@ export function onEnterRoomDescend(get: GetFn, set: SetFn): void {
|
|||||||
|
|
||||||
if (didReset) {
|
if (didReset) {
|
||||||
const newRoom = generateSpireFloorState(s.currentFloor, s.currentRoomIndex, s.roomsPerFloor, s.runId);
|
const newRoom = generateSpireFloorState(s.currentFloor, s.currentRoomIndex, s.roomsPerFloor, s.runId);
|
||||||
set({ currentRoom: newRoom, castProgress: 0 });
|
set({ currentRoom: newRoom, castProgress: 0, weaponCastProgress: {} });
|
||||||
get().addActivityLog('floor_transition',
|
get().addActivityLog('floor_transition',
|
||||||
`Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1} has reset — enemies respawned`);
|
`Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1} has reset — enemies respawned`);
|
||||||
|
|
||||||
@@ -263,6 +267,7 @@ export function createEnterSpireMode(get: GetFn, set: SetFn) {
|
|||||||
floorMaxHP: calcRoomHP(freshRoom),
|
floorMaxHP: calcRoomHP(freshRoom),
|
||||||
currentRoom: freshRoom,
|
currentRoom: freshRoom,
|
||||||
castProgress: 0,
|
castProgress: 0,
|
||||||
|
weaponCastProgress: {},
|
||||||
climbDirection: 'up',
|
climbDirection: 'up',
|
||||||
isDescending: false,
|
isDescending: false,
|
||||||
clearedFloors: {},
|
clearedFloors: {},
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export interface CombatState {
|
|||||||
// Equipment spell states for multi-casting
|
// Equipment spell states for multi-casting
|
||||||
equipmentSpellStates: EquipmentSpellState[];
|
equipmentSpellStates: EquipmentSpellState[];
|
||||||
|
|
||||||
|
// Per-weapon cast progress records (spec §3.1, D-01 fix)
|
||||||
|
// Key: 'primary' for active spell, or instanceId for equipment spells
|
||||||
|
weaponCastProgress: Record<string, number>;
|
||||||
|
|
||||||
// Combat special effect tracking
|
// Combat special effect tracking
|
||||||
comboHitCount: number;
|
comboHitCount: number;
|
||||||
floorHitCount: number;
|
floorHitCount: number;
|
||||||
@@ -158,7 +162,7 @@ export interface CombatActions {
|
|||||||
maxMana: number,
|
maxMana: number,
|
||||||
attackSpeedMult: number,
|
attackSpeedMult: number,
|
||||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
onDamageDealt: (damage: number, skipSpecials?: boolean) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||||
signedPacts: number[],
|
signedPacts: number[],
|
||||||
golemancyState: { activeGolems: RuntimeActiveGolem[] },
|
golemancyState: { activeGolems: RuntimeActiveGolem[] },
|
||||||
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
// Equipment spell states
|
// Equipment spell states
|
||||||
equipmentSpellStates: [],
|
equipmentSpellStates: [],
|
||||||
|
|
||||||
|
// Per-weapon cast progress records (spec §3.1, D-01 fix)
|
||||||
|
weaponCastProgress: {},
|
||||||
|
|
||||||
// Combat tracking
|
// Combat tracking
|
||||||
comboHitCount: 0,
|
comboHitCount: 0,
|
||||||
floorHitCount: 0,
|
floorHitCount: 0,
|
||||||
@@ -178,6 +181,7 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
floorMaxHP: getFloorMaxHP(newFloor),
|
floorMaxHP: getFloorMaxHP(newFloor),
|
||||||
floorHP: getFloorMaxHP(newFloor),
|
floorHP: getFloorMaxHP(newFloor),
|
||||||
castProgress: 0,
|
castProgress: 0,
|
||||||
|
weaponCastProgress: {},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -290,7 +294,7 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
maxMana: number,
|
maxMana: number,
|
||||||
attackSpeedMult: number,
|
attackSpeedMult: number,
|
||||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
onDamageDealt: (damage: number, skipSpecials?: boolean) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||||
signedPacts: number[],
|
signedPacts: number[],
|
||||||
golemancyState: { activeGolems: RuntimeActiveGolem[] },
|
golemancyState: { activeGolems: RuntimeActiveGolem[] },
|
||||||
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
@@ -355,6 +359,7 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
clearedFloors: state.clearedFloors,
|
clearedFloors: state.clearedFloors,
|
||||||
golemancy: state.golemancy,
|
golemancy: state.golemancy,
|
||||||
equipmentSpellStates: state.equipmentSpellStates,
|
equipmentSpellStates: state.equipmentSpellStates,
|
||||||
|
weaponCastProgress: state.weaponCastProgress,
|
||||||
comboHitCount: state.comboHitCount,
|
comboHitCount: state.comboHitCount,
|
||||||
floorHitCount: state.floorHitCount,
|
floorHitCount: state.floorHitCount,
|
||||||
activityLog: state.activityLog,
|
activityLog: state.activityLog,
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ export function processDoTPhase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let enemyHp = enemy.hp;
|
let enemyHp = enemy.hp;
|
||||||
let enemyArmor = enemy.effectiveArmor;
|
|
||||||
const remainingEffects: ActiveEffect[] = [];
|
const remainingEffects: ActiveEffect[] = [];
|
||||||
|
let totalCorrodeMagnitude = 0;
|
||||||
|
|
||||||
for (const effect of enemy.activeEffects) {
|
for (const effect of enemy.activeEffects) {
|
||||||
if (DOT_EFFECT_TYPES.has(effect.type)) {
|
if (DOT_EFFECT_TYPES.has(effect.type)) {
|
||||||
@@ -110,8 +110,8 @@ export function processDoTPhase(
|
|||||||
// Curse: amplifies incoming damage (tracked on enemy, applied next tick)
|
// Curse: amplifies incoming damage (tracked on enemy, applied next tick)
|
||||||
// No immediate HP effect — curse multiplier is stored on the enemy
|
// No immediate HP effect — curse multiplier is stored on the enemy
|
||||||
} else if (effect.type === 'armor_corrode') {
|
} else if (effect.type === 'armor_corrode') {
|
||||||
// Armor corrode: reduce effective armor
|
// Armor corrode: accumulate magnitude for temporary reduction (spec §6.3, D-31 fix)
|
||||||
enemyArmor = Math.max(0, enemyArmor - effect.magnitude);
|
totalCorrodeMagnitude += effect.magnitude;
|
||||||
}
|
}
|
||||||
// freeze, slow, blind: soft CC — no HP effect, just tracked
|
// freeze, slow, blind: soft CC — no HP effect, just tracked
|
||||||
|
|
||||||
@@ -122,10 +122,13 @@ export function processDoTPhase(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute effectiveArmor from base armor minus active corrode effects (temporary)
|
||||||
|
const effectiveArmor = Math.max(0, enemy.armor - totalCorrodeMagnitude);
|
||||||
|
|
||||||
updatedEnemies.push({
|
updatedEnemies.push({
|
||||||
...enemy,
|
...enemy,
|
||||||
hp: enemyHp,
|
hp: enemyHp,
|
||||||
effectiveArmor: enemyArmor,
|
effectiveArmor,
|
||||||
activeEffects: remainingEffects,
|
activeEffects: remainingEffects,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,8 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
// Combat
|
// Combat
|
||||||
if (ctx.combat.currentAction === 'climb') {
|
if (ctx.combat.currentAction === 'climb') {
|
||||||
const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore });
|
const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore });
|
||||||
|
// Guardian regen once per tick (not per-damage-event, spec §5.3, D-25 fix)
|
||||||
|
combatCbs.applyGuardianRegen();
|
||||||
const roomEnemies = ctx.combat.currentRoom?.enemies ?? [];
|
const roomEnemies = ctx.combat.currentRoom?.enemies ?? [];
|
||||||
const primaryEnemy = roomEnemies[0] ?? null;
|
const primaryEnemy = roomEnemies[0] ?? null;
|
||||||
const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy;
|
const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy;
|
||||||
@@ -272,7 +274,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
rawMana = cr.rawMana; elements = cr.elements;
|
rawMana = cr.rawMana; elements = cr.elements;
|
||||||
totalManaGathered += cr.totalManaGathered || 0;
|
totalManaGathered += cr.totalManaGathered || 0;
|
||||||
if (cr.logMessages) cr.logMessages.forEach(msg => addLog(msg));
|
if (cr.logMessages) cr.logMessages.forEach(msg => addLog(msg));
|
||||||
writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom };
|
writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, weaponCastProgress: cr.weaponCastProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom } as Partial<CombatState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-combat room tick (library, recovery, treasure, puzzle)
|
// Non-combat room tick (library, recovery, treasure, puzzle)
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export function processGolemManaRegen(
|
|||||||
export function processGolemAttacks(
|
export function processGolemAttacks(
|
||||||
activeGolems: RuntimeActiveGolem[],
|
activeGolems: RuntimeActiveGolem[],
|
||||||
golemDesigns: Record<string, SerializedDesign>,
|
golemDesigns: Record<string, SerializedDesign>,
|
||||||
onDamageDealt: (damage: number) => {
|
onDamageDealt: (damage: number, skipSpecials?: boolean) => {
|
||||||
rawMana: number;
|
rawMana: number;
|
||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
modifiedDamage?: number;
|
modifiedDamage?: number;
|
||||||
@@ -295,7 +295,7 @@ export function processGolemAttacks(
|
|||||||
updatedGolem.currentMana -= spellManaCost;
|
updatedGolem.currentMana -= spellManaCost;
|
||||||
updatedGolem.spellCastIndex = (updatedGolem.spellCastIndex + 1) % design.selectedSpells.length;
|
updatedGolem.spellCastIndex = (updatedGolem.spellCastIndex + 1) % design.selectedSpells.length;
|
||||||
|
|
||||||
const dmgResult = onDamageDealt(spellDmg);
|
const dmgResult = onDamageDealt(spellDmg, true);
|
||||||
const finalDamage = dmgResult.modifiedDamage || spellDmg;
|
const finalDamage = dmgResult.modifiedDamage || spellDmg;
|
||||||
|
|
||||||
if (Number.isFinite(finalDamage)) {
|
if (Number.isFinite(finalDamage)) {
|
||||||
@@ -326,7 +326,7 @@ export function processGolemAttacks(
|
|||||||
onApplyEnchantmentEffects(targetEnemy.id, enchantmentEffects);
|
onApplyEnchantmentEffects(targetEnemy.id, enchantmentEffects);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dmgResult = onDamageDealt(dmg);
|
const dmgResult = onDamageDealt(dmg, true);
|
||||||
const finalDamage = dmgResult.modifiedDamage || dmg;
|
const finalDamage = dmgResult.modifiedDamage || dmg;
|
||||||
|
|
||||||
if (Number.isFinite(finalDamage)) {
|
if (Number.isFinite(finalDamage)) {
|
||||||
|
|||||||
@@ -89,7 +89,14 @@ export function applyEnemyDefenses(
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Barrier absorption (percentage, spec §5.2) — skipped if bypassBarrier
|
// 2a. Shield pool absorption (flat HP one-time pool, spec §5.1) — skipped if bypassBarrier
|
||||||
|
if (!bypassBarrier && enemy.shieldPool && enemy.shieldPool > 0) {
|
||||||
|
const absorb = Math.min(enemy.shieldPool, dmg);
|
||||||
|
enemy.shieldPool -= absorb;
|
||||||
|
dmg -= absorb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Barrier absorption (percentage, spec §5.2) — skipped if bypassBarrier
|
||||||
if (!bypassBarrier && enemy.barrier && enemy.barrier > 0) {
|
if (!bypassBarrier && enemy.barrier && enemy.barrier > 0) {
|
||||||
dmg *= (1 - enemy.barrier);
|
dmg *= (1 - enemy.barrier);
|
||||||
}
|
}
|
||||||
@@ -135,6 +142,7 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
|
|||||||
|
|
||||||
/** Mage barrier recharge rate (spec §5.2): 5% per tick */
|
/** Mage barrier recharge rate (spec §5.2): 5% per tick */
|
||||||
const MAGE_BARRIER_RECHARGE_RATE = 0.05;
|
const MAGE_BARRIER_RECHARGE_RATE = 0.05;
|
||||||
|
const MAGE_BARRIER_MAX = 0.4; // maxBarrier from MODIFIER_CONFIG.mage
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply mage barrier recharge (spec §5.2).
|
* Apply mage barrier recharge (spec §5.2).
|
||||||
@@ -160,45 +168,63 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
|
|||||||
defCtx: EnemyDefenseCtx,
|
defCtx: EnemyDefenseCtx,
|
||||||
addLog: (msg: string) => void,
|
addLog: (msg: string) => void,
|
||||||
) => {
|
) => {
|
||||||
return (damage: number) => {
|
return (damage: number, skipSpecials?: boolean) => {
|
||||||
const rawMana = rawManaRef();
|
const rawMana = rawManaRef();
|
||||||
const elements = elementsRef();
|
const elements = elementsRef();
|
||||||
let dmg = damage;
|
let dmg = damage;
|
||||||
|
|
||||||
// Discipline specials (Executioner, Berserker) — before enemy defenses
|
// Discipline specials (Executioner, Berserker) — before enemy defenses
|
||||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) {
|
// Skipped for golem attacks (spec §9.4, D-04 fix)
|
||||||
dmg *= 2;
|
if (!skipSpecials) {
|
||||||
}
|
// Executioner: per-enemy HP check (spec §4.4, D-15 fix)
|
||||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
const executionerTarget = defCtx.enemy;
|
||||||
dmg *= 1.5;
|
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && executionerTarget && executionerTarget.hp < executionerTarget.maxHP * 0.25) {
|
||||||
|
dmg *= 2;
|
||||||
|
}
|
||||||
|
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
||||||
|
dmg *= 1.5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply regular enemy defenses for ALL enemies (spec §5.2)
|
// Apply regular enemy defenses for ALL enemies (spec §5.2)
|
||||||
dmg = defApply(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)
|
// Mage barrier recharge (spec §5.2) — after damage, recharge mage barrier on the enemy
|
||||||
|
if (defCtx.enemy && defCtx.enemy.barrier && defCtx.enemy.barrier > 0 && defCtx.enemy.name.startsWith('Mage')) {
|
||||||
|
const rechargedBarrier = Math.min(
|
||||||
|
MAGE_BARRIER_MAX,
|
||||||
|
defCtx.enemy.barrier + MAGE_BARRIER_RECHARGE_RATE * HOURS_PER_TICK,
|
||||||
|
);
|
||||||
|
// Update enemy barrier on the store (mutates via reference)
|
||||||
|
defCtx.enemy.barrier = rechargedBarrier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guardian-specific defensive pipeline (spec §5.3, §11)
|
||||||
const guardian = getGuardianForFloor(ctx.combat.currentFloor);
|
const guardian = getGuardianForFloor(ctx.combat.currentFloor);
|
||||||
if (guardian && (guardian.shield || guardian.barrier || guardian.healthRegen)) {
|
if (guardian) {
|
||||||
let shield = ctx.combat.guardianShield;
|
let shield = ctx.combat.guardianShield;
|
||||||
const shieldMax = ctx.combat.guardianShieldMax;
|
const shieldMax = ctx.combat.guardianShieldMax;
|
||||||
let barrier = ctx.combat.guardianBarrier;
|
let barrier = ctx.combat.guardianBarrier;
|
||||||
const barrierMax = ctx.combat.guardianBarrierMax;
|
const barrierMax = ctx.combat.guardianBarrierMax;
|
||||||
|
|
||||||
if (guardian.shieldRegen && shield < shieldMax) {
|
// Shield absorption (flat pool, per-hit)
|
||||||
shield = Math.min(shieldMax, shield + guardian.shieldRegen * HOURS_PER_TICK);
|
|
||||||
}
|
|
||||||
if (guardian.barrierRegen && barrier < barrierMax) {
|
|
||||||
barrier = Math.min(barrierMax, barrier + guardian.barrierRegen * HOURS_PER_TICK);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shield > 0 && dmg > 0) {
|
if (shield > 0 && dmg > 0) {
|
||||||
const absorb = Math.min(shield, dmg);
|
const absorb = Math.min(shield, dmg);
|
||||||
shield -= absorb;
|
shield -= absorb;
|
||||||
dmg -= absorb;
|
dmg -= absorb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Barrier reduction (percentage, per-hit)
|
||||||
if (barrier > 0 && dmg > 0) {
|
if (barrier > 0 && dmg > 0) {
|
||||||
dmg *= (1 - barrier);
|
dmg *= (1 - barrier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guardian armor reduction (spec §11, D-26 fix)
|
||||||
|
if (guardian.armor && guardian.armor > 0 && dmg > 0) {
|
||||||
|
dmg *= (1 - guardian.armor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health regen reduces net damage (per-hit, already scaled by HOURS_PER_TICK)
|
||||||
if (guardian.healthRegen && guardian.healthRegen > 0) {
|
if (guardian.healthRegen && guardian.healthRegen > 0) {
|
||||||
const healAmount = guardian.healthRegenIsPercent
|
const healAmount = guardian.healthRegenIsPercent
|
||||||
? Math.floor(ctx.combat.floorMaxHP * guardian.healthRegen / 100 * HOURS_PER_TICK)
|
? Math.floor(ctx.combat.floorMaxHP * guardian.healthRegen / 100 * HOURS_PER_TICK)
|
||||||
@@ -218,5 +244,40 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return { onFloorCleared, makeOnDamageDealt, applyMageBarrierRecharge };
|
// ─── Guardian Regen (once per tick, spec §5.3, D-25 fix) ─────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply guardian shield/barrier regen once per tick.
|
||||||
|
* Must be called exactly once per combat tick, not per-damage-event.
|
||||||
|
*/
|
||||||
|
const applyGuardianRegen = () => {
|
||||||
|
const guardian = getGuardianForFloor(ctx.combat.currentFloor);
|
||||||
|
if (!guardian) return;
|
||||||
|
|
||||||
|
let shield = ctx.combat.guardianShield;
|
||||||
|
const shieldMax = ctx.combat.guardianShieldMax;
|
||||||
|
let barrier = ctx.combat.guardianBarrier;
|
||||||
|
const barrierMax = ctx.combat.guardianBarrierMax;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (guardian.shieldRegen && shield < shieldMax) {
|
||||||
|
shield = Math.min(shieldMax, shield + guardian.shieldRegen * HOURS_PER_TICK);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (guardian.barrierRegen && barrier < barrierMax) {
|
||||||
|
barrier = Math.min(barrierMax, barrier + guardian.barrierRegen * HOURS_PER_TICK);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
useCombatStore.setState({
|
||||||
|
guardianShield: shield,
|
||||||
|
guardianShieldMax: shieldMax,
|
||||||
|
guardianBarrier: barrier,
|
||||||
|
guardianBarrierMax: barrierMax,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { onFloorCleared, makeOnDamageDealt, applyMageBarrierRecharge, applyGuardianRegen };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export interface EnemyState {
|
|||||||
maxHP: number;
|
maxHP: number;
|
||||||
armor: number; // Damage reduction (0-1)
|
armor: number; // Damage reduction (0-1)
|
||||||
dodgeChance: number; // For speed rooms (0-1)
|
dodgeChance: number; // For speed rooms (0-1)
|
||||||
barrier?: number; // Shield that absorbs damage before HP (0-1 as percentage of max HP)
|
barrier?: number; // Percentage damage reduction (0-1), e.g. from mage modifier
|
||||||
|
shieldPool?: number; // Flat HP one-time shield pool (spec §5.1), e.g. from shield modifier
|
||||||
element: string;
|
element: string;
|
||||||
activeEffects: ActiveEffect[]; // DoT/debuff effects currently on this enemy
|
activeEffects: ActiveEffect[]; // DoT/debuff effects currently on this enemy
|
||||||
effectiveArmor: number; // Armor after corrode effects (0-1)
|
effectiveArmor: number; // Armor after corrode effects (0-1)
|
||||||
|
|||||||
@@ -33,19 +33,20 @@ export interface DPSCalcParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Elemental Damage Bonus ──────────────────────────────────────────────────
|
// ─── Elemental Damage Bonus ──────────────────────────────────────────────────
|
||||||
|
// +50% if spell element opposes floor element (super effective)
|
||||||
// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
|
|
||||||
// -25% if spell element matches its own opposite (weak)
|
// -25% if spell element matches its own opposite (weak)
|
||||||
export function getElementalBonus(spellElem: string, floorElem: string): number {
|
export function getElementalBonus(spellElem: string, floorElem: string): number {
|
||||||
if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus
|
if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus
|
||||||
|
|
||||||
if (spellElem === floorElem) return 1.25; // Same element: +25% damage
|
if (spellElem === floorElem) return 1.25; // Same element: +25% damage
|
||||||
|
|
||||||
// Check for super effective first: spell is the opposite of floor
|
// Check for super effective: spellElem is in floorElem's opposites list
|
||||||
if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective: +50% damage
|
const floorOpposites = ELEMENT_OPPOSITES[floorElem];
|
||||||
|
if (floorOpposites && floorOpposites.includes(spellElem)) return 1.5; // Super effective: +50% damage
|
||||||
|
|
||||||
// Check for weak: spell's opposite matches floor
|
// Check for weak: floorElem is in spellElem's opposites list
|
||||||
if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak: -25% damage
|
const spellOpposites = ELEMENT_OPPOSITES[spellElem];
|
||||||
|
if (spellOpposites && spellOpposites.includes(floorElem)) return 0.75; // Weak: -25% damage
|
||||||
|
|
||||||
return 1.0; // Neutral
|
return 1.0; // Neutral
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const MODIFIER_CONFIG = {
|
|||||||
barrierRechargeRate: 0.05, // Recharges 5% of max HP per tick
|
barrierRechargeRate: 0.05, // Recharges 5% of max HP per tick
|
||||||
},
|
},
|
||||||
shield: {
|
shield: {
|
||||||
shieldAmount: 0.15, // 15% of max HP as one-time shield
|
shieldAmount: 0.15, // 15% of max HP as one-time flat shield pool (spec §5.1)
|
||||||
},
|
},
|
||||||
armored: {
|
armored: {
|
||||||
armorPerFloor: 0.003,
|
armorPerFloor: 0.003,
|
||||||
@@ -37,8 +37,8 @@ const MODIFIER_CONFIG = {
|
|||||||
armorPerFloor: 0.002,
|
armorPerFloor: 0.002,
|
||||||
},
|
},
|
||||||
agile: {
|
agile: {
|
||||||
baseDodge: 0.20,
|
baseDodge: 0,
|
||||||
dodgePerFloor: 0.004,
|
dodgePerFloor: 0.003,
|
||||||
maxDodge: 0.55,
|
maxDodge: 0.55,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -126,10 +126,11 @@ export function generateEnemy(floor: number, modifiers?: EnemyModifier[]): Gener
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeModifiers.includes('shield')) {
|
if (activeModifiers.includes('shield')) {
|
||||||
barrier = Math.max(barrier, MODIFIER_CONFIG.shield.shieldAmount);
|
|
||||||
name = `${name} (Shielded)`;
|
name = `${name} (Shielded)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shieldPool = activeModifiers.includes('shield') ? hp * MODIFIER_CONFIG.shield.shieldAmount : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: 'enemy',
|
id: 'enemy',
|
||||||
name,
|
name,
|
||||||
@@ -138,7 +139,10 @@ export function generateEnemy(floor: number, modifiers?: EnemyModifier[]): Gener
|
|||||||
armor,
|
armor,
|
||||||
dodgeChance,
|
dodgeChance,
|
||||||
barrier,
|
barrier,
|
||||||
|
shieldPool,
|
||||||
element,
|
element,
|
||||||
|
activeEffects: [],
|
||||||
|
effectiveArmor: armor,
|
||||||
modifiers: activeModifiers,
|
modifiers: activeModifiers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -172,6 +176,8 @@ export function generateSwarm(floor: number, modifiers?: EnemyModifier[]): Gener
|
|||||||
: 0,
|
: 0,
|
||||||
barrier: 0,
|
barrier: 0,
|
||||||
element,
|
element,
|
||||||
|
activeEffects: [],
|
||||||
|
effectiveArmor: armor,
|
||||||
modifiers: activeModifiers,
|
modifiers: activeModifiers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user