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