23e629f37e
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m6s
- Add applyDamageToRoom() function targeting individual enemies - Add lowestHPEnemy() helper for focus-fire targeting - AoE spells distribute damage across all enemies - Single-target attacks hit lowest HP enemy first - Refactor processCombatTick spell/equipment/melee/DoT damage to use per-enemy system - Update golemApplyDamageToRoom in golem-combat pipeline for per-enemy targeting - Add currentRoom to CombatTickResult and sync through gameStore - Update combat-actions tests with proper enemy setup for per-enemy tests - Extract combat-damage.ts module to stay under 400-line limit
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { processCombatTick, makeInitialSpells } from '../stores/combat-actions';
|
|
import { useCombatStore, makeInitialSpells as makeStoreInitialSpells } from '../stores/combatStore';
|
|
import { useManaStore, makeInitialElements } from '../stores/manaStore';
|
|
import { useGameStore } from '../stores/gameStore';
|
|
import { usePrestigeStore } from '../stores/prestigeStore';
|
|
import { useUIStore } from '../stores/uiStore';
|
|
import { useDisciplineStore } from '../stores/discipline-slice';
|
|
import { getFloorMaxHP } from '../utils';
|
|
import type { CombatTickResult } from '../stores/combat-actions';
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function resetStores() {
|
|
useUIStore.setState({ paused: false, gameOver: false, victory: false, logs: [] });
|
|
useGameStore.setState({ day: 1, hour: 0, incursionStrength: 0, containmentWards: 0, initialized: true });
|
|
useManaStore.setState({ rawMana: 1000, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(500, {}) });
|
|
useCombatStore.setState({
|
|
currentFloor: 1,
|
|
floorHP: getFloorMaxHP(1),
|
|
floorMaxHP: getFloorMaxHP(1),
|
|
maxFloorReached: 1,
|
|
activeSpell: 'manaBolt',
|
|
currentAction: 'climb',
|
|
castProgress: 0,
|
|
spireMode: false,
|
|
currentRoom: { roomType: 'combat', enemies: [{ id: 'enemy', name: 'Test Enemy', hp: getFloorMaxHP(1), maxHP: getFloorMaxHP(1), armor: 0, dodgeChance: 0, barrier: 0, element: 'fire', activeEffects: [], effectiveArmor: 0 }] },
|
|
clearedFloors: {},
|
|
climbDirection: 'up',
|
|
isDescending: false,
|
|
startFloor: 1,
|
|
exitFloor: 1,
|
|
currentRoomIndex: 0,
|
|
roomsPerFloor: 5,
|
|
descentPeak: null,
|
|
roomResetState: {},
|
|
clearedRooms: {},
|
|
isDescentComplete: false,
|
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
|
equipmentSpellStates: [],
|
|
comboHitCount: 0,
|
|
floorHitCount: 0,
|
|
spells: makeStoreInitialSpells(),
|
|
activityLog: [],
|
|
achievements: { unlocked: [], progress: {} },
|
|
totalSpellsCast: 0,
|
|
totalDamageDealt: 0,
|
|
totalCraftsCompleted: 0,
|
|
});
|
|
usePrestigeStore.setState({
|
|
loopCount: 0, insight: 0, totalInsight: 0, loopInsight: 0,
|
|
prestigeUpgrades: {}, pactSlots: 1, defeatedGuardians: [],
|
|
signedPacts: [], signedPactDetails: {},
|
|
pactRitualFloor: null, pactRitualProgress: 0,
|
|
});
|
|
useDisciplineStore.setState({ disciplines: {}, activeIds: [], concurrentLimit: 1, totalXP: 0, processedPerks: [] });
|
|
}
|
|
|
|
function golemApplyDamageToRoom(dmg: number): { floorHP: number; floorMaxHP: number; roomCleared: boolean } {
|
|
const cs = useCombatStore.getState();
|
|
const newFloorHP = Math.max(0, cs.floorHP - dmg);
|
|
return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: newFloorHP <= 0 };
|
|
}
|
|
|
|
function runCombatTick(rawMana: number, elements: Record<string, { current: number; max: number; unlocked: boolean }>): CombatTickResult {
|
|
return processCombatTick(
|
|
() => useCombatStore.getState(),
|
|
(partial) => useCombatStore.setState(partial),
|
|
rawMana,
|
|
elements,
|
|
1000, // maxMana
|
|
1, // attackSpeedMult
|
|
vi.fn(), // onFloorCleared
|
|
(dmg) => ({ rawMana, elements, modifiedDamage: dmg }), // onDamageDealt (no modifiers)
|
|
[], // signedPacts
|
|
{ activeGolems: [] }, // golemancyState
|
|
golemApplyDamageToRoom,
|
|
(dmg: number) => dmg, // applyEnemyDefenses (passthrough for tests)
|
|
);
|
|
}
|
|
|
|
function processCombatTickDirect(
|
|
get: () => ReturnType<typeof useCombatStore.getState>,
|
|
set: (partial: Record<string, unknown>) => void,
|
|
rawMana: number,
|
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
|
maxMana: number,
|
|
attackSpeedMult: number,
|
|
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
|
onDamageDealt: (dmg: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }>; modifiedDamage?: number },
|
|
signedPacts: number[],
|
|
): CombatTickResult {
|
|
return processCombatTick(
|
|
get, set, rawMana, elements, maxMana, attackSpeedMult,
|
|
onFloorCleared, onDamageDealt, signedPacts,
|
|
{ activeGolems: [] },
|
|
golemApplyDamageToRoom,
|
|
(dmg: number) => dmg, // applyEnemyDefenses (passthrough for tests)
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// COMBAT ACTIONS — processCombatTick
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('processCombatTick', () => {
|
|
beforeEach(resetStores);
|
|
|
|
describe('basic mechanics', () => {
|
|
it('should return default result when currentAction is not climb', () => {
|
|
useCombatStore.setState({ currentAction: 'meditate' });
|
|
const elements = makeInitialElements(500, {});
|
|
const result = runCombatTick(1000, elements);
|
|
expect(result.currentFloor).toBe(1);
|
|
expect(result.floorHP).toBe(getFloorMaxHP(1));
|
|
});
|
|
|
|
it('should return default result when spell is not found', () => {
|
|
useCombatStore.setState({ activeSpell: 'nonexistentSpell' });
|
|
const elements = makeInitialElements(500, {});
|
|
const result = runCombatTick(1000, elements);
|
|
expect(result.currentFloor).toBe(1);
|
|
});
|
|
|
|
it('should progress cast bar on each tick', () => {
|
|
const elements = makeInitialElements(500, {});
|
|
const result = runCombatTick(1000, elements);
|
|
expect(result.castProgress).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should reduce floor HP when cast completes', () => {
|
|
// Set cast progress close to 1 so next cast completes
|
|
useCombatStore.setState({ castProgress: 0.99 });
|
|
const elements = makeInitialElements(500, {});
|
|
const initialHP = useCombatStore.getState().floorHP;
|
|
const result = runCombatTick(1000, elements);
|
|
// Either HP decreased or floor advanced (if HP reached 0)
|
|
expect(result.floorHP).toBeLessThanOrEqual(initialHP);
|
|
});
|
|
});
|
|
|
|
describe('floor advancement', () => {
|
|
it('should advance room when HP reaches 0 (not last room)', () => {
|
|
// Set floor HP very low so it's cleared in one cast
|
|
// currentRoomIndex=0, roomsPerFloor=5 → clears room 0, advances to room 1
|
|
useCombatStore.setState({ castProgress: 0.99, climbDirection: 'up', currentRoomIndex: 0, roomsPerFloor: 5, currentRoom: { roomType: 'combat', enemies: [{ id: 'enemy', name: 'Test Enemy', hp: 1, maxHP: 100, armor: 0, dodgeChance: 0, barrier: 0, element: 'fire', activeEffects: [], effectiveArmor: 0 }] } });
|
|
const elements = makeInitialElements(500, {});
|
|
const result = runCombatTick(1000, elements);
|
|
// Room advanced, floor stays the same
|
|
expect(result.currentFloor).toBe(1);
|
|
// floorHP should be the new room's enemy HP
|
|
expect(result.floorHP).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should advance floor when last room on floor is cleared', () => {
|
|
// Set currentRoomIndex to last room so clearing it advances the floor
|
|
useCombatStore.setState({ castProgress: 0.99, climbDirection: 'up', currentRoomIndex: 4, roomsPerFloor: 5, currentRoom: { roomType: 'combat', enemies: [{ id: 'enemy', name: 'Test Enemy', hp: 1, maxHP: 100, armor: 0, dodgeChance: 0, barrier: 0, element: 'fire', activeEffects: [], effectiveArmor: 0 }] } });
|
|
const elements = makeInitialElements(500, {});
|
|
const result = runCombatTick(1000, elements);
|
|
expect(result.currentFloor).toBe(2);
|
|
expect(result.floorHP).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should update maxFloorReached when advancing', () => {
|
|
useCombatStore.setState({ floorHP: 1, castProgress: 0.99, maxFloorReached: 5 });
|
|
const elements = makeInitialElements(500, {});
|
|
const result = runCombatTick(1000, elements);
|
|
expect(result.maxFloorReached).toBeGreaterThanOrEqual(5);
|
|
});
|
|
|
|
it('should not advance beyond floor 100', () => {
|
|
useCombatStore.setState({ currentFloor: 100, floorHP: 1, castProgress: 0.99 });
|
|
const elements = makeInitialElements(500, {});
|
|
const result = runCombatTick(1000, elements);
|
|
expect(result.currentFloor).toBeLessThanOrEqual(100);
|
|
});
|
|
});
|
|
|
|
describe('mana cost', () => {
|
|
it('should deduct raw mana when spell costs raw mana', () => {
|
|
const elements = makeInitialElements(500, {});
|
|
const startMana = 1000;
|
|
const result = runCombatTick(startMana, elements);
|
|
// manaBolt costs raw mana, so after casting, rawMana should decrease
|
|
expect(result.rawMana).toBeLessThanOrEqual(startMana);
|
|
});
|
|
|
|
it('should not cast when insufficient mana', () => {
|
|
const elements = makeInitialElements(500, {});
|
|
const state = useCombatStore.getState();
|
|
// With 0 mana and 0 progress, no cast should complete
|
|
const result = processCombatTickDirect(
|
|
() => state,
|
|
() => {},
|
|
0, // no mana
|
|
elements,
|
|
1000,
|
|
1,
|
|
vi.fn(),
|
|
(dmg) => ({ rawMana: 0, elements, modifiedDamage: dmg }),
|
|
[],
|
|
);
|
|
expect(result.rawMana).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('equipment spell states', () => {
|
|
it('should initialize empty equipmentSpellStates', () => {
|
|
const elements = makeInitialElements(500, {});
|
|
const result = runCombatTick(1000, elements);
|
|
expect(result.equipmentSpellStates).toBeDefined();
|
|
});
|
|
|
|
it('should not error with empty equipment spell states', () => {
|
|
useCombatStore.setState({ equipmentSpellStates: [] });
|
|
const elements = makeInitialElements(500, {});
|
|
expect(() => runCombatTick(1000, elements)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should return safe defaults when spell def is missing (early return path)', () => {
|
|
// When the spell definition is not found, processCombatTick returns
|
|
// a default result without crashing — this exercises the early return
|
|
// path that acts as a safety net.
|
|
useCombatStore.setState({ activeSpell: 'totallyInvalidSpell' });
|
|
const elements = makeInitialElements(500, {});
|
|
const result = processCombatTickDirect(
|
|
() => useCombatStore.getState(),
|
|
() => {},
|
|
1000,
|
|
elements,
|
|
1000,
|
|
1,
|
|
vi.fn(),
|
|
(dmg) => ({ rawMana: 1000, elements, modifiedDamage: dmg }),
|
|
[],
|
|
);
|
|
expect(result).toBeDefined();
|
|
expect(result.rawMana).toBe(1000);
|
|
expect(result.logMessages).toBeDefined();
|
|
});
|
|
|
|
it('should not throw when onDamageDealt callback throws', () => {
|
|
// The try/catch in processCombatTick should catch errors from callbacks
|
|
// and return safe defaults instead of crashing
|
|
const elements = makeInitialElements(500, {});
|
|
useCombatStore.setState({ castProgress: 0.99 });
|
|
expect(() => {
|
|
processCombatTickDirect(
|
|
() => useCombatStore.getState(),
|
|
() => {},
|
|
1000,
|
|
elements,
|
|
1000,
|
|
1,
|
|
vi.fn(),
|
|
(_dmg) => { throw new Error('damage callback error'); },
|
|
[],
|
|
);
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('helper: makeInitialSpells', () => {
|
|
it('should create manaBolt as default spell', () => {
|
|
const spells = makeInitialSpells();
|
|
expect(spells.manaBolt).toBeDefined();
|
|
expect(spells.manaBolt.learned).toBe(true);
|
|
expect(spells.manaBolt.level).toBe(1);
|
|
});
|
|
|
|
it('should keep additional spells when specified', () => {
|
|
const spells = makeInitialSpells(['fireball']);
|
|
expect(spells.manaBolt).toBeDefined();
|
|
expect(spells.fireball).toBeDefined();
|
|
expect(spells.fireball.learned).toBe(true);
|
|
});
|
|
|
|
it('should not duplicate manaBolt when included in keep list', () => {
|
|
const spells = makeInitialSpells(['manaBolt']);
|
|
expect(Object.keys(spells).length).toBe(1);
|
|
});
|
|
});
|
|
});
|