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.
814 lines
25 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|
|
|