aether-shards/test/systems/EffectProcessor.test.js
Matthew Mone f04905044d Implement EffectProcessor and related systems for enhanced game mechanics
Introduce the EffectProcessor class to manage game state changes through various effects, including damage, healing, and status application. Define type specifications for effects, conditions, and passive abilities in Effects.d.ts. Add a comprehensive JSON registry for passive skills and item effects, enhancing gameplay dynamics. Update the GameLoop and TurnSystem to integrate the EffectProcessor, ensuring proper handling of environmental hazards and passive effects during combat. Enhance testing coverage for the EffectProcessor and environmental interactions to validate functionality and performance.
2025-12-30 20:50:11 -08:00

814 lines
25 KiB
JavaScript

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