feat: Implement Invocation System for Invoker attunement
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:
2026-06-13 13:42:05 +02:00
parent 7dda515a71
commit b7afe7a434
14 changed files with 1165 additions and 59 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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."
},
+4
View File
@@ -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');
});
});
+50
View File
@@ -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',
+30 -53
View File
@@ -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) {
+266
View File
@@ -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 };
}
+107
View File
@@ -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;
}
+12
View File
@@ -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,
}),
}
)
+213
View File
@@ -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;
}