From 4863dbc324f343e3abde3df14803ad7094fb8261 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Fri, 12 Jun 2026 18:56:51 +0200 Subject: [PATCH] fix: apply mana drain to conversion disciplines (bug #379) - 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. --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 1 + .../invoker/systems/invocation-system-spec.md | 611 ++++++++++++++++++ .../store-actions-discipline.test.ts | 40 +- .../disciplines/elemental-regen-advanced.ts | 2 +- .../game/data/disciplines/elemental-regen.ts | 2 +- src/lib/game/stores/discipline-slice.ts | 12 +- 8 files changed, 657 insertions(+), 15 deletions(-) create mode 100644 docs/specs/attunements/invoker/systems/invocation-system-spec.md diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 1b8321a..d9b7e3b 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # 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. 1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 9e9805a..c03d7e6 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_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.", "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." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 96bb02f..6820ea8 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -24,6 +24,7 @@ Mana-Loop/ │ │ │ │ └── fabricator-spec.md │ │ │ ├── invoker/ │ │ │ │ ├── systems/ +│ │ │ │ │ ├── invocation-system-spec.md │ │ │ │ │ └── pact-system-spec.md │ │ │ │ └── invoker-spec.md │ │ │ └── attunement-system-spec.md diff --git a/docs/specs/attunements/invoker/systems/invocation-system-spec.md b/docs/specs/attunements/invoker/systems/invocation-system-spec.md new file mode 100644 index 0000000..a34d4a7 --- /dev/null +++ b/docs/specs/attunements/invoker/systems/invocation-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, + rawMana: number, + elements: Record, + 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 diff --git a/src/lib/game/__tests__/store-actions-discipline.test.ts b/src/lib/game/__tests__/store-actions-discipline.test.ts index b0fa236..4fef92f 100644 --- a/src/lib/game/__tests__/store-actions-discipline.test.ts +++ b/src/lib/game/__tests__/store-actions-discipline.test.ts @@ -131,7 +131,7 @@ describe('DisciplineStore', () => { 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({ disciplines: {}, activeIds: [], @@ -139,13 +139,45 @@ describe('DisciplineStore', () => { totalXP: 0, }); // 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', { 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 - expect(result.rawMana).toBe(1000); - expect(result.elements.fire.current).toBe(100); + // Conversion disciplines MUST drain from their mana pool (bug fix #379) + // regen-fire: drainBase=1.5, xp=0, difficultyFactor=120 + // 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', () => { diff --git a/src/lib/game/data/disciplines/elemental-regen-advanced.ts b/src/lib/game/data/disciplines/elemental-regen-advanced.ts index eab28b3..de9033d 100644 --- a/src/lib/game/data/disciplines/elemental-regen-advanced.ts +++ b/src/lib/game/data/disciplines/elemental-regen-advanced.ts @@ -4,7 +4,7 @@ // // NEW MODEL: Disciplines contribute to conversion_{element} stat bonus. // 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 type { DisciplineDefinition } from '../../types/disciplines'; diff --git a/src/lib/game/data/disciplines/elemental-regen.ts b/src/lib/game/data/disciplines/elemental-regen.ts index 4f45960..5d78f79 100644 --- a/src/lib/game/data/disciplines/elemental-regen.ts +++ b/src/lib/game/data/disciplines/elemental-regen.ts @@ -4,7 +4,7 @@ // // NEW MODEL: Disciplines contribute to conversion_{element} stat bonus. // 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 type { DisciplineDefinition } from '../../types/disciplines'; diff --git a/src/lib/game/stores/discipline-slice.ts b/src/lib/game/stores/discipline-slice.ts index 5845667..84d388e 100644 --- a/src/lib/game/stores/discipline-slice.ts +++ b/src/lib/game/stores/discipline-slice.ts @@ -212,13 +212,11 @@ export const useDisciplineStore = create()( const def = DISCIPLINE_MAP[id]; if (!def) continue; - // ── Mana drain (skip for conversion disciplines) ────────────── - // Conversion disciplines (with sourceManaTypes) are handled by - // the unified conversion system — they contribute to conversion - // rates, not direct pool drain. - const isConversionDiscipline = !!(def.sourceManaTypes && def.sourceManaTypes.length > 0); - - if (!isConversionDiscipline) { + // ── Mana drain ────────────────────────────────────────────────── + // All disciplines (including conversion) drain from their mana pool. + // Conversion disciplines both drain their target mana type AND + // contribute to conversion rates. + { const drain = calculateManaDrain(def.drainBase, disc.xp, def.difficultyFactor); // Determine which mana pool to drain from