import { expect } from "@esm-bundle/chai"; import { EffectProcessor } from "../../src/systems/EffectProcessor.js"; import { VoxelGrid } from "../../src/grid/VoxelGrid.js"; import { UnitManager } from "../../src/managers/UnitManager.js"; import { SeededRandom } from "../../src/utils/SeededRandom.js"; describe("Systems: EffectProcessor", function () { let processor; let grid; let unitManager; let mockRegistry; let sourceUnit; let targetUnit; beforeEach(() => { // Create mock registry mockRegistry = new Map(); mockRegistry.set("CLASS_VANGUARD", { id: "CLASS_VANGUARD", name: "Vanguard", type: "EXPLORER", base_stats: { health: 100, attack: 10, defense: 5, magic: 5, speed: 10, movement: 4, }, }); mockRegistry.set("ENEMY_GOBLIN", { id: "ENEMY_GOBLIN", name: "Goblin", type: "ENEMY", stats: { health: 50, attack: 8, defense: 3, magic: 0, }, }); unitManager = new UnitManager(mockRegistry); grid = new VoxelGrid(20, 5, 20); // Create a simple walkable floor at y=1 for (let x = 0; x < 20; x++) { for (let z = 0; z < 20; z++) { // Floor at y=0 grid.setCell(x, 0, z, 1); // Air at y=1 (walkable) grid.setCell(x, 1, z, 0); // Air at y=2 (headroom) grid.setCell(x, 2, z, 0); } } processor = new EffectProcessor(grid, unitManager); // Create test units sourceUnit = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); sourceUnit.position = { x: 5, y: 1, z: 5 }; sourceUnit.currentHealth = 100; sourceUnit.maxHealth = 100; grid.placeUnit(sourceUnit, sourceUnit.position); targetUnit = unitManager.createUnit("ENEMY_GOBLIN", "ENEMY"); targetUnit.position = { x: 6, y: 1, z: 5 }; targetUnit.currentHealth = 50; targetUnit.maxHealth = 50; grid.placeUnit(targetUnit, targetUnit.position); }); describe("Constructor", () => { it("should initialize with VoxelGrid and UnitManager", () => { expect(processor.voxelGrid).to.equal(grid); expect(processor.unitManager).to.equal(unitManager); expect(processor.handlers).to.be.instanceOf(Map); expect(processor.handlers.size).to.be.greaterThan(0); }); it("should register all handler types", () => { expect(processor.handlers.has("DAMAGE")).to.be.true; expect(processor.handlers.has("HEAL")).to.be.true; expect(processor.handlers.has("APPLY_STATUS")).to.be.true; expect(processor.handlers.has("REMOVE_STATUS")).to.be.true; expect(processor.handlers.has("PUSH")).to.be.true; expect(processor.handlers.has("PULL")).to.be.true; expect(processor.handlers.has("TELEPORT")).to.be.true; }); it("should accept optional RNG", () => { const rng = new SeededRandom(12345); const processorWithRng = new EffectProcessor(grid, unitManager, rng); expect(processorWithRng.rng).to.equal(rng); }); }); describe("CoA 1: Attribute Scaling", () => { it("should calculate power with attribute scaling", () => { const effectDef = { type: "DAMAGE", power: 10, attribute: "magic", scaling: 1.0, }; // Source has magic: 5, so power should be 10 + (5 * 1.0) = 15 const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(result.data.amount).to.equal(15 - targetUnit.baseStats.defense); // 15 - 3 = 12 }); it("should handle custom scaling multiplier", () => { const effectDef = { type: "DAMAGE", power: 10, attribute: "magic", scaling: 2.0, }; // Source has magic: 5, so power should be 10 + (5 * 2.0) = 20 const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(result.data.amount).to.equal(20 - targetUnit.baseStats.defense); // 20 - 3 = 17 }); it("should use base power when no attribute specified", () => { const effectDef = { type: "DAMAGE", power: 10, }; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(result.data.amount).to.equal(10 - targetUnit.baseStats.defense); // 10 - 3 = 7 }); }); describe("CoA 2: Conditional Logic", () => { it("should not execute if target_status condition is not met", () => { const effectDef = { type: "DAMAGE", power: 10, condition: { target_status: "WET", }, }; // Target does not have WET status targetUnit.statusEffects = []; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.false; expect(result.error).to.equal("Conditions not met"); expect(targetUnit.currentHealth).to.equal(50); // Health unchanged }); it("should execute if target_status condition is met", () => { const effectDef = { type: "DAMAGE", power: 10, condition: { target_status: "WET", }, }; // Target has WET status targetUnit.statusEffects = [{ id: "WET", type: "STATUS", duration: 3 }]; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(targetUnit.currentHealth).to.be.lessThan(50); // Health changed }); it("should not execute if hp_threshold condition is not met", () => { const effectDef = { type: "DAMAGE", power: 10, condition: { hp_threshold: 0.3, // 30% HP }, }; // Target has 50/50 HP = 100%, which is > 30% targetUnit.currentHealth = 50; targetUnit.maxHealth = 50; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.false; expect(result.error).to.equal("Conditions not met"); }); it("should execute if hp_threshold condition is met", () => { const effectDef = { type: "DAMAGE", power: 10, condition: { hp_threshold: 0.3, // 30% HP }, }; // Target has 10/50 HP = 20%, which is < 30% targetUnit.currentHealth = 10; targetUnit.maxHealth = 50; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(targetUnit.currentHealth).to.be.lessThan(10); // Health changed }); it("should execute if no conditions are specified", () => { const effectDef = { type: "DAMAGE", power: 10, }; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; }); }); describe("CoA 3: State Mutation", () => { it("should add status effect to target's statusEffects array", () => { const effectDef = { type: "APPLY_STATUS", status_id: "BURN", duration: 3, }; targetUnit.statusEffects = []; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(targetUnit.statusEffects).to.have.lengthOf(1); expect(targetUnit.statusEffects[0].id).to.equal("BURN"); expect(targetUnit.statusEffects[0].duration).to.equal(3); expect(targetUnit.statusEffects[0].type).to.equal("STATUS"); }); it("should refresh duration if status already exists", () => { const effectDef = { type: "APPLY_STATUS", status_id: "BURN", duration: 5, }; // Target already has BURN with duration 2 targetUnit.statusEffects = [ { id: "BURN", type: "STATUS", duration: 2 }, ]; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(targetUnit.statusEffects).to.have.lengthOf(1); expect(targetUnit.statusEffects[0].duration).to.equal(5); // Refreshed to max }); it("should not reduce duration if existing is longer", () => { const effectDef = { type: "APPLY_STATUS", status_id: "BURN", duration: 2, }; // Target already has BURN with duration 5 targetUnit.statusEffects = [ { id: "BURN", type: "STATUS", duration: 5 }, ]; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(targetUnit.statusEffects[0].duration).to.equal(5); // Kept longer duration }); }); describe("CoA 4: Physics Safety", () => { it("should not move unit into solid terrain on PUSH", () => { const effectDef = { type: "PUSH", force: 2, }; // Place a wall behind the target const wallPos = { x: 7, y: 1, z: 5 }; grid.setCell(wallPos.x, wallPos.y, wallPos.z, 10); // Wall ID const oldPosition = { ...targetUnit.position }; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.false; expect(result.error).to.equal("Push/Pull blocked by solid terrain"); expect(targetUnit.position.x).to.equal(oldPosition.x); expect(targetUnit.position.z).to.equal(oldPosition.z); }); it("should successfully push unit when path is clear", () => { const effectDef = { type: "PUSH", force: 1, }; const oldPosition = { ...targetUnit.position }; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; // Position should change (at least one coordinate) expect( targetUnit.position.x !== oldPosition.x || targetUnit.position.z !== oldPosition.z ).to.be.true; // Verify the unit was actually moved in the grid expect(grid.getUnitAt(targetUnit.position)).to.equal(targetUnit); }); }); describe("Handler: DAMAGE", () => { it("should reduce target's currentHealth", () => { const effectDef = { type: "DAMAGE", power: 20, }; const initialHealth = targetUnit.currentHealth; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(result.data.type).to.equal("DAMAGE"); expect(result.data.amount).to.be.greaterThan(0); expect(targetUnit.currentHealth).to.be.lessThan(initialHealth); expect(targetUnit.currentHealth).to.equal( initialHealth - result.data.amount ); }); it("should apply defense reduction", () => { const effectDef = { type: "DAMAGE", power: 10, }; const defense = targetUnit.baseStats.defense; // 3 const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(result.data.amount).to.equal(10 - defense); // 7 }); it("should not reduce health below 0", () => { const effectDef = { type: "DAMAGE", power: 1000, }; targetUnit.currentHealth = 10; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(targetUnit.currentHealth).to.equal(0); }); it("should return error if target is not a Unit", () => { const effectDef = { type: "DAMAGE", power: 10, }; const position = { x: 5, y: 1, z: 5 }; const result = processor.process(effectDef, sourceUnit, position); expect(result.success).to.be.false; expect(result.error).to.equal("Damage target must be a Unit"); }); }); describe("Handler: HEAL", () => { it("should increase target's currentHealth", () => { const effectDef = { type: "HEAL", power: 20, }; targetUnit.currentHealth = 30; const initialHealth = targetUnit.currentHealth; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(result.data.type).to.equal("HEAL"); expect(result.data.amount).to.be.greaterThan(0); expect(targetUnit.currentHealth).to.be.greaterThan(initialHealth); }); it("should not exceed maxHealth", () => { const effectDef = { type: "HEAL", power: 1000, }; targetUnit.currentHealth = 40; targetUnit.maxHealth = 50; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(targetUnit.currentHealth).to.equal(50); // Capped at maxHealth expect(result.data.amount).to.equal(10); // Only healed 10 }); it("should support attribute scaling", () => { const effectDef = { type: "HEAL", power: 10, attribute: "magic", scaling: 1.0, }; targetUnit.currentHealth = 30; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; // Should heal 10 + (5 * 1.0) = 15, but capped at maxHealth expect(targetUnit.currentHealth).to.be.greaterThan(30); }); }); describe("Handler: APPLY_STATUS", () => { it("should apply status with chance roll", () => { const effectDef = { type: "APPLY_STATUS", status_id: "STUN", duration: 2, chance: 1.0, // 100% chance }; targetUnit.statusEffects = []; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(targetUnit.statusEffects).to.have.lengthOf(1); expect(targetUnit.statusEffects[0].id).to.equal("STUN"); }); it("should fail if chance roll fails", () => { const effectDef = { type: "APPLY_STATUS", status_id: "STUN", duration: 2, chance: 0.0, // 0% chance }; targetUnit.statusEffects = []; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.false; expect(result.error).to.equal("Status effect failed chance roll"); expect(targetUnit.statusEffects).to.have.lengthOf(0); }); it("should use default chance of 1.0 if not specified", () => { const effectDef = { type: "APPLY_STATUS", status_id: "POISON", duration: 3, // No chance specified }; targetUnit.statusEffects = []; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(targetUnit.statusEffects).to.have.lengthOf(1); }); it("should return error if status_id is missing", () => { const effectDef = { type: "APPLY_STATUS", duration: 2, // Missing status_id }; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.false; expect(result.error).to.equal("Status effect missing status_id"); }); }); describe("Handler: REMOVE_STATUS", () => { it("should remove status effect from target", () => { const effectDef = { type: "REMOVE_STATUS", status_id: "BURN", }; targetUnit.statusEffects = [ { id: "BURN", type: "STATUS", duration: 3 }, { id: "POISON", type: "STATUS", duration: 2 }, ]; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(targetUnit.statusEffects).to.have.lengthOf(1); expect(targetUnit.statusEffects[0].id).to.equal("POISON"); }); it("should return error if status not found", () => { const effectDef = { type: "REMOVE_STATUS", status_id: "BURN", }; targetUnit.statusEffects = []; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.false; expect(result.error).to.equal("Status effect not found on target"); }); }); describe("Handler: PUSH", () => { it("should push unit away from source", () => { const effectDef = { type: "PUSH", force: 2, }; const oldX = targetUnit.position.x; const oldZ = targetUnit.position.z; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(result.data.type).to.equal("PUSH"); // Target should be pushed away from source (5,5) -> (6,5) // Direction is (1, 0), so pushed to (8, 5) with force 2 expect(targetUnit.position.x).to.be.greaterThan(oldX); }); it("should not push if destination is occupied", () => { const effectDef = { type: "PUSH", force: 1, }; // Place another unit at the push destination const otherUnit = unitManager.createUnit("ENEMY_GOBLIN", "ENEMY"); const pushDest = { x: 7, y: 1, z: 5 }; grid.placeUnit(otherUnit, pushDest); const oldPosition = { ...targetUnit.position }; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.false; expect(result.error).to.equal("Push/Pull destination is occupied"); expect(targetUnit.position.x).to.equal(oldPosition.x); }); }); describe("Handler: PULL", () => { it("should pull unit toward source", () => { const effectDef = { type: "PULL", force: 1, }; // Move target further away first targetUnit.position = { x: 8, y: 1, z: 5 }; grid.placeUnit(targetUnit, targetUnit.position); const oldX = targetUnit.position.x; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(result.data.type).to.equal("PULL"); // Target should be pulled toward source (5,5) from (8,5) // Direction is (-1, 0), so pulled to (7, 5) with force 1 expect(targetUnit.position.x).to.be.lessThan(oldX); }); }); describe("Handler: TELEPORT", () => { it("should teleport unit to valid destination", () => { const destination = { x: 10, y: 1, z: 10 }; const effectDef = { type: "TELEPORT", destination: "TARGET", }; const result = processor.process(effectDef, sourceUnit, destination); expect(result.success).to.be.true; expect(result.data.type).to.equal("TELEPORT"); expect(sourceUnit.position.x).to.equal(10); expect(sourceUnit.position.z).to.equal(10); }); it("should not teleport to solid terrain", () => { const destination = { x: 10, y: 1, z: 10 }; grid.setCell(10, 1, 10, 10); // Place a wall const effectDef = { type: "TELEPORT", destination: "TARGET", }; const oldPosition = { ...sourceUnit.position }; const result = processor.process(effectDef, sourceUnit, destination); expect(result.success).to.be.false; expect(result.error).to.equal("Teleport destination is solid"); expect(sourceUnit.position.x).to.equal(oldPosition.x); }); it("should not teleport to occupied position", () => { const destination = { x: 6, y: 1, z: 5 }; // Where targetUnit is const effectDef = { type: "TELEPORT", destination: "TARGET", }; const oldPosition = { ...sourceUnit.position }; const result = processor.process(effectDef, sourceUnit, destination); expect(result.success).to.be.false; expect(result.error).to.equal("Teleport destination is occupied"); expect(sourceUnit.position.x).to.equal(oldPosition.x); }); it("should teleport behind target when destination is BEHIND_TARGET", () => { // Source at (5,5), target at (6,5) // Behind target would be (7,5) const effectDef = { type: "TELEPORT", destination: "BEHIND_TARGET", }; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(sourceUnit.position.x).to.equal(7); expect(sourceUnit.position.z).to.equal(5); }); }); describe("Process Method", () => { it("should return error for invalid effect definition", () => { const result = processor.process(null, sourceUnit, targetUnit); expect(result.success).to.be.false; expect(result.error).to.equal("Invalid effect definition"); }); it("should return error for unknown effect type", () => { const effectDef = { type: "UNKNOWN_TYPE", power: 10, }; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.false; expect(result.error).to.equal("Unknown effect type: UNKNOWN_TYPE"); }); it("should handle handler execution errors gracefully", () => { // Create a processor with a broken handler const brokenProcessor = new EffectProcessor(grid, unitManager); brokenProcessor.handlers.set("DAMAGE", () => { throw new Error("Handler error"); }); const effectDef = { type: "DAMAGE", power: 10, }; const result = brokenProcessor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.false; expect(result.error).to.equal("Handler error"); }); }); describe("Handler: CHAIN_DAMAGE", () => { it("should apply damage to primary target and chain to nearby enemies", () => { // Create additional enemy units for chaining const enemy2 = unitManager.createUnit("ENEMY_GOBLIN", "ENEMY"); enemy2.position = { x: 7, y: 1, z: 5 }; // Distance 1 from targetUnit enemy2.currentHealth = 50; enemy2.maxHealth = 50; grid.placeUnit(enemy2, enemy2.position); const enemy3 = unitManager.createUnit("ENEMY_GOBLIN", "ENEMY"); enemy3.position = { x: 8, y: 1, z: 5 }; // Distance 2 from targetUnit enemy3.currentHealth = 50; enemy3.maxHealth = 50; grid.placeUnit(enemy3, enemy3.position); const effectDef = { type: "CHAIN_DAMAGE", power: 10, bounces: 2, chainRange: 3, decay: 0.5, }; const initialPrimaryHP = targetUnit.currentHealth; const initialEnemy2HP = enemy2.currentHealth; const initialEnemy3HP = enemy3.currentHealth; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; expect(result.data.type).to.equal("CHAIN_DAMAGE"); expect(result.data.chainTargets.length).to.be.greaterThan(0); // Primary target should have taken damage expect(targetUnit.currentHealth).to.be.lessThan(initialPrimaryHP); // Chained targets should have taken reduced damage if (result.data.chainTargets.includes(enemy2.id)) { expect(enemy2.currentHealth).to.be.lessThan(initialEnemy2HP); } }); it("should apply double damage to targets with synergy trigger status", () => { // Create enemy with WET status const wetEnemy = unitManager.createUnit("ENEMY_GOBLIN", "ENEMY"); wetEnemy.position = { x: 7, y: 1, z: 5 }; wetEnemy.currentHealth = 50; wetEnemy.maxHealth = 50; wetEnemy.statusEffects = [{ id: "STATUS_WET", type: "STATUS", duration: 2 }]; grid.placeUnit(wetEnemy, wetEnemy.position); const effectDef = { type: "CHAIN_DAMAGE", power: 10, bounces: 1, chainRange: 3, decay: 0.5, synergy_trigger: "STATUS_WET", }; const initialWetHP = wetEnemy.currentHealth; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; // WET enemy should have taken double damage (10 * 0.5 * 2 = 10, minus defense) // Without synergy it would be (10 * 0.5 = 5, minus defense) const defense = wetEnemy.baseStats.defense || 0; const expectedRegularDamage = Math.max(0, 10 * 0.5 - defense); // 5 - defense const expectedSynergyDamage = Math.max(0, 10 * 0.5 * 2 - defense); // 10 - defense const actualDamage = initialWetHP - wetEnemy.currentHealth; expect(wetEnemy.currentHealth).to.be.lessThan(initialWetHP); // Should have taken synergy damage (double), which is more than regular damage expect(actualDamage).to.be.greaterThan(expectedRegularDamage); expect(actualDamage).to.equal(expectedSynergyDamage); }); it("should respect bounce limit", () => { // Create multiple enemies const enemies = []; for (let i = 0; i < 5; i++) { const enemy = unitManager.createUnit("ENEMY_GOBLIN", "ENEMY"); enemy.position = { x: 6 + i, y: 1, z: 5 }; enemy.currentHealth = 50; enemy.maxHealth = 50; grid.placeUnit(enemy, enemy.position); enemies.push(enemy); } const effectDef = { type: "CHAIN_DAMAGE", power: 10, bounces: 2, // Only 2 bounces chainRange: 10, decay: 0.5, }; const result = processor.process(effectDef, sourceUnit, targetUnit); expect(result.success).to.be.true; // Should only chain to 2 enemies (bounces limit) expect(result.data.chainTargets.length).to.be.at.most(2); }); }); });