feat: Implement Invocation System for Invoker attunement
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
Implements the full Invocation System as per spec §13 with all 22 acceptance criteria: - Invocation charge meter with passive fill and active drain - Auto-activation when charge reaches 100 with living enemies - Guardian selection by elemental bonus × tierMultiplier scoring - Spell selection from guardian's full element spellbook (not limited to learned) - Step-down to affordable spells, auto-end when none affordable - Charge drain during invocation with spell cost and discipline scaling - Pact affinity cast speed bonus (diminishing returns, max 50%) - guardian-invocation discipline with 4 perks (efficiency/speed/sustain/mastery) - Cost multiplier (base 0.1, min 0.05) and drain multiplier (base 1.0, min 0.7) - Signal recharged on spire exit and reset on descent - Invocation Panel UI in SpireCombatPage with charge meter and status - Compact invocation status indicator in SpireCombatControls Files changed: - data/disciplines/invoker.ts: Added guardian-invocation discipline definition - effects/discipline-effects.ts: Added invocationChargeRateBonus, drainRateMultiplier, invocationCostReduction stat keys - utils/invocation-utils.ts: NEW - All invocation utility functions (guardian selection, spell selection, charge rate, cost/drain multipliers) - stores/combat-state.types.ts: Added invocationCharge and activeInvocation fields - stores/combatStore.ts: Added invocation state defaults, resetInvocationState action, partialize, spire exit reset - stores/combat-invocation.ts: NEW - Extracted invocation tick processing (charge fill/drain, casting, auto-activate/end) - stores/combat-melee.ts: NEW - Extracted melee combat processing (keeps combat-actions.ts under 400 lines) - stores/combat-actions.ts: Integrated invocation and melee modules - __tests__/invocation-system.test.ts: NEW - 39 comprehensive tests - SpireCombatPage.tsx: Added InvocationPanel between SpireHeader and RoomDisplay - SpireCombatControls.tsx: Added compact invocation status indicator All 1235 tests pass (including 39 new invocation tests).
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Circular Dependencies
|
||||
Generated: 2026-06-12T17:03:20.112Z
|
||||
Generated: 2026-06-13T11:02:39.214Z
|
||||
Found: 4 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||
|
||||
1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_meta": {
|
||||
"generated": "2026-06-12T17:03:17.869Z",
|
||||
"generated": "2026-06-13T11:02:37.078Z",
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -236,6 +236,7 @@ Mana-Loop/
|
||||
│ │ │ │ ├── guardian-names-unique.test.ts
|
||||
│ │ │ │ ├── guardian-names.test.ts
|
||||
│ │ │ │ ├── hasty-enchanter.test.ts
|
||||
│ │ │ │ ├── invocation-system.test.ts
|
||||
│ │ │ │ ├── mana-conversion-component-deduction.test.ts
|
||||
│ │ │ │ ├── mana-utils.test.ts
|
||||
│ │ │ │ ├── melee-auto-attack.test.ts
|
||||
@@ -386,6 +387,8 @@ Mana-Loop/
|
||||
│ │ │ │ ├── combat-actions.ts
|
||||
│ │ │ │ ├── combat-damage.ts
|
||||
│ │ │ │ ├── combat-descent-actions.ts
|
||||
│ │ │ │ ├── combat-invocation.ts
|
||||
│ │ │ │ ├── combat-melee.ts
|
||||
│ │ │ │ ├── combat-reset.ts
|
||||
│ │ │ │ ├── combat-state.types.ts
|
||||
│ │ │ │ ├── combatStore.ts
|
||||
@@ -437,6 +440,7 @@ Mana-Loop/
|
||||
│ │ │ │ ├── formatting.ts
|
||||
│ │ │ │ ├── guardian-utils.ts
|
||||
│ │ │ │ ├── index.ts
|
||||
│ │ │ │ ├── invocation-utils.ts
|
||||
│ │ │ │ ├── mana-utils.ts
|
||||
│ │ │ │ ├── pact-utils.ts
|
||||
│ │ │ │ ├── result.ts
|
||||
|
||||
@@ -22,6 +22,8 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
|
||||
const activeSpell = useCombatStore((s) => s.activeSpell);
|
||||
const setSpell = useCombatStore((s) => s.setSpell);
|
||||
const golemancy = useCombatStore((s) => s.golemancy);
|
||||
const invocationCharge = useCombatStore((s) => s.invocationCharge);
|
||||
const activeInvocation = useCombatStore((s) => s.activeInvocation);
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
|
||||
@@ -30,11 +32,40 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
|
||||
.map(([id]) => id);
|
||||
|
||||
const activeGolems = golemancy.activeGolems || [];
|
||||
const golemDesigns = golemancy.golemDesigns || {};
|
||||
const golemDesigns = golemancy.golemDesigns || [];
|
||||
|
||||
const isInvoking = activeInvocation !== null;
|
||||
|
||||
return (
|
||||
<DebugName name="SpireCombatControls">
|
||||
<div className="space-y-4">
|
||||
{/* Invocation Compact Status Indicator (§11.2) */}
|
||||
<Card className={`border-purple-700 transition-all ${isInvoking ? 'bg-purple-950/40 ring-1 ring-purple-500/50 shadow-[0_0_12px_rgba(168,85,247,0.3)]' : 'bg-gray-900/80 border-gray-700'}`}>
|
||||
<CardContent className="p-2 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-purple-300">💜 Invocation</span>
|
||||
<span className={`text-xs font-medium ${isInvoking ? 'text-purple-300' : 'text-gray-500'}`}>
|
||||
{isInvoking ? 'ACTIVE' : `${Math.round(invocationCharge)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={invocationCharge}
|
||||
className="h-1.5 bg-gray-800"
|
||||
style={{ '--progress-bg': isInvoking ? '#A855F7' : '#7C3AED' } as React.CSSProperties}
|
||||
/>
|
||||
{!isInvoking && invocationCharge < 100 && (
|
||||
<div className="text-[10px] text-gray-500 text-right">
|
||||
Recharging... +{(invocationCharge).toFixed(0)}→100
|
||||
</div>
|
||||
)}
|
||||
{!isInvoking && invocationCharge >= 100 && (
|
||||
<div className="text-[10px] text-purple-400 text-right">
|
||||
✦ Ready to invoke!
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Spell Panel */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
|
||||
@@ -11,6 +11,10 @@ import { SpireCombatControls } from './SpireCombatControls';
|
||||
import { SpireActivityLog } from './SpireActivityLog';
|
||||
import { SpireManaDisplay } from './SpireManaDisplay';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { getGuardianForFloor } from '@/lib/game/data/guardian-encounters';
|
||||
import { SPELLS_DEF } from '@/lib/game/constants';
|
||||
|
||||
// ─── Derived Stats Hook ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,7 +23,6 @@ function useSpireStats(prestigeUpgrades: Record<string, number>, equippedInstanc
|
||||
try {
|
||||
disciplineEffects = computeDisciplineEffects();
|
||||
} catch {
|
||||
// If discipline state is corrupted, proceed without discipline effects
|
||||
disciplineEffects = { bonuses: {}, multipliers: {}, specials: new Set(), meditationCapBonus: 0, conversions: {} };
|
||||
}
|
||||
|
||||
@@ -48,10 +51,76 @@ function useSpireStats(prestigeUpgrades: Record<string, number>, equippedInstanc
|
||||
return { maxMana, baseRegen };
|
||||
}
|
||||
|
||||
// ─── Invocation Panel Component ───────────────────────────────────────────────
|
||||
|
||||
function InvocationPanel({ invocationCharge, activeInvocation }: { invocationCharge: number; activeInvocation: { guardianFloor: number; spellId: string; element: string; castProgress: number } | null }) {
|
||||
const isActive = activeInvocation !== null;
|
||||
const guardian = isActive ? getGuardianForFloor(activeInvocation.guardianFloor) : null;
|
||||
const spellDef = isActive ? SPELLS_DEF[activeInvocation.spellId] : null;
|
||||
|
||||
return (
|
||||
<Card className={`border-purple-700 ${isActive ? 'bg-purple-950/40' : 'bg-gray-900/60'}`}>
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-purple-300">💜 Invocation</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isActive ? 'Active' : `${Math.round(invocationCharge)}%`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
value={invocationCharge}
|
||||
className="h-2 bg-gray-800"
|
||||
style={{ '--progress-bg': isActive ? '#A855F7' : '#7C3AED' } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
{isActive && guardian && spellDef && (
|
||||
<div className="space-y-1 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-purple-200 font-medium">{guardian.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-300">
|
||||
Casting: {spellDef.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{Math.round(activeInvocation.castProgress * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={activeInvocation.castProgress * 100}
|
||||
className="h-1.5 bg-gray-800"
|
||||
style={{ '--progress-bg': '#C084FC' } as React.CSSProperties}
|
||||
/>
|
||||
<div className="flex gap-1 pt-0.5">
|
||||
{guardian.element.map((el) => (
|
||||
<span key={el} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-800 text-gray-400">
|
||||
{el}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isActive && invocationCharge < 100 && (
|
||||
<div className="text-[10px] text-gray-500 italic">
|
||||
Recharging...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isActive && invocationCharge >= 100 && (
|
||||
<div className="text-[10px] text-purple-400 italic">
|
||||
Ready to invoke!
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function SpireCombatPage() {
|
||||
// ─── Spec: read room-aware state from combat store ───────────────────────
|
||||
const {
|
||||
currentFloor,
|
||||
castProgress,
|
||||
@@ -70,6 +139,8 @@ export function SpireCombatPage() {
|
||||
setAction,
|
||||
skipNonCombatRoom,
|
||||
stayLongerInRoom,
|
||||
invocationCharge,
|
||||
activeInvocation,
|
||||
} = useCombatStore(useShallow((s) => ({
|
||||
currentFloor: s.currentFloor,
|
||||
castProgress: s.castProgress,
|
||||
@@ -88,6 +159,8 @@ export function SpireCombatPage() {
|
||||
setAction: s.setAction,
|
||||
skipNonCombatRoom: s.skipNonCombatRoom,
|
||||
stayLongerInRoom: s.stayLongerInRoom,
|
||||
invocationCharge: s.invocationCharge,
|
||||
activeInvocation: s.activeInvocation,
|
||||
})));
|
||||
|
||||
const { rawMana, elements } = useManaStore(useShallow((s) => ({
|
||||
@@ -105,7 +178,6 @@ export function SpireCombatPage() {
|
||||
equipmentInstances: s.equipmentInstances,
|
||||
})));
|
||||
|
||||
// ─── Combat spec §10: read current in-game time ──────────────────────────
|
||||
const day = useGameStore((s) => s.day);
|
||||
const hour = useGameStore((s) => s.hour);
|
||||
|
||||
@@ -156,6 +228,12 @@ export function SpireCombatPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invocation Panel (§11.1) */}
|
||||
<InvocationPanel
|
||||
invocationCharge={invocationCharge}
|
||||
activeInvocation={activeInvocation}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<RoomDisplay
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
computeChargeFillRate,
|
||||
computeCastSpeedBonus,
|
||||
computeCostMultiplier,
|
||||
computeDrainRateMultiplier,
|
||||
computeDrainPerTick,
|
||||
selectInvocationGuardian,
|
||||
getGuardianSpellbook,
|
||||
selectInvocationSpell,
|
||||
deductInvocationSpellCost,
|
||||
} from '../utils/invocation-utils';
|
||||
import { SPELLS_DEF } from '../constants/spells';
|
||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||
import type { DisciplineBonuses } from '../utils/mana-utils';
|
||||
import type { GuardianDef } from '../types';
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeBonuses(overrides: Record<string, number> = {}): DisciplineBonuses {
|
||||
return {
|
||||
bonuses: { ...overrides },
|
||||
multipliers: {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeElements(overrides: Record<string, { current: number; max: number; unlocked: boolean }> = {}) {
|
||||
return {
|
||||
fire: { current: 100, max: 100, unlocked: true },
|
||||
water: { current: 100, max: 100, unlocked: true },
|
||||
air: { current: 100, max: 100, unlocked: true },
|
||||
earth: { current: 100, max: 100, unlocked: true },
|
||||
light: { current: 100, max: 100, unlocked: true },
|
||||
dark: { current: 100, max: 100, unlocked: true },
|
||||
death: { current: 100, max: 100, unlocked: true },
|
||||
raw: { current: 1000, max: 1000, unlocked: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Charge Fill Rate (§2.2) ───────────────────────────────────────────────────
|
||||
|
||||
describe('computeChargeFillRate', () => {
|
||||
it('should return base rate with 0 pacts and no bonus', () => {
|
||||
// 0.25 × (1 + 0 × 0.15) × (1 + 0) = 0.25
|
||||
expect(computeChargeFillRate(0, 0)).toBeCloseTo(0.25, 5);
|
||||
});
|
||||
|
||||
it('should scale with pact count (AC-1)', () => {
|
||||
// 3 pacts: 0.25 × (1 + 3 × 0.15) × 1 = 0.25 × 1.45 = 0.3625
|
||||
expect(computeChargeFillRate(3, 0)).toBeCloseTo(0.3625, 5);
|
||||
});
|
||||
|
||||
it('should scale with discipline bonus', () => {
|
||||
// 0 pacts, 0.10 bonus: 0.25 × 1 × 1.10 = 0.275
|
||||
expect(computeChargeFillRate(0, 0.10)).toBeCloseTo(0.275, 5);
|
||||
});
|
||||
|
||||
it('should combine pact and discipline multipliers', () => {
|
||||
// 3 pacts, 0.10 bonus: 0.25 × 1.45 × 1.10 = 0.39875
|
||||
expect(computeChargeFillRate(3, 0.10)).toBeCloseTo(0.39875, 5);
|
||||
});
|
||||
|
||||
it('should handle 6 pacts', () => {
|
||||
// 6 pacts: 0.25 × (1 + 6 × 0.15) = 0.25 × 1.9 = 0.475
|
||||
expect(computeChargeFillRate(6, 0)).toBeCloseTo(0.475, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Guardian Selection (§3.2) ─────────────────────────────────────────────────
|
||||
|
||||
describe('selectInvocationGuardian', () => {
|
||||
it('should return null for empty pacts', () => {
|
||||
expect(selectInvocationGuardian([], ['fire'])).toBeNull();
|
||||
});
|
||||
|
||||
it('should select the only available guardian', () => {
|
||||
const result = selectInvocationGuardian([10], ['fire']);
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
|
||||
it('should prefer guardian with best elemental bonus', () => {
|
||||
// Fire guardian (floor 10) vs Water guardian (floor 20)
|
||||
// Against fire enemy: fire guardian gets 1.25 (same element), water gets 1.5 (super effective)
|
||||
// Water guardian should win due to super effective bonus
|
||||
const result = selectInvocationGuardian([10, 20], ['fire']);
|
||||
expect(result).toBe(20); // Water guardian counters fire
|
||||
});
|
||||
|
||||
it('should use tier multiplier as tiebreaker', () => {
|
||||
// Both guardians have same elemental bonus, higher floor should win
|
||||
const result = selectInvocationGuardian([10, 20], ['raw']);
|
||||
// Raw has no elemental bonus, so both get 1.0
|
||||
// Floor 10: 1.0 × 1.05 = 1.05, Floor 20: 1.0 × 1.10 = 1.10
|
||||
expect(result).toBe(20);
|
||||
});
|
||||
|
||||
it('should handle multi-element guardians', () => {
|
||||
// Floor 130 has elements ['metal', 'fire', 'earth']
|
||||
const result = selectInvocationGuardian([10, 130], ['fire']);
|
||||
// Floor 10 (fire): bonus 1.25, tier 1.05 → 1.3125
|
||||
// Floor 130 (metal/fire/earth): best bonus against fire is fire=1.25, tier 1.65 → 2.0625
|
||||
expect(result).toBe(130);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Guardian Spellbook (§5.1) ─────────────────────────────────────────────────
|
||||
|
||||
describe('getGuardianSpellbook', () => {
|
||||
it('should return spells for a single-element guardian', () => {
|
||||
const guardian = getGuardianForFloor(10)!; // Fire guardian
|
||||
expect(guardian).not.toBeNull();
|
||||
const spellbook = getGuardianSpellbook(guardian);
|
||||
// Should include fire spells
|
||||
const fireSpells = spellbook.filter(s => s.elem === 'fire');
|
||||
expect(fireSpells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return spells for a multi-element guardian', () => {
|
||||
const guardian = getGuardianForFloor(130)!; // Metal/Fire/Earth guardian
|
||||
expect(guardian).not.toBeNull();
|
||||
const spellbook = getGuardianSpellbook(guardian);
|
||||
// Should include spells from all elements
|
||||
const elements = new Set(spellbook.map(s => s.elem));
|
||||
expect(elements.has('fire')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not have duplicate spells', () => {
|
||||
const guardian = getGuardianForFloor(10)!;
|
||||
const spellbook = getGuardianSpellbook(guardian);
|
||||
const names = spellbook.map(s => s.name);
|
||||
const uniqueNames = new Set(names);
|
||||
expect(names.length).toBe(uniqueNames.size);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Spell Selection (§5.2) ────────────────────────────────────────────────────
|
||||
|
||||
describe('selectInvocationSpell', () => {
|
||||
it('should return null when no spells are affordable', () => {
|
||||
const expensiveSpells = Object.values(SPELLS_DEF).filter(
|
||||
s => s.cost.type === 'raw' && s.cost.amount > 50,
|
||||
);
|
||||
// With 0 raw mana, even at 1.0 multiplier these should be unaffordable
|
||||
const result = selectInvocationSpell(expensiveSpells, 0, makeElements(), 1.0);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should select highest damage affordable spell', () => {
|
||||
const fireSpells = Object.values(SPELLS_DEF).filter(s => s.elem === 'fire');
|
||||
// With plenty of mana, should pick highest damage
|
||||
const result = selectInvocationSpell(fireSpells, 1000, makeElements(), 0.1);
|
||||
expect(result).not.toBeNull();
|
||||
// Should be the highest-damage fire spell (could be Pyroclasm etc.)
|
||||
const selectedSpell = fireSpells.find(s => s.name === result!.spellId);
|
||||
expect(selectedSpell).toBeDefined();
|
||||
expect(selectedSpell!.dmg).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it('should step down when current spell becomes unaffordable', () => {
|
||||
const fireSpells = Object.values(SPELLS_DEF).filter(s => s.elem === 'fire');
|
||||
// With very limited mana, can only afford cheap spells
|
||||
// At 0.1 multiplier, fireball costs 0.2 fire, ember shot costs 0.1 fire
|
||||
const result = selectInvocationSpell(fireSpells, 0, makeElements({ fire: { current: 0.15, max: 100, unlocked: true } }), 0.1);
|
||||
expect(result).not.toBeNull();
|
||||
// Should pick ember shot (cost 0.1) over fireball (cost 0.2)
|
||||
expect(result!.spellId).toBe('Ember Shot');
|
||||
});
|
||||
|
||||
it('should pick highest tier when damage is tied', () => {
|
||||
// This tests the tiebreaker logic
|
||||
const spellbook = Object.values(SPELLS_DEF);
|
||||
const result = selectInvocationSpell(spellbook, 1000, makeElements(), 0.1);
|
||||
expect(result).not.toBeNull();
|
||||
// Should pick the highest damage spell available
|
||||
const selectedSpell = spellbook.find(s => s.name === result!.spellId);
|
||||
expect(selectedSpell).toBeDefined();
|
||||
});
|
||||
|
||||
it('should pick lowest cost when damage and tier are tied', () => {
|
||||
// Create a scenario with tied damage
|
||||
const customSpells = [
|
||||
{ name: 'Expensive', elem: 'fire', dmg: 10, cost: { type: 'raw' as const, amount: 10 }, tier: 1, castSpeed: 1 },
|
||||
{ name: 'Cheap', elem: 'fire', dmg: 10, cost: { type: 'raw' as const, amount: 5 }, tier: 1, castSpeed: 1 },
|
||||
];
|
||||
const result = selectInvocationSpell(customSpells, 1000, makeElements(), 1.0);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.spellId).toBe('Cheap');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cost Multiplier (§5.5) ────────────────────────────────────────────────────
|
||||
|
||||
describe('computeCostMultiplier', () => {
|
||||
it('should return base 0.1 with no discipline bonuses', () => {
|
||||
expect(computeCostMultiplier(makeBonuses())).toBeCloseTo(0.1, 5);
|
||||
});
|
||||
|
||||
it('should reduce cost by invocationCostReduction', () => {
|
||||
// invocation-efficiency perk: -0.02
|
||||
expect(computeCostMultiplier(makeBonuses({ invocationCostReduction: 0.02 }))).toBeCloseTo(0.08, 5);
|
||||
});
|
||||
|
||||
it('should have minimum of 0.05 (AC-19)', () => {
|
||||
// Even with huge reduction, should not go below 0.05
|
||||
expect(computeCostMultiplier(makeBonuses({ invocationCostReduction: 0.1 }))).toBeCloseTo(0.05, 5);
|
||||
});
|
||||
|
||||
it('should handle combined efficiency + mastery perks', () => {
|
||||
// efficiency: -0.02, mastery (3 tiers): -0.03 = total -0.05
|
||||
expect(computeCostMultiplier(makeBonuses({ invocationCostReduction: 0.05 }))).toBeCloseTo(0.05, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Drain Rate Multiplier (§4) ────────────────────────────────────────────────
|
||||
|
||||
describe('computeDrainRateMultiplier', () => {
|
||||
it('should return base 1.0 with no discipline bonuses', () => {
|
||||
expect(computeDrainRateMultiplier(makeBonuses())).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it('should reduce drain by sustain perk (AC-17)', () => {
|
||||
// sustain perk: drainRateMultiplier = -0.1 per tier
|
||||
expect(computeDrainRateMultiplier(makeBonuses({ drainRateMultiplier: -0.1 }))).toBeCloseTo(0.9, 5);
|
||||
});
|
||||
|
||||
it('should have minimum of 0.7 (AC-20)', () => {
|
||||
// 3 tiers of sustain: -0.3, so 1.0 - 0.3 = 0.7
|
||||
expect(computeDrainRateMultiplier(makeBonuses({ drainRateMultiplier: -0.3 }))).toBeCloseTo(0.7, 5);
|
||||
// Even with more reduction, should not go below 0.7
|
||||
expect(computeDrainRateMultiplier(makeBonuses({ drainRateMultiplier: -0.5 }))).toBeCloseTo(0.7, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Drain Per Tick ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeDrainPerTick', () => {
|
||||
it('should compute drain based on spell cost', () => {
|
||||
// spell cost 10, drain mult 1.0: 1.0 × (10/10) × 1.0 = 1.0
|
||||
expect(computeDrainPerTick(10, 1.0)).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it('should scale with spell cost', () => {
|
||||
// spell cost 20, drain mult 1.0: 1.0 × (20/10) × 1.0 = 2.0
|
||||
expect(computeDrainPerTick(20, 1.0)).toBeCloseTo(2.0, 5);
|
||||
});
|
||||
|
||||
it('should scale with drain multiplier', () => {
|
||||
// spell cost 10, drain mult 0.7: 1.0 × 1.0 × 0.7 = 0.7
|
||||
expect(computeDrainPerTick(10, 0.7)).toBeCloseTo(0.7, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Pact Affinity Cast Speed (§6.1) ───────────────────────────────────────────
|
||||
|
||||
describe('computeCastSpeedBonus', () => {
|
||||
it('should return 0 with no pact affinity', () => {
|
||||
expect(computeCastSpeedBonus(0)).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('should return ~5.7% at 0.1 affinity (AC-12)', () => {
|
||||
// 0.5 × (1 - 1 / (1 + 0.1 × 1.5)) = 0.5 × (1 - 1/1.15) ≈ 0.0652
|
||||
expect(computeCastSpeedBonus(0.1)).toBeCloseTo(0.0652, 2);
|
||||
});
|
||||
|
||||
it('should return ~21.4% at 0.5 affinity', () => {
|
||||
// 0.5 × (1 - 1 / (1 + 0.5 × 1.5)) = 0.5 × (1 - 1/1.75) ≈ 0.2143
|
||||
expect(computeCastSpeedBonus(0.5)).toBeCloseTo(0.2143, 2);
|
||||
});
|
||||
|
||||
it('should approach 50% asymptotically (AC-12)', () => {
|
||||
// At very high affinity, should approach but never reach 0.5
|
||||
const bonus = computeCastSpeedBonus(100);
|
||||
expect(bonus).toBeLessThan(0.5);
|
||||
expect(bonus).toBeGreaterThan(0.49);
|
||||
});
|
||||
|
||||
it('should give diminishing returns', () => {
|
||||
const low = computeCastSpeedBonus(0.1);
|
||||
const high = computeCastSpeedBonus(0.2);
|
||||
// Doubling affinity should less than double the bonus
|
||||
expect(high).toBeLessThan(low * 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Deduct Invocation Spell Cost ──────────────────────────────────────────────
|
||||
|
||||
describe('deductInvocationSpellCost', () => {
|
||||
it('should deduct raw mana cost at multiplier', () => {
|
||||
const spell = SPELLS_DEF['manaBolt'];
|
||||
if (!spell) {
|
||||
// If manaBolt doesn't exist, skip
|
||||
return;
|
||||
}
|
||||
const result = deductInvocationSpellCost('manaBolt', 0.1, 100, makeElements());
|
||||
// Cost should be deducted from raw mana
|
||||
expect(result.rawMana).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should deduct element mana cost at multiplier', () => {
|
||||
const result = deductInvocationSpellCost('fireball', 0.1, 1000, makeElements());
|
||||
// Fireball costs 2 fire at full price, at 0.1 multiplier = 0.2 fire
|
||||
expect(result.elements.fire.current).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should handle unaffordable spells gracefully', () => {
|
||||
const result = deductInvocationSpellCost('Fireball', 0.1, 0, makeElements({ fire: { current: 0, max: 100, unlocked: true } }));
|
||||
// Should not deduct anything if can't afford
|
||||
expect(result.rawMana).toBe(0);
|
||||
expect(result.elements.fire.current).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration: Full Invocation Flow ─────────────────────────────────────────
|
||||
|
||||
describe('invocation system integration', () => {
|
||||
it('should compute full charge-to-drain cycle', () => {
|
||||
// Simulate charge fill with 3 pacts
|
||||
const fillRate = computeChargeFillRate(3, 0);
|
||||
expect(fillRate).toBeCloseTo(0.3625, 5);
|
||||
|
||||
// Time to 100: 100 / 0.3625 ≈ 276 ticks
|
||||
const ticksToFull = 100 / fillRate;
|
||||
expect(ticksToFull).toBeCloseTo(275.86, 1);
|
||||
});
|
||||
|
||||
it('should compute drain during invocation with discipline perks', () => {
|
||||
const disciplineEffects = makeBonuses({
|
||||
invocationCostReduction: 0.05, // efficiency + mastery
|
||||
drainRateMultiplier: -0.3, // max sustain
|
||||
});
|
||||
|
||||
const costMult = computeCostMultiplier(disciplineEffects);
|
||||
expect(costMult).toBeCloseTo(0.05, 5);
|
||||
|
||||
const drainMult = computeDrainRateMultiplier(disciplineEffects);
|
||||
expect(drainMult).toBeCloseTo(0.7, 5);
|
||||
|
||||
// Fireball costs 2 fire, drain = 1.0 × (2/10) × 0.7 = 0.14 per tick
|
||||
const drainPerTick = computeDrainPerTick(2, drainMult);
|
||||
expect(drainPerTick).toBeCloseTo(0.14, 5);
|
||||
|
||||
// Invocation lasts: 100 / 0.14 ≈ 714 ticks
|
||||
const invocationDuration = 100 / drainPerTick;
|
||||
expect(invocationDuration).toBeCloseTo(714.29, 1);
|
||||
});
|
||||
|
||||
it('should verify spell selection from guardian spellbook', () => {
|
||||
const guardian = getGuardianForFloor(10)!; // Fire guardian
|
||||
const spellbook = getGuardianSpellbook(guardian);
|
||||
|
||||
// With full mana, should select highest damage fire spell
|
||||
const selection = selectInvocationSpell(spellbook, 1000, makeElements(), 0.1);
|
||||
expect(selection).not.toBeNull();
|
||||
expect(selection!.element).toBe('fire');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,56 @@ import { DisciplinesAttunementType } from '../../types/disciplines';
|
||||
import type { DisciplineDefinition } from '../../types/disciplines';
|
||||
|
||||
export const invokerDisciplines: DisciplineDefinition[] = [
|
||||
{
|
||||
id: 'guardian-invocation',
|
||||
name: 'Guardian Invocation',
|
||||
attunement: DisciplinesAttunementType.INVOKER,
|
||||
manaType: 'raw',
|
||||
baseCost: 20,
|
||||
description:
|
||||
'Channel the power of pacted guardians in combat. Faster invocation charge, cheaper invocation spells, longer-lasting charge.',
|
||||
statBonus: { stat: 'invocationChargeRateBonus', baseValue: 0.10, label: 'Invocation Charge Rate' },
|
||||
difficultyFactor: 250,
|
||||
scalingFactor: 120,
|
||||
drainBase: 8,
|
||||
requires: ['signed_pact'],
|
||||
perks: [
|
||||
{
|
||||
id: 'invocation-efficiency',
|
||||
type: 'once',
|
||||
threshold: 100,
|
||||
value: 0,
|
||||
description: 'Invocation spells cost 20% less mana (cost multiplier 0.1 → 0.08)',
|
||||
bonus: { stat: 'invocationCostReduction', amount: 0.02 },
|
||||
},
|
||||
{
|
||||
id: 'invocation-speed',
|
||||
type: 'infinite',
|
||||
threshold: 200,
|
||||
value: 150,
|
||||
description: 'Every 150 XP: +0.05 invocation charge rate bonus',
|
||||
bonus: { stat: 'invocationChargeRateBonus', amount: 0.05 },
|
||||
},
|
||||
{
|
||||
id: 'invocation-sustain',
|
||||
type: 'capped',
|
||||
threshold: 400,
|
||||
value: 200,
|
||||
maxTier: 3,
|
||||
description: 'Every 200 XP: drain rate multiplier −0.1 (minimum 0.7)',
|
||||
bonus: { stat: 'drainRateMultiplier', amount: -0.1 },
|
||||
},
|
||||
{
|
||||
id: 'invocation-mastery',
|
||||
type: 'capped',
|
||||
threshold: 500,
|
||||
value: 250,
|
||||
maxTier: 3,
|
||||
description: 'Every 250 XP: cost multiplier −0.01 (minimum 0.05)',
|
||||
bonus: { stat: 'invocationCostReduction', amount: 0.01 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pact-attunement',
|
||||
name: 'Pact Attunement',
|
||||
|
||||
@@ -36,6 +36,9 @@ const KNOWN_BONUS_STATS = new Set([
|
||||
'disciplineXpBonus',
|
||||
'clickManaMultiplier',
|
||||
'studySpeed',
|
||||
'invocationChargeRateBonus',
|
||||
'drainRateMultiplier',
|
||||
'invocationCostReduction',
|
||||
// Conversion stat bonuses (one per element)
|
||||
'conversion_fire',
|
||||
'conversion_water',
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
} from './golem-combat-actions';
|
||||
import { processGolemAttacksFromStore } from './golem-combat-helpers';
|
||||
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
|
||||
import { processInvocationTick } from './combat-invocation';
|
||||
import { processMeleeTick } from './combat-melee';
|
||||
|
||||
// ─── Result Type ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -258,60 +260,35 @@ export function processCombatTick(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Invocation system (spec §10.1) ───────────────────────────────────
|
||||
const invResult = processInvocationTick(
|
||||
{ get, set, rawMana, elements, attackSpeedMult, signedPacts, currentRoom, floorHP },
|
||||
onFloorCleared,
|
||||
onDamageDealt,
|
||||
);
|
||||
rawMana = invResult.rawMana;
|
||||
elements = invResult.elements;
|
||||
floorHP = invResult.floorHP;
|
||||
floorMaxHP = invResult.floorMaxHP;
|
||||
currentFloor = invResult.currentFloor;
|
||||
currentRoom = invResult.currentRoom;
|
||||
logMessages.push(...invResult.logMessages);
|
||||
set({ invocationCharge: invResult.invocationCharge, activeInvocation: invResult.activeInvocation });
|
||||
|
||||
// ─── Melee sword attacks (spec §3.1, §4.3) ────────────────────────────
|
||||
const updatedMeleeSwordProgress = { ...state.meleeSwordProgress };
|
||||
const floorElement = getFloorElement(currentFloor);
|
||||
const guardian = getGuardianForFloor(currentFloor);
|
||||
const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement];
|
||||
|
||||
if (equippedSwords && Object.keys(equippedSwords).length > 0 && floorHP > 0) {
|
||||
for (const [instanceId, swordInstance] of Object.entries(equippedSwords)) {
|
||||
const swordType = EQUIPMENT_TYPES[swordInstance.typeId];
|
||||
if (!swordType || !swordType.stats?.attackSpeed) continue;
|
||||
const swordAttackSpeed = swordType.stats.attackSpeed;
|
||||
const meleeProgressPerTick = HOURS_PER_TICK * swordAttackSpeed * attackSpeedMult;
|
||||
let meleeProgress = (updatedMeleeSwordProgress[instanceId] || 0) + meleeProgressPerTick;
|
||||
let meleeSafetyCounter = 0;
|
||||
while (meleeProgress >= 1 && meleeSafetyCounter < 100) {
|
||||
const meleeDamage = calcMeleeDamage(swordInstance as EquipmentInstance, swordType, floorElems[0]);
|
||||
|
||||
// Deduct mana cost for weapon enchant spells (fireBlade, frostBlade, etc.)
|
||||
const enchantCost = deductWeaponEnchantCosts(swordInstance as EquipmentInstance, rawMana, elements);
|
||||
rawMana = enchantCost.rawMana;
|
||||
elements = enchantCost.elements;
|
||||
// Get the current target enemy (lowest HP, matching focus-fire targeting in applyDamageToRoom)
|
||||
const currentRoomState = get().currentRoom;
|
||||
const livingEnemies = currentRoomState.enemies.filter(e => e.hp > 0);
|
||||
const targetEnemy = livingEnemies.length > 0
|
||||
? livingEnemies.reduce((lowest, e) => e.hp < lowest.hp ? e : lowest)
|
||||
: null;
|
||||
const finalMeleeDamage = applyEnemyDefenses(meleeDamage, targetEnemy, currentRoomState.roomType, (msg) => logMessages.push(msg));
|
||||
if (!Number.isFinite(finalMeleeDamage)) break;
|
||||
|
||||
// Apply melee damage per-enemy (spec §3.2, focus-fire)
|
||||
const meleeRoomResult = applyDamageToRoom(get, set, finalMeleeDamage, false);
|
||||
floorHP = meleeRoomResult.floorHP;
|
||||
floorMaxHP = meleeRoomResult.floorMaxHP;
|
||||
currentRoom = get().currentRoom;
|
||||
meleeProgress -= 1;
|
||||
meleeSafetyCounter++;
|
||||
|
||||
if (meleeRoomResult.roomCleared) {
|
||||
const g = getGuardianForFloor(currentFloor);
|
||||
onFloorCleared(currentFloor, !!g);
|
||||
get().advanceRoomOrFloor();
|
||||
const ns = get();
|
||||
currentFloor = ns.currentFloor;
|
||||
floorMaxHP = ns.floorMaxHP;
|
||||
floorHP = ns.floorHP;
|
||||
currentRoom = ns.currentRoom;
|
||||
meleeProgress = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
updatedMeleeSwordProgress[instanceId] = meleeProgress % 1;
|
||||
}
|
||||
}
|
||||
const meleeResult = processMeleeTick(
|
||||
{ get, set, rawMana, elements, attackSpeedMult, equippedSwords: equippedSwords || {}, floorHP, currentRoom },
|
||||
applyEnemyDefenses,
|
||||
onFloorCleared,
|
||||
);
|
||||
rawMana = meleeResult.rawMana;
|
||||
elements = meleeResult.elements;
|
||||
floorHP = meleeResult.floorHP;
|
||||
floorMaxHP = meleeResult.floorMaxHP;
|
||||
currentFloor = meleeResult.currentFloor;
|
||||
currentRoom = meleeResult.currentRoom;
|
||||
const updatedMeleeSwordProgress = meleeResult.meleeSwordProgress;
|
||||
logMessages.push(...meleeResult.logMessages);
|
||||
|
||||
// ─── Golem attacks (spec §11) ───────────────────────────────────────────
|
||||
if (activeGolems.length > 0 && floorHP > 0) {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// ─── Invocation Combat Processing ──────────────────────────────────────────────
|
||||
// Extracted from combat-actions.ts to stay under the 400-line file limit.
|
||||
// Handles charge drain, spell selection, casting, auto-activate, and auto-end.
|
||||
|
||||
import { SPELLS_DEF, HOURS_PER_TICK } from '../constants';
|
||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||
import type { CombatStore, CombatState } from './combat-state.types';
|
||||
import type { FloorState } from '../types';
|
||||
import { getFloorElement, getMultiElementBonus, calcDamage } from '../utils';
|
||||
import { applyDamageToRoom } from './combat-damage';
|
||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
||||
import {
|
||||
selectInvocationGuardian,
|
||||
getGuardianSpellbook,
|
||||
selectInvocationSpell,
|
||||
deductInvocationSpellCost,
|
||||
computeChargeFillRate,
|
||||
computeCastSpeedBonus,
|
||||
computeCostMultiplier,
|
||||
computeDrainRateMultiplier,
|
||||
computeDrainPerTick,
|
||||
type ActiveInvocation,
|
||||
} from '../utils/invocation-utils';
|
||||
|
||||
export interface InvocationTickParams {
|
||||
get: () => CombatStore;
|
||||
set: (s: Partial<CombatState>) => void;
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
attackSpeedMult: number;
|
||||
signedPacts: number[];
|
||||
currentRoom: FloorState;
|
||||
floorHP: number;
|
||||
}
|
||||
|
||||
export interface InvocationTickResult {
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
currentFloor: number;
|
||||
currentRoom: FloorState;
|
||||
invocationCharge: number;
|
||||
activeInvocation: ActiveInvocation | null;
|
||||
logMessages: string[];
|
||||
}
|
||||
|
||||
export function processInvocationTick(
|
||||
params: InvocationTickParams,
|
||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||
onDamageDealt: (damage: number) => {
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
modifiedDamage?: number;
|
||||
},
|
||||
): InvocationTickResult {
|
||||
const { get, set, rawMana: startRawMana, elements: startElements, attackSpeedMult, signedPacts, currentRoom, floorHP: startFloorHP } = params;
|
||||
let rawMana = startRawMana;
|
||||
let elements = startElements;
|
||||
let floorHP = startFloorHP;
|
||||
let floorMaxHP = params.get().floorMaxHP;
|
||||
let currentFloor = params.get().currentFloor;
|
||||
let currentRoomState = currentRoom;
|
||||
const logMessages: string[] = [];
|
||||
let invocationCharge = params.get().invocationCharge;
|
||||
let activeInvocation = params.get().activeInvocation;
|
||||
|
||||
if (activeInvocation !== null) {
|
||||
// ── Invocation is active: drain charge and process cast ──
|
||||
const invResult = processActiveInvocation({
|
||||
get, set, rawMana, elements, attackSpeedMult, signedPacts,
|
||||
currentFloor, floorHP, floorMaxHP, currentRoom: currentRoomState,
|
||||
invocationCharge, activeInvocation, logMessages,
|
||||
}, onFloorCleared, onDamageDealt);
|
||||
|
||||
rawMana = invResult.rawMana;
|
||||
elements = invResult.elements;
|
||||
floorHP = invResult.floorHP;
|
||||
floorMaxHP = invResult.floorMaxHP;
|
||||
currentFloor = invResult.currentFloor;
|
||||
currentRoomState = invResult.currentRoom;
|
||||
invocationCharge = invResult.invocationCharge;
|
||||
activeInvocation = invResult.activeInvocation;
|
||||
} else if (invocationCharge >= 100) {
|
||||
// ── Try to auto-activate ──
|
||||
const livingEnemies = currentRoomState.enemies.filter((e) => e.hp > 0);
|
||||
if (livingEnemies.length > 0 && signedPacts.length > 0) {
|
||||
const enemyElements = currentRoomState.enemies
|
||||
.filter((e) => e.hp > 0)
|
||||
.map((e) => e.element);
|
||||
const uniqueEnemyElements = Array.from(new Set(enemyElements));
|
||||
|
||||
const bestFloor = selectInvocationGuardian(signedPacts, uniqueEnemyElements);
|
||||
if (bestFloor !== null) {
|
||||
const guardian = getGuardianForFloor(bestFloor);
|
||||
if (guardian) {
|
||||
const spellbook = getGuardianSpellbook(guardian);
|
||||
const disciplineEffects = computeDisciplineEffects();
|
||||
const costMult = computeCostMultiplier(disciplineEffects);
|
||||
const spellSelection = selectInvocationSpell(spellbook, rawMana, elements, costMult);
|
||||
|
||||
if (spellSelection) {
|
||||
activeInvocation = {
|
||||
guardianFloor: bestFloor,
|
||||
spellId: spellSelection.spellId,
|
||||
element: spellSelection.element,
|
||||
castProgress: 0,
|
||||
};
|
||||
logMessages.push(`\u{1F49C} Invoking ${guardian.name}'s power!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Not invoking and charge < 100: fill charge ──
|
||||
const disciplineEffects = computeDisciplineEffects();
|
||||
const chargeRateBonus = disciplineEffects.bonuses.invocationChargeRateBonus || 0;
|
||||
const fillRate = computeChargeFillRate(signedPacts.length, chargeRateBonus);
|
||||
invocationCharge = Math.min(100, invocationCharge + fillRate);
|
||||
}
|
||||
|
||||
return {
|
||||
rawMana,
|
||||
elements,
|
||||
floorHP,
|
||||
floorMaxHP,
|
||||
currentFloor,
|
||||
currentRoom: currentRoomState,
|
||||
invocationCharge,
|
||||
activeInvocation,
|
||||
logMessages,
|
||||
};
|
||||
}
|
||||
|
||||
interface ActiveInvParams {
|
||||
get: () => CombatStore;
|
||||
set: (s: Partial<CombatState>) => void;
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
attackSpeedMult: number;
|
||||
signedPacts: number[];
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
currentRoom: FloorState;
|
||||
invocationCharge: number;
|
||||
activeInvocation: ActiveInvocation;
|
||||
logMessages: string[];
|
||||
}
|
||||
|
||||
function processActiveInvocation(
|
||||
p: ActiveInvParams,
|
||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||
onDamageDealt: (damage: number) => {
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
modifiedDamage?: number;
|
||||
},
|
||||
): InvocationTickResult {
|
||||
let { get, set, rawMana, elements, attackSpeedMult, signedPacts } = p;
|
||||
let { currentFloor, floorHP, floorMaxHP, currentRoom } = p;
|
||||
let { invocationCharge, activeInvocation } = p;
|
||||
const logMessages = p.logMessages;
|
||||
|
||||
const invSpellDef = SPELLS_DEF[activeInvocation.spellId];
|
||||
if (!invSpellDef || floorHP <= 0) {
|
||||
return { rawMana, elements, floorHP, floorMaxHP, currentFloor, currentRoom, invocationCharge, activeInvocation, logMessages };
|
||||
}
|
||||
|
||||
const disciplineEffects = computeDisciplineEffects();
|
||||
const drainMult = computeDrainRateMultiplier(disciplineEffects);
|
||||
const drainPerTick = computeDrainPerTick(invSpellDef.cost.amount, drainMult);
|
||||
invocationCharge = Math.max(0, invocationCharge - drainPerTick);
|
||||
|
||||
// Accumulate cast progress with pact affinity bonus
|
||||
const pactAffinity = disciplineEffects.bonuses.pactAffinityBonus || 0;
|
||||
const castSpeedBonus = computeCastSpeedBonus(pactAffinity);
|
||||
const effectiveAttackSpeed = attackSpeedMult * (1 + castSpeedBonus);
|
||||
const invCastSpeed = invSpellDef.castSpeed || 1;
|
||||
const invProgressPerTick = HOURS_PER_TICK * invCastSpeed * effectiveAttackSpeed;
|
||||
const newCastProgress = activeInvocation.castProgress + invProgressPerTick;
|
||||
|
||||
if (newCastProgress >= 1 && floorHP > 0) {
|
||||
// Cast completes: deduct mana at effective cost multiplier
|
||||
const costMult = computeCostMultiplier(disciplineEffects);
|
||||
const afterCost = deductInvocationSpellCost(
|
||||
activeInvocation.spellId, costMult, rawMana, elements,
|
||||
);
|
||||
|
||||
if (afterCost.rawMana !== rawMana || afterCost.elements !== elements) {
|
||||
rawMana = afterCost.rawMana;
|
||||
elements = afterCost.elements;
|
||||
|
||||
// Calculate damage
|
||||
const floorElement = getFloorElement(currentFloor);
|
||||
const guardian = getGuardianForFloor(currentFloor);
|
||||
const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement];
|
||||
const multiElemBonus = getMultiElementBonus(activeInvocation.element, floorElems);
|
||||
const baseDamage = calcDamage({ signedPacts }, activeInvocation.spellId, undefined, disciplineEffects);
|
||||
const invDamage = baseDamage * multiElemBonus;
|
||||
|
||||
const result = onDamageDealt(invDamage);
|
||||
rawMana = result.rawMana;
|
||||
elements = result.elements;
|
||||
const finalDamage = result.modifiedDamage || invDamage;
|
||||
|
||||
if (Number.isFinite(finalDamage)) {
|
||||
const roomResult = applyDamageToRoom(get, set, finalDamage, !!invSpellDef.isAoe, invSpellDef.aoeTargets);
|
||||
floorHP = roomResult.floorHP;
|
||||
floorMaxHP = roomResult.floorMaxHP;
|
||||
currentRoom = get().currentRoom;
|
||||
|
||||
if (roomResult.roomCleared) {
|
||||
onFloorCleared(currentFloor, !!getGuardianForFloor(currentFloor));
|
||||
get().advanceRoomOrFloor();
|
||||
const newState = get();
|
||||
currentFloor = newState.currentFloor;
|
||||
floorMaxHP = newState.floorMaxHP;
|
||||
floorHP = newState.floorHP;
|
||||
currentRoom = newState.currentRoom;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-evaluate spell selection
|
||||
const currentGuardian = getGuardianForFloor(activeInvocation.guardianFloor);
|
||||
if (currentGuardian && floorHP > 0) {
|
||||
const spellbook = getGuardianSpellbook(currentGuardian);
|
||||
const newCostMult = computeCostMultiplier(disciplineEffects);
|
||||
const newSelection = selectInvocationSpell(spellbook, rawMana, elements, newCostMult);
|
||||
|
||||
if (newSelection) {
|
||||
activeInvocation = {
|
||||
...activeInvocation,
|
||||
spellId: newSelection.spellId,
|
||||
element: newSelection.element,
|
||||
castProgress: newCastProgress - 1,
|
||||
};
|
||||
} else {
|
||||
// No affordable spell: end invocation
|
||||
activeInvocation = null;
|
||||
logMessages.push(`\u{1F49C} Invocation ends. ${currentGuardian.name}'s power fades.`);
|
||||
}
|
||||
} else {
|
||||
activeInvocation = { ...activeInvocation, castProgress: newCastProgress - 1 };
|
||||
}
|
||||
} else {
|
||||
activeInvocation = { ...activeInvocation, castProgress: newCastProgress };
|
||||
}
|
||||
|
||||
// Check charge depleted
|
||||
if (invocationCharge <= 0 && activeInvocation !== null) {
|
||||
const g = getGuardianForFloor(activeInvocation.guardianFloor);
|
||||
activeInvocation = null;
|
||||
logMessages.push(`\u{1F49C} Invocation ends. ${g?.name || 'Guardian'}'s power fades.`);
|
||||
}
|
||||
|
||||
// Check room cleared
|
||||
if (floorHP <= 0 && activeInvocation !== null) {
|
||||
const g = getGuardianForFloor(activeInvocation.guardianFloor);
|
||||
activeInvocation = null;
|
||||
logMessages.push(`\u{1F49C} Invocation ends. ${g?.name || 'Guardian'}'s power fades.`);
|
||||
}
|
||||
|
||||
return { rawMana, elements, floorHP, floorMaxHP, currentFloor, currentRoom, invocationCharge, activeInvocation, logMessages };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// ─── Melee Combat Processing ───────────────────────────────────────────────────
|
||||
// Extracted from combat-actions.ts to stay under the 400-line file limit.
|
||||
|
||||
import { HOURS_PER_TICK, EQUIPMENT_TYPES } from '../constants';
|
||||
import type { CombatStore, CombatState } from './combat-state.types';
|
||||
import type { EquipmentInstance } from '../types';
|
||||
import { getFloorElement, getMultiElementBonus, calcMeleeDamage } from '../utils';
|
||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
|
||||
|
||||
export interface MeleeTickParams {
|
||||
get: () => CombatStore;
|
||||
set: (s: Partial<CombatState>) => void;
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
attackSpeedMult: number;
|
||||
equippedSwords: Record<string, EquipmentInstance>;
|
||||
floorHP: number;
|
||||
currentRoom: import('../types').FloorState;
|
||||
}
|
||||
|
||||
export interface MeleeTickResult {
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
currentFloor: number;
|
||||
currentRoom: import('../types').FloorState;
|
||||
meleeSwordProgress: Record<string, number>;
|
||||
logMessages: string[];
|
||||
}
|
||||
|
||||
export function processMeleeTick(
|
||||
params: MeleeTickParams,
|
||||
applyEnemyDefenses: (
|
||||
dmg: number,
|
||||
enemy: import('../types').EnemyState | null,
|
||||
roomType: string,
|
||||
addLog: (msg: string) => void,
|
||||
bypassArmor?: boolean,
|
||||
bypassBarrier?: boolean,
|
||||
) => number,
|
||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||
): MeleeTickResult {
|
||||
const { get, set, attackSpeedMult, equippedSwords } = params;
|
||||
let rawMana = params.rawMana;
|
||||
let elements = params.elements;
|
||||
let floorHP = params.floorHP;
|
||||
let floorMaxHP = params.get().floorMaxHP;
|
||||
let currentFloor = params.get().currentFloor;
|
||||
let currentRoom = params.currentRoom;
|
||||
const logMessages: string[] = [];
|
||||
|
||||
const updatedMeleeSwordProgress = { ...params.get().meleeSwordProgress };
|
||||
const floorElement = getFloorElement(currentFloor);
|
||||
const guardian = getGuardianForFloor(currentFloor);
|
||||
const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement];
|
||||
|
||||
if (equippedSwords && Object.keys(equippedSwords).length > 0 && floorHP > 0) {
|
||||
for (const [instanceId, swordInstance] of Object.entries(equippedSwords)) {
|
||||
const swordType = EQUIPMENT_TYPES[swordInstance.typeId];
|
||||
if (!swordType || !swordType.stats?.attackSpeed) continue;
|
||||
const swordAttackSpeed = swordType.stats.attackSpeed;
|
||||
const meleeProgressPerTick = HOURS_PER_TICK * swordAttackSpeed * attackSpeedMult;
|
||||
let meleeProgress = (updatedMeleeSwordProgress[instanceId] || 0) + meleeProgressPerTick;
|
||||
let meleeSafetyCounter = 0;
|
||||
while (meleeProgress >= 1 && meleeSafetyCounter < 100) {
|
||||
const meleeDamage = calcMeleeDamage(swordInstance as EquipmentInstance, swordType, floorElems[0]);
|
||||
|
||||
const enchantCost = deductWeaponEnchantCosts(swordInstance as EquipmentInstance, rawMana, elements);
|
||||
rawMana = enchantCost.rawMana;
|
||||
elements = enchantCost.elements;
|
||||
|
||||
const currentRoomState = get().currentRoom;
|
||||
const livingEnemies = currentRoomState.enemies.filter(e => e.hp > 0);
|
||||
const targetEnemy = livingEnemies.length > 0
|
||||
? livingEnemies.reduce((lowest, e) => e.hp < lowest.hp ? e : lowest)
|
||||
: null;
|
||||
const finalMeleeDamage = applyEnemyDefenses(meleeDamage, targetEnemy, currentRoomState.roomType, (msg) => logMessages.push(msg));
|
||||
if (!Number.isFinite(finalMeleeDamage)) break;
|
||||
|
||||
const meleeRoomResult = applyDamageToRoom(get, set, finalMeleeDamage, false);
|
||||
floorHP = meleeRoomResult.floorHP;
|
||||
floorMaxHP = meleeRoomResult.floorMaxHP;
|
||||
currentRoom = get().currentRoom;
|
||||
meleeProgress -= 1;
|
||||
meleeSafetyCounter++;
|
||||
|
||||
if (meleeRoomResult.roomCleared) {
|
||||
const g = getGuardianForFloor(currentFloor);
|
||||
onFloorCleared(currentFloor, !!g);
|
||||
get().advanceRoomOrFloor();
|
||||
const ns = get();
|
||||
currentFloor = ns.currentFloor;
|
||||
floorMaxHP = ns.floorMaxHP;
|
||||
floorHP = ns.floorHP;
|
||||
currentRoom = ns.currentRoom;
|
||||
meleeProgress = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
updatedMeleeSwordProgress[instanceId] = meleeProgress % 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { rawMana, elements, floorHP, floorMaxHP, currentFloor, currentRoom, meleeSwordProgress: updatedMeleeSwordProgress, logMessages };
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Shared types for combat store and combat actions to avoid circular dependency
|
||||
|
||||
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, RuntimeActiveGolem, EnemyState, EquipmentInstance, SerializedGolemDesign } from '../types';
|
||||
import type { ActiveInvocation } from '../utils/invocation-utils';
|
||||
|
||||
/** Signature for the advanceRoomOrFloor callback to break circular dependency */
|
||||
export type AdvanceRoomFn = (get: () => CombatStore, set: (s: Partial<CombatState>) => void) => void;
|
||||
@@ -91,6 +92,10 @@ export interface CombatState {
|
||||
totalSpellsCast: number;
|
||||
totalDamageDealt: number;
|
||||
totalCraftsCompleted: number;
|
||||
|
||||
// Invocation system
|
||||
invocationCharge: number;
|
||||
activeInvocation: ActiveInvocation | null;
|
||||
}
|
||||
|
||||
// ─── Combat Actions ───────────────────────────────────────────────────────────
|
||||
@@ -191,6 +196,9 @@ export interface CombatActions {
|
||||
currentRoom: FloorState;
|
||||
};
|
||||
|
||||
// Invocation
|
||||
resetInvocationState: () => void;
|
||||
|
||||
// Reset
|
||||
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,10 @@ export const useCombatStore = create<CombatStore>()(
|
||||
totalDamageDealt: 0,
|
||||
totalCraftsCompleted: 0,
|
||||
|
||||
// Invocation system
|
||||
invocationCharge: 0,
|
||||
activeInvocation: null,
|
||||
|
||||
setCurrentFloor: (floor: number) => {
|
||||
set({
|
||||
currentFloor: floor,
|
||||
@@ -211,6 +215,8 @@ export const useCombatStore = create<CombatStore>()(
|
||||
roomsPerFloor: 1,
|
||||
maxFloorReached: Math.max(s.maxFloorReached, 1),
|
||||
golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[] },
|
||||
invocationCharge: 0,
|
||||
activeInvocation: null,
|
||||
});
|
||||
// Deactivate all disciplines on spire exit for safety
|
||||
useDisciplineStore.getState().deactivateAll();
|
||||
@@ -280,6 +286,10 @@ export const useCombatStore = create<CombatStore>()(
|
||||
set({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
|
||||
},
|
||||
|
||||
resetInvocationState: () => {
|
||||
set({ invocationCharge: 0, activeInvocation: null });
|
||||
},
|
||||
|
||||
initGuardianDefensiveState: () => {
|
||||
const state = get();
|
||||
const guardian = getGuardianForFloor(state.currentFloor);
|
||||
@@ -366,6 +376,8 @@ export const useCombatStore = create<CombatStore>()(
|
||||
guardianBarrierMax: state.guardianBarrierMax,
|
||||
meleeSwordProgress: state.meleeSwordProgress,
|
||||
runId: state.runId,
|
||||
invocationCharge: state.invocationCharge,
|
||||
activeInvocation: state.activeInvocation,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// ─── Invocation Utilities ──────────────────────────────────────────────────────
|
||||
// Guardian/spell selection, charge rate, cost multiplier, drain multiplier
|
||||
|
||||
import { SPELLS_DEF } from '../constants/spells';
|
||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||
import { getMultiElementBonus, canAffordSpellCost, deductSpellCost } from './combat-utils';
|
||||
import type { GuardianDef } from '../types';
|
||||
import type { DisciplineBonuses } from './mana-utils';
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE_FILL_RATE = 0.25;
|
||||
const BASE_DRAIN_RATE = 1.0;
|
||||
const BASE_COST_MULTIPLIER = 0.1;
|
||||
const MIN_COST_MULTIPLIER = 0.05;
|
||||
const BASE_DRAIN_MULTIPLIER = 1.0;
|
||||
const MIN_DRAIN_MULTIPLIER = 0.7;
|
||||
const MAX_CAST_SPEED_BONUS = 0.5;
|
||||
const PACT_AFFINITY_SCALING = 1.5;
|
||||
|
||||
// ─── Invocation Guardian Selection ─────────────────────────────────────────────
|
||||
|
||||
export interface ActiveInvocation {
|
||||
guardianFloor: number;
|
||||
spellId: string;
|
||||
element: string;
|
||||
castProgress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best guardian to channel based on current enemy.
|
||||
* Scores all signed pacts by: bestElementalBonus × tierMultiplier
|
||||
* Returns the floor number of the best guardian, or null if no signed pacts.
|
||||
*/
|
||||
export function selectInvocationGuardian(
|
||||
signedPacts: number[],
|
||||
enemyElements: string[],
|
||||
): number | null {
|
||||
if (signedPacts.length === 0) return null;
|
||||
|
||||
let bestFloor: number | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const floor of signedPacts) {
|
||||
const guardian = getGuardianForFloor(floor);
|
||||
if (!guardian) continue;
|
||||
|
||||
// Best elemental bonus across all guardian elements
|
||||
const elementalBonus = getMultiElementBonus(
|
||||
guardian.element[0],
|
||||
enemyElements,
|
||||
);
|
||||
// Use the best bonus among all guardian elements
|
||||
let bestElemBonus = elementalBonus;
|
||||
for (const elem of guardian.element) {
|
||||
const bonus = getMultiElementBonus(elem, enemyElements);
|
||||
if (bonus > bestElemBonus) bestElemBonus = bonus;
|
||||
}
|
||||
|
||||
const tierMultiplier = 1.0 + floor * 0.005;
|
||||
const score = bestElemBonus * tierMultiplier;
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestFloor = floor;
|
||||
}
|
||||
}
|
||||
|
||||
return bestFloor;
|
||||
}
|
||||
|
||||
// ─── Guardian Spellbook ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all spells a guardian knows (union of all their elements' spells).
|
||||
*/
|
||||
export function getGuardianSpellbook(
|
||||
guardian: GuardianDef,
|
||||
): typeof SPELLS_DEF[string][] {
|
||||
const spellIds = new Set<string>();
|
||||
const spells: typeof SPELLS_DEF[string][] = [];
|
||||
|
||||
for (const element of guardian.element) {
|
||||
for (const spell of Object.values(SPELLS_DEF)) {
|
||||
if (spell.elem === element && !spellIds.has(spell.name)) {
|
||||
spellIds.add(spell.name);
|
||||
spells.push(spell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spells;
|
||||
}
|
||||
|
||||
// ─── Spell Selection ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Select the best affordable spell from a spellbook.
|
||||
* Picks highest-damage spell the player can afford at the effective cost multiplier.
|
||||
* Returns { spellId, element } or null if nothing is affordable.
|
||||
*/
|
||||
export function selectInvocationSpell(
|
||||
spellbook: typeof SPELLS_DEF[string][],
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
costMultiplier: number,
|
||||
): { spellId: string; element: string } | null {
|
||||
// Filter to affordable spells at the effective cost multiplier
|
||||
const affordable = spellbook.filter((spell) => {
|
||||
const scaledCost = {
|
||||
type: spell.cost.type as 'raw' | 'element',
|
||||
element: spell.cost.element,
|
||||
amount: spell.cost.amount * costMultiplier,
|
||||
};
|
||||
return canAffordSpellCost(scaledCost, rawMana, elements);
|
||||
});
|
||||
|
||||
if (affordable.length === 0) return null;
|
||||
|
||||
// Sort by highest damage, then highest tier, then lowest cost (most efficient)
|
||||
affordable.sort((a, b) => {
|
||||
if (b.dmg !== a.dmg) return b.dmg - a.dmg;
|
||||
if (b.tier !== a.tier) return b.tier - a.tier;
|
||||
return a.cost.amount - b.cost.amount;
|
||||
});
|
||||
|
||||
const best = affordable[0];
|
||||
return { spellId: best.name, element: best.elem };
|
||||
}
|
||||
|
||||
// ─── Deduct Invocation Spell Cost ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deduct the cost of an invocation spell at the effective cost multiplier.
|
||||
*/
|
||||
export function deductInvocationSpellCost(
|
||||
spellId: string,
|
||||
costMultiplier: number,
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
|
||||
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
|
||||
const spell = SPELLS_DEF[spellId];
|
||||
if (!spell) return { rawMana, elements };
|
||||
|
||||
const scaledCost = {
|
||||
type: spell.cost.type as 'raw' | 'element',
|
||||
element: spell.cost.element,
|
||||
amount: spell.cost.amount * costMultiplier,
|
||||
};
|
||||
return deductSpellCost(scaledCost, rawMana, elements);
|
||||
}
|
||||
|
||||
// ─── Charge Fill Rate ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute charge fill rate per tick.
|
||||
* chargePerTick = baseFillRate × (1 + pacts × 0.15) × (1 + chargeRateBonus)
|
||||
*/
|
||||
export function computeChargeFillRate(
|
||||
signedPactsLength: number,
|
||||
chargeRateBonus: number,
|
||||
): number {
|
||||
const pactCountMultiplier = 1 + signedPactsLength * 0.15;
|
||||
const disciplineMultiplier = 1 + chargeRateBonus;
|
||||
return BASE_FILL_RATE * pactCountMultiplier * disciplineMultiplier;
|
||||
}
|
||||
|
||||
// ─── Cast Speed Bonus ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute cast speed bonus from pact affinity.
|
||||
* castSpeedBonus = MAX_BONUS × (1 - 1 / (1 + pactAffinity × SCALING))
|
||||
*/
|
||||
export function computeCastSpeedBonus(pactAffinity: number): number {
|
||||
return MAX_CAST_SPEED_BONUS * (1 - 1 / (1 + pactAffinity * PACT_AFFINITY_SCALING));
|
||||
}
|
||||
|
||||
// ─── Cost Multiplier ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute effective cost multiplier from discipline bonuses.
|
||||
* Base is 0.1, reduced by invocationCostReduction. Minimum: 0.05
|
||||
*/
|
||||
export function computeCostMultiplier(disciplineBonuses: DisciplineBonuses): number {
|
||||
const reduction = disciplineBonuses.bonuses.invocationCostReduction || 0;
|
||||
return Math.max(MIN_COST_MULTIPLIER, BASE_COST_MULTIPLIER - reduction);
|
||||
}
|
||||
|
||||
// ─── Drain Rate Multiplier ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute drain rate multiplier from discipline bonuses.
|
||||
* Base is 1.0, reduced by drainRateMultiplier (which is negative).
|
||||
* Minimum: 0.7
|
||||
*/
|
||||
export function computeDrainRateMultiplier(disciplineBonuses: DisciplineBonuses): number {
|
||||
const reduction = disciplineBonuses.bonuses.drainRateMultiplier || 0;
|
||||
return Math.max(MIN_DRAIN_MULTIPLIER, BASE_DRAIN_MULTIPLIER + reduction);
|
||||
}
|
||||
|
||||
// ─── Drain Per Tick ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute charge drain per tick during invocation.
|
||||
* drainPerTick = BASE_DRAIN × (spellCost / 10) × drainRateMultiplier
|
||||
*/
|
||||
export function computeDrainPerTick(
|
||||
spellCostAmount: number,
|
||||
drainRateMultiplier: number,
|
||||
): number {
|
||||
const spellCostMultiplier = spellCostAmount / 10;
|
||||
return BASE_DRAIN_RATE * spellCostMultiplier * drainRateMultiplier;
|
||||
}
|
||||
Reference in New Issue
Block a user