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
|
# 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.
|
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) data/guardian-encounters.ts > data/guardian-procedural.ts
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_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.",
|
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
||||||
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── guardian-names-unique.test.ts
|
│ │ │ │ ├── guardian-names-unique.test.ts
|
||||||
│ │ │ │ ├── guardian-names.test.ts
|
│ │ │ │ ├── guardian-names.test.ts
|
||||||
│ │ │ │ ├── hasty-enchanter.test.ts
|
│ │ │ │ ├── hasty-enchanter.test.ts
|
||||||
|
│ │ │ │ ├── invocation-system.test.ts
|
||||||
│ │ │ │ ├── mana-conversion-component-deduction.test.ts
|
│ │ │ │ ├── mana-conversion-component-deduction.test.ts
|
||||||
│ │ │ │ ├── mana-utils.test.ts
|
│ │ │ │ ├── mana-utils.test.ts
|
||||||
│ │ │ │ ├── melee-auto-attack.test.ts
|
│ │ │ │ ├── melee-auto-attack.test.ts
|
||||||
@@ -386,6 +387,8 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── combat-actions.ts
|
│ │ │ │ ├── combat-actions.ts
|
||||||
│ │ │ │ ├── combat-damage.ts
|
│ │ │ │ ├── combat-damage.ts
|
||||||
│ │ │ │ ├── combat-descent-actions.ts
|
│ │ │ │ ├── combat-descent-actions.ts
|
||||||
|
│ │ │ │ ├── combat-invocation.ts
|
||||||
|
│ │ │ │ ├── combat-melee.ts
|
||||||
│ │ │ │ ├── combat-reset.ts
|
│ │ │ │ ├── combat-reset.ts
|
||||||
│ │ │ │ ├── combat-state.types.ts
|
│ │ │ │ ├── combat-state.types.ts
|
||||||
│ │ │ │ ├── combatStore.ts
|
│ │ │ │ ├── combatStore.ts
|
||||||
@@ -437,6 +440,7 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── formatting.ts
|
│ │ │ │ ├── formatting.ts
|
||||||
│ │ │ │ ├── guardian-utils.ts
|
│ │ │ │ ├── guardian-utils.ts
|
||||||
│ │ │ │ ├── index.ts
|
│ │ │ │ ├── index.ts
|
||||||
|
│ │ │ │ ├── invocation-utils.ts
|
||||||
│ │ │ │ ├── mana-utils.ts
|
│ │ │ │ ├── mana-utils.ts
|
||||||
│ │ │ │ ├── pact-utils.ts
|
│ │ │ │ ├── pact-utils.ts
|
||||||
│ │ │ │ ├── result.ts
|
│ │ │ │ ├── result.ts
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
|
|||||||
const activeSpell = useCombatStore((s) => s.activeSpell);
|
const activeSpell = useCombatStore((s) => s.activeSpell);
|
||||||
const setSpell = useCombatStore((s) => s.setSpell);
|
const setSpell = useCombatStore((s) => s.setSpell);
|
||||||
const golemancy = useCombatStore((s) => s.golemancy);
|
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 rawMana = useManaStore((s) => s.rawMana);
|
||||||
const elements = useManaStore((s) => s.elements);
|
const elements = useManaStore((s) => s.elements);
|
||||||
|
|
||||||
@@ -30,11 +32,40 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
|
|||||||
.map(([id]) => id);
|
.map(([id]) => id);
|
||||||
|
|
||||||
const activeGolems = golemancy.activeGolems || [];
|
const activeGolems = golemancy.activeGolems || [];
|
||||||
const golemDesigns = golemancy.golemDesigns || {};
|
const golemDesigns = golemancy.golemDesigns || [];
|
||||||
|
|
||||||
|
const isInvoking = activeInvocation !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DebugName name="SpireCombatControls">
|
<DebugName name="SpireCombatControls">
|
||||||
<div className="space-y-4">
|
<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 */}
|
{/* Active Spell Panel */}
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { SpireCombatControls } from './SpireCombatControls';
|
|||||||
import { SpireActivityLog } from './SpireActivityLog';
|
import { SpireActivityLog } from './SpireActivityLog';
|
||||||
import { SpireManaDisplay } from './SpireManaDisplay';
|
import { SpireManaDisplay } from './SpireManaDisplay';
|
||||||
import { DebugName } from '@/components/game/debug/debug-context';
|
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 ──────────────────────────────────────────────────────
|
// ─── Derived Stats Hook ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -19,7 +23,6 @@ function useSpireStats(prestigeUpgrades: Record<string, number>, equippedInstanc
|
|||||||
try {
|
try {
|
||||||
disciplineEffects = computeDisciplineEffects();
|
disciplineEffects = computeDisciplineEffects();
|
||||||
} catch {
|
} catch {
|
||||||
// If discipline state is corrupted, proceed without discipline effects
|
|
||||||
disciplineEffects = { bonuses: {}, multipliers: {}, specials: new Set(), meditationCapBonus: 0, conversions: {} };
|
disciplineEffects = { bonuses: {}, multipliers: {}, specials: new Set(), meditationCapBonus: 0, conversions: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,10 +51,76 @@ function useSpireStats(prestigeUpgrades: Record<string, number>, equippedInstanc
|
|||||||
return { maxMana, baseRegen };
|
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 ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SpireCombatPage() {
|
export function SpireCombatPage() {
|
||||||
// ─── Spec: read room-aware state from combat store ───────────────────────
|
|
||||||
const {
|
const {
|
||||||
currentFloor,
|
currentFloor,
|
||||||
castProgress,
|
castProgress,
|
||||||
@@ -70,6 +139,8 @@ export function SpireCombatPage() {
|
|||||||
setAction,
|
setAction,
|
||||||
skipNonCombatRoom,
|
skipNonCombatRoom,
|
||||||
stayLongerInRoom,
|
stayLongerInRoom,
|
||||||
|
invocationCharge,
|
||||||
|
activeInvocation,
|
||||||
} = useCombatStore(useShallow((s) => ({
|
} = useCombatStore(useShallow((s) => ({
|
||||||
currentFloor: s.currentFloor,
|
currentFloor: s.currentFloor,
|
||||||
castProgress: s.castProgress,
|
castProgress: s.castProgress,
|
||||||
@@ -88,6 +159,8 @@ export function SpireCombatPage() {
|
|||||||
setAction: s.setAction,
|
setAction: s.setAction,
|
||||||
skipNonCombatRoom: s.skipNonCombatRoom,
|
skipNonCombatRoom: s.skipNonCombatRoom,
|
||||||
stayLongerInRoom: s.stayLongerInRoom,
|
stayLongerInRoom: s.stayLongerInRoom,
|
||||||
|
invocationCharge: s.invocationCharge,
|
||||||
|
activeInvocation: s.activeInvocation,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
const { rawMana, elements } = useManaStore(useShallow((s) => ({
|
const { rawMana, elements } = useManaStore(useShallow((s) => ({
|
||||||
@@ -105,7 +178,6 @@ export function SpireCombatPage() {
|
|||||||
equipmentInstances: s.equipmentInstances,
|
equipmentInstances: s.equipmentInstances,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
// ─── Combat spec §10: read current in-game time ──────────────────────────
|
|
||||||
const day = useGameStore((s) => s.day);
|
const day = useGameStore((s) => s.day);
|
||||||
const hour = useGameStore((s) => s.hour);
|
const hour = useGameStore((s) => s.hour);
|
||||||
|
|
||||||
@@ -156,6 +228,12 @@ export function SpireCombatPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<RoomDisplay
|
<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';
|
import type { DisciplineDefinition } from '../../types/disciplines';
|
||||||
|
|
||||||
export const invokerDisciplines: DisciplineDefinition[] = [
|
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',
|
id: 'pact-attunement',
|
||||||
name: 'Pact Attunement',
|
name: 'Pact Attunement',
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ const KNOWN_BONUS_STATS = new Set([
|
|||||||
'disciplineXpBonus',
|
'disciplineXpBonus',
|
||||||
'clickManaMultiplier',
|
'clickManaMultiplier',
|
||||||
'studySpeed',
|
'studySpeed',
|
||||||
|
'invocationChargeRateBonus',
|
||||||
|
'drainRateMultiplier',
|
||||||
|
'invocationCostReduction',
|
||||||
// Conversion stat bonuses (one per element)
|
// Conversion stat bonuses (one per element)
|
||||||
'conversion_fire',
|
'conversion_fire',
|
||||||
'conversion_water',
|
'conversion_water',
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
} from './golem-combat-actions';
|
} from './golem-combat-actions';
|
||||||
import { processGolemAttacksFromStore } from './golem-combat-helpers';
|
import { processGolemAttacksFromStore } from './golem-combat-helpers';
|
||||||
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
|
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
|
||||||
|
import { processInvocationTick } from './combat-invocation';
|
||||||
|
import { processMeleeTick } from './combat-melee';
|
||||||
|
|
||||||
// ─── Result Type ───────────────────────────────────────────────────────────────
|
// ─── 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) ────────────────────────────
|
// ─── Melee sword attacks (spec §3.1, §4.3) ────────────────────────────
|
||||||
const updatedMeleeSwordProgress = { ...state.meleeSwordProgress };
|
const meleeResult = processMeleeTick(
|
||||||
const floorElement = getFloorElement(currentFloor);
|
{ get, set, rawMana, elements, attackSpeedMult, equippedSwords: equippedSwords || {}, floorHP, currentRoom },
|
||||||
const guardian = getGuardianForFloor(currentFloor);
|
applyEnemyDefenses,
|
||||||
const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement];
|
onFloorCleared,
|
||||||
|
);
|
||||||
if (equippedSwords && Object.keys(equippedSwords).length > 0 && floorHP > 0) {
|
rawMana = meleeResult.rawMana;
|
||||||
for (const [instanceId, swordInstance] of Object.entries(equippedSwords)) {
|
elements = meleeResult.elements;
|
||||||
const swordType = EQUIPMENT_TYPES[swordInstance.typeId];
|
floorHP = meleeResult.floorHP;
|
||||||
if (!swordType || !swordType.stats?.attackSpeed) continue;
|
floorMaxHP = meleeResult.floorMaxHP;
|
||||||
const swordAttackSpeed = swordType.stats.attackSpeed;
|
currentFloor = meleeResult.currentFloor;
|
||||||
const meleeProgressPerTick = HOURS_PER_TICK * swordAttackSpeed * attackSpeedMult;
|
currentRoom = meleeResult.currentRoom;
|
||||||
let meleeProgress = (updatedMeleeSwordProgress[instanceId] || 0) + meleeProgressPerTick;
|
const updatedMeleeSwordProgress = meleeResult.meleeSwordProgress;
|
||||||
let meleeSafetyCounter = 0;
|
logMessages.push(...meleeResult.logMessages);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Golem attacks (spec §11) ───────────────────────────────────────────
|
// ─── Golem attacks (spec §11) ───────────────────────────────────────────
|
||||||
if (activeGolems.length > 0 && floorHP > 0) {
|
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
|
// 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 { 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 */
|
/** Signature for the advanceRoomOrFloor callback to break circular dependency */
|
||||||
export type AdvanceRoomFn = (get: () => CombatStore, set: (s: Partial<CombatState>) => void) => void;
|
export type AdvanceRoomFn = (get: () => CombatStore, set: (s: Partial<CombatState>) => void) => void;
|
||||||
@@ -91,6 +92,10 @@ export interface CombatState {
|
|||||||
totalSpellsCast: number;
|
totalSpellsCast: number;
|
||||||
totalDamageDealt: number;
|
totalDamageDealt: number;
|
||||||
totalCraftsCompleted: number;
|
totalCraftsCompleted: number;
|
||||||
|
|
||||||
|
// Invocation system
|
||||||
|
invocationCharge: number;
|
||||||
|
activeInvocation: ActiveInvocation | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Combat Actions ───────────────────────────────────────────────────────────
|
// ─── Combat Actions ───────────────────────────────────────────────────────────
|
||||||
@@ -191,6 +196,9 @@ export interface CombatActions {
|
|||||||
currentRoom: FloorState;
|
currentRoom: FloorState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Invocation
|
||||||
|
resetInvocationState: () => void;
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
|
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
totalDamageDealt: 0,
|
totalDamageDealt: 0,
|
||||||
totalCraftsCompleted: 0,
|
totalCraftsCompleted: 0,
|
||||||
|
|
||||||
|
// Invocation system
|
||||||
|
invocationCharge: 0,
|
||||||
|
activeInvocation: null,
|
||||||
|
|
||||||
setCurrentFloor: (floor: number) => {
|
setCurrentFloor: (floor: number) => {
|
||||||
set({
|
set({
|
||||||
currentFloor: floor,
|
currentFloor: floor,
|
||||||
@@ -211,6 +215,8 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
roomsPerFloor: 1,
|
roomsPerFloor: 1,
|
||||||
maxFloorReached: Math.max(s.maxFloorReached, 1),
|
maxFloorReached: Math.max(s.maxFloorReached, 1),
|
||||||
golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[] },
|
golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[] },
|
||||||
|
invocationCharge: 0,
|
||||||
|
activeInvocation: null,
|
||||||
});
|
});
|
||||||
// Deactivate all disciplines on spire exit for safety
|
// Deactivate all disciplines on spire exit for safety
|
||||||
useDisciplineStore.getState().deactivateAll();
|
useDisciplineStore.getState().deactivateAll();
|
||||||
@@ -280,6 +286,10 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
set({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
|
set({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetInvocationState: () => {
|
||||||
|
set({ invocationCharge: 0, activeInvocation: null });
|
||||||
|
},
|
||||||
|
|
||||||
initGuardianDefensiveState: () => {
|
initGuardianDefensiveState: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const guardian = getGuardianForFloor(state.currentFloor);
|
const guardian = getGuardianForFloor(state.currentFloor);
|
||||||
@@ -366,6 +376,8 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
guardianBarrierMax: state.guardianBarrierMax,
|
guardianBarrierMax: state.guardianBarrierMax,
|
||||||
meleeSwordProgress: state.meleeSwordProgress,
|
meleeSwordProgress: state.meleeSwordProgress,
|
||||||
runId: state.runId,
|
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