aether-shards/test/systems/EffectProcessor.test.js

815 lines
25 KiB
JavaScript
Raw Permalink Normal View History

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