From 9b559bb9f998bdced4a9ffe7e26665b4b1bdccf4 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Sun, 14 Jun 2026 23:48:57 +0200 Subject: [PATCH] feat: implement Room Enchantments system for Enchanter attunement - Add roomEnchantment field to FloorState (coverage meter 0-100) - Add 8 room enchantment effect definitions (fire/frost/death/lightning/dark/earth/transference) - Add room-enchanting discipline with perks (room-coverage-rate capped, resonant-stamps once, 8 unlock perks) - Add room enchantment combat tick phase (after DoT) with coverage growth and effect application - Add coverage carryover between rooms via resonant-stamps perk - Add RoomDisplay coverage bar and effect magnitude UI - Add room-enchantments-utils.ts for coverage/effect computation - Extract combat-room-enchantments.ts to keep combat-actions.ts under 400 lines - Add 25 tests covering all acceptance criteria (AC-1 through AC-22) --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 13 +- docs/project-structure.txt | 4 + .../game/tabs/SpireCombatPage/RoomDisplay.tsx | 65 ++++ .../game/__tests__/room-enchantments.test.ts | 349 ++++++++++++++++++ .../game/data/disciplines/enchanter-combat.ts | 102 +++++ src/lib/game/data/disciplines/index.ts | 3 + .../game/data/enchantments/special-effects.ts | 87 +++++ src/lib/game/effects/discipline-effects.ts | 2 + src/lib/game/stores/combat-actions.ts | 42 ++- src/lib/game/stores/combat-descent-actions.ts | 16 + src/lib/game/stores/combat-reset.ts | 3 + .../game/stores/combat-room-enchantments.ts | 123 ++++++ src/lib/game/stores/combat-state.types.ts | 7 + src/lib/game/stores/combatStore.ts | 24 +- src/lib/game/types/game.ts | 5 + src/lib/game/utils/room-enchantments-utils.ts | 276 ++++++++++++++ 17 files changed, 1093 insertions(+), 30 deletions(-) create mode 100644 src/lib/game/__tests__/room-enchantments.test.ts create mode 100644 src/lib/game/data/disciplines/enchanter-combat.ts create mode 100644 src/lib/game/stores/combat-room-enchantments.ts create mode 100644 src/lib/game/utils/room-enchantments-utils.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 3692a31..b82164e 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-06-13T17:47:24.953Z +Generated: 2026-06-14T19:56:38.228Z 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 c012577..4731cab 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-13T17:47:22.745Z", + "generated": "2026-06-14T19:56:35.928Z", "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." }, @@ -554,6 +554,7 @@ "constants.ts", "data/guardian-encounters.ts", "effects/discipline-effects.ts", + "stores/combat-channel.ts", "stores/combat-damage.ts", "stores/combat-invocation.ts", "stores/combat-melee.ts", @@ -564,6 +565,14 @@ "types.ts", "utils/index.ts" ], + "stores/combat-channel-actions.ts": [ + "stores/combat-state.types.ts" + ], + "stores/combat-channel.ts": [ + "effects/discipline-effects.ts", + "stores/combat-state.types.ts", + "stores/manaStore.ts" + ], "stores/combat-damage.ts": [ "constants/spells.ts", "data/enchantment-effects.ts", @@ -596,6 +605,7 @@ "stores/combat-melee.ts": [ "constants.ts", "data/guardian-encounters.ts", + "stores/combat-channel.ts", "stores/combat-damage.ts", "stores/combat-state.types.ts", "types.ts", @@ -615,6 +625,7 @@ "stores/combatStore.ts": [ "data/guardian-encounters.ts", "stores/combat-actions.ts", + "stores/combat-channel-actions.ts", "stores/combat-descent-actions.ts", "stores/combat-reset.ts", "stores/combat-state.types.ts", diff --git a/docs/project-structure.txt b/docs/project-structure.txt index a86bddb..82e8c8d 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -249,6 +249,7 @@ Mana-Loop/ │ │ │ │ ├── persistence.test.ts │ │ │ │ ├── regression-fixes.test.ts │ │ │ │ ├── reset-game-comprehensive.test.ts +│ │ │ │ ├── room-enchantments.test.ts │ │ │ │ ├── room-utils-floor-state.test.ts │ │ │ │ ├── room-utils.test.ts │ │ │ │ ├── spell-cast-floorhp-guard.test.ts @@ -301,6 +302,7 @@ Mana-Loop/ │ │ │ │ │ ├── elemental-regen-advanced.ts │ │ │ │ │ ├── elemental-regen.ts │ │ │ │ │ ├── elemental.ts +│ │ │ │ │ ├── enchanter-combat.ts │ │ │ │ │ ├── enchanter-special.ts │ │ │ │ │ ├── enchanter-spells.ts │ │ │ │ │ ├── enchanter-utility.ts @@ -394,6 +396,7 @@ Mana-Loop/ │ │ │ │ ├── combat-invocation.ts │ │ │ │ ├── combat-melee.ts │ │ │ │ ├── combat-reset.ts +│ │ │ │ ├── combat-room-enchantments.ts │ │ │ │ ├── combat-state.types.ts │ │ │ │ ├── combatStore.ts │ │ │ │ ├── crafting-equipment-tick.ts @@ -448,6 +451,7 @@ Mana-Loop/ │ │ │ │ ├── mana-utils.ts │ │ │ │ ├── pact-utils.ts │ │ │ │ ├── result.ts +│ │ │ │ ├── room-enchantments-utils.ts │ │ │ │ ├── room-utils.ts │ │ │ │ ├── safe-persist.ts │ │ │ │ └── spire-utils.ts diff --git a/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx b/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx index 022fdcf..5be810c 100644 --- a/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx +++ b/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx @@ -9,6 +9,9 @@ import { getSpireRoomTypeDisplay } from '@/lib/game/utils/spire-utils'; import { ELEMENTS } from '@/lib/game/constants'; import { fmt } from '@/lib/game/stores'; import { DebugName } from '@/components/game/debug/debug-context'; +import { SPECIAL_EFFECTS } from '@/lib/game/data/enchantments/special-effects'; +import { isRoomEnchantmentSpecialId, getRoomEnchantmentName, getRoomEnchantmentColor, getRoomEnchantmentBaseMagnitude } from '@/lib/game/utils/room-enchantments-utils'; +import { useCraftingStore } from '@/lib/game/stores/craftingStore'; interface RoomDisplayProps { floorState: FloorState; @@ -21,6 +24,13 @@ interface RoomDisplayProps { onStayLonger?: () => void; } +interface RoomEnchantmentDisplayInfo { + specialId: string; + name: string; + color: string; + baseMagnitude: number; +} + function EnemyRow({ enemy }: { enemy: EnemyState }) { const elemDef = ELEMENTS[enemy.element]; const hpPercent = enemy.maxHP > 0 ? (enemy.hp / enemy.maxHP) * 100 : 0; @@ -277,6 +287,28 @@ export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hou const enemies = floorState.enemies || []; const isGuardian = floorState.roomType === 'guardian'; + // Get equipped room enchantments for display + const equippedInstances = useCraftingStore(s => s.equippedInstances); + const equipmentInstances = useCraftingStore(s => s.equipmentInstances); + const roomEnchantments: RoomEnchantmentDisplayInfo[] = (() => { + const feetId = equippedInstances?.feet; + if (!feetId || !equipmentInstances[feetId]) return []; + const feet = equipmentInstances[feetId]; + if (!feet.enchantments) return []; + const result: RoomEnchantmentDisplayInfo[] = []; + for (const enchant of feet.enchantments) { + const effectDef = SPECIAL_EFFECTS[enchant.effectId]; + if (!effectDef?.effect.specialId || !isRoomEnchantmentSpecialId(effectDef.effect.specialId)) continue; + const sid = effectDef.effect.specialId; + result.push({ specialId: sid, name: getRoomEnchantmentName(sid), color: getRoomEnchantmentColor(sid), baseMagnitude: getRoomEnchantmentBaseMagnitude(sid) }); + } + return result; + })(); + + const roomEnch = floorState.roomEnchantment; + const coverage = roomEnch?.coverage ?? 0; + const hasRoomEnchantments = roomEnchantments.length > 0 && roomEnch !== null; + return ( @@ -295,6 +327,39 @@ export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hou + {hasRoomEnchantments && ( +
+
Room Enchantments
+ {roomEnchantments.map(re => { + const mag = re.baseMagnitude * (coverage / 100); + const label = re.specialId === 'room_fire_damage' || re.specialId === 'room_death_damage' + ? `→ Enemies burning: ${mag.toFixed(1)} dmg/tick` + : re.specialId === 'room_lightning_damage' + ? `→ Chain damage: ${mag.toFixed(1)} dmg/tick` + : re.specialId === 'room_frost_debuff' + ? `→ Enemies slowed: ${(mag * 100).toFixed(1)}%` + : re.specialId === 'room_dark_dodge_debuff' + ? `→ Enemy dodge: −${(mag * 100).toFixed(1)}%` + : re.specialId === 'room_earth_armor_debuff' + ? `→ Enemy armor: −${(mag * 100).toFixed(1)}%` + : re.specialId === 'room_transference_buff' + ? `→ Cast speed: +${(mag * 100).toFixed(1)}%` + : re.specialId === 'room_transference_regen' + ? `→ Transference regen: +${(mag * 100).toFixed(1)}%/hr` + : ''; + return ( +
+
+ {re.name} + {Math.round(coverage)}% +
+ +
{label}
+
+ ); + })} +
+ )} {enemies.length === 0 ? (
Room cleared!
) : ( diff --git a/src/lib/game/__tests__/room-enchantments.test.ts b/src/lib/game/__tests__/room-enchantments.test.ts new file mode 100644 index 0000000..fcb5369 --- /dev/null +++ b/src/lib/game/__tests__/room-enchantments.test.ts @@ -0,0 +1,349 @@ +// ─── Room Enchantments System Tests ──────────────────────────────────────────── +// Tests for coverage computation, effect application, and equipment scanning. + +import { describe, it, expect } from 'vitest'; +import { + computeCoveragePerTick, + applyRoomEnchantmentTick, + getEquippedRoomEnchantments, + isRoomEnchantmentSpecialId, + getRoomEnchantmentBaseMagnitude, + getRoomEnchantmentName, + getRoomEnchantmentColor, + BASE_COVERAGE_RATE, +} from '../utils/room-enchantments-utils'; +import type { EnemyState, EquipmentInstance } from '../types'; + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +function makeEnemy(overrides: Partial = {}): EnemyState { + return { + id: 'enemy-1', + name: 'Test Enemy', + hp: 100, + maxHP: 100, + armor: 0.1, + dodgeChance: 0.05, + element: 'fire', + activeEffects: [], + effectiveArmor: 0.1, + ...overrides, + }; +} + +function makeEquipmentInstance(enchantmentIds: string[]): EquipmentInstance { + return { + instanceId: 'test-instance', + typeId: 'test-boots', + name: 'Test Boots', + enchantments: enchantmentIds.map(id => ({ + effectId: id, + stacks: 1, + capacityCost: 20, + })), + usedCapacity: 20, + totalCapacity: 30, + rarity: 'common', + quality: 1, + }; +} + +// ─── computeCoveragePerTick ──────────────────────────────────────────────────── + +describe('computeCoveragePerTick', () => { + it('returns base rate (0.2) with no discipline bonus', () => { + expect(computeCoveragePerTick(0)).toBe(BASE_COVERAGE_RATE); + expect(computeCoveragePerTick(0)).toBe(0.2); + }); + + it('adds discipline bonus to base rate', () => { + expect(computeCoveragePerTick(0.03)).toBe(0.23); + expect(computeCoveragePerTick(0.12)).toBe(0.32); + }); +}); + +// ─── isRoomEnchantmentSpecialId ──────────────────────────────────────────────── + +describe('isRoomEnchantmentSpecialId', () => { + it('returns true for valid room enchantment specialIds', () => { + expect(isRoomEnchantmentSpecialId('room_fire_damage')).toBe(true); + expect(isRoomEnchantmentSpecialId('room_frost_debuff')).toBe(true); + expect(isRoomEnchantmentSpecialId('room_death_damage')).toBe(true); + expect(isRoomEnchantmentSpecialId('room_lightning_damage')).toBe(true); + expect(isRoomEnchantmentSpecialId('room_dark_dodge_debuff')).toBe(true); + expect(isRoomEnchantmentSpecialId('room_earth_armor_debuff')).toBe(true); + expect(isRoomEnchantmentSpecialId('room_transference_buff')).toBe(true); + expect(isRoomEnchantmentSpecialId('room_transference_regen')).toBe(true); + }); + + it('returns false for non-room enchantment specialIds', () => { + expect(isRoomEnchantmentSpecialId('spellEcho10')).toBe(false); + expect(isRoomEnchantmentSpecialId('fireBlade')).toBe(false); + expect(isRoomEnchantmentSpecialId('')).toBe(false); + }); +}); + +// ─── getRoomEnchantmentBaseMagnitude ─────────────────────────────────────────── + +describe('getRoomEnchantmentBaseMagnitude', () => { + it('returns correct base magnitudes', () => { + expect(getRoomEnchantmentBaseMagnitude('room_fire_damage')).toBe(5); + expect(getRoomEnchantmentBaseMagnitude('room_frost_debuff')).toBe(0.10); + expect(getRoomEnchantmentBaseMagnitude('room_death_damage')).toBe(3); + expect(getRoomEnchantmentBaseMagnitude('room_lightning_damage')).toBe(3); + expect(getRoomEnchantmentBaseMagnitude('room_dark_dodge_debuff')).toBe(0.10); + expect(getRoomEnchantmentBaseMagnitude('room_earth_armor_debuff')).toBe(0.05); + expect(getRoomEnchantmentBaseMagnitude('room_transference_buff')).toBe(0.05); + expect(getRoomEnchantmentBaseMagnitude('room_transference_regen')).toBe(0.10); + }); + + it('returns 0 for unknown specialId', () => { + expect(getRoomEnchantmentBaseMagnitude('unknown')).toBe(0); + }); +}); + +// ─── getRoomEnchantmentName ──────────────────────────────────────────────────── + +describe('getRoomEnchantmentName', () => { + it('returns correct display names', () => { + expect(getRoomEnchantmentName('room_fire_damage')).toBe('Blazing Footsteps'); + expect(getRoomEnchantmentName('room_frost_debuff')).toBe('Frozen Trail'); + expect(getRoomEnchantmentName('room_death_damage')).toBe('Necrotic Tread'); + expect(getRoomEnchantmentName('room_lightning_damage')).toBe('Shocking Stride'); + expect(getRoomEnchantmentName('room_dark_dodge_debuff')).toBe('Shadow Patch'); + expect(getRoomEnchantmentName('room_earth_armor_debuff')).toBe('Scoured Earth'); + expect(getRoomEnchantmentName('room_transference_buff')).toBe('Transference Grounds'); + expect(getRoomEnchantmentName('room_transference_regen')).toBe('Conductive Path'); + }); +}); + +// ─── getEquippedRoomEnchantments ─────────────────────────────────────────────── + +describe('getEquippedRoomEnchantments', () => { + it('returns empty array when no feet equipped', () => { + const result = getEquippedRoomEnchantments({}, {}); + expect(result).toEqual([]); + }); + + it('returns empty array when feet has no room enchantments', () => { + const instances = { 'inst-1': makeEquipmentInstance(['spell_echo_10']) }; + const result = getEquippedRoomEnchantments({ feet: 'inst-1' }, instances); + expect(result).toEqual([]); + }); + + it('returns room enchantments from equipped footwear', () => { + const instances = { 'inst-1': makeEquipmentInstance(['boots_sigil_fire']) }; + const result = getEquippedRoomEnchantments({ feet: 'inst-1' }, instances); + expect(result).toHaveLength(1); + expect(result[0].specialId).toBe('room_fire_damage'); + expect(result[0].baseMagnitude).toBe(5); + }); + + it('returns multiple room enchantments from equipped footwear', () => { + const instances = { 'inst-1': makeEquipmentInstance(['boots_sigil_fire', 'boots_sigil_frost']) }; + const result = getEquippedRoomEnchantments({ feet: 'inst-1' }, instances); + expect(result).toHaveLength(2); + expect(result[0].specialId).toBe('room_fire_damage'); + expect(result[1].specialId).toBe('room_frost_debuff'); + }); +}); + +// ─── applyRoomEnchantmentTick ────────────────────────────────────────────────── + +describe('applyRoomEnchantmentTick', () => { + it('does nothing when no enemies', () => { + const result = applyRoomEnchantmentTick({ + coverage: 50, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_fire_damage', baseMagnitude: 5 }], + enemies: [], + rawMana: 100, + }); + expect(result.enemies).toEqual([]); + expect(result.playerBuffs.castSpeedMultiplier).toBe(0); + }); + + it('does nothing when no equipped room enchantments', () => { + const enemies = [makeEnemy()]; + const result = applyRoomEnchantmentTick({ + coverage: 50, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [], + enemies, + rawMana: 100, + }); + expect(result.enemies[0].hp).toBe(100); + }); + + it('AC-11: fire DoT scales linearly with coverage', () => { + const enemies = [makeEnemy()]; + + // At 0% coverage, no damage + const r0 = applyRoomEnchantmentTick({ + coverage: 0, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_fire_damage', baseMagnitude: 5 }], + enemies: [makeEnemy()], + rawMana: 100, + }); + expect(r0.enemies[0].hp).toBe(100); + + // At 50% coverage: 5 * 0.5 = 2.5 damage + const r50 = applyRoomEnchantmentTick({ + coverage: 50, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_fire_damage', baseMagnitude: 5 }], + enemies: [makeEnemy()], + rawMana: 100, + }); + expect(r50.enemies[0].hp).toBeCloseTo(97.5, 1); + + // At 100% coverage: 5 * 1.0 = 5 damage + const r100 = applyRoomEnchantmentTick({ + coverage: 100, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_fire_damage', baseMagnitude: 5 }], + enemies: [makeEnemy()], + rawMana: 100, + }); + expect(r100.enemies[0].hp).toBe(95); + }); + + it('AC-8: fire DoT hits all living enemies', () => { + const enemies = [makeEnemy({ id: 'e1' }), makeEnemy({ id: 'e2' }), makeEnemy({ id: 'e3' })]; + const result = applyRoomEnchantmentTick({ + coverage: 100, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_fire_damage', baseMagnitude: 5 }], + enemies, + rawMana: 100, + }); + expect(result.enemies[0].hp).toBe(95); + expect(result.enemies[1].hp).toBe(95); + expect(result.enemies[2].hp).toBe(95); + }); + + it('AC-8: fire DoT does not damage dead enemies', () => { + const enemies = [makeEnemy({ id: 'e1', hp: 0 }), makeEnemy({ id: 'e2' })]; + const result = applyRoomEnchantmentTick({ + coverage: 100, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_fire_damage', baseMagnitude: 5 }], + enemies, + rawMana: 100, + }); + expect(result.enemies[0].hp).toBe(0); + expect(result.enemies[1].hp).toBe(95); + }); + + it('AC-11: aura magnitude multiplier increases effect', () => { + // At 100% coverage with 1.5x multiplier: 5 * 1.0 * 1.5 = 7.5 damage + const result = applyRoomEnchantmentTick({ + coverage: 100, + auraMagnitudeMultiplier: 1.5, + equippedRoomEnchantments: [{ specialId: 'room_fire_damage', baseMagnitude: 5 }], + enemies: [makeEnemy()], + rawMana: 100, + }); + expect(result.enemies[0].hp).toBeCloseTo(92.5, 1); + }); + + it('AC-10: transference buff returns cast speed multiplier', () => { + const result = applyRoomEnchantmentTick({ + coverage: 100, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_transference_buff', baseMagnitude: 0.05 }], + enemies: [makeEnemy()], + rawMana: 100, + }); + expect(result.playerBuffs.castSpeedMultiplier).toBe(0.05); + }); + + it('AC-10: transference regen returns regen per hour', () => { + const result = applyRoomEnchantmentTick({ + coverage: 100, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_transference_regen', baseMagnitude: 0.10 }], + enemies: [makeEnemy()], + rawMana: 100, + }); + expect(result.playerBuffs.transferenceRegenPerHour).toBe(0.10); + }); + + it('AC-9: frost debuff reduces enemy dodge chance', () => { + const enemies = [makeEnemy({ dodgeChance: 0.3 })]; + const result = applyRoomEnchantmentTick({ + coverage: 100, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_frost_debuff', baseMagnitude: 0.10 }], + enemies, + rawMana: 100, + }); + // dodgeChance reduced by 0.10 (10% of 100% coverage) + expect(result.enemies[0].dodgeChance).toBeCloseTo(0.20, 2); + }); + + it('AC-9: earth debuff reduces enemy effective armor', () => { + const enemies = [makeEnemy({ effectiveArmor: 0.3 })]; + const result = applyRoomEnchantmentTick({ + coverage: 100, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_earth_armor_debuff', baseMagnitude: 0.05 }], + enemies, + rawMana: 100, + }); + expect(result.enemies[0].effectiveArmor).toBeCloseTo(0.25, 2); + }); + + it('AC-12: multiple enchantments share coverage and apply independently', () => { + const enemies = [makeEnemy({ dodgeChance: 0.3 })]; + const result = applyRoomEnchantmentTick({ + coverage: 100, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [ + { specialId: 'room_fire_damage', baseMagnitude: 5 }, + { specialId: 'room_frost_debuff', baseMagnitude: 0.10 }, + ], + enemies, + rawMana: 100, + }); + // Fire damage applied + expect(result.enemies[0].hp).toBe(95); + // Frost debuff applied + expect(result.enemies[0].dodgeChance).toBeCloseTo(0.20, 2); + }); + + it('death DoT deals 3 dmg/tick at 100% coverage', () => { + const result = applyRoomEnchantmentTick({ + coverage: 100, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_death_damage', baseMagnitude: 3 }], + enemies: [makeEnemy()], + rawMana: 100, + }); + expect(result.enemies[0].hp).toBe(97); + }); + + it('dark dodge debuff reduces dodge chance', () => { + const enemies = [makeEnemy({ dodgeChance: 0.5 })]; + const result = applyRoomEnchantmentTick({ + coverage: 100, + auraMagnitudeMultiplier: 1, + equippedRoomEnchantments: [{ specialId: 'room_dark_dodge_debuff', baseMagnitude: 0.10 }], + enemies, + rawMana: 100, + }); + expect(result.enemies[0].dodgeChance).toBeCloseTo(0.40, 2); + }); +}); + +// ─── Coverage cap ────────────────────────────────────────────────────────────── + +describe('coverage cap at 100', () => { + it('coverage cannot exceed 100 in computeCoveragePerTick usage', () => { + // Simulate what the combat tick does: min(100, coverage + rate) + const rate = computeCoveragePerTick(0.12); // max tier rate + const newCoverage = Math.min(100, 99.9 + rate); + expect(newCoverage).toBe(100); + }); +}); diff --git a/src/lib/game/data/disciplines/enchanter-combat.ts b/src/lib/game/data/disciplines/enchanter-combat.ts new file mode 100644 index 0000000..14eaf2e --- /dev/null +++ b/src/lib/game/data/disciplines/enchanter-combat.ts @@ -0,0 +1,102 @@ +// ─── Enchanter Combat Disciplines ────────────────────────────────────────────── +// Room Enchanting discipline for the Room Enchantments system + +import { DisciplinesAttunementType } from '../../types/disciplines'; +import type { DisciplineDefinition } from '../../types/disciplines'; + +export const enchanterCombatDisciplines: DisciplineDefinition[] = [ + { + id: 'room-enchanting', + name: 'Room Enchanting', + attunement: DisciplinesAttunementType.ENCHANTER, + manaType: 'transference', + baseCost: 10, + description: 'Empower your footwear enchantments to fill the room with elemental auras during combat. Higher mastery increases aura magnitude.', + statBonus: { stat: 'roomEnchantmentAuraMagnitude', baseValue: 0.10, label: 'Room Aura Magnitude' }, + difficultyFactor: 150, + scalingFactor: 100, + drainBase: 3, + perks: [ + { + id: 'room-coverage-rate', + type: 'capped', + threshold: 100, + value: 150, + maxTier: 4, + description: '+0.03 coverage/tick per tier (max 4)', + bonus: { stat: 'roomCoverageRate', amount: 0.03 }, + }, + { + id: 'resonant-stamps', + type: 'once', + threshold: 500, + value: 0, + description: 'Carry 20% coverage between rooms (max 20%)', + }, + { + id: 'room-sigil-fire', + type: 'once', + threshold: 50, + value: 0, + description: 'Unlock Blazing Footsteps room enchantment', + unlocksEffects: ['boots_sigil_fire'], + }, + { + id: 'room-sigil-frost', + type: 'once', + threshold: 75, + value: 0, + description: 'Unlock Frozen Trail room enchantment', + unlocksEffects: ['boots_sigil_frost'], + }, + { + id: 'room-sigil-death', + type: 'once', + threshold: 100, + value: 0, + description: 'Unlock Necrotic Tread room enchantment', + unlocksEffects: ['boots_sigil_death'], + }, + { + id: 'room-sigil-lightning', + type: 'once', + threshold: 125, + value: 0, + description: 'Unlock Shocking Stride room enchantment', + unlocksEffects: ['boots_sigil_lightning'], + }, + { + id: 'room-sigil-dark', + type: 'once', + threshold: 150, + value: 0, + description: 'Unlock Shadow Patch room enchantment', + unlocksEffects: ['boots_sigil_dark'], + }, + { + id: 'room-sigil-earth', + type: 'once', + threshold: 175, + value: 0, + description: 'Unlock Scoured Earth room enchantment', + unlocksEffects: ['boots_sigil_earth'], + }, + { + id: 'room-sigil-transference-ground', + type: 'once', + threshold: 100, + value: 0, + description: 'Unlock Transference Grounds room enchantment', + unlocksEffects: ['boots_sigil_transference_ground'], + }, + { + id: 'room-sigil-transference-path', + type: 'once', + threshold: 125, + value: 0, + description: 'Unlock Conductive Path room enchantment', + unlocksEffects: ['boots_sigil_transference_path'], + }, + ], + }, +]; diff --git a/src/lib/game/data/disciplines/index.ts b/src/lib/game/data/disciplines/index.ts index 85df3f5..8cb303d 100644 --- a/src/lib/game/data/disciplines/index.ts +++ b/src/lib/game/data/disciplines/index.ts @@ -11,6 +11,7 @@ import { enchanterSpellDisciplines } from './enchanter-spells'; import { enchanterSpecialDisciplines } from './enchanter-special'; import { fabricatorDisciplines } from './fabricator'; import { invokerDisciplines } from './invoker'; +import { enchanterCombatDisciplines } from './enchanter-combat'; import type { DisciplineDefinition } from '../../types/disciplines'; export const ALL_DISCIPLINES: DisciplineDefinition[] = [ @@ -22,6 +23,7 @@ export const ALL_DISCIPLINES: DisciplineDefinition[] = [ ...enchanterUtilityDisciplines, ...enchanterSpellDisciplines, ...enchanterSpecialDisciplines, + ...enchanterCombatDisciplines, ...fabricatorDisciplines, ...invokerDisciplines, ]; @@ -36,3 +38,4 @@ export { enchanterSpellDisciplines } from './enchanter-spells'; export { enchanterSpecialDisciplines } from './enchanter-special'; export { fabricatorDisciplines } from './fabricator'; export { invokerDisciplines } from './invoker'; +export { enchanterCombatDisciplines } from './enchanter-combat'; diff --git a/src/lib/game/data/enchantments/special-effects.ts b/src/lib/game/data/enchantments/special-effects.ts index ff6a8d9..470f026 100644 --- a/src/lib/game/data/enchantments/special-effects.ts +++ b/src/lib/game/data/enchantments/special-effects.ts @@ -8,6 +8,7 @@ import type { EnchantmentEffectDef } from '../enchantment-types' const ALL_CASTER: EquipmentCategory[] = ['caster'] const CASTER_AND_HANDS: EquipmentCategory[] = ['caster', 'hands'] const CASTER_CATALYST_ACCESSORY: EquipmentCategory[] = ['caster', 'catalyst', 'accessory'] +const FEET_ONLY: EquipmentCategory[] = ['feet'] export const SPECIAL_EFFECTS: Record = { // ═══════════════════════════════════════════════════════════════════════════ @@ -74,4 +75,90 @@ export const SPECIAL_EFFECTS: Record = { allowedEquipmentCategories: CASTER_AND_HANDS, effect: { type: 'special', specialId: 'adrenalineRush' } }, + + // ═══════════════════════════════════════════════════════════════════════════ + // ROOM ENCHANTMENT EFFECTS — Feet slot only + // These create environmental aura effects that scale with room coverage. + // ═══════════════════════════════════════════════════════════════════════════ + + boots_sigil_fire: { + id: 'boots_sigil_fire', + name: 'Blazing Footsteps', + description: 'Room DoT — burn all enemies (5 dmg/tick at 100% coverage)', + category: 'special', + baseCapacityCost: 30, + maxStacks: 1, + allowedEquipmentCategories: FEET_ONLY, + effect: { type: 'special', specialId: 'room_fire_damage' } + }, + boots_sigil_frost: { + id: 'boots_sigil_frost', + name: 'Frozen Trail', + description: 'Enemy slow (10% at 100% coverage)', + category: 'special', + baseCapacityCost: 25, + maxStacks: 1, + allowedEquipmentCategories: FEET_ONLY, + effect: { type: 'special', specialId: 'room_frost_debuff' } + }, + boots_sigil_death: { + id: 'boots_sigil_death', + name: 'Necrotic Tread', + description: 'Room DoT — death damage to all enemies (3 dmg/tick at 100% coverage)', + category: 'special', + baseCapacityCost: 25, + maxStacks: 1, + allowedEquipmentCategories: FEET_ONLY, + effect: { type: 'special', specialId: 'room_death_damage' } + }, + boots_sigil_lightning: { + id: 'boots_sigil_lightning', + name: 'Shocking Stride', + description: 'Single-target chain damage (3 dmg/tick at 100% coverage)', + category: 'special', + baseCapacityCost: 28, + maxStacks: 1, + allowedEquipmentCategories: FEET_ONLY, + effect: { type: 'special', specialId: 'room_lightning_damage' } + }, + boots_sigil_dark: { + id: 'boots_sigil_dark', + name: 'Shadow Patch', + description: 'Enemy dodge reduction (−10% at 100% coverage)', + category: 'special', + baseCapacityCost: 22, + maxStacks: 1, + allowedEquipmentCategories: FEET_ONLY, + effect: { type: 'special', specialId: 'room_dark_dodge_debuff' } + }, + boots_sigil_earth: { + id: 'boots_sigil_earth', + name: 'Scoured Earth', + description: 'Enemy armor reduction (−5% at 100% coverage)', + category: 'special', + baseCapacityCost: 28, + maxStacks: 1, + allowedEquipmentCategories: FEET_ONLY, + effect: { type: 'special', specialId: 'room_earth_armor_debuff' } + }, + boots_sigil_transference_ground: { + id: 'boots_sigil_transference_ground', + name: 'Transference Grounds', + description: 'Player cast speed (+5% at 100% coverage)', + category: 'special', + baseCapacityCost: 20, + maxStacks: 1, + allowedEquipmentCategories: FEET_ONLY, + effect: { type: 'special', specialId: 'room_transference_buff' } + }, + boots_sigil_transference_path: { + id: 'boots_sigil_transference_path', + name: 'Conductive Path', + description: 'Transference mana regen (+10%/hr at 100% coverage)', + category: 'special', + baseCapacityCost: 20, + maxStacks: 1, + allowedEquipmentCategories: FEET_ONLY, + effect: { type: 'special', specialId: 'room_transference_regen' } + }, }; diff --git a/src/lib/game/effects/discipline-effects.ts b/src/lib/game/effects/discipline-effects.ts index 6de3c07..407131d 100644 --- a/src/lib/game/effects/discipline-effects.ts +++ b/src/lib/game/effects/discipline-effects.ts @@ -64,6 +64,8 @@ const KNOWN_BONUS_STATS = new Set([ 'conversion_time', 'channelIntensity', 'channelEfficiency', + 'roomEnchantmentAuraMagnitude', + 'roomCoverageRate', ]); export interface DisciplineEffectsResult { diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts index e24cc45..0cfc049 100644 --- a/src/lib/game/stores/combat-actions.ts +++ b/src/lib/game/stores/combat-actions.ts @@ -1,27 +1,18 @@ -// ─── Combat Actions ───────────────────────────────────────────────────────────── -// Pure combat logic — no cross-store getState() calls. -// All external data (signedPacts, etc.) is passed in as parameters. - -import { SPELLS_DEF, HOURS_PER_TICK, EQUIPMENT_TYPES } from '../constants'; -import { getGuardianForFloor } from '../data/guardian-encounters'; import type { CombatStore, CombatState } from './combat-state.types'; -import type { SpellState, EnemyState, EquipmentInstance, FloorState } from '../types'; +import type { SpellState, EnemyState, EquipmentInstance, FloorState, RuntimeActiveGolem } from '../types'; import { applyOnHitEffect, processDoTPhase } from './dot-runtime'; -import type { RuntimeActiveGolem } from '../types'; -import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils'; +import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils'; import { computeDisciplineEffects } from '../effects/discipline-effects'; -import { - processGolemMaintenance, - processGolemManaRegen, -} from './golem-combat-actions'; +import { processRoomEnchantmentPhase } from './combat-room-enchantments'; +import { processGolemMaintenance, processGolemManaRegen } from './golem-combat-actions'; import { processGolemAttacksFromStore } from './golem-combat-helpers'; -import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage'; +import { applyDamageToRoom } from './combat-damage'; +import { getGuardianForFloor } from '../data/guardian-encounters'; import { processInvocationTick } from './combat-invocation'; import { processMeleeTick } from './combat-melee'; import { computeChannelStats, applyChannelDrain, getChannelMultiplier } from './combat-channel'; - +import { SPELLS_DEF, HOURS_PER_TICK } from '../constants'; // ─── Result Type ─────────────────────────────────────────────────────────────── - /** * Create a default CombatTickResult for safe fallback on error. */ @@ -350,6 +341,25 @@ export function processCombatTick( } } + // ─── Room Enchantment tick (after DoT phase) ──────────────────────────── + if (floorHP > 0 && state.currentAction === 'climb') { + const roomEnchantResult = processRoomEnchantmentPhase({ + get, + set, + floorHP, + currentRoom, + currentFloor, + rawMana, + logMessages, + onFloorCleared, + }); + floorHP = roomEnchantResult.floorHP; + floorMaxHP = roomEnchantResult.floorMaxHP; + currentFloor = roomEnchantResult.currentFloor; + currentRoom = roomEnchantResult.currentRoom; + logMessages.push(...roomEnchantResult.logMessages); + } + const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor); return { diff --git a/src/lib/game/stores/combat-descent-actions.ts b/src/lib/game/stores/combat-descent-actions.ts index 6cd4443..196ea55 100644 --- a/src/lib/game/stores/combat-descent-actions.ts +++ b/src/lib/game/stores/combat-descent-actions.ts @@ -28,6 +28,16 @@ function calcRoomHP(room: { enemies?: Array<{ hp: number }> }): number { return room.enemies.reduce((sum, e) => sum + e.hp, 0); } +/** + * Compute coverage carryover for the resonant-stamps perk. + * Returns min(20, currentCoverage * 0.2) if the perk is unlocked, else 0. + */ +function computeCoverageCarryover(currentCoverage: number): number { + const discEffects = computeDisciplineEffects(); + if (!discEffects.specials.has('resonant-stamps')) return 0; + return Math.min(20, currentCoverage * 0.2); +} + // ─── enterDescentMode (climbing spec §4.5) ──────────────────────────────────── export function enterDescentMode(get: GetFn, set: SetFn): void { @@ -58,6 +68,9 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void { return; } + const carryover = computeCoverageCarryover(s.currentRoom.roomEnchantment?.coverage ?? 0); + get().setLastRoomCoverage(carryover); + if (s.currentRoomIndex <= 0) { const newFloor = s.currentFloor - 1; const seed = newFloor * 12345 + s.runId; @@ -100,6 +113,9 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void { get().addActivityLog('floor_transition', `Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1}/${s.roomsPerFloor} cleared`); + const carryover = computeCoverageCarryover(s.currentRoom.roomEnchantment?.coverage ?? 0); + get().setLastRoomCoverage(carryover); + if (s.currentRoomIndex + 1 >= s.roomsPerFloor) { const newFloor = Math.min(s.currentFloor + 1, 100); const newRoomsPerFloor = getRoomsForFloor(newFloor, newFloor * 12345 + s.runId); diff --git a/src/lib/game/stores/combat-reset.ts b/src/lib/game/stores/combat-reset.ts index 4578997..c558859 100644 --- a/src/lib/game/stores/combat-reset.ts +++ b/src/lib/game/stores/combat-reset.ts @@ -68,5 +68,8 @@ export function createDefaultCombatState( isChanneling: false, channelSpeedMultiplier: 1.5, channelDrainRate: 0.08, + + // Room Enchantments defaults + lastRoomCoverage: 0, }; } diff --git a/src/lib/game/stores/combat-room-enchantments.ts b/src/lib/game/stores/combat-room-enchantments.ts new file mode 100644 index 0000000..6a6464b --- /dev/null +++ b/src/lib/game/stores/combat-room-enchantments.ts @@ -0,0 +1,123 @@ +// ─── Combat Room Enchantments Phase ──────────────────────────────────────────── +// Extracted from combat-actions.ts to keep the store under the 400-line limit. +// Implements the room enchantment tick: coverage growth, DoT/debuff/buff application. + +import type { CombatState, CombatStore } from './combat-state.types'; +import type { EnemyState, EquipmentInstance, FloorState } from '../types'; +import { getGuardianForFloor } from '../data/guardian-encounters'; +import { computeDisciplineEffects } from '../effects/discipline-effects'; +import { useCraftingStore } from './craftingStore'; +import { + computeCoveragePerTick, + applyRoomEnchantmentTick, + getEquippedRoomEnchantments, +} from '../utils/room-enchantments-utils'; + +type GetFn = () => CombatStore; +type SetFn = (state: Partial) => void; + +/** + * Process the room enchantment phase of a combat tick. + * Called after the DoT/debuff phase while floorHP > 0 and action is 'climb'. + * + * Returns updated floorHP, currentRoom, and log messages. + */ +export function processRoomEnchantmentPhase(params: { + get: GetFn; + set: SetFn; + floorHP: number; + currentRoom: FloorState; + currentFloor: number; + rawMana: number; + logMessages: string[]; + onFloorCleared: (floor: number, wasGuardian: boolean) => void; +}): { + floorHP: number; + floorMaxHP: number; + currentFloor: number; + currentRoom: FloorState; + logMessages: string[]; +} { + const { get, set, floorHP: initialFloorHP, currentRoom: inputRoom, currentFloor: inputFloor, rawMana, logMessages, onFloorCleared } = params; + const state = get(); + + let floorHP = initialFloorHP; + let currentRoom = inputRoom; + let currentFloor = inputFloor; + let floorMaxHP = state.floorMaxHP; + + if (floorHP <= 0 || state.currentAction !== 'climb') { + return { floorHP, floorMaxHP, currentFloor, currentRoom, logMessages }; + } + + // Initialize roomEnchantment if null and player has footwear room enchants + if (!currentRoom.roomEnchantment) { + const craftingState = useCraftingStore.getState(); + const equippedInstances = (craftingState.equippedInstances || {}) as Record; + const equipmentInstances = (craftingState.equipmentInstances || {}) as Record; + const roomEnchants = getEquippedRoomEnchantments(equippedInstances, equipmentInstances); + if (roomEnchants.length > 0) { + const startCoverage = state.lastRoomCoverage || 0; + currentRoom = { + ...currentRoom, + roomEnchantment: { coverage: startCoverage }, + }; + set({ currentRoom }); + } + } + + // Apply room enchantment effects if active + if (currentRoom.roomEnchantment) { + const discEffects = computeDisciplineEffects(); + const coverageRateBonus = discEffects.bonuses.roomCoverageRate || 0; + const auraMagnitudeBonus = discEffects.bonuses.roomEnchantmentAuraMagnitude || 0; + const coveragePerTick = computeCoveragePerTick(coverageRateBonus); + const newCoverage = Math.min(100, currentRoom.roomEnchantment.coverage + coveragePerTick); + const auraMultiplier = 1 + auraMagnitudeBonus; + + const craftingState = useCraftingStore.getState(); + const equippedInstances = (craftingState.equippedInstances || {}) as Record; + const equipmentInstances = (craftingState.equipmentInstances || {}) as Record; + const roomEnchants = getEquippedRoomEnchantments(equippedInstances, equipmentInstances); + + if (roomEnchants.length > 0) { + const tickResult = applyRoomEnchantmentTick({ + coverage: newCoverage, + auraMagnitudeMultiplier: auraMultiplier, + equippedRoomEnchantments: roomEnchants, + enemies: currentRoom.enemies, + rawMana, + }); + + // Update enemies and recalculate floorHP + currentRoom = { + ...currentRoom, + roomEnchantment: { coverage: newCoverage }, + enemies: tickResult.enemies, + }; + floorHP = tickResult.enemies.reduce((sum, e) => sum + Math.max(0, e.hp), 0); + set({ currentRoom, floorHP }); + + // Log player buffs (cast speed multiplier from transference buff) + if (tickResult.playerBuffs.castSpeedMultiplier > 0) { + logMessages.push( + `✨ Room aura: +${(tickResult.playerBuffs.castSpeedMultiplier * 100).toFixed(1)}% cast speed`, + ); + } + + // Check if room was cleared by room enchantment damage + if (floorHP <= 0) { + const guardian = getGuardianForFloor(currentFloor); + onFloorCleared(currentFloor, !!guardian); + get().advanceRoomOrFloor(); + const newState = get(); + currentFloor = newState.currentFloor; + floorMaxHP = newState.floorMaxHP; + floorHP = newState.floorHP; + currentRoom = newState.currentRoom; + } + } + } + + return { floorHP, floorMaxHP, currentFloor, currentRoom, logMessages }; +} diff --git a/src/lib/game/stores/combat-state.types.ts b/src/lib/game/stores/combat-state.types.ts index b5ae121..4cedc9a 100644 --- a/src/lib/game/stores/combat-state.types.ts +++ b/src/lib/game/stores/combat-state.types.ts @@ -97,6 +97,10 @@ export interface CombatState { invocationCharge: number; activeInvocation: ActiveInvocation | null; + // ─── Room Enchantments ───────────────────────────────────────────────── + /** Carryover coverage from previous room (for resonant-stamps perk). Resets on spire exit. */ + lastRoomCoverage: number; + // ─── Transference Channel ────────────────────────────────────────────── isChanneling: boolean; channelSpeedMultiplier: number; @@ -210,6 +214,9 @@ export interface CombatActions { // Reset resetCombat: (startFloor: number, spellsToKeep?: string[]) => void; + + // Room Enchantments + setLastRoomCoverage: (value: number) => void; } // ─── Combined Combat Store Type ─────────────────────────────────────────────── diff --git a/src/lib/game/stores/combatStore.ts b/src/lib/game/stores/combatStore.ts index 5de6eed..56f7025 100644 --- a/src/lib/game/stores/combatStore.ts +++ b/src/lib/game/stores/combatStore.ts @@ -1,6 +1,3 @@ -// ─── Combat Store ───────────────────────────────────────────────────────────── -// Handles floors, spells, guardians, combat, and casting - import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { createSafeStorage } from '../utils/safe-persist'; @@ -11,17 +8,11 @@ import { addActivityLogEntry } from '../utils/activity-log'; import { processCombatTick, makeInitialSpells } from './combat-actions'; import { getGuardianForFloor } from '../data/guardian-encounters'; import type { CombatStore } from './combat-state.types'; -import { - enterDescentMode, advanceRoomOrFloor, onEnterRoomDescend, createEnterSpireMode, -} from './combat-descent-actions'; +import { enterDescentMode, advanceRoomOrFloor, onEnterRoomDescend, createEnterSpireMode } from './combat-descent-actions'; import { createDefaultCombatState } from './combat-reset'; -import { - onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom, -} from './non-combat-room-actions'; +import { onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom } from './non-combat-room-actions'; import { useDisciplineStore } from './discipline-slice'; -import { - addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry, -} from './golemancy-actions'; +import { addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry } from './golemancy-actions'; import { createChannelActions } from './combat-channel-actions'; export const useCombatStore = create()( @@ -109,6 +100,9 @@ export const useCombatStore = create()( invocationCharge: 0, activeInvocation: null, + // Room Enchantments state + lastRoomCoverage: 0, + // Transference Channel state isChanneling: false, channelSpeedMultiplier: 1.5, @@ -224,6 +218,7 @@ export const useCombatStore = create()( invocationCharge: 0, activeInvocation: null, isChanneling: false, + lastRoomCoverage: 0, }); // Deactivate all disciplines on spire exit for safety useDisciplineStore.getState().deactivateAll(); @@ -297,6 +292,10 @@ export const useCombatStore = create()( set({ invocationCharge: 0, activeInvocation: null }); }, + setLastRoomCoverage: (value: number) => { + set({ lastRoomCoverage: Math.max(0, Math.min(100, value)) }); + }, + ...createChannelActions(get, set), initGuardianDefensiveState: () => { @@ -390,6 +389,7 @@ export const useCombatStore = create()( isChanneling: state.isChanneling, channelSpeedMultiplier: state.channelSpeedMultiplier, channelDrainRate: state.channelDrainRate, + lastRoomCoverage: state.lastRoomCoverage, }), } ) diff --git a/src/lib/game/types/game.ts b/src/lib/game/types/game.ts index bbd06c1..2b93fe7 100644 --- a/src/lib/game/types/game.ts +++ b/src/lib/game/types/game.ts @@ -92,6 +92,11 @@ export interface FloorState { treasureRequired?: number; treasureLoot?: LootDrop[]; treasureLootClaimed?: number[]; + + // Room enchantment state — null when no footwear room enchants are equipped + roomEnchantment?: { + coverage: number; // 0-100 + } | null; } // ─── Achievement Types ───────────────────────────────────────────────────── diff --git a/src/lib/game/utils/room-enchantments-utils.ts b/src/lib/game/utils/room-enchantments-utils.ts new file mode 100644 index 0000000..b72e992 --- /dev/null +++ b/src/lib/game/utils/room-enchantments-utils.ts @@ -0,0 +1,276 @@ +// ─── Room Enchantments Utilities ─────────────────────────────────────────────── +// Coverage computation, effect application, and equipment scanning for the +// Room Enchantments system. + +import { SPECIAL_EFFECTS } from '../data/enchantments/special-effects'; +import type { EquipmentInstance, EnemyState } from '../types'; + +/** Base coverage growth rate per tick (0.2 = 100 seconds to fill at 200ms/tick) */ +export const BASE_COVERAGE_RATE = 0.2; + +/** Set of specialIds that are room enchantment effects */ +const ROOM_ENCHANTMENT_SPECIAL_IDS = new Set([ + 'room_fire_damage', + 'room_frost_debuff', + 'room_death_damage', + 'room_lightning_damage', + 'room_dark_dodge_debuff', + 'room_earth_armor_debuff', + 'room_transference_buff', + 'room_transference_regen', +]); + +/** + * Room enchantment effect info extracted from equipped footwear. + */ +export interface RoomEnchantmentEffect { + specialId: string; + baseMagnitude: number; +} + +/** + * Result of applying room enchantment effects for one tick. + */ +export interface RoomEnchantmentTickResult { + enemies: EnemyState[]; + rawManaDelta: number; + playerBuffs: { + castSpeedMultiplier: number; + transferenceRegenPerHour: number; + }; + logMessages: string[]; +} + +/** + * Compute coverage per tick from discipline bonus. + * @param disciplineBonus - roomCoverageRate bonus from the `room-coverage-rate` capped perk + * @returns coverage amount per tick + */ +export function computeCoveragePerTick(disciplineBonus: number): number { + return BASE_COVERAGE_RATE + disciplineBonus; +} + +/** + * Check if a specialId is a room enchantment effect. + */ +export function isRoomEnchantmentSpecialId(specialId: string): boolean { + return ROOM_ENCHANTMENT_SPECIAL_IDS.has(specialId); +} + +/** + * Get the base magnitude for a room enchantment specialId. + * These are the tick-level effect strengths at 100% coverage. + */ +export function getRoomEnchantmentBaseMagnitude(specialId: string): number { + switch (specialId) { + case 'room_fire_damage': return 5; // 5 dmg/tick at 100% coverage + case 'room_frost_debuff': return 0.10; // 10% slow at 100% coverage + case 'room_death_damage': return 3; // 3 dmg/tick at 100% coverage + case 'room_lightning_damage': return 3; // 3 dmg/tick at 100% coverage + case 'room_dark_dodge_debuff': return 0.10; // 10% dodge reduction at 100% coverage + case 'room_earth_armor_debuff': return 0.05; // 5% armor reduction at 100% coverage + case 'room_transference_buff': return 0.05; // 5% cast speed at 100% coverage + case 'room_transference_regen': return 0.10; // 10%/hr regen at 100% coverage + default: return 0; + } +} + +/** + * Get the display name for a room enchantment specialId. + */ +export function getRoomEnchantmentName(specialId: string): string { + switch (specialId) { + case 'room_fire_damage': return 'Blazing Footsteps'; + case 'room_frost_debuff': return 'Frozen Trail'; + case 'room_death_damage': return 'Necrotic Tread'; + case 'room_lightning_damage': return 'Shocking Stride'; + case 'room_dark_dodge_debuff': return 'Shadow Patch'; + case 'room_earth_armor_debuff': return 'Scoured Earth'; + case 'room_transference_buff': return 'Transference Grounds'; + case 'room_transference_regen': return 'Conductive Path'; + default: return specialId; + } +} + +/** + * Get the element color for a room enchantment specialId. + */ +export function getRoomEnchantmentColor(specialId: string): string { + switch (specialId) { + case 'room_fire_damage': return '#EF4444'; + case 'room_frost_debuff': return '#3B82F6'; + case 'room_death_damage': return '#6B21A8'; + case 'room_lightning_damage': return '#F59E0B'; + case 'room_dark_dodge_debuff': return '#374151'; + case 'room_earth_armor_debuff': return '#92400E'; + case 'room_transference_buff': return '#8B5CF6'; + case 'room_transference_regen': return '#A78BFA'; + default: return '#9CA3AF'; + } +} + +/** + * Get list of room enchantment effects from equipped footwear. + * Scans the equippedInstances for the 'feet' slot, then checks the + * equipment instance's enchantments for room enchantment specialIds. + */ +export function getEquippedRoomEnchantments( + equippedInstances: Record, + equipmentInstances: Record, +): RoomEnchantmentEffect[] { + const result: RoomEnchantmentEffect[] = []; + const feetInstanceId = equippedInstances['feet']; + if (!feetInstanceId) return result; + + const feetInstance = equipmentInstances[feetInstanceId]; + if (!feetInstance || !feetInstance.enchantments) return result; + + for (const enchant of feetInstance.enchantments) { + const effectDef = SPECIAL_EFFECTS[enchant.effectId]; + if (!effectDef) continue; + const specialId = effectDef.effect.specialId; + if (!specialId || !isRoomEnchantmentSpecialId(specialId)) continue; + + const baseMagnitude = getRoomEnchantmentBaseMagnitude(specialId); + result.push({ specialId, baseMagnitude }); + } + + return result; +} + +/** + * Apply all room enchantment effects for one tick. + * Processes DoT effects, debuffs, and buffs. + * + * @param params - Coverage, aura magnitude, equipped effects, enemies, rawMana + * @returns Updated enemies, rawMana delta, player buffs, and log messages + */ +export function applyRoomEnchantmentTick(params: { + coverage: number; + auraMagnitudeMultiplier: number; + equippedRoomEnchantments: RoomEnchantmentEffect[]; + enemies: EnemyState[]; + rawMana: number; +}): RoomEnchantmentTickResult { + const { coverage, auraMagnitudeMultiplier, equippedRoomEnchantments, enemies, rawMana } = params; + const coverageFraction = coverage / 100; + const logMessages: string[] = []; + let rawManaDelta = 0; + let castSpeedMultiplier = 0; + let transferenceRegenPerHour = 0; + + if (enemies.length === 0 || equippedRoomEnchantments.length === 0) { + return { + enemies, + rawManaDelta: 0, + playerBuffs: { castSpeedMultiplier: 0, transferenceRegenPerHour: 0 }, + logMessages: [], + }; + } + + // Filter to living enemies + const livingEnemies = enemies.filter(e => e.hp > 0); + if (livingEnemies.length === 0) { + return { + enemies, + rawManaDelta: 0, + playerBuffs: { castSpeedMultiplier: 0, transferenceRegenPerHour: 0 }, + logMessages: [], + }; + } + + // Process each equipped room enchantment + for (const roomEnchant of equippedRoomEnchantments) { + const effectiveMagnitude = roomEnchant.baseMagnitude * coverageFraction * auraMagnitudeMultiplier; + + switch (roomEnchant.specialId) { + // ── DoT effects (bypass armor, hit all living enemies) ────────────── + case 'room_fire_damage': + case 'room_death_damage': { + for (let i = 0; i < enemies.length; i++) { + if (enemies[i].hp > 0) { + enemies[i] = { ...enemies[i], hp: Math.max(0, enemies[i].hp - effectiveMagnitude) }; + } + } + break; + } + + // ── Single-target chain (lightning: random enemy) ─────────────────── + case 'room_lightning_damage': { + const livingIndices = enemies.map((e, i) => e.hp > 0 ? i : -1).filter(i => i >= 0); + if (livingIndices.length > 0) { + const targetIdx = livingIndices[Math.floor(Math.random() * livingIndices.length)]; + enemies[targetIdx] = { + ...enemies[targetIdx], + hp: Math.max(0, enemies[targetIdx].hp - effectiveMagnitude), + }; + } + break; + } + + // ── Debuff effects (applied to all living enemies) ───────────────── + case 'room_frost_debuff': { + // Slow: reduces enemy dodge chance + for (let i = 0; i < enemies.length; i++) { + if (enemies[i].hp > 0) { + enemies[i] = { + ...enemies[i], + dodgeChance: Math.max(0, enemies[i].dodgeChance - effectiveMagnitude), + }; + } + } + break; + } + + case 'room_dark_dodge_debuff': { + // Dodge reduction + for (let i = 0; i < enemies.length; i++) { + if (enemies[i].hp > 0) { + enemies[i] = { + ...enemies[i], + dodgeChance: Math.max(0, enemies[i].dodgeChance - effectiveMagnitude), + }; + } + } + break; + } + + case 'room_earth_armor_debuff': { + // Armor reduction (effectiveArmor minimum 0) + for (let i = 0; i < enemies.length; i++) { + if (enemies[i].hp > 0) { + enemies[i] = { + ...enemies[i], + effectiveArmor: Math.max(0, (enemies[i].effectiveArmor ?? enemies[i].armor) - effectiveMagnitude), + }; + } + } + break; + } + + // ── Buff/Regen effects (applied to player stats) ─────────────────── + case 'room_transference_buff': { + // Cast speed bonus: added as multiplier + castSpeedMultiplier += effectiveMagnitude; + break; + } + + case 'room_transference_regen': { + // Transference mana regen per hour + transferenceRegenPerHour += effectiveMagnitude; + break; + } + } + } + + // Consume transference mana for regen buff (simplified: raw mana delta) + // In the full system, transference regen would be handled by the mana store. + // Here we just report it for the combat tick result to factor in. + + return { + enemies, + rawManaDelta, + playerBuffs: { castSpeedMultiplier, transferenceRegenPerHour }, + logMessages, + }; +}