From 718aed38b1fbc20bbccaa21cd1ff9b0b7b867052 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Sun, 14 Jun 2026 21:56:20 +0200 Subject: [PATCH] feat: implement Transference Channel system for Enchanter attunement - Add isChanneling, channelSpeedMultiplier, channelDrainRate to CombatState - Add startChanneling/stopChanneling actions to combat store - Add transference-channeling discipline with 3 perks (channel-efficiency, channel-power, channel-mastery) - Add channelIntensity and channelEfficiency to KNOWN_BONUS_STATS - Create combat-channel.ts with drain + speed multiplier computation - Apply channel speed multiplier to equipment spells and melee attacks - Add Channel Transference hold-button UI to SpireCombatPage - Add compact channel status indicator to SpireCombatControls - Channel state resets on spire exit, persists across room transitions - All 1235 existing tests pass --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 3 +- docs/project-structure.txt | 6 +- .../systems/room-enchantments-spec.md | 572 ++++++++++++++++++ .../systems/transference-channel-spec.md | 442 ++++++++++++++ .../SpireCombatPage/SpireCombatControls.tsx | 16 +- .../tabs/SpireCombatPage/SpireCombatPage.tsx | 76 ++- src/lib/game/data/disciplines/enchanter.ts | 39 ++ src/lib/game/effects/discipline-effects.ts | 2 + src/lib/game/stores/combat-actions.ts | 14 +- src/lib/game/stores/combat-channel-actions.ts | 20 + src/lib/game/stores/combat-channel.ts | 73 +++ src/lib/game/stores/combat-melee.ts | 4 +- src/lib/game/stores/combat-reset.ts | 5 + src/lib/game/stores/combat-state.types.ts | 9 + src/lib/game/stores/combatStore.ts | 12 + 16 files changed, 1286 insertions(+), 9 deletions(-) create mode 100644 docs/specs/attunements/enchanter/systems/room-enchantments-spec.md create mode 100644 docs/specs/attunements/enchanter/systems/transference-channel-spec.md create mode 100644 src/lib/game/stores/combat-channel-actions.ts create mode 100644 src/lib/game/stores/combat-channel.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 4d91a20..3692a31 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-06-13T15:26:15.912Z +Generated: 2026-06-13T17:47:24.953Z 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 f96f0a0..c012577 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-13T15:26:13.488Z", + "generated": "2026-06-13T17:47:22.745Z", "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." }, @@ -760,7 +760,6 @@ "utils/element-cap-bonus.ts", "utils/element-distance.ts", "utils/index.ts", - "utils/invocation-utils.ts", "utils/safe-persist.ts" ], "stores/gameStore.types.ts": [], diff --git a/docs/project-structure.txt b/docs/project-structure.txt index dd8bb0b..a86bddb 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -15,7 +15,9 @@ Mana-Loop/ │ │ ├── attunements/ │ │ │ ├── enchanter/ │ │ │ │ ├── systems/ -│ │ │ │ │ └── enchanting-spec.md +│ │ │ │ │ ├── enchanting-spec.md +│ │ │ │ │ ├── room-enchantments-spec.md +│ │ │ │ │ └── transference-channel-spec.md │ │ │ │ └── enchanter-spec.md │ │ │ ├── fabricator/ │ │ │ │ ├── systems/ @@ -385,6 +387,8 @@ Mana-Loop/ │ │ │ │ │ └── pact-ritual.ts │ │ │ │ ├── attunementStore.ts │ │ │ │ ├── combat-actions.ts +│ │ │ │ ├── combat-channel-actions.ts +│ │ │ │ ├── combat-channel.ts │ │ │ │ ├── combat-damage.ts │ │ │ │ ├── combat-descent-actions.ts │ │ │ │ ├── combat-invocation.ts diff --git a/docs/specs/attunements/enchanter/systems/room-enchantments-spec.md b/docs/specs/attunements/enchanter/systems/room-enchantments-spec.md new file mode 100644 index 0000000..390c069 --- /dev/null +++ b/docs/specs/attunements/enchanter/systems/room-enchantments-spec.md @@ -0,0 +1,572 @@ +# Room Enchantments System — Design Spec + +> Describes the Room Enchantments system: a semi-combat, semi-preparation system for the +> Enchanter attunement. Footwear enchantments passively stamp the room with elemental auras +> during combat. The longer the fight, the more the room becomes the Enchanter's weapon. + +--- + +## 1. Objective + +The Enchanter currently has no active combat system. The enchanting pipeline (Design → +Prepare → Apply) is entirely offline. The Room Enchantments system adds an **always-on +combat presence** that scales with time: as the Enchanter fights in a room, their enchanted +footwear gradually covers the room with elemental aura effects that damage enemies, apply +debuffs, or buff the player. + +**Design goals:** +- Give the Enchanter an active combat identity distinct from Invocation (parallel cast + track) and Golemancy (summoned units): the **environmental controller** +- Create a "semi-combat, semi-preparation" loop: choose footwear enchantments offline, + then they work passively during combat with no active input required +- Give transference mana a combat application through the `boots_sigil_transference` + enchantment and the coverage-rate discipline +- Make longer fights (especially guardians) more rewarding the longer they go +- Keep the system simple: one coverage meter, linear scaling, no thresholds or activation + decisions during combat + +--- + +## 2. Identity + +| Property | Value | +|---|---| +| **System name** | Room Enchantments | +| **Attunement** | Enchanter | +| **Equipment slot** | Feet | +| **Core resource** | Coverage meter (0–100) per room | +| **Primary mana** | Transference (discipline fuel + transference sigil regen) | +| **Combat role** | Environmental aura control — scales with time-in-combat | +| **Preparation** | Enchant boots via the existing Design → Prepare → Apply pipeline | +| **Active element** | None — entirely passive during combat | + +### Attunement Comparison + +| Attunement | Combat Identity | Scaling | Player Input | +|---|---|---|---| +| Invoker | Parallel auto-cast (guardian spells) | Charge meter fill/spend | Auto-activate | +| Fabricator | Summoned golems (independent actors) | Golem maintenance | Design golems offline | +| **Enchanter** | **Environmental aura control** | **Time-in-combat coverage** | **Enchant boots offline** | + +--- + +## 3. Core Mechanic: The Coverage Meter + +### 3.1 The Meter + +Each room has a single **coverage meter** from 0 to 100, stored on `FloorState`: + +```typescript +// Addition to FloorState in types/game.ts +roomEnchantment: { + coverage: number; // 0-100, current coverage percentage +} | null; // null when no footwear room enchantments are equipped +``` + +- `null` when the player has no footwear with room enchantment sigils equipped. +- Initialized to `{ coverage: 0 }` on room entry if footwear has room enchantments. +- Resets to 0 on every room transition (fresh room, fresh canvas). + +### 3.2 Coverage Growth + +Coverage grows by a flat amount per combat tick: + +``` +coveragePerTick = baseRate + disciplineBonus + +where: + baseRate = 0.2 + disciplineBonus = roomCoverageRateBonus (from the `room-coverage-rate` capped perk) +``` + +At base rate (no discipline bonus): +- 0.2 per tick → 500 ticks to reach 100 +- At 200ms/tick → **100 seconds real time** to full coverage + +Coverage growth requires ALL of the following: +1. Player is in `climb` action +2. Current room has living enemies (`floorHP > 0`) +3. Player has at least one footwear room enchantment equipped + +Coverage does **NOT** scale with cast speed, attack speed, transference spending, or any +other stat. It is purely time-based. This is intentional — the system is designed to be +zero-input during combat. + +### 3.3 Coverage Cap + +Coverage is hard-capped at 100. Once full, room enchantments operate at full strength and +stop accumulating. + +--- + +## 4. Enchantment Definitions + +### 4.1 Effect Category + +Room enchantments use `category: 'special'` with unique `specialId` values, following the +exact same pattern as existing equipment effects (e.g., `sword_fire` → `specialId: +'fireBlade'`). They are defined alongside existing enchantment definitions. + +Allowed equipment category: `['feet']` only. + +### 4.2 Effect Structure + +```typescript +// Room enchantment effects use the same EnchantmentEffectDef type +// category: 'special' +// effect.type: 'special' +// effect.specialId: a unique string handled by the room enchantment tick processor +``` + +The `specialId` is a new convention: `room__` (e.g., +`room_fire_damage`, `room_frost_debuff`, `room_transference_buff`). + +> **Note:** Room enchantment `specialId` values use the game's existing mana type names +> (e.g., `death` not `poison`, `transference` not `arcane`) for consistency with the +> 22 defined mana types. + +### 4.3 Scaling Formula + +Each room enchantment's effect scales linearly with coverage: + +``` +effectMagnitude = baseMagnitude × (coverage / 100) +``` + +Examples: +- `boots_sigil_fire` at 25% coverage: burn = 5 × 0.25 = **1.25 damage/tick** to all enemies +- `boots_sigil_fire` at 50% coverage: burn = 5 × 0.50 = **2.5 damage/tick** to all enemies +- `boots_sigil_fire` at 100% coverage: burn = 5 × 1.00 = **5 damage/tick** to all enemies + +All magnitudes are floating-point. Damage per tick is applied as-is (not rounded per +tick; rounding only occurs on display). + +### 4.4 Enchantment Table + +| Enchant ID | Name | specialId | Effect Type | Base Magnitude (at 100%) | Capacity Cost | +|---|---|---|---|---|---| +| `boots_sigil_fire` | Blazing Footsteps | `room_fire_damage` | Room DoT (burn all enemies) | 5 dmg/tick | 30 | +| `boots_sigil_frost` | Frozen Trail | `room_frost_debuff` | Enemy slow | 10% slow | 25 | +| `boots_sigil_death` | Necrotic Tread | `room_death_damage` | Room DoT (death all enemies) | 3 dmg/tick | 25 | +| `boots_sigil_lightning` | Shocking Stride | `room_lightning_damage` | Single-target chain dmg | 3 dmg/tick random enemy | 28 | +| `boots_sigil_dark` | Shadow Patch | `room_dark_dodge_debuff` | Enemy dodge reduction | −10% dodge | 22 | +| `boots_sigil_earth` | Scoured Earth | `room_earth_armor_debuff` | Enemy armor reduction | −5% armor | 28 | +| `boots_sigil_transference_ground` | Transference Grounds | `room_transference_buff` | Player cast speed | +5% cast speed | 20 | +| `boots_sigil_transference_path` | Conductive Path | `room_transference_regen` | Transference mana regen | +10%/hr regen | 20 | + +All costs fit within footwear capacity range (15–35), meaning players can fit 1–2 room +enchantments per pair of boots. + +### 4.5 Effect Application Rules + +**DoT effects** (damage over time: fire, death, lightning): +- Applied to ALL living enemies in the room each tick +- Bypass armor — room-wide DoT ignores enemy armor, dodge, and barrier +- Do NOT apply to dead enemies (hp ≤ 0) +- For single-target effects (lightning): target is chosen randomly among living enemies + each tick + +**Debuff effects** (frost slow, dark dodge reduction, dark armor reduction): +- Applied as a modifier to all living enemies, recalculated each tick based on current + coverage +- Slow: reduces enemy dodge chance (negative value subtracted from base dodge) +- Dodge reduction: directly reduces enemy dodge chance +- Armor reduction: directly reduces enemy effectiveArmor (minimum 0) +- Applied at the end of the room enchantment phase (after the DoT/debuff phase). Debuffs + take effect on the **next** tick — there is a one-tick delay before reduced dodge/armor + affects incoming attacks. This is intentional: the room enchantment phase runs late in + the tick pipeline. + +**Buff/Regen effects** (transference cast speed, transference regen): +- Applied as a modifier to the player's combat bonuses, recalculated each tick +- Stored in the combat tick result and factored into existing `computeAllEffects()` math +- Cast speed: added to `attackSpeedMultiplier` as `1 + (baseBonus × coverage%)` +- Transference regen: added to transference mana regen per hour as `baseRegen × coverage%` + +> **Note on debuff timing:** Because the room enchantment phase runs after the DoT/debuff +> phase in the tick pipeline, debuffs (frost slow, dark dodge reduction, dark armor reduction) +> are applied to enemy state at the end of the tick. The modified enemy stats take effect +> on the **following** tick when the enemy defense pipeline runs. This means there is always +> a one-tick delay between coverage growth and debuff impact on incoming attacks. + +### 4.6 Stacking Multiple Enchantments + +If the player has two room enchantments on their boots (e.g., `boots_sigil_fire` + +`boots_sigil_frost`): +- Both share the same coverage meter (there is only one) +- Both scale off the same coverage percentage +- Both apply their effects independently each tick +- Total capacity cost must fit within the footwear's total capacity + +--- + +## 5. State Changes + +### 5.1 FloorState (`types/game.ts`) + +```typescript +export interface FloorState { + // ... all existing fields ... + + // Room enchantment state — null when no footwear room enchants are equipped + roomEnchantment: { + coverage: number; // 0-100 + } | null; +} +``` + +### 5.2 Combat Store Additions + +The combat store needs a new field to persist the previous room's coverage across room +transitions (for the `resonant-stamps` perk). Add to `CombatState` in +`combat-state.types.ts`: + +```typescript +// Room enchantment carryover (for resonant-stamps perk) +lastRoomCoverage: number; // 0-20, carryover from previous room, resets on spire exit +``` + +And the corresponding action: + +```typescript +setLastRoomCoverage: (value: number) => void; +``` + +### 5.3 Combat Processing + +The room enchantment tick is processed in `combat-actions.ts` → `processCombatTick()`, +executed in the tick pipeline immediately after the DoT/debuff phase: + +``` +Tick order in processCombatTick: + 1. Golem maintenance + 2. Active spell casting + 3. Equipment spell states + 4. Invocation tick + 5. Melee attacks + 6. Golem attacks + 7. DoT/debuff tick processing + 8. ── Room enchantment tick ← NEW + +> **File size note:** `combat-actions.ts` is currently 377 lines. Adding the room +> enchantment phase may push it toward the 400-line limit. If so, extract the phase +> logic into a new file (e.g., `combat-room-enchantments.ts`) and call it from +> `processCombatTick`. +``` + +### 5.4 New Utility File + +A new file `src/lib/game/utils/room-enchantments-utils.ts` exports: + +```typescript +// Compute coverage per tick from discipline stats +export function computeCoveragePerTick(disciplineBonus: number): number; + +// Apply all room enchantment effects for one tick +// Returns updated { enemies, rawMana, elementStates, playerBuffs } +export function applyRoomEnchantmentTick(params: { + coverage: number; + auraMagnitude: number; // roomEnchantmentAuraMagnitude from discipline effects + equippedRoomEnchantments: Array<{ specialId: string; baseMagnitude: number }>; + enemies: EnemyState[]; + rawMana: number; + elements: Record; +}): { + enemies: EnemyState[]; + rawMana: number; + elements: Record; + playerBuffs: { + castSpeedBonus: number; + transferenceRegenBonus: number; + }; +}; + +// Get list of room enchantment effects from equipped footwear +export function getEquippedRoomEnchantments( + equippedInstances: Record, + equipmentInstances: Record, +): Array<{ specialId: string; baseMagnitude: number }>; +``` + +### 5.5 Room Transition Behavior + +Coverage resets on every room transition. This is handled in the existing +`advanceRoomOrFloor()` flow in `combat-descent-actions.ts`: + +- When a new `FloorState` is generated (via `generateSpireFloorState()`), the new room + starts with `roomEnchantment: null` +- Just before the `FloorState` is finalized, the current room's coverage is saved to + `lastRoomCoverage` on the combat store (capped at 20 if `resonant-stamps` perk is + active, otherwise 0) +- At the start of the combat tick, if `roomEnchantment` is null and the player has + footwear room enchantments equipped, it is initialized to + `{ coverage: lastRoomCoverage }` +- If the player has no footwear room enchantments, it stays null and the room + enchantment tick is skipped entirely +- `lastRoomCoverage` resets to 0 on spire exit + +--- + +## 6. Discipline Integration + +### 6.1 New Discipline: Room Enchanting + +A new Enchanter discipline that directly enhances the Room Enchantment system. + +**Definition:** + +| Field | Value | +|---|---| +| **ID** | `room-enchanting` | +| **Name** | Room Enchanting | +| **Attunement** | `enchanter` | +| **Mana Type** | `transference` | +| **Base Cost** | 10 | +| **Stat Bonus** | `roomEnchantmentAuraMagnitude` +0.10 (base) | +| **Scaling Factor** | 100 | +| **Difficulty Factor** | 150 | +| **Drain Base** | 3 | + +**Main stat: `roomEnchantmentAuraMagnitude`** + +This stat is a multiplier on all room enchantment aura magnitudes: + +``` +effectMagnitude = baseMagnitude × coverage% × (1 + roomEnchantmentAuraMagnitude) +``` + +The stat scales with XP via the standard discipline math: + +``` +StatBonus = baseValue × (XP / scalingFactor)^0.65 + = 0.10 × (XP / 100)^0.65 +``` + +This replaces the old `empowered-auras` perk — the main stat now continuously scales +aura strength, while a new capped perk handles coverage rate. + +**Magnitude scaling at key XP levels:** + +| XP | Stat Bonus | Magnitude Multiplier | Fire DoT @ 100% coverage | +|---|---|---|---| +| 0 | +0.000 | 1.000× | 5.00 dmg/tick | +| 100 | +0.100 | 1.100× | 5.50 dmg/tick | +| 300 | +0.204 | 1.204× | 6.02 dmg/tick | +| 500 | +0.285 | 1.285× | 6.42 dmg/tick | +| 1000 | +0.447 | 1.447× | 7.23 dmg/tick | +| 2000 | +0.701 | 1.701× | 8.50 dmg/tick | + +**Perks:** + +| Perk ID | Type | Threshold | Bonus | Description | +|---|---|---|---|---| +| `room-coverage-rate` | `capped` | 100 | Coverage rate +0.03/tick per tier, max 4 tiers | Room fills faster with each tier | +| `resonant-stamps` | `once` | 500 | Carry 20% coverage between rooms (max 20% starting coverage) | Head start on each room, but never instant | + +**Perk details:** + +- **`room-coverage-rate`** (capped, threshold 100 XP, interval 150 XP, max 4 tiers): Each + tier adds +0.03/tick to the coverage growth rate. Tier progression: 1 tier at 100 XP, + 2 tiers at 250 XP, 3 tiers at 400 XP, 4 tiers at 550 XP (capped). + + | Tiers | Rate Bonus | Total Rate | Seconds to Fill | + |---|---|---|---| + | 0 (no perk) | +0.00 | 0.20/tick | 100.0s | + | 1 (100 XP) | +0.03 | 0.23/tick | 87.0s | + | 2 (250 XP) | +0.06 | 0.26/tick | 76.9s | + | 3 (400 XP) | +0.09 | 0.29/tick | 69.0s | + | 4 (550 XP) | +0.12 | 0.32/tick | 62.5s | + + At max tier (4), the room fills in ~62.5 seconds instead of 100 seconds — a 37.5% + speedup. The perk is capped so coverage always requires meaningful combat time. + +- **`resonant-stamps`** (once @ 500 XP): When transitioning between rooms on the same + floor, the carryover is computed as `min(20, previousRoom.coverage × 0.2)`. This means + 20% of the previous room's coverage value carries over, capped at a maximum of 20 + percentage points. Examples: ending at 80% → next room starts at 16%; ending at 100% → + next room starts at 20% (the cap). This ensures the player always needs to actively + build coverage during combat — the perk provides a head start but never eliminates the + buildup phase. + + **State persistence:** The carryover value is stored in `lastRoomCoverage` on the + combat store (see §5.2). When `advanceRoomOrFloor()` generates a new `FloorState` + with `roomEnchantment: null`, the room enchantment tick reads `lastRoomCoverage` to + initialize the new room's starting coverage. This field persists across room + transitions within a climb but resets to 0 on spire exit. + +**Combined progression at key XP levels (stat + capped perk):** + +| XP | Coverage Rate | Magnitude | Fire DoT @ 100% | Time to Fill | +|---|---|---|---|---| +| 0 | 0.20/tick | 1.000× | 5.00 dmg/tick | 100.0s | +| 100 | 0.23/tick | 1.100× | 5.50 dmg/tick | 87.0s | +| 300 | 0.26/tick | 1.204× | 6.02 dmg/tick | 76.9s | +| 500 | 0.29/tick | 1.285× | 6.42 dmg/tick | 69.0s | +| 1000 | 0.32/tick | 1.447× | 7.23 dmg/tick | 62.5s | +| 2000 | 0.32/tick | 1.701× | 8.50 dmg/tick | 62.5s | + +The capped perk maxes out at 550 XP (4 tiers, 0.32/tick, 62.5s). Beyond that, only the +main stat continues to grow, increasing aura magnitude. At 2000 XP, the room still takes +62.5 seconds to fill but fire does 8.50 dmg/tick instead of 5.00 — a 70% damage +increase from the main stat alone. + +### 6.2 Discipline Dependency + +``` +room-enchanting (root — no prerequisites) +``` + +This is a root discipline with no prerequisites, making it available as soon as the +Enchanter attunement is active. + +### 6.3 Stat Registration + +The new stat `roomEnchantmentAuraMagnitude` must be added to: +- `KNOWN_BONUS_STATS` in `discipline-effects.ts` +- The `addBonus()` routing in `computeDisciplineEffects()` (no special routing needed — + it's a standard bonus stat) + +### 6.4 Enchantment Unlock Perks + +Room enchantment effects are unlocked via perks on the new discipline: + +| Perk ID | Type | Threshold | Unlocks | +|---|---|---|---| +| `room-sigil-fire` | `once` | 50 | `boots_sigil_fire` | +| `room-sigil-frost` | `once` | 75 | `boots_sigil_frost` | +| `room-sigil-death` | `once` | 100 | `boots_sigil_death` | +| `room-sigil-lightning` | `once` | 125 | `boots_sigil_lightning` | +| `room-sigil-dark` | `once` | 150 | `boots_sigil_dark` | +| `room-sigil-earth` | `once` | 175 | `boots_sigil_earth` | +| `room-sigil-transference-ground` | `once` | 100 | `boots_sigil_transference_ground` | +| `room-sigil-transference-path` | `once` | 125 | `boots_sigil_transference_path` | + +--- + +## 7. Data Flow Summary + +``` +gameStore.tick() + → buildTickContext() [snapshots all stores] + → processCombatTick() [combat-actions.ts] + → ... (golem, spell, equipment, invocation, melee, DoT phases) ... + → Room Enchantment Phase: + 1. If roomEnchantment is null and player has footwear room enchants: + Initialize roomEnchantment = { coverage: lastRoomCoverage } + 2. If roomEnchantment is not null AND floorHP > 0: + a. coverage += 0.2 + roomCoverageRateBonus + b. Clamp coverage to 100 + c. Magnitude multiplier = 1.0 + roomEnchantmentAuraMagnitude + d. For each equipped room enchantment: + - Compute magnitude = baseMagnitude × (coverage / 100) × magnitude multiplier + e. Apply to enemies (DoT/debuffs) or player (buffs) + f. Recalculate floorHP from updated enemy HP + → applyTickWrites() [writes combat store changes back] + +On room transition (advanceRoomOrFloor): + → Before generating new FloorState, save current coverage to combat store: + lastRoomCoverage = resonant-stamps active ? min(20, currentRoom.coverage * 0.2) : 0 + → New FloorState generated with roomEnchantment: null + → Room enchantment tick reads lastRoomCoverage to initialize starting coverage +``` + +--- + +## 8. UI Changes + +### 8.1 RoomDisplay Component + +Add a **Room Enchantment Bar** to the combat room display in `RoomDisplay.tsx`, shown +only when the player has footwear room enchantments equipped and the room has living +enemies: + +``` +┌─────────────────────────────────────────────┐ +│ 🔥 Blazing Footsteps ████████████░░░░ 62% │ +│ → Enemies burning: 3.1 dmg/tick │ +│❄️ Frozen Trail ████████████░░░░ 62% │ +│ → Enemies slowed: 6.2% │ +└─────────────────────────────────────────────┘ +``` + +- Progress bar showing coverage percentage (0–100%) +- Color-coded by element (fire = red, frost = blue, etc.) +- Shows current effect magnitude in text below each bar +- Only displayed when `roomEnchantment` is not null + +### 8.2 Enchantment Designer + +Room enchantment effects appear in the existing `EffectSelector` component under a new +"Room" filter category (or under "Special" with a "Room Only" tag). They are only +selectable when the equipment type being designed for is in the `feet` category. + +### 8.3 No New Tabs + +The room enchantment system does not require a new tab. All information is visible in +the existing SpireCombatPage layout and the existing CraftingTab enchantment designer. + +--- + +## 9. Acceptance Criteria + +| # | Criterion | +|---|---| +| AC-1 | `roomEnchantment` field exists on `FloorState` and defaults to `null`. | +| AC-2 | Coverage initializes to `{ coverage: 0 }` on room entry when footwear has room enchantments equipped. | +| AC-3 | Coverage resets to 0 on every room transition (new room = fresh coverage). | +| AC-4 | Coverage grows at 0.2 per tick while in `climb` action with living enemies. | +| AC-5 | Coverage is hard-capped at 100. | +| AC-6 | Coverage does NOT grow when `floorHP <= 0` or `currentAction !== 'climb'`. | +| AC-7 | At base rate (no discipline), coverage reaches 100 in ~100 seconds real time (500 ticks). | +| AC-8 | Room DoT effects (fire, death, lightning) apply to ALL living enemies each tick, bypassing armor. | +| AC-9 | Room debuff effects (frost slow, dark dodge reduction, earth armor reduction) modify enemy stats at end of room enchantment phase (one-tick delay before affecting incoming attacks). | +| AC-10 | Room buff effects (transference cast speed, transference regen) are factored into player stat computation. | +| AC-11 | Effect magnitudes scale linearly: `baseMagnitude × (coverage / 100)`. | +| AC-12 | Multiple room enchantments on one pair of boots share the same coverage meter and apply independently. | +| AC-13 | Total capacity cost of room enchantments must fit within footwear capacity (15–35). | +| AC-14 | The `room-enchanting` discipline uses transference mana and has `roomEnchantmentAuraMagnitude` as its stat. | +| AC-15 | `room-coverage-rate` capped perk adds +0.03/tick per tier, max 4 tiers. | +| AC-16 | `room-coverage-rate` capped perk reaches max 4 tiers at 550 XP (0.12/tick, 62.5s to fill). | +| AC-17 | `resonant-stamps` once perk computes carryover as `min(20, previousRoom.coverage × 0.2)`, stored in `lastRoomCoverage` on the combat store. | +| AC-18 | Room enchantment effects are unlocked via discipline perks at the specified XP thresholds. | +| AC-19 | Room enchantment effects only appear in the EffectSelector when designing for `feet` equipment. | +| AC-20 | RoomDisplay shows coverage bar and current effect magnitudes when room enchantments are active. | +| AC-21 | Existing saves without `roomEnchantment` field default to `null` (backward compatible). | +| AC-22 | Existing saves without `roomEnchantmentAuraMagnitude` stat default to 0 (backward compatible). | + +--- + +## 10. Files Reference + +| File | Role | +|---|---| +| `src/lib/game/types/game.ts` | Add `roomEnchantment` field to `FloorState` | +| `src/lib/game/data/enchantments/special-effects.ts` | Add room enchantment effect definitions (8 effects) | +| `src/lib/game/data/disciplines/enchanter.ts` | Add `room-enchanting` discipline definition with perks | + +> **Implementation note:** `enchanter.ts` currently has 4 disciplines (146 lines). Adding +> `room-enchanting` with its ~8 unlock perks may push it toward the 400-line limit. +> If so, create a new file `enchanter-combat.ts` (following the existing pattern of +> `enchanter-utility.ts`, `enchanter-spells.ts`, etc.) and re-export from +> `data/disciplines/index.ts`. +| `src/lib/game/effects/discipline-effects.ts` | Add `roomEnchantmentAuraMagnitude` to `KNOWN_BONUS_STATS` | +| `src/lib/game/stores/combat-actions.ts` | Add room enchantment phase in `processCombatTick` (after DoT phase) | +| `src/lib/game/utils/room-enchantments-utils.ts` | **NEW** — Coverage computation, effect application, equipment scanning | +| `src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx` | Add coverage bar and effect magnitude display | +| `src/components/game/crafting/EnchantmentDesigner/EffectSelector.tsx` | Show room enchantments when designing for feet | +| `docs/specs/attunements/enchanter/systems/room-enchantments-spec.md` | **THIS FILE** | + +--- + +## 11. Out of Scope + +- Active player input during combat (no button to spend transference for faster coverage) +- Coverage scaling with cast speed, attack speed, or any combat stat +- Coverage persisting between rooms at launch (added only via `resonant-stamps` perk) +- Room enchantments on non-footwear equipment slots +- Room enchantments interacting with golem combat +- Room enchantments affecting non-combat rooms (library, recovery, treasure, puzzle) +- Visual effects / animations for the coverage bar (UI-only indicator in v1) +- Room enchantment effects that heal the player (violates no-healing rule) +- More than 8 room enchantment types in v1 +- Composite or exotic element room sigils in v1 (base 7 + transference only) diff --git a/docs/specs/attunements/enchanter/systems/transference-channel-spec.md b/docs/specs/attunements/enchanter/systems/transference-channel-spec.md new file mode 100644 index 0000000..f112c12 --- /dev/null +++ b/docs/specs/attunements/enchanter/systems/transference-channel-spec.md @@ -0,0 +1,442 @@ +# Transference Channel — Design Spec + +> Describes the Transference Channel system: an active combat mechanic for the +> Enchanter attunement that lets the player hold a button to drain Transference +> mana and boost the cast speed of all equipment spells and melee attacks. + +--- + +## 1. Objective + +The Enchanter attunement currently lacks an active combat mechanic. The Invocation +system (Invoker attunement) provides automatic combat acceleration through +guardian channeling. The Transference Channel gives the Enchanter a **manual, +hold-to-channel button** that spends Transference mana to temporarily accelerate +all equipment-based combat actions. + +**Design goals:** +- Give the Enchanter an **active, holdable** combat button +- Spend **Transference mana** — the only utility mana type — as the fuel +- Boost **equipment spells and melee attacks only** — not the player's active spell +- Create a **resource management decision**: channel now for burst speed, or + conserve Transference for enchanting and disciplines +- Scale through a new Enchanter discipline that trades mana cost for potency +- Complement (not compete with) Invocation — different resource, different + trigger, different target + +--- + +## 2. The Channel Button + +### 2.1 UI Placement + +A **"Channel Transference"** button on the `SpireCombatPage`, visible only when: +- The Enchanter attunement is active (`attunements.enchanter.active === true`) +- The player is in `climb` action (combat) +- The player has at least 1 Transference mana + +### 2.2 Interaction Model + +| Property | Value | +|---|---| +| **Interaction** | Press and hold (mousedown / touchstart) to channel, release to stop | +| **Keyboard** | Optional hotkey (e.g., `C`) for accessibility | +| **Visual** | Button glows while active; shows Transference drain rate | +| **Cooldown** | None — player controls start/stop | + +The hold-to-channel interaction is intentional: it creates a "power moment" +where the player chooses to actively engage. The game is fully playable without +it — this is an optional acceleration for players who want active involvement. + +### 2.3 Hold Behavior + +While the button is held: +1. Transference mana drains at `drainRate` per tick (see §3) +2. All equipment spells and melee attacks gain `speedMultiplier` cast speed +3. If Transference reaches 0: channel stops automatically (no penalty) +4. If player leaves `climb` action: channel stops automatically +5. If player releases button: channel stops immediately + +--- + +## 3. Drain Rate and Speed Multiplier + +### 3.1 Mana Economy Context + +Transference mana is generated slowly through the Enchanter's automatic +conversion (0.2/hour at level 1, scaling with `1.5^(level-1)`). At level 10, +this is ~7.69/hour. Transference is also the fuel for all Enchanter disciplines +and the enchanting pipeline. + +The Channel system is designed as a **burst expenditure** — the player +accumulates Transference over time and then spends it in combat for temporary +acceleration. The drain rate must be high enough to create meaningful decisions +("can I afford to channel right now?") but not so high that it's trivially +exhausted. + +### 3.2 Base Values + +| Parameter | Base Value | Description | +|---|---|---| +| `baseDrainRate` | 0.08 Transference/tick | 0.4 Transference/second at 200ms/tick | +| `baseSpeedMultiplier` | 1.5× | Equipment spells and melee attack 50% faster | + +### 3.3 Duration Estimate + +At base values with various Transference pool sizes: + +| Transference Pool | Drain Rate | Approximate Duration | +|---|---|---| +| 20 | 0.08/tick | 250 ticks = 50 seconds | +| 50 | 0.08/tick | 625 ticks = 125 seconds | +| 100 | 0.08/tick | 1250 ticks = 250 seconds | + +With discipline scaling (e.g., 1.8× speed at ~1.8× drain): + +| Transference Pool | Drain Rate | Approximate Duration | +|---|---|---| +| 50 | 0.144/tick | 347 ticks = 69 seconds | +| 100 | 0.144/tick | 694 ticks = 139 seconds | + +The discipline makes each "pool of Transference" worth less raw seconds but +more effective combat time because actions complete faster. + +### 3.4 Scaling with Discipline + +A new discipline (`transference-channeling`) controls the tradeoff between +mana cost and speed: + +``` +effectiveDrainRate = baseDrainRate × (1 + intensityBonus) × (1 - channelEfficiency) +effectiveSpeedMultiplier = baseSpeedMultiplier + speedBonus +``` + +Where `intensityBonus` and `speedBonus` come from the discipline's stat bonus +and perks (see §5). + +**Design philosophy:** The discipline lets the player invest XP to get more +speed, but at proportionally higher mana cost. Perks can shift the ratio +in the player's favor (more speed per mana). + +--- + +## 4. What Gets Boosted + +### 4.1 Affected + +| Action | Boosted? | Notes | +|---|---|---| +| **Equipment spell casts** | ✅ Yes | All `equipmentSpellStates` cast progress | +| **Melee sword attacks** | ✅ Yes | All `meleeSwordProgress` accumulators | + +### 4.2 Not Affected + +| Action | Boosted? | Notes | +|---|---|---| +| **Player's active spell** | ❌ No | The manually-selected spell is not equipment | +| **Invocation spells** | ❌ No | Invocation is pact-based, not equipment-based | +| **Golem attacks** | ❌ No | Golems are independent entities | +| **DoT ticks** | ❌ No | DoTs are time-based, not cast-based | + +### 4.3 Implementation — Speed Multiplier Application + +The channel state is tracked in the combat store. When active, the multiplier +is applied to `progressPerTick` calculations for equipment spells and melee: + +``` +// Equipment spells (in combat-actions.ts equipment spell block) +const channelMult = state.isChanneling ? state.channelSpeedMultiplier : 1.0; +const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * totalAttackSpeed * channelMult; + +// Melee (in combat-melee.ts) +const channelMult = state.isChanneling ? state.channelSpeedMultiplier : 1.0; +const meleeProgressPerTick = HOURS_PER_TICK * swordAttackSpeed * attackSpeedMult * channelMult; +``` + +**Important:** The speed multiplier only affects cast *progress accumulation*. +It does NOT affect mana costs per cast. Equipment spells still cost the same +mana per cast — they just complete faster. This prevents the feedback loop +where faster casting drains mana exponentially faster. + +--- + +## 5. New Discipline: Transference Channeling + +### 5.1 Definition + +| Field | Value | +|---|---| +| **ID** | `transference-channeling` | +| **Name** | Transference Channeling | +| **Attunement** | `enchanter` | +| **Mana Type** | `transference` | +| **Base Cost** | 15 | +| **Stat Bonus** | `channelIntensity` +0.10 (base) | +| **Scaling Factor** | 100 | +| **Difficulty Factor** | 180 | +| **Drain Base** | 4 | + +**Main stat: `channelIntensity`** + +This stat controls both the speed boost and the drain rate: + +``` +speedBonus = channelIntensity × 0.5 // added to baseSpeedMultiplier +intensityBonus = channelIntensity × 1.0 // multiplier on drain rate +``` + +At 0 XP: `channelIntensity = 0.10` → speed = 1.5 + 0.05 = 1.55×, drain = 0.08 × 1.10 = 0.088/tick. + +The stat scales with XP via the standard discipline math: + +``` +StatBonus = baseValue × (XP / scalingFactor)^0.65 + = 0.10 × (XP / 100)^0.65 +``` + +### 5.2 Perks + +| Perk ID | Type | Threshold | Bonus | Description | +|---|---|---|---|---| +| `channel-efficiency` | `once` | 100 | `channelEfficiency` +0.15 | 15% less drain for same speed — shifts the ratio in the player's favor | +| `channel-power` | `infinite` | 200 | Every 150 XP: `channelIntensity` +0.05 | Core scaling — more speed (and proportionally more drain) | +| `channel-mastery` | `capped` | 400 | Every 200 XP: `channelEfficiency` +0.10, max 3 tiers | Late-game efficiency — up to 45% less drain | + +### 5.3 Effective Formulas with Perks + +``` +channelEfficiency = 0 + sum of efficiency perks (0.15 from once, up to 0.30 from capped) + hard-capped at 0.60 — prevents drain rate from reaching 0 +effectiveSpeedMultiplier = 1.5 + (channelIntensity × 0.5) +effectiveDrainRate = 0.08 × (1 + channelIntensity × 1.0) × (1 - channelEfficiency) +``` + +> **The `channelEfficiency` cap of 0.60** is enforced in the formula itself. Even if +> perk bonuses would exceed 0.60, the effective drain rate can never go below +> `0.08 × (1 + intensity) × 0.40`. This ensures channeling always costs meaningful +> Transference mana. + +**Example at 500 XP with all perks:** +- `channelIntensity` = 0.10 × (500/100)^0.65 + 0.05 × 2 (two infinite intervals) ≈ 0.24 + 0.10 = 0.34 +- `channelEfficiency` = 0.15 + 0.20 (two capped tiers) = 0.35 +- Speed = 1.5 + 0.34 × 0.5 = 1.67× +- Drain = 0.08 × (1 + 0.34) × (1 - 0.35) = 0.08 × 1.34 × 0.65 = 0.070/tick + +### 5.4 Discipline Identity + +*"I channel transference mana through my equipment, making my enchanted gear +strike and cast faster. The more I master this, the faster I go — but it +costs more mana to sustain."* + +--- + +## 6. Store Changes + +### 6.1 Combat Store (`combatStore.ts`) + +New state fields: + +```typescript +// Transference Channel state +isChanneling: boolean; // true while button is held +channelSpeedMultiplier: number; // current speed multiplier (1.5+) +channelDrainRate: number; // current drain rate per tick +``` + +New actions: + +```typescript +startChanneling: () => void; +stopChanneling: () => void; +``` + +### 6.2 Combat State Types (`combat-state.types.ts`) + +Add to `CombatState`: + +```typescript +isChanneling: boolean; +channelSpeedMultiplier: number; +channelDrainRate: number; +``` + +Add to `CombatActions`: + +```typescript +startChanneling: () => void; +stopChanneling: () => void; +``` + +### 6.3 No Changes To + +- `manaStore.ts` — Transference mana is drained via existing element deduction +- `craftingStore.ts` — enchanting is unaffected +- `prestigeStore.ts` — no prestige interaction +- `invocation-utils.ts` — Invocation is completely separate + +--- + +## 7. Combat Tick Integration + +### 7.1 Modified Flow in `combat-actions.ts` + +The channel state is read from the combat store at the start of each tick. +The speed multiplier is applied to equipment spell and melee progress: + +``` +1. Read isChanneling from combat store +2. If isChanneling: + a. Drain Transference: transferencePool -= channelDrainRate + b. If transference <= 0: stop channeling (set isChanneling = false) +3. Compute channelMult = isChanneling ? channelSpeedMultiplier : 1.0 +4. Equipment spell progress: eProgressPerTick *= channelMult +5. Melee progress: meleeProgressPerTick *= channelMult +``` + +> **File size note:** `combat-actions.ts` is currently 377 lines. Adding channel drain +> logic and speed multiplier application may push it toward the 400-line limit. If so, +> extract the channel logic into a new file (e.g., `combat-channel.ts`) and call it +> from `processCombatTick`. + +### 7.2 Transference Drain + +Transference is drained from the mana store. The drain uses the existing +element deduction pattern: + +```typescript +// Per tick while channeling: +const transferencePool = useManaStore.getState().elements.transference; +if (transferencePool.current >= channelDrainRate) { + useManaStore.getState().deductElement('transference', channelDrainRate); +} else { + // Insufficient mana — stop channeling + useCombatStore.getState().stopChanneling(); +} +``` + +### 7.3 Auto-Stop Conditions + +Channeling stops when any of the following is true: +1. Player releases the button +2. Transference mana reaches 0 +3. Player leaves `climb` action +4. Enchanter attunement is deactivated mid-combat + +> **Room transitions:** Channeling **persists** across room transitions within the same +> climb. If the player is channeling and kills an enemy (triggering +> `advanceRoomOrFloor`), channeling continues into the next room. The `isChanneling` +> state is stored on the combat store and is not reset by room transitions — only by +> the auto-stop conditions above or by spire exit. + +--- + +## 8. UI Changes + +### 8.1 SpireCombatPage + +Add a **"Channel Transference"** button between the SpireHeader and RoomDisplay: + +- **Visible when:** Enchanter active + in `climb` action +- **Button style:** Teal-colored (#1ABC9C, matching Enchanter), with Transference icon (🔗) +- **While channeling:** Button glows, shows drain rate per second +- **Transference bar:** Small bar below button showing remaining Transference mana +- **Tooltip:** "Hold to channel transference mana through your equipment, boosting attack speed" + +### 8.2 SpireCombatControls + +Add a compact channel status indicator: +- Shows "⚡ Channeling" with speed multiplier when active +- Hidden when not channeling + +### 8.3 No New Tabs + +All channel information is visible in the existing SpireCombatPage layout. + +--- + +## 9. Data Flow Summary + +``` +Player holds button + → startChanneling() [combatStore] + → isChanneling = true, channelSpeedMultiplier = 1.5+, channelDrainRate = 0.08+ + +gameStore.tick() + → buildTickContext() [snapshots all stores] + → processCombatTick() [combat-actions.ts] + → If isChanneling: + - Deduct transference mana from manaStore + - If transference <= 0: stopChanneling() + → channelMult = isChanneling ? channelSpeedMultiplier : 1.0 + → Equipment spell progress: eProgressPerTick *= channelMult + → Melee progress: meleeProgressPerTick *= channelMult + → applyTickWrites() [writes combat store changes back] + +Player releases button + → stopChanneling() [combatStore] + → isChanneling = false +``` + +--- + +## 10. Acceptance Criteria + +| # | Criterion | +|---|---| +| AC-1 | "Channel Transference" button is visible on SpireCombatPage when Enchanter is active and player is in `climb` action. | +| AC-2 | Button is hidden when Enchanter is inactive, player is not in `climb`, or Transference pool is 0. | +| AC-3 | Holding the button (mousedown) activates channeling; releasing deactivates it. | +| AC-4 | While channeling, Transference mana drains at `channelDrainRate` per tick. | +| AC-5 | When Transference reaches 0, channeling stops automatically with no penalty. | +| AC-6 | While channeling, all equipment spell cast progress is multiplied by `channelSpeedMultiplier`. | +| AC-7 | While channeling, all melee sword attack progress is multiplied by `channelSpeedMultiplier`. | +| AC-8 | The player's active spell is NOT affected by channeling. | +| AC-9 | Invocation spells are NOT affected by channeling. | +| AC-10 | Golem attacks are NOT affected by channeling. | +| AC-11 | DoT ticks are NOT affected by channeling. | +| AC-12 | Base drain rate is 0.08 Transference/tick; base speed multiplier is 1.5×. | +| AC-13 | `transference-channeling` discipline scales `channelIntensity` stat with XP. | +| AC-14 | `channel-efficiency` once perk (100 XP) grants 15% drain reduction. | +| AC-15 | `channel-power` infinite perk (200 XP, every 150 XP) grants +0.05 `channelIntensity`. | +| AC-16 | `channel-mastery` capped perk (400 XP, every 200 XP, max 3 tiers) grants +0.10 `channelEfficiency` per tier. | +| AC-17 | `channelEfficiency` is capped at 0.60 (prevents drain from reaching 0). | +| AC-18 | Channel state resets on spire exit (`isChanneling = false`). | +| AC-19 | Existing saves without channel fields get default values (`isChanneling = false`, `channelSpeedMultiplier = 1.5`, `channelDrainRate = 0.08`). | +| AC-20 | Channel stops automatically when player leaves `climb` action. | +| AC-21 | Speed multiplier affects cast progress accumulation only — mana costs per cast are unchanged. | + +--- + +## 11. Files Reference + +| File | Role | +|---|---| +| `src/lib/game/stores/combatStore.ts` | New channel state fields + `startChanneling`/`stopChanneling` actions | +| `src/lib/game/stores/combat-state.types.ts` | Type definitions for new state | +| `src/lib/game/stores/combat-actions.ts` | Channel drain + speed multiplier application for equipment spells (or extracted to `combat-channel.ts` if file size exceeds 400 lines) | +| `src/lib/game/stores/combat-melee.ts` | Speed multiplier application for melee attacks | +| `src/lib/game/data/disciplines/enchanter.ts` | Add `transference-channeling` discipline definition | + +> **Implementation note:** `enchanter.ts` currently has 4 disciplines (146 lines). If +> adding `transference-channeling` would push it toward the 400-line limit (e.g. when +> combined with the `room-enchanting` discipline from the room enchantments spec), +> create a new file `enchanter-combat.ts` (following the existing pattern of +> `enchanter-utility.ts`, `enchanter-spells.ts`, etc.) and re-export from +> `data/disciplines/index.ts`. +| `src/lib/game/effects/discipline-effects.ts` | Add `channelIntensity` and `channelEfficiency` to known bonus stats | +| `src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx` | Channel button UI | +| `src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx` | Compact channel status indicator | +| `docs/specs/attunements/enchanter/systems/transference-channel-spec.md` | **THIS FILE** | + +--- + +## 12. Out of Scope + +- Channel affecting the player's active spell +- Channel affecting Invocation spells +- Channel affecting golem attacks +- Channel affecting DoT ticks +- Auto-channel toggle (manual hold only in v1) +- Channel working outside of combat (`climb` action only) +- Prestige upgrades that affect channeling +- Channel interacting with the enchanting pipeline (Design/Prepare/Apply) diff --git a/src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx b/src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx index e136983..7e80a5e 100644 --- a/src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx +++ b/src/components/game/tabs/SpireCombatPage/SpireCombatControls.tsx @@ -9,6 +9,8 @@ import { DebugName } from '@/components/game/debug/debug-context'; interface SpireCombatControlsProps { castProgress: number; + isChanneling: boolean; + channelSpeedMultiplier: number; } function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string { @@ -17,7 +19,7 @@ function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amou return `${cost.amount} ${elemDef?.sym || '?'}`; } -export function SpireCombatControls({ castProgress }: SpireCombatControlsProps) { +export function SpireCombatControls({ castProgress, isChanneling, channelSpeedMultiplier }: SpireCombatControlsProps) { const spells = useCombatStore((s) => s.spells); const activeSpell = useCombatStore((s) => s.activeSpell); const setSpell = useCombatStore((s) => s.setSpell); @@ -66,6 +68,18 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps) + {/* Transference Channel Status Indicator */} + {isChanneling && ( + + +
+ ⚡ Channeling + {channelSpeedMultiplier.toFixed(2)}× +
+
+
+ )} + {/* Active Spell Panel */} diff --git a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx index 15c96a0..26e09fb 100644 --- a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx +++ b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx @@ -5,6 +5,7 @@ import { useCombatStore, useManaStore, usePrestigeStore, useGameStore, fmt, comp import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects'; import { getUnifiedEffects } from '@/lib/game/effects'; import { useCraftingStore } from '@/lib/game/stores/craftingStore'; +import { useAttunementStore } from '@/lib/game/stores/attunementStore'; import { SpireHeader } from './SpireHeader'; import { RoomDisplay } from './RoomDisplay'; import { SpireCombatControls } from './SpireCombatControls'; @@ -15,6 +16,7 @@ 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'; +import { computeChannelStats } from '@/lib/game/stores/combat-channel'; // ─── Derived Stats Hook ────────────────────────────────────────────────────── @@ -124,6 +126,7 @@ export function SpireCombatPage() { const { currentFloor, castProgress, + currentAction, isDescending, currentRoom, activityLog, @@ -141,9 +144,13 @@ export function SpireCombatPage() { stayLongerInRoom, invocationCharge, activeInvocation, + isChanneling, + startChanneling, + stopChanneling, } = useCombatStore(useShallow((s) => ({ currentFloor: s.currentFloor, castProgress: s.castProgress, + currentAction: s.currentAction, isDescending: s.isDescending, currentRoom: s.currentRoom, activityLog: s.activityLog, @@ -161,6 +168,9 @@ export function SpireCombatPage() { stayLongerInRoom: s.stayLongerInRoom, invocationCharge: s.invocationCharge, activeInvocation: s.activeInvocation, + isChanneling: s.isChanneling, + startChanneling: s.startChanneling, + stopChanneling: s.stopChanneling, }))); const { rawMana, elements } = useManaStore(useShallow((s) => ({ @@ -180,6 +190,11 @@ export function SpireCombatPage() { const day = useGameStore((s) => s.day); const hour = useGameStore((s) => s.hour); + const enchanterActive = useAttunementStore((s) => s.attunements?.enchanter?.active ?? false); + const transference = elements.transference; + const channelStats = computeChannelStats(); + + const showChannelButton = enchanterActive && currentAction === 'climb' && transference && transference.current > 0; const { maxMana, baseRegen } = useSpireStats(prestigeUpgrades, equippedInstances, equipmentInstances); @@ -198,6 +213,18 @@ export function SpireCombatPage() { exitSpireMode(); }; + const handleChannelMouseDown = () => { + startChanneling(); + }; + + const handleChannelMouseUp = () => { + stopChanneling(); + }; + + const handleChannelMouseLeave = () => { + if (isChanneling) stopChanneling(); + }; + return (
@@ -228,12 +255,53 @@ export function SpireCombatPage() {
- {/* Invocation Panel (§11.1) */} + {/* Invocation Panel */} + {/* Transference Channel Button */} + {showChannelButton && ( + + + +
+
+ Transference + + {fmt(transference.current)} / {fmt(transference.max)} + +
+ + {isChanneling && ( +
+ ⚡ {channelStats.speedMultiplier.toFixed(2)}× speed + Draining {(channelStats.drainRate * 5).toFixed(2)}/s +
+ )} +
+
+
+ )} +
- +
diff --git a/src/lib/game/data/disciplines/enchanter.ts b/src/lib/game/data/disciplines/enchanter.ts index 1c26c7c..4ddb057 100644 --- a/src/lib/game/data/disciplines/enchanter.ts +++ b/src/lib/game/data/disciplines/enchanter.ts @@ -143,4 +143,43 @@ export const enchanterDisciplines: DisciplineDefinition[] = [ }, ], }, + { + id: 'transference-channeling', + name: 'Transference Channeling', + attunement: DisciplinesAttunementType.ENCHANTER, + manaType: 'transference', + baseCost: 15, + description: 'Channel transference mana through your equipment, making enchanted gear strike and cast faster. Higher mastery costs more mana but grants greater speed.', + statBonus: { stat: 'channelIntensity', baseValue: 0.10, label: 'Channel Intensity' }, + difficultyFactor: 180, + scalingFactor: 100, + drainBase: 4, + perks: [ + { + id: 'channel-efficiency', + type: 'once', + threshold: 100, + value: 0, + description: '15% less drain for same speed', + bonus: { stat: 'channelEfficiency', amount: 0.15 }, + }, + { + id: 'channel-power', + type: 'infinite', + threshold: 200, + value: 150, + description: '+0.05 Channel Intensity per tier', + bonus: { stat: 'channelIntensity', amount: 0.05 }, + }, + { + id: 'channel-mastery', + type: 'capped', + threshold: 400, + value: 200, + maxTier: 3, + description: '+0.10 Channel Efficiency per tier (max 3)', + bonus: { stat: 'channelEfficiency', amount: 0.10 }, + }, + ], + }, ]; diff --git a/src/lib/game/effects/discipline-effects.ts b/src/lib/game/effects/discipline-effects.ts index 9946d1a..6de3c07 100644 --- a/src/lib/game/effects/discipline-effects.ts +++ b/src/lib/game/effects/discipline-effects.ts @@ -62,6 +62,8 @@ const KNOWN_BONUS_STATS = new Set([ 'conversion_soul', 'conversion_plasma', 'conversion_time', + 'channelIntensity', + 'channelEfficiency', ]); export interface DisciplineEffectsResult { diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts index 9e46b43..e24cc45 100644 --- a/src/lib/game/stores/combat-actions.ts +++ b/src/lib/game/stores/combat-actions.ts @@ -18,6 +18,7 @@ import { processGolemAttacksFromStore } from './golem-combat-helpers'; import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage'; import { processInvocationTick } from './combat-invocation'; import { processMeleeTick } from './combat-melee'; +import { computeChannelStats, applyChannelDrain, getChannelMultiplier } from './combat-channel'; // ─── Result Type ─────────────────────────────────────────────────────────────── @@ -103,6 +104,16 @@ export function processCombatTick( } try { + // ─── Transference Channel: update stats + drain ────────────────────── + const channelStats = computeChannelStats(); + set({ + channelSpeedMultiplier: channelStats.speedMultiplier, + channelDrainRate: channelStats.drainRate, + }); + if (state.isChanneling) { + applyChannelDrain(get, set, channelStats.drainRate); + } + // ─── Golem maintenance (spec §13) ────────────────────────────────────── const golemDesigns = state.golemancy.golemDesigns || {}; const maintenanceResult = processGolemMaintenance( @@ -206,7 +217,8 @@ export function processCombatTick( const isESpellAoe = !!eSpellDef.isAoe; const eSpellCastSpeed = eSpellDef.castSpeed || 1; - const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * totalAttackSpeed; + const channelMult = getChannelMultiplier(get); + const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * totalAttackSpeed * channelMult; let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick; let eSafetyCounter = 0; diff --git a/src/lib/game/stores/combat-channel-actions.ts b/src/lib/game/stores/combat-channel-actions.ts new file mode 100644 index 0000000..92d726f --- /dev/null +++ b/src/lib/game/stores/combat-channel-actions.ts @@ -0,0 +1,20 @@ +// ─── Combat Channel Actions ──────────────────────────────────────────────────── +// Extracted from combatStore.ts to keep the store under the 400-line limit. +// Provides startChanneling/stopChanneling action factory for the combat store. + +import type { CombatStore, CombatState } from './combat-state.types'; + +type GetFn = () => CombatStore; +type SetFn = (state: Partial) => void; + +export function createChannelActions(get: GetFn, set: SetFn) { + return { + startChanneling: () => { + set({ isChanneling: true }); + }, + + stopChanneling: () => { + set({ isChanneling: false }); + }, + }; +} diff --git a/src/lib/game/stores/combat-channel.ts b/src/lib/game/stores/combat-channel.ts new file mode 100644 index 0000000..dee222a --- /dev/null +++ b/src/lib/game/stores/combat-channel.ts @@ -0,0 +1,73 @@ +// ─── Transference Channel Combat Logic ───────────────────────────────────────── +// Extracted from combat-actions.ts to stay under the 400-line file limit. +// Handles Transference mana drain and speed multiplier application. + +import { computeDisciplineEffects } from '../effects/discipline-effects'; +import { useManaStore } from './manaStore'; +import type { CombatStore, CombatState } from './combat-state.types'; + +// ─── Base Values ─────────────────────────────────────────────────────────────── + +const BASE_DRAIN_RATE = 0.08; +const BASE_SPEED_MULTIPLIER = 1.5; +const CHANNEL_EFFICIENCY_CAP = 0.60; + +// ─── Effective Stat Computation ──────────────────────────────────────────────── + +export interface ChannelStats { + speedMultiplier: number; + drainRate: number; +} + +/** + * Compute effective channel speed multiplier and drain rate from discipline stats. + * + * Formulas: + * channelEfficiency = min(CHANNEL_EFFICIENCY_CAP, sum of efficiency perks) + * effectiveSpeedMultiplier = 1.5 + (channelIntensity × 0.5) + * effectiveDrainRate = 0.08 × (1 + channelIntensity × 1.0) × (1 - channelEfficiency) + */ +export function computeChannelStats(): ChannelStats { + const effects = computeDisciplineEffects(); + const channelIntensity = effects.bonuses.channelIntensity || 0; + const rawEfficiency = effects.bonuses.channelEfficiency || 0; + const channelEfficiency = Math.min(CHANNEL_EFFICIENCY_CAP, rawEfficiency); + + const speedMultiplier = BASE_SPEED_MULTIPLIER + channelIntensity * 0.5; + const drainRate = BASE_DRAIN_RATE * (1 + channelIntensity * 1.0) * (1 - channelEfficiency); + + return { speedMultiplier, drainRate }; +} + +// ─── Channel Drain ───────────────────────────────────────────────────────────── + +/** + * Apply one tick of Transference drain while channeling. + * Returns the amount of Transference actually drained (0 if insufficient mana). + */ +export function applyChannelDrain( + get: () => CombatStore, + set: (s: Partial) => void, + drainRate: number, +): number { + const transference = useManaStore.getState().elements.transference; + if (!transference || transference.current < drainRate) { + set({ isChanneling: false }); + return 0; + } + useManaStore.getState().deductElement('transference', drainRate); + return drainRate; +} + +// ─── Speed Multiplier Application ────────────────────────────────────────────── + +/** + * Get the current channel speed multiplier. + * Returns 1.0 if not channeling. + */ +export function getChannelMultiplier( + get: () => CombatStore, +): number { + const state = get(); + return state.isChanneling ? state.channelSpeedMultiplier : 1.0; +} diff --git a/src/lib/game/stores/combat-melee.ts b/src/lib/game/stores/combat-melee.ts index 98bc6ac..7885276 100644 --- a/src/lib/game/stores/combat-melee.ts +++ b/src/lib/game/stores/combat-melee.ts @@ -7,6 +7,7 @@ import type { EquipmentInstance } from '../types'; import { getFloorElement, getMultiElementBonus, calcMeleeDamage } from '../utils'; import { getGuardianForFloor } from '../data/guardian-encounters'; import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage'; +import { getChannelMultiplier } from './combat-channel'; export interface MeleeTickParams { get: () => CombatStore; @@ -61,7 +62,8 @@ export function processMeleeTick( const swordType = EQUIPMENT_TYPES[swordInstance.typeId]; if (!swordType || !swordType.stats?.attackSpeed) continue; const swordAttackSpeed = swordType.stats.attackSpeed; - const meleeProgressPerTick = HOURS_PER_TICK * swordAttackSpeed * attackSpeedMult; + const channelMult = getChannelMultiplier(get); + const meleeProgressPerTick = HOURS_PER_TICK * swordAttackSpeed * attackSpeedMult * channelMult; let meleeProgress = (updatedMeleeSwordProgress[instanceId] || 0) + meleeProgressPerTick; let meleeSafetyCounter = 0; while (meleeProgress >= 1 && meleeSafetyCounter < 100) { diff --git a/src/lib/game/stores/combat-reset.ts b/src/lib/game/stores/combat-reset.ts index 9c8f645..4578997 100644 --- a/src/lib/game/stores/combat-reset.ts +++ b/src/lib/game/stores/combat-reset.ts @@ -63,5 +63,10 @@ export function createDefaultCombatState( totalSpellsCast: 0, totalDamageDealt: 0, totalCraftsCompleted: 0, + + // Transference Channel defaults + isChanneling: false, + channelSpeedMultiplier: 1.5, + channelDrainRate: 0.08, }; } diff --git a/src/lib/game/stores/combat-state.types.ts b/src/lib/game/stores/combat-state.types.ts index d0044ec..b5ae121 100644 --- a/src/lib/game/stores/combat-state.types.ts +++ b/src/lib/game/stores/combat-state.types.ts @@ -96,6 +96,11 @@ export interface CombatState { // Invocation system invocationCharge: number; activeInvocation: ActiveInvocation | null; + + // ─── Transference Channel ────────────────────────────────────────────── + isChanneling: boolean; + channelSpeedMultiplier: number; + channelDrainRate: number; } // ─── Combat Actions ─────────────────────────────────────────────────────────── @@ -199,6 +204,10 @@ export interface CombatActions { // Invocation resetInvocationState: () => void; + // Transference Channel + startChanneling: () => void; + stopChanneling: () => void; + // Reset resetCombat: (startFloor: number, spellsToKeep?: string[]) => void; } diff --git a/src/lib/game/stores/combatStore.ts b/src/lib/game/stores/combatStore.ts index dd8bddb..5de6eed 100644 --- a/src/lib/game/stores/combatStore.ts +++ b/src/lib/game/stores/combatStore.ts @@ -22,6 +22,7 @@ import { useDisciplineStore } from './discipline-slice'; import { addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry, } from './golemancy-actions'; +import { createChannelActions } from './combat-channel-actions'; export const useCombatStore = create()( persist( @@ -108,6 +109,11 @@ export const useCombatStore = create()( invocationCharge: 0, activeInvocation: null, + // Transference Channel state + isChanneling: false, + channelSpeedMultiplier: 1.5, + channelDrainRate: 0.08, + setCurrentFloor: (floor: number) => { set({ currentFloor: floor, @@ -217,6 +223,7 @@ export const useCombatStore = create()( golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[] }, invocationCharge: 0, activeInvocation: null, + isChanneling: false, }); // Deactivate all disciplines on spire exit for safety useDisciplineStore.getState().deactivateAll(); @@ -290,6 +297,8 @@ export const useCombatStore = create()( set({ invocationCharge: 0, activeInvocation: null }); }, + ...createChannelActions(get, set), + initGuardianDefensiveState: () => { const state = get(); const guardian = getGuardianForFloor(state.currentFloor); @@ -378,6 +387,9 @@ export const useCombatStore = create()( runId: state.runId, invocationCharge: state.invocationCharge, activeInvocation: state.activeInvocation, + isChanneling: state.isChanneling, + channelSpeedMultiplier: state.channelSpeedMultiplier, + channelDrainRate: state.channelDrainRate, }), } )