feat: implement Transference Channel system for Enchanter attunement
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
- 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
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# Circular Dependencies
|
# 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.
|
Found: 4 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||||
|
|
||||||
1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts
|
1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"generated": "2026-06-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.",
|
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
||||||
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
||||||
},
|
},
|
||||||
@@ -760,7 +760,6 @@
|
|||||||
"utils/element-cap-bonus.ts",
|
"utils/element-cap-bonus.ts",
|
||||||
"utils/element-distance.ts",
|
"utils/element-distance.ts",
|
||||||
"utils/index.ts",
|
"utils/index.ts",
|
||||||
"utils/invocation-utils.ts",
|
|
||||||
"utils/safe-persist.ts"
|
"utils/safe-persist.ts"
|
||||||
],
|
],
|
||||||
"stores/gameStore.types.ts": [],
|
"stores/gameStore.types.ts": [],
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ Mana-Loop/
|
|||||||
│ │ ├── attunements/
|
│ │ ├── attunements/
|
||||||
│ │ │ ├── enchanter/
|
│ │ │ ├── enchanter/
|
||||||
│ │ │ │ ├── systems/
|
│ │ │ │ ├── systems/
|
||||||
│ │ │ │ │ └── enchanting-spec.md
|
│ │ │ │ │ ├── enchanting-spec.md
|
||||||
|
│ │ │ │ │ ├── room-enchantments-spec.md
|
||||||
|
│ │ │ │ │ └── transference-channel-spec.md
|
||||||
│ │ │ │ └── enchanter-spec.md
|
│ │ │ │ └── enchanter-spec.md
|
||||||
│ │ │ ├── fabricator/
|
│ │ │ ├── fabricator/
|
||||||
│ │ │ │ ├── systems/
|
│ │ │ │ ├── systems/
|
||||||
@@ -385,6 +387,8 @@ Mana-Loop/
|
|||||||
│ │ │ │ │ └── pact-ritual.ts
|
│ │ │ │ │ └── pact-ritual.ts
|
||||||
│ │ │ │ ├── attunementStore.ts
|
│ │ │ │ ├── attunementStore.ts
|
||||||
│ │ │ │ ├── combat-actions.ts
|
│ │ │ │ ├── combat-actions.ts
|
||||||
|
│ │ │ │ ├── combat-channel-actions.ts
|
||||||
|
│ │ │ │ ├── combat-channel.ts
|
||||||
│ │ │ │ ├── combat-damage.ts
|
│ │ │ │ ├── combat-damage.ts
|
||||||
│ │ │ │ ├── combat-descent-actions.ts
|
│ │ │ │ ├── combat-descent-actions.ts
|
||||||
│ │ │ │ ├── combat-invocation.ts
|
│ │ │ │ ├── combat-invocation.ts
|
||||||
|
|||||||
@@ -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_<element>_<effectType>` (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<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
}): {
|
||||||
|
enemies: EnemyState[];
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
playerBuffs: {
|
||||||
|
castSpeedBonus: number;
|
||||||
|
transferenceRegenBonus: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get list of room enchantment effects from equipped footwear
|
||||||
|
export function getEquippedRoomEnchantments(
|
||||||
|
equippedInstances: Record<string, string | null>,
|
||||||
|
equipmentInstances: Record<string, EquipmentInstance>,
|
||||||
|
): 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)
|
||||||
@@ -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)
|
||||||
@@ -9,6 +9,8 @@ import { DebugName } from '@/components/game/debug/debug-context';
|
|||||||
|
|
||||||
interface SpireCombatControlsProps {
|
interface SpireCombatControlsProps {
|
||||||
castProgress: number;
|
castProgress: number;
|
||||||
|
isChanneling: boolean;
|
||||||
|
channelSpeedMultiplier: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSpellCost(cost: { type: 'raw' | 'element'; element?: string; amount: number }): string {
|
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 || '?'}`;
|
return `${cost.amount} ${elemDef?.sym || '?'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SpireCombatControls({ castProgress }: SpireCombatControlsProps) {
|
export function SpireCombatControls({ castProgress, isChanneling, channelSpeedMultiplier }: SpireCombatControlsProps) {
|
||||||
const spells = useCombatStore((s) => s.spells);
|
const spells = useCombatStore((s) => s.spells);
|
||||||
const activeSpell = useCombatStore((s) => s.activeSpell);
|
const activeSpell = useCombatStore((s) => s.activeSpell);
|
||||||
const setSpell = useCombatStore((s) => s.setSpell);
|
const setSpell = useCombatStore((s) => s.setSpell);
|
||||||
@@ -66,6 +68,18 @@ export function SpireCombatControls({ castProgress }: SpireCombatControlsProps)
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Transference Channel Status Indicator */}
|
||||||
|
{isChanneling && (
|
||||||
|
<Card className="border-teal-700 bg-teal-950/40 ring-1 ring-teal-500/50 shadow-[0_0_12px_rgba(26,188,156,0.3)]">
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-teal-300">⚡ Channeling</span>
|
||||||
|
<span className="text-xs text-teal-400">{channelSpeedMultiplier.toFixed(2)}×</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Active Spell Panel */}
|
{/* Active Spell Panel */}
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
<Card className="bg-gray-900/80 border-gray-700">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCombatStore, useManaStore, usePrestigeStore, useGameStore, fmt, comp
|
|||||||
import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
|
import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
|
||||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||||
import { useCraftingStore } from '@/lib/game/stores/craftingStore';
|
import { useCraftingStore } from '@/lib/game/stores/craftingStore';
|
||||||
|
import { useAttunementStore } from '@/lib/game/stores/attunementStore';
|
||||||
import { SpireHeader } from './SpireHeader';
|
import { SpireHeader } from './SpireHeader';
|
||||||
import { RoomDisplay } from './RoomDisplay';
|
import { RoomDisplay } from './RoomDisplay';
|
||||||
import { SpireCombatControls } from './SpireCombatControls';
|
import { SpireCombatControls } from './SpireCombatControls';
|
||||||
@@ -15,6 +16,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { getGuardianForFloor } from '@/lib/game/data/guardian-encounters';
|
import { getGuardianForFloor } from '@/lib/game/data/guardian-encounters';
|
||||||
import { SPELLS_DEF } from '@/lib/game/constants';
|
import { SPELLS_DEF } from '@/lib/game/constants';
|
||||||
|
import { computeChannelStats } from '@/lib/game/stores/combat-channel';
|
||||||
|
|
||||||
// ─── Derived Stats Hook ──────────────────────────────────────────────────────
|
// ─── Derived Stats Hook ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -124,6 +126,7 @@ export function SpireCombatPage() {
|
|||||||
const {
|
const {
|
||||||
currentFloor,
|
currentFloor,
|
||||||
castProgress,
|
castProgress,
|
||||||
|
currentAction,
|
||||||
isDescending,
|
isDescending,
|
||||||
currentRoom,
|
currentRoom,
|
||||||
activityLog,
|
activityLog,
|
||||||
@@ -141,9 +144,13 @@ export function SpireCombatPage() {
|
|||||||
stayLongerInRoom,
|
stayLongerInRoom,
|
||||||
invocationCharge,
|
invocationCharge,
|
||||||
activeInvocation,
|
activeInvocation,
|
||||||
|
isChanneling,
|
||||||
|
startChanneling,
|
||||||
|
stopChanneling,
|
||||||
} = useCombatStore(useShallow((s) => ({
|
} = useCombatStore(useShallow((s) => ({
|
||||||
currentFloor: s.currentFloor,
|
currentFloor: s.currentFloor,
|
||||||
castProgress: s.castProgress,
|
castProgress: s.castProgress,
|
||||||
|
currentAction: s.currentAction,
|
||||||
isDescending: s.isDescending,
|
isDescending: s.isDescending,
|
||||||
currentRoom: s.currentRoom,
|
currentRoom: s.currentRoom,
|
||||||
activityLog: s.activityLog,
|
activityLog: s.activityLog,
|
||||||
@@ -161,6 +168,9 @@ export function SpireCombatPage() {
|
|||||||
stayLongerInRoom: s.stayLongerInRoom,
|
stayLongerInRoom: s.stayLongerInRoom,
|
||||||
invocationCharge: s.invocationCharge,
|
invocationCharge: s.invocationCharge,
|
||||||
activeInvocation: s.activeInvocation,
|
activeInvocation: s.activeInvocation,
|
||||||
|
isChanneling: s.isChanneling,
|
||||||
|
startChanneling: s.startChanneling,
|
||||||
|
stopChanneling: s.stopChanneling,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
const { rawMana, elements } = useManaStore(useShallow((s) => ({
|
const { rawMana, elements } = useManaStore(useShallow((s) => ({
|
||||||
@@ -180,6 +190,11 @@ export function SpireCombatPage() {
|
|||||||
|
|
||||||
const day = useGameStore((s) => s.day);
|
const day = useGameStore((s) => s.day);
|
||||||
const hour = useGameStore((s) => s.hour);
|
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);
|
const { maxMana, baseRegen } = useSpireStats(prestigeUpgrades, equippedInstances, equipmentInstances);
|
||||||
|
|
||||||
@@ -198,6 +213,18 @@ export function SpireCombatPage() {
|
|||||||
exitSpireMode();
|
exitSpireMode();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChannelMouseDown = () => {
|
||||||
|
startChanneling();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChannelMouseUp = () => {
|
||||||
|
stopChanneling();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChannelMouseLeave = () => {
|
||||||
|
if (isChanneling) stopChanneling();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DebugName name="SpireCombatPage">
|
<DebugName name="SpireCombatPage">
|
||||||
<div className="min-h-screen bg-gray-950 flex flex-col">
|
<div className="min-h-screen bg-gray-950 flex flex-col">
|
||||||
@@ -228,12 +255,53 @@ export function SpireCombatPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invocation Panel (§11.1) */}
|
{/* Invocation Panel */}
|
||||||
<InvocationPanel
|
<InvocationPanel
|
||||||
invocationCharge={invocationCharge}
|
invocationCharge={invocationCharge}
|
||||||
activeInvocation={activeInvocation}
|
activeInvocation={activeInvocation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Transference Channel Button */}
|
||||||
|
{showChannelButton && (
|
||||||
|
<Card className={`border-teal-700 transition-all ${isChanneling ? 'bg-teal-950/40 ring-1 ring-teal-500/50 shadow-[0_0_12px_rgba(26,188,156,0.3)]' : 'bg-gray-900/80 border-gray-700'}`}>
|
||||||
|
<CardContent className="p-3 space-y-2">
|
||||||
|
<button
|
||||||
|
onMouseDown={handleChannelMouseDown}
|
||||||
|
onMouseUp={handleChannelMouseUp}
|
||||||
|
onMouseLeave={handleChannelMouseLeave}
|
||||||
|
onTouchStart={handleChannelMouseDown}
|
||||||
|
onTouchEnd={handleChannelMouseUp}
|
||||||
|
className={`w-full py-2 px-4 rounded font-medium text-sm transition-all ${
|
||||||
|
isChanneling
|
||||||
|
? 'bg-teal-600 text-white shadow-[0_0_16px_rgba(26,188,156,0.5)]'
|
||||||
|
: 'bg-teal-800/60 text-teal-200 hover:bg-teal-700/60 border border-teal-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🔗 {isChanneling ? 'Channeling...' : 'Channel Transference'}
|
||||||
|
</button>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-400">Transference</span>
|
||||||
|
<span className="text-teal-300">
|
||||||
|
{fmt(transference.current)} / {fmt(transference.max)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={(transference.current / transference.max) * 100}
|
||||||
|
className="h-1.5 bg-gray-800"
|
||||||
|
style={{ '--progress-bg': '#1ABC9C' } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
{isChanneling && (
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-gray-500">
|
||||||
|
<span>⚡ {channelStats.speedMultiplier.toFixed(2)}× speed</span>
|
||||||
|
<span>Draining {(channelStats.drainRate * 5).toFixed(2)}/s</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<RoomDisplay
|
<RoomDisplay
|
||||||
@@ -248,7 +316,11 @@ export function SpireCombatPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<SpireCombatControls castProgress={castProgress} />
|
<SpireCombatControls
|
||||||
|
castProgress={castProgress}
|
||||||
|
isChanneling={isChanneling}
|
||||||
|
channelSpeedMultiplier={channelStats.speedMultiplier}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ const KNOWN_BONUS_STATS = new Set([
|
|||||||
'conversion_soul',
|
'conversion_soul',
|
||||||
'conversion_plasma',
|
'conversion_plasma',
|
||||||
'conversion_time',
|
'conversion_time',
|
||||||
|
'channelIntensity',
|
||||||
|
'channelEfficiency',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export interface DisciplineEffectsResult {
|
export interface DisciplineEffectsResult {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { processGolemAttacksFromStore } from './golem-combat-helpers';
|
|||||||
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
|
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
|
||||||
import { processInvocationTick } from './combat-invocation';
|
import { processInvocationTick } from './combat-invocation';
|
||||||
import { processMeleeTick } from './combat-melee';
|
import { processMeleeTick } from './combat-melee';
|
||||||
|
import { computeChannelStats, applyChannelDrain, getChannelMultiplier } from './combat-channel';
|
||||||
|
|
||||||
// ─── Result Type ───────────────────────────────────────────────────────────────
|
// ─── Result Type ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -103,6 +104,16 @@ export function processCombatTick(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) ──────────────────────────────────────
|
// ─── Golem maintenance (spec §13) ──────────────────────────────────────
|
||||||
const golemDesigns = state.golemancy.golemDesigns || {};
|
const golemDesigns = state.golemancy.golemDesigns || {};
|
||||||
const maintenanceResult = processGolemMaintenance(
|
const maintenanceResult = processGolemMaintenance(
|
||||||
@@ -206,7 +217,8 @@ export function processCombatTick(
|
|||||||
|
|
||||||
const isESpellAoe = !!eSpellDef.isAoe;
|
const isESpellAoe = !!eSpellDef.isAoe;
|
||||||
const eSpellCastSpeed = eSpellDef.castSpeed || 1;
|
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 eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick;
|
||||||
|
|
||||||
let eSafetyCounter = 0;
|
let eSafetyCounter = 0;
|
||||||
|
|||||||
@@ -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<CombatState>) => void;
|
||||||
|
|
||||||
|
export function createChannelActions(get: GetFn, set: SetFn) {
|
||||||
|
return {
|
||||||
|
startChanneling: () => {
|
||||||
|
set({ isChanneling: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
stopChanneling: () => {
|
||||||
|
set({ isChanneling: false });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<CombatState>) => 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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { EquipmentInstance } from '../types';
|
|||||||
import { getFloorElement, getMultiElementBonus, calcMeleeDamage } from '../utils';
|
import { getFloorElement, getMultiElementBonus, calcMeleeDamage } from '../utils';
|
||||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||||
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
|
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
|
||||||
|
import { getChannelMultiplier } from './combat-channel';
|
||||||
|
|
||||||
export interface MeleeTickParams {
|
export interface MeleeTickParams {
|
||||||
get: () => CombatStore;
|
get: () => CombatStore;
|
||||||
@@ -61,7 +62,8 @@ export function processMeleeTick(
|
|||||||
const swordType = EQUIPMENT_TYPES[swordInstance.typeId];
|
const swordType = EQUIPMENT_TYPES[swordInstance.typeId];
|
||||||
if (!swordType || !swordType.stats?.attackSpeed) continue;
|
if (!swordType || !swordType.stats?.attackSpeed) continue;
|
||||||
const swordAttackSpeed = swordType.stats.attackSpeed;
|
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 meleeProgress = (updatedMeleeSwordProgress[instanceId] || 0) + meleeProgressPerTick;
|
||||||
let meleeSafetyCounter = 0;
|
let meleeSafetyCounter = 0;
|
||||||
while (meleeProgress >= 1 && meleeSafetyCounter < 100) {
|
while (meleeProgress >= 1 && meleeSafetyCounter < 100) {
|
||||||
|
|||||||
@@ -63,5 +63,10 @@ export function createDefaultCombatState(
|
|||||||
totalSpellsCast: 0,
|
totalSpellsCast: 0,
|
||||||
totalDamageDealt: 0,
|
totalDamageDealt: 0,
|
||||||
totalCraftsCompleted: 0,
|
totalCraftsCompleted: 0,
|
||||||
|
|
||||||
|
// Transference Channel defaults
|
||||||
|
isChanneling: false,
|
||||||
|
channelSpeedMultiplier: 1.5,
|
||||||
|
channelDrainRate: 0.08,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ export interface CombatState {
|
|||||||
// Invocation system
|
// Invocation system
|
||||||
invocationCharge: number;
|
invocationCharge: number;
|
||||||
activeInvocation: ActiveInvocation | null;
|
activeInvocation: ActiveInvocation | null;
|
||||||
|
|
||||||
|
// ─── Transference Channel ──────────────────────────────────────────────
|
||||||
|
isChanneling: boolean;
|
||||||
|
channelSpeedMultiplier: number;
|
||||||
|
channelDrainRate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Combat Actions ───────────────────────────────────────────────────────────
|
// ─── Combat Actions ───────────────────────────────────────────────────────────
|
||||||
@@ -199,6 +204,10 @@ export interface CombatActions {
|
|||||||
// Invocation
|
// Invocation
|
||||||
resetInvocationState: () => void;
|
resetInvocationState: () => void;
|
||||||
|
|
||||||
|
// Transference Channel
|
||||||
|
startChanneling: () => void;
|
||||||
|
stopChanneling: () => void;
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
|
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useDisciplineStore } from './discipline-slice';
|
|||||||
import {
|
import {
|
||||||
addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry,
|
addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry,
|
||||||
} from './golemancy-actions';
|
} from './golemancy-actions';
|
||||||
|
import { createChannelActions } from './combat-channel-actions';
|
||||||
|
|
||||||
export const useCombatStore = create<CombatStore>()(
|
export const useCombatStore = create<CombatStore>()(
|
||||||
persist(
|
persist(
|
||||||
@@ -108,6 +109,11 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
invocationCharge: 0,
|
invocationCharge: 0,
|
||||||
activeInvocation: null,
|
activeInvocation: null,
|
||||||
|
|
||||||
|
// Transference Channel state
|
||||||
|
isChanneling: false,
|
||||||
|
channelSpeedMultiplier: 1.5,
|
||||||
|
channelDrainRate: 0.08,
|
||||||
|
|
||||||
setCurrentFloor: (floor: number) => {
|
setCurrentFloor: (floor: number) => {
|
||||||
set({
|
set({
|
||||||
currentFloor: floor,
|
currentFloor: floor,
|
||||||
@@ -217,6 +223,7 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[] },
|
golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[] },
|
||||||
invocationCharge: 0,
|
invocationCharge: 0,
|
||||||
activeInvocation: null,
|
activeInvocation: null,
|
||||||
|
isChanneling: false,
|
||||||
});
|
});
|
||||||
// Deactivate all disciplines on spire exit for safety
|
// Deactivate all disciplines on spire exit for safety
|
||||||
useDisciplineStore.getState().deactivateAll();
|
useDisciplineStore.getState().deactivateAll();
|
||||||
@@ -290,6 +297,8 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
set({ invocationCharge: 0, activeInvocation: null });
|
set({ invocationCharge: 0, activeInvocation: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
...createChannelActions(get, set),
|
||||||
|
|
||||||
initGuardianDefensiveState: () => {
|
initGuardianDefensiveState: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const guardian = getGuardianForFloor(state.currentFloor);
|
const guardian = getGuardianForFloor(state.currentFloor);
|
||||||
@@ -378,6 +387,9 @@ export const useCombatStore = create<CombatStore>()(
|
|||||||
runId: state.runId,
|
runId: state.runId,
|
||||||
invocationCharge: state.invocationCharge,
|
invocationCharge: state.invocationCharge,
|
||||||
activeInvocation: state.activeInvocation,
|
activeInvocation: state.activeInvocation,
|
||||||
|
isChanneling: state.isChanneling,
|
||||||
|
channelSpeedMultiplier: state.channelSpeedMultiplier,
|
||||||
|
channelDrainRate: state.channelDrainRate,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user