Files
Mana-Loop/docs/specs/attunements/invoker/systems/invocation-system-spec.md
T
n8n-gitea 505481cefc
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
fix: Restrict pact affinity cast speed bonus to invocation spells only
Previously (commit 62638d6), the pact affinity cast speed bonus was applied
to ALL spell casts (active, equipment, invocation). Per design intent, it
should only apply to invocation system spells.

Changes:
- invocation-system-spec.md: Update §6.2, §10.2, AC-13 to clarify bonus
  applies to invocation spells only
- gameStore.ts: Stop computing pact affinity bonus into attackSpeedMult;
  pass base 1.0 instead
- combat-actions.ts: Remove stale AC-13 comment
- combat-invocation.ts: Compute pact affinity cast speed bonus locally
  for invocation spells only, using prestige upgrade level + discipline bonus
- invocation-utils.ts: Remove computeAttackSpeedMultFromPactAffinity (no
  longer needed)

All 1235 tests pass.
2026-06-13 19:47:06 +02:00

613 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Invocation System — Design Spec
> Describes the Invocation system: a new combat mechanic for the Invoker attunement
> that lets the player channel pacted guardians to auto-cast elemental spells at
> reduced cost. Also extends Pact Affinity with a new combat benefit (cast speed).
---
## 1. Objective
The Invoker attunement currently treats pacts as passive, permanent boons. The
Invocation system adds an **active combat layer**: the player builds up an
Invocation Charge meter, and when full, can channel a pacted guardian to
auto-cast elemental spells at a fraction of their normal cost.
**Design goals:**
- Make pacts feel *active* in combat, not just passive stat sticks
- Reward players who have signed more/higher-tier pacts (faster charge, stronger spells)
- Create meaningful decisions: when to invoke, which guardian gets channeled, mana management during invocation
- Give Pact Affinity a combat role (cast speed for invocation spells) so it matters outside of ritual time reduction
- Add a new Invoker discipline (`guardian-invocation`) for vertical progression of the system
---
## 2. Invocation Charge
### 2.1 The Meter
A new resource tracked in the combat store:
| Field | Type | Range | Description |
|---|---|---|---|
| `invocationCharge` | `number` | 0100 | The invocation meter. Fills passively. Drains while invoking. |
| `activeInvocation` | `object \| null` | — | See §3. |
There is **no separate cooldown field**. Cooldown is implicit: after invocation ends,
the player must wait for `invocationCharge` to fully recharge to 100 before invoking
again. See §2.3.
### 2.2 Charge Fill Rate
Charge fills passively every combat tick while in `climb` action and invocation
is **not** active:
```
chargePerTick = baseFillRate × pactCountMultiplier × disciplineMultiplier
```
| Parameter | Value | Source |
|---|---|---|
| `baseFillRate` | 0.25 per tick | Constant |
| `pactCountMultiplier` | `1 + signedPacts.length × 0.15` | More pacts = faster fill |
| `disciplineMultiplier` | `1 + invocationChargeRateBonus` | From `guardian-invocation` discipline (see §7) |
**Example:** 3 signed pacts, no discipline bonus:
- `pactCountMultiplier = 1 + 3 × 0.15 = 1.45`
- `chargePerTick = 0.25 × 1.45 = 0.3625`
- Time to 100: ~276 ticks (~55 seconds at 200ms/tick)
At 0 pacts, charge fills at 0.25/tick → 400 ticks to full (~80 seconds).
### 2.3 Cooldown = Full Recharge
There is no separate cooldown counter. When invocation ends (for any reason —
see §3.3), the charge meter is at 0 (or near 0). The player must wait for
`invocationCharge` to reach 100 again before invocation can reactivate.
This means the "cooldown" is directly tied to the fill rate:
- With 0 pacts: ~80 seconds to recharge
- With 3 pacts: ~55 seconds to recharge
- With 6 pacts: ~42 seconds to recharge
- With `guardian-invocation` discipline bonuses: even faster
Charge only fills while in `climb` action. If the player leaves combat, filling
pauses.
---
## 3. Active Invocation State
When invocation is active, the following state is tracked:
```typescript
activeInvocation: {
guardianFloor: number; // Which guardian is being channeled (their floor number)
spellId: string; // Currently auto-cast spell ID
element: string; // Element of the chosen spell
castProgress: number; // 0-1, cast progress accumulator for invocation spell
} | null
```
### 3.1 Activation
Invocation auto-activates when **all** of the following are true:
1. `invocationCharge >= 100`
2. `activeInvocation === null`
3. Player is in `climb` action (combat)
4. Current room has living enemies
On activation:
1. `invocationCharge` begins draining (see §4)
2. A guardian is selected (see §3.2)
3. A spell is selected from that guardian's elements (see §5)
4. Log: `"💜 Invoking {guardianName}'s power!"`
### 3.2 Guardian Selection
The channeled guardian is chosen by scoring all signed pacts:
```
score(floor) = bestElementalBonus(floor) × tierMultiplier(floor)
```
**`bestElementalBonus(floor)`:**
- Get the guardian's elements
- For each element, compute `getMultiElementBonus(element, currentEnemyElements)` using the existing combat-utils function
- Take the **maximum** bonus across all the guardian's elements
**`tierMultiplier(floor)`:**
- `1.0 + floor × 0.005` — higher-tier guardians are preferred as a tiebreaker
The guardian with the **highest score** is selected. If no signed pacts exist,
invocation cannot activate (but this is prevented by the charge fill being
extremely slow without pacts — see §2.2).
### 3.3 Ending Invocation
Invocation ends when **any** of the following occurs:
1. `invocationCharge` reaches 0 (depleted)
2. Current room is cleared (all enemies defeated)
3. Player leaves `climb` action
4. Player cannot afford **any** spell from any signed pact's elements (see §5.3)
On end:
1. Set `activeInvocation = null`
2. Log: `"💜 Invocation ends. {guardianName}'s power fades."`
**Important:** If charge reaches 0 mid-cast, the current spell cast completes
before invocation ends. The cast-in-progress is allowed to finish (charge check
happens at cast *start*, not during).
After invocation ends, the charge meter begins refilling from its current value
(typically 0). Invocation cannot reactivate until charge reaches 100 again (§2.3).
---
## 4. Charge Drain During Invocation
While invocation is active, charge drains per tick:
```
drainPerTick = BASE_DRAIN × spellCostMultiplier × drainRateMultiplier
```
| Parameter | Value | Source |
|---|---|---|
| `BASE_DRAIN` | 1.0 per tick | Constant |
| `spellCostMultiplier` | `spell.cost.amount / 10` | Scales with the current spell's cost |
| `drainRateMultiplier` | 1.0 base, reduced by `guardian-invocation` discipline | See §7 |
Higher-cost spells drain charge faster, creating a natural ramp-down: as the
player's mana depletes and they step down to cheaper spells, charge drains more
slowly, extending the tail end of invocation.
The `drainRateMultiplier` starts at 1.0 and is reduced by the `invocation-sustain`
perk (see §7.2). Minimum value: **0.7** (at max 3 tiers).
---
## 5. Spell Selection and Casting
### 5.1 Guardian Spellbook
A guardian "knows" all spells from all their elements. For example, a
BlackFlame guardian with `element: ['metal', 'fire', 'earth']` knows every
spell in `SPELLS_DEF` where `spell.elem` is `'metal'`, `'fire'`, or `'earth'`.
### 5.2 Spell Selection Algorithm
On activation and after each cast, the invocation spell is re-evaluated.
The player is channeling the **guardian's** power — the invocation spellbook is
all spells from the guardian's elements, regardless of what the player has
personally learned:
```
1. Gather all spells from the invoked guardian's elements (the full guardian spellbook)
2. Filter to spells the player can afford at the effective cost multiplier:
- For raw cost: rawMana >= cost.amount × effectiveCostMultiplier
- For element cost: elements[element].current >= cost.amount × effectiveCostMultiplier
3. From remaining spells, pick the one with the highest base damage (spell.dmg)
4. If tied, pick the highest tier
5. If still tied, pick the lowest cost (most efficient)
```
### 5.3 Auto-End When Unaffordable
The invoked guardian is fixed for the entire invocation — the player cannot swap
to a different guardian mid-invocation. If the player cannot afford **any** spell
from the **invoked guardian's** elements at the effective cost multiplier,
invocation ends immediately (§3.3, condition 4).
### 5.4 Invocation Spell Cast Slot
The invocation spell operates as a **third parallel cast track** alongside:
1. The player's active spell (primary cast progress)
2. Equipment spell states (array of concurrent spells)
In `processCombatTick`, the invocation spell is processed similarly to equipment
spells:
- It has its own `castProgress` accumulator
- It uses the same `HOURS_PER_TICK × spellCastSpeed × effectiveAttackSpeed` progress formula
- On cast **start**, it deducts mana at the effective cost multiplier (same pattern as the active spell)
- Damage is calculated using the existing `calcDamage()` with the invocation spell's element
The invocation spell does **not** interfere with the player's active spell. Both
cast simultaneously, draining from the same mana pools.
### 5.5 Cost Multiplier
The base cost multiplier is **0.1** (1/10th). This is reduced by the
`guardian-invocation` discipline (see §7):
```
effectiveCostMultiplier = 0.1 - costReductionFromDiscipline
```
Minimum effective cost multiplier: **0.05** (1/20th).
---
## 6. Pact Affinity — Combat Extension
Pact Affinity currently only reduces ritual time. It now also grants a **cast
speed bonus** in combat.
### 6.1 Cast Speed Formula
```
castSpeedBonus = MAX_BONUS × (1 - 1 / (1 + pactAffinity × SCALING))
```
| Parameter | Value | Description |
|---|---|---|
| `MAX_BONUS` | 0.5 (50%) | Hard cap on cast speed increase |
| `pactAffinity` | 0.0+ | Combined affinity (prestige upgrade + discipline bonus) |
| `SCALING` | 1.5 | Controls the curve shape (diminishing returns) |
This gives diminishing returns:
| Pact Affinity | Cast Speed Bonus |
|---|---|
| 0.0 | 0% |
| 0.1 | 5.7% |
| 0.3 | 14.6% |
| 0.5 | 21.4% |
| 0.7 | 26.9% |
| 0.9 | 31.6% |
| 1.5 | 37.5% |
| 3.0 | 42.9% |
| ∞ | → 50% (asymptote, never reached) |
### 6.2 Application
The cast speed bonus applies to **invocation spells only** — the spells auto-cast
by the Invocation system while channeling a guardian. It does **not** apply to the
player's active spell or equipment spells. It is applied as a multiplier to the
attack speed used in the invocation spell's cast progress calculation:
```
effectiveAttackSpeed = baseAttackSpeed × (1 + castSpeedBonus)
```
### 6.3 Affinity Sources (Unchanged)
Pact affinity is the sum of:
- `pactAffinityUpgrade`: prestige upgrade level × 0.1 (max 0.9)
- `pactAffinityBonus`: from Pact Attunement discipline (base 0.05 + perks)
Total is capped at 0.9 for the ritual time formula. For the cast speed formula,
the raw sum is used (can exceed 0.9 but with heavy diminishing returns per the
curve above).
---
## 7. New Discipline: Guardian Invocation
A third Invoker discipline that directly enhances the Invocation system.
### 7.1 Definition
| Field | Value |
|---|---|
| **ID** | `guardian-invocation` |
| **Name** | Guardian Invocation |
| **Attunement** | `invoker` |
| **Mana Type** | `raw` |
| **Base Cost** | 20 |
| **Requires** | `['signed_pact']` |
| **Stat Bonus** | `invocationChargeRateBonus` +0.10 (base) |
| **Scaling Factor** | 120 |
| **Difficulty Factor** | 250 |
| **Drain Base** | 8 |
**Main stat: `invocationChargeRateBonus`**
This is the primary stat the discipline scales. It directly feeds into the charge
fill rate formula (§2.2):
```
disciplineMultiplier = 1 + invocationChargeRateBonus
```
The stat scales with XP via the standard discipline math:
```
StatBonus = baseValue × (XP / scalingFactor)^0.65
= 0.10 × (XP / 120)^0.65
```
At 0 XP: +0.10. At 100 XP: ~0.09. At 500 XP: ~0.22. At 1000 XP: ~0.34.
### 7.2 Perks
| Perk ID | Type | Threshold | Bonus | Description |
|---|---|---|---|---|
| `invocation-efficiency` | `once` | 100 | Cost multiplier 0.02 (0.1 → 0.08) | Early power spike — invocation spells cost less mana |
| `invocation-speed` | `infinite` | 200 | Every 150 XP: `invocationChargeRateBonus` +0.05 | Core scaling — faster charge cycling with more XP |
| `invocation-sustain` | `capped` | 400 | Every 200 XP: `drainRateMultiplier` 0.1, max 3 tiers | Charge depletes slower while invoking |
| `invocation-mastery` | `capped` | 500 | Every 250 XP: cost multiplier 0.01, max 3 tiers | Late-game — pushes cost multiplier down further |
**Perk details:**
- **`invocation-efficiency`** (once @ 100 XP): Immediately reduces the effective
cost multiplier from 0.1 to 0.08. This means all invocation spells cost 20%
less mana from the start. A noticeable early power spike.
- **`invocation-speed`** (infinite @ 200 XP, every 150 XP): Adds +0.05 directly
to `invocationChargeRateBonus`. This is the primary scaling perk — the more
XP earned, the faster the charge meter fills. At 500 XP (150 XP past threshold,
1 interval): +0.05. At 800 XP (4 intervals): +0.20. At 1400 XP (8 intervals):
+0.40. This perk has no cap, so it always scales.
- **`invocation-sustain`** (capped @ 400 XP, 3 tiers, every 200 XP): Reduces
`drainRateMultiplier` by 0.1 per tier. At max (3 tiers, 800 XP past threshold):
drain multiplier = 1.0 0.3 = 0.7. This means charge drains 30% slower,
making each invocation last ~43% longer.
- **`invocation-mastery`** (capped @ 500 XP, 3 tiers, every 250 XP): Further
reduces the cost multiplier by 0.01 per tier. At max (3 tiers, 1250 XP past
threshold): cost multiplier = 0.08 0.03 = 0.05. Combined with
`invocation-efficiency`, the total reduction is 0.1 → 0.05 (1/20th cost).
### 7.3 Maximum Theoretical Bonuses
| Source | Value |
|---|---|
| Base charge rate bonus | +0.10 |
| `invocation-speed` infinite perk | +0.05 per 150 XP (unlimited) |
| `invocation-efficiency` once perk | cost mult 0.1 → 0.08 |
| `invocation-mastery` capped perk (3 tiers) | cost mult 0.08 → 0.05 |
| `invocation-sustain` capped perk (3 tiers) | drain mult 1.0 → 0.7 |
| **Minimum cost multiplier** | **0.05** (1/20th) |
| **Minimum drain multiplier** | **0.7** (30% slower drain) |
### 7.4 Discipline Identity
The `guardian-invocation` discipline's identity is: *"I fill my invocation meter
faster, my invocation spells cost less mana, and my charge lasts longer."* All
four perks serve the same fantasy — making the invocation system more efficient
and more available.
The discipline is significantly more expensive to run than the other two Invoker
disciplines (`drainBase: 8` vs 4 and 6), reflecting that it's a combat-active
discipline the player will want to keep running during spire climbs. The high
drain creates interesting choices about when to activate it relative to other
disciplines competing for the raw mana budget.
---
## 8. Store Changes
### 8.1 Combat Store (`combatStore.ts`)
New state fields:
```typescript
// Invocation state
invocationCharge: number; // 0-100, default 0
activeInvocation: {
guardianFloor: number;
spellId: string;
element: string;
castProgress: number; // 0-1, cast progress for invocation spell
} | null;
```
New actions:
```typescript
// Reset invocation state (called on spire exit)
resetInvocationState: () => void;
```
### 8.2 Combat State Types (`combat-state.types.ts`)
Add to `CombatState`:
```typescript
invocationCharge: number;
activeInvocation: { guardianFloor: number; spellId: string; element: string; castProgress: number } | null;
```
Add to `CombatActions`:
```typescript
resetInvocationState: () => void;
```
### 8.3 No Changes To
- `prestigeStore.ts` — pact state is unchanged
- `manaStore.ts` — mana pools are unchanged
- `gameStore.ts` — tick pipeline passes invocation state through combat store
---
## 9. New Utility: `invocation-utils.ts`
A new utility file at `src/lib/game/utils/invocation-utils.ts`:
```typescript
// Select the best guardian to channel based on current enemy
export function selectInvocationGuardian(
signedPacts: number[],
enemyElements: string[],
): number | null;
// Get all spells a guardian knows (union of their elements' spells)
export function getGuardianSpellbook(
guardian: GuardianDef,
): SpellDef[];
// Select the best affordable spell from a spellbook
export function selectInvocationSpell(
spellbook: SpellDef[],
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
costMultiplier: number,
): { spellId: string; element: string } | null;
// Compute charge fill rate per tick
export function computeChargeFillRate(
signedPactsLength: number,
chargeRateBonus: number,
): number;
// Compute cast speed bonus from pact affinity
export function computeCastSpeedBonus(pactAffinity: number): number;
// Compute effective cost multiplier from discipline bonuses
export function computeCostMultiplier(disciplineEffects: DisciplineBonuses): number;
// Compute drain rate multiplier from discipline bonuses
export function computeDrainRateMultiplier(disciplineEffects: DisciplineBonuses): number;
```
---
## 10. Combat Tick Integration
### 10.1 Modified Flow in `combat-actions.ts`
In `processCombatTick`, after the active spell block and before the equipment
spell block, add an **invocation block**:
```
1. If activeInvocation !== null:
a. Drain charge: invocationCharge -= drainPerTick
b. If charge <= 0: end invocation (but let current cast finish)
c. Accumulate cast progress for invocation spell
d. On cast completion:
- Calculate damage using calcDamage() with invocation spell element
- Apply damage via applyDamageToRoom()
- Re-evaluate spell selection (§5.2)
- If no affordable spell from the invoked guardian: end invocation
e. If room cleared: end invocation
2. If activeInvocation === null AND invocationCharge >= 100
AND room has enemies:
a. Select guardian (§3.2)
b. Select spell (§5.2)
c. Set activeInvocation
d. Log activation
3. If NOT invoking AND charge < 100:
a. invocationCharge += chargePerTick
b. Clamp to 100
```
### 10.2 Pact Affinity Cast Speed
In the invocation spell cast progress calculation, apply the cast speed bonus:
```
const castSpeedBonus = computeCastSpeedBonus(pactAffinity);
const effectiveAttackSpeed = baseAttackSpeed × (1 + castSpeedBonus);
const invProgressPerTick = HOURS_PER_TICK × invCastSpeed × effectiveAttackSpeed;
```
This applies to **invocation spells only** (not to the player's active spell or
equipment spells).
---
## 11. UI Changes
### 11.1 SpireCombatPage
Add an **Invocation Panel** between the SpireHeader and RoomDisplay sections.
The panel shows:
- **Charge meter:** Progress bar 0100 with purple color gradient
- **Recharge status:** "Recharging..." with current charge value when charge < 100 and not invoking
- **Active invocation display:** Guardian name + spell name + element icon when invoking
- **Channeled guardian icon:** Small element badges for the guardian's elements
### 11.2 SpireCombatControls
Add a compact invocation status indicator showing:
- Charge percentage
- Whether invocation is active (glowing border when active)
- Recharge progress when not invoking
### 11.3 No New Tabs
The invocation system does not require a new tab. All information is visible
in the existing SpireCombatPage layout.
---
## 12. Data Flow Summary
```
gameStore.tick()
→ buildTickContext() [snapshots all stores]
→ processCombatTick() [combat-actions.ts]
→ If invoking:
- Drain invocationCharge
- Accumulate invocation castProgress
- On cast: deductSpellCost() at reduced multiplier
- calcDamage() with invocation spell element
- applyDamageToRoom()
- Re-evaluate spell selection
→ If not invoking:
- Fill invocationCharge
- Auto-activate when charge >= 100
→ applyTickWrites() [writes combat store changes back]
```
---
## 13. Acceptance Criteria
| # | Criterion |
|---|---|
| AC-1 | `invocationCharge` fills passively at `baseFillRate × pactCountMultiplier × disciplineMultiplier` per tick while in `climb` action and not invoking. |
| AC-2 | Invocation auto-activates when charge ≥ 100, `activeInvocation === null`, and room has enemies. |
| AC-3 | The channeled guardian is selected by `bestElementalBonus × tierMultiplier` scoring. |
| AC-4 | The invocation spell is the highest-damage spell from the guardian's elements that the player can afford at the effective cost multiplier (not limited to spells the player has learned). |
| AC-5 | Invocation spell steps down to the next affordable spell when the current one becomes unaffordable. |
| AC-6 | If no spells are affordable from the invoked guardian's elements, invocation ends. |
| AC-7 | Invocation ends when charge reaches 0, room is cleared, or player leaves `climb`. |
| AC-8 | A cast already in progress completes even if charge hits 0 during the cast. |
| AC-9 | After invocation ends, charge must fully recharge to 100 before invocation can reactivate. |
| AC-10 | Charge only fills while in `climb` action. |
| AC-11 | Invocation spell casts in parallel with the player's active spell and equipment spells. |
| AC-12 | Pact Affinity grants a cast speed bonus using `MAX_BONUS × (1 - 1 / (1 + pactAffinity × 1.5))`, capped at 50%. |
| AC-13 | Cast speed bonus applies to invocation spells only (not active/equipment spells). |
| AC-14 | The `guardian-invocation` discipline requires at least one signed pact. |
| AC-15 | `invocation-efficiency` once perk reduces cost multiplier by 0.02. |
| AC-16 | `invocation-speed` infinite perk grants +0.05 charge rate bonus every 150 XP. |
| AC-17 | `invocation-sustain` capped perk reduces drain rate multiplier by 0.1 per tier, max 3 tiers. |
| AC-18 | `invocation-mastery` capped perk reduces cost multiplier by 0.01 per tier, max 3 tiers. |
| AC-19 | Minimum effective cost multiplier is 0.05 (1/20th). |
| AC-20 | Minimum drain rate multiplier is 0.7 (30% slower drain). |
| AC-21 | Invocation state resets on spire exit (`invocationCharge = 0`, `activeInvocation = null`). |
| AC-22 | Existing saves without invocation fields get default values (0, null). |
---
## 14. Files Reference
| File | Role |
|---|---|
| `src/lib/game/stores/combatStore.ts` | New invocation state fields + `resetInvocationState` action |
| `src/lib/game/stores/combat-state.types.ts` | Type definitions for new state |
| `src/lib/game/stores/combat-actions.ts` | Invocation processing block in `processCombatTick` |
| `src/lib/game/utils/invocation-utils.ts` | **NEW** — Guardian/spell selection, charge rate, cost multiplier, drain multiplier |
| `src/lib/game/data/disciplines/invoker.ts` | Add `guardian-invocation` discipline definition |
| `src/lib/game/effects/discipline-effects.ts` | Add new stat bonus keys (`invocationChargeRateBonus`, `drainRateMultiplier`) |
| `src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx` | Invocation panel UI |
| `src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx` | Compact invocation status indicator |
| `docs/specs/attunements/invoker/systems/invocation-system-spec.md` | **THIS FILE** |
---
## 15. Out of Scope
- Manual invocation toggle (auto-activate only in v1)
- Guardian-specific signature spells (guardians know all spells from their elements)
- Invocation affecting non-combat rooms
- Pact affinity affecting anything beyond ritual time and cast speed
- Visual effects / animations (UI-only indicator in v1)
- Invocation interacting with golem combat