Files
Mana-Loop/src/lib/game/__tests__/room-enchantments.test.ts
T
n8n-gitea 9b559bb9f9
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s
feat: implement Room Enchantments system for Enchanter attunement
- Add roomEnchantment field to FloorState (coverage meter 0-100)
- Add 8 room enchantment effect definitions (fire/frost/death/lightning/dark/earth/transference)
- Add room-enchanting discipline with perks (room-coverage-rate capped, resonant-stamps once, 8 unlock perks)
- Add room enchantment combat tick phase (after DoT) with coverage growth and effect application
- Add coverage carryover between rooms via resonant-stamps perk
- Add RoomDisplay coverage bar and effect magnitude UI
- Add room-enchantments-utils.ts for coverage/effect computation
- Extract combat-room-enchantments.ts to keep combat-actions.ts under 400 lines
- Add 25 tests covering all acceptance criteria (AC-1 through AC-22)
2026-06-14 23:48:57 +02:00

350 lines
14 KiB
TypeScript

// ─── 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);
});
});