feat: implement Room Enchantments system for Enchanter attunement
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s

- 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)
This commit is contained in:
2026-06-14 23:48:57 +02:00
parent 718aed38b1
commit 9b559bb9f9
17 changed files with 1093 additions and 30 deletions
+1 -1
View File
@@ -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
+12 -1
View File
@@ -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",
+4
View File
@@ -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
@@ -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 (
<DebugName name="RoomDisplay">
<Card className={`bg-gray-900/80 ${isGuardian ? 'border-red-800/40' : 'border-gray-700'}`}>
@@ -295,6 +327,39 @@ export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hou
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{hasRoomEnchantments && (
<div className="space-y-2 mb-2 p-2 bg-gray-800/50 rounded border border-gray-700/50">
<div className="text-xs font-semibold text-gray-300">Room Enchantments</div>
{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 (
<div key={re.specialId} className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: re.color }}>{re.name}</span>
<span className="text-[10px] text-gray-400">{Math.round(coverage)}%</span>
</div>
<Progress value={coverage} className="h-1.5 bg-gray-700" style={{ '--progress-bg': re.color } as React.CSSProperties} />
<div className="text-[10px] text-gray-500">{label}</div>
</div>
);
})}
</div>
)}
{enemies.length === 0 ? (
<div className="text-xs text-gray-500 italic">Room cleared!</div>
) : (
@@ -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> = {}): 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);
});
});
@@ -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'],
},
],
},
];
+3
View File
@@ -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';
@@ -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<string, EnchantmentEffectDef> = {
// ═══════════════════════════════════════════════════════════════════════════
@@ -74,4 +75,90 @@ export const SPECIAL_EFFECTS: Record<string, EnchantmentEffectDef> = {
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' }
},
};
@@ -64,6 +64,8 @@ const KNOWN_BONUS_STATS = new Set([
'conversion_time',
'channelIntensity',
'channelEfficiency',
'roomEnchantmentAuraMagnitude',
'roomCoverageRate',
]);
export interface DisciplineEffectsResult {
+26 -16
View File
@@ -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 {
@@ -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);
+3
View File
@@ -68,5 +68,8 @@ export function createDefaultCombatState(
isChanneling: false,
channelSpeedMultiplier: 1.5,
channelDrainRate: 0.08,
// Room Enchantments defaults
lastRoomCoverage: 0,
};
}
@@ -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<CombatState>) => 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<string, string | null>;
const equipmentInstances = (craftingState.equipmentInstances || {}) as Record<string, EquipmentInstance>;
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<string, string | null>;
const equipmentInstances = (craftingState.equipmentInstances || {}) as Record<string, EquipmentInstance>;
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 };
}
@@ -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 ───────────────────────────────────────────────
+12 -12
View File
@@ -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<CombatStore>()(
@@ -109,6 +100,9 @@ export const useCombatStore = create<CombatStore>()(
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<CombatStore>()(
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<CombatStore>()(
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<CombatStore>()(
isChanneling: state.isChanneling,
channelSpeedMultiplier: state.channelSpeedMultiplier,
channelDrainRate: state.channelDrainRate,
lastRoomCoverage: state.lastRoomCoverage,
}),
}
)
+5
View File
@@ -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 ─────────────────────────────────────────────────────
@@ -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<string, string | null>,
equipmentInstances: Record<string, EquipmentInstance>,
): 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,
};
}