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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user