fix: apply mana drain to conversion disciplines (bug #379)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m24s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m24s
- Remove isConversionDiscipline guard in discipline-slice.ts:processTick that was skipping mana drain for all 24 conversion disciplines - Update test to verify conversion disciplines DO drain mana - Add regression tests for auto-pause and XP accrual on conversion disciplines - Fix misleading comments in elemental-regen.ts and elemental-regen-advanced.ts All 1196 tests pass.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# Circular Dependencies
|
# Circular Dependencies
|
||||||
Generated: 2026-06-12T10:39:16.751Z
|
Generated: 2026-06-12T12:18:22.789Z
|
||||||
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-12T10:39:14.588Z",
|
"generated": "2026-06-12T12:18:20.580Z",
|
||||||
"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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Mana-Loop/
|
|||||||
│ │ │ │ └── fabricator-spec.md
|
│ │ │ │ └── fabricator-spec.md
|
||||||
│ │ │ ├── invoker/
|
│ │ │ ├── invoker/
|
||||||
│ │ │ │ ├── systems/
|
│ │ │ │ ├── systems/
|
||||||
|
│ │ │ │ │ ├── invocation-system-spec.md
|
||||||
│ │ │ │ │ └── pact-system-spec.md
|
│ │ │ │ │ └── pact-system-spec.md
|
||||||
│ │ │ │ └── invoker-spec.md
|
│ │ │ │ └── invoker-spec.md
|
||||||
│ │ │ └── attunement-system-spec.md
|
│ │ │ └── attunement-system-spec.md
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
# 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) 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` | 0–100 | 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:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Gather all spells from the guardian's elements
|
||||||
|
2. Filter to spells the player has learned (spells[spellId].learned === true)
|
||||||
|
3. 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
|
||||||
|
4. From remaining spells, pick the one with the highest base damage (spell.dmg)
|
||||||
|
5. If tied, pick the highest tier
|
||||||
|
6. If still tied, pick the lowest cost (most efficient)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Fallback and Auto-End
|
||||||
|
|
||||||
|
If no spells are affordable at the effective cost multiplier from the current
|
||||||
|
guardian:
|
||||||
|
1. Try the next guardian in the scoring order (§3.2)
|
||||||
|
2. If no guardian has affordable spells, invocation ends (§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 completion, it deducts mana at the effective cost multiplier
|
||||||
|
- 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 **all spell casts** (active spell, equipment
|
||||||
|
spells, and invocation spells) while in combat (`climb` action). It is applied
|
||||||
|
as a multiplier to the `totalAttackSpeed` value used in cast progress
|
||||||
|
calculation:
|
||||||
|
|
||||||
|
```
|
||||||
|
effectiveAttackSpeed = totalAttackSpeed × (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[],
|
||||||
|
playerSpells: Record<string, SpellState>,
|
||||||
|
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:
|
||||||
|
- Deduct mana at effectiveCostMultiplier
|
||||||
|
- Calculate damage using calcDamage() with invocation spell element
|
||||||
|
- Apply damage via applyDamageToRoom()
|
||||||
|
- Re-evaluate spell selection (§5.2)
|
||||||
|
- If no affordable spell from any 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 cast progress calculation, apply the cast speed bonus:
|
||||||
|
|
||||||
|
```
|
||||||
|
const castSpeedBonus = computeCastSpeedBonus(pactAffinity);
|
||||||
|
const effectiveAttackSpeed = totalAttackSpeed × (1 + castSpeedBonus);
|
||||||
|
const progressPerTick = HOURS_PER_TICK × spellCastSpeed × effectiveAttackSpeed;
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to **all** cast progress calculations (active, equipment, invocation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. UI Changes
|
||||||
|
|
||||||
|
### 11.1 SpireCombatPage
|
||||||
|
|
||||||
|
Add an **Invocation Panel** between the SpireHeader and RoomDisplay sections.
|
||||||
|
The panel shows:
|
||||||
|
|
||||||
|
- **Charge meter:** Progress bar 0–100 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 learned spell the player can afford at the effective cost multiplier. |
|
||||||
|
| AC-5 | Invocation spell steps down to the next affordable spell when the current one becomes unaffordable. |
|
||||||
|
| AC-6 | If no guardian has affordable spells, 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 all spell casts (active, equipment, invocation). |
|
||||||
|
| 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
|
||||||
@@ -131,7 +131,7 @@ describe('DisciplineStore', () => {
|
|||||||
expect(result.elements.fire.current).toBe(98);
|
expect(result.elements.fire.current).toBe(98);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not drain mana for conversion disciplines (sourceManaTypes)', () => {
|
it('should drain mana for conversion disciplines (regression: was zero cost)', () => {
|
||||||
useDisciplineStore.setState({
|
useDisciplineStore.setState({
|
||||||
disciplines: {},
|
disciplines: {},
|
||||||
activeIds: [],
|
activeIds: [],
|
||||||
@@ -139,13 +139,45 @@ describe('DisciplineStore', () => {
|
|||||||
totalXP: 0,
|
totalXP: 0,
|
||||||
});
|
});
|
||||||
// regen-fire is a conversion discipline with sourceManaTypes: ['raw']
|
// regen-fire is a conversion discipline with sourceManaTypes: ['raw']
|
||||||
|
// It has manaType='fire' and drainBase=1.5 — it MUST drain fire mana
|
||||||
useDisciplineStore.getState().activate('regen-fire', {
|
useDisciplineStore.getState().activate('regen-fire', {
|
||||||
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
|
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
|
||||||
});
|
});
|
||||||
const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } } });
|
const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } } });
|
||||||
// Conversion disciplines should NOT drain from pools
|
// Conversion disciplines MUST drain from their mana pool (bug fix #379)
|
||||||
expect(result.rawMana).toBe(1000);
|
// regen-fire: drainBase=1.5, xp=0, difficultyFactor=120
|
||||||
expect(result.elements.fire.current).toBe(100);
|
// drain = 1.5 * (1 + (0/120)^0.4) = 1.5
|
||||||
|
expect(result.elements.fire.current).toBeCloseTo(98.5, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-pause conversion discipline when element mana is insufficient', () => {
|
||||||
|
useDisciplineStore.setState({
|
||||||
|
disciplines: {},
|
||||||
|
activeIds: [],
|
||||||
|
concurrentLimit: 1,
|
||||||
|
totalXP: 0,
|
||||||
|
});
|
||||||
|
// regen-fire has drainBase=1.5, needs at least 1.5 fire mana
|
||||||
|
useDisciplineStore.getState().activate('regen-fire', {
|
||||||
|
elements: { fire: { unlocked: true, current: 1, max: 100, baseMax: 100 } },
|
||||||
|
});
|
||||||
|
const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: { fire: { unlocked: true, current: 1, max: 100, baseMax: 100 } } });
|
||||||
|
expect(useDisciplineStore.getState().disciplines['regen-fire'].autoPaused).toBe(true);
|
||||||
|
expect(result.autoPausedNames).toContain('Fire Mana Conversion Speed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still accrue XP for conversion disciplines when mana is sufficient', () => {
|
||||||
|
useDisciplineStore.setState({
|
||||||
|
disciplines: {},
|
||||||
|
activeIds: [],
|
||||||
|
concurrentLimit: 1,
|
||||||
|
totalXP: 0,
|
||||||
|
});
|
||||||
|
useDisciplineStore.getState().activate('regen-fire', {
|
||||||
|
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
|
||||||
|
});
|
||||||
|
useDisciplineStore.getState().processTick({ rawMana: 1000, elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } } });
|
||||||
|
expect(useDisciplineStore.getState().disciplines['regen-fire'].xp).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should re-activate auto-paused discipline when mana is restored', () => {
|
it('should re-activate auto-paused discipline when mana is restored', () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// NEW MODEL: Disciplines contribute to conversion_{element} stat bonus.
|
// NEW MODEL: Disciplines contribute to conversion_{element} stat bonus.
|
||||||
// The unified conversion-rates.ts calculator handles rate computation.
|
// The unified conversion-rates.ts calculator handles rate computation.
|
||||||
// No direct mana drain — costs are deducted from regen.
|
// Mana drain is applied from the target mana type pool each tick.
|
||||||
|
|
||||||
import { DisciplinesAttunementType } from '../../types/disciplines';
|
import { DisciplinesAttunementType } from '../../types/disciplines';
|
||||||
import type { DisciplineDefinition } from '../../types/disciplines';
|
import type { DisciplineDefinition } from '../../types/disciplines';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// NEW MODEL: Disciplines contribute to conversion_{element} stat bonus.
|
// NEW MODEL: Disciplines contribute to conversion_{element} stat bonus.
|
||||||
// The unified conversion-rates.ts calculator handles rate computation.
|
// The unified conversion-rates.ts calculator handles rate computation.
|
||||||
// No direct mana drain — costs are deducted from regen.
|
// Mana drain is applied from the target mana type pool each tick.
|
||||||
|
|
||||||
import { DisciplinesAttunementType } from '../../types/disciplines';
|
import { DisciplinesAttunementType } from '../../types/disciplines';
|
||||||
import type { DisciplineDefinition } from '../../types/disciplines';
|
import type { DisciplineDefinition } from '../../types/disciplines';
|
||||||
|
|||||||
@@ -212,13 +212,11 @@ export const useDisciplineStore = create<DisciplineStore>()(
|
|||||||
const def = DISCIPLINE_MAP[id];
|
const def = DISCIPLINE_MAP[id];
|
||||||
if (!def) continue;
|
if (!def) continue;
|
||||||
|
|
||||||
// ── Mana drain (skip for conversion disciplines) ──────────────
|
// ── Mana drain ──────────────────────────────────────────────────
|
||||||
// Conversion disciplines (with sourceManaTypes) are handled by
|
// All disciplines (including conversion) drain from their mana pool.
|
||||||
// the unified conversion system — they contribute to conversion
|
// Conversion disciplines both drain their target mana type AND
|
||||||
// rates, not direct pool drain.
|
// contribute to conversion rates.
|
||||||
const isConversionDiscipline = !!(def.sourceManaTypes && def.sourceManaTypes.length > 0);
|
{
|
||||||
|
|
||||||
if (!isConversionDiscipline) {
|
|
||||||
const drain = calculateManaDrain(def.drainBase, disc.xp, def.difficultyFactor);
|
const drain = calculateManaDrain(def.drainBase, disc.xp, def.difficultyFactor);
|
||||||
|
|
||||||
// Determine which mana pool to drain from
|
// Determine which mana pool to drain from
|
||||||
|
|||||||
Reference in New Issue
Block a user