feat: implement Room Enchantments system for Enchanter attunement
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user