359 lines
11 KiB
JavaScript
359 lines
11 KiB
JavaScript
|
|
import { expect } from "@esm-bundle/chai";
|
||
|
|
import { GameLoop } from "../../src/core/GameLoop.js";
|
||
|
|
import { VoxelGrid } from "../../src/grid/VoxelGrid.js";
|
||
|
|
import { UnitManager } from "../../src/managers/UnitManager.js";
|
||
|
|
import { EffectProcessor } from "../../src/systems/EffectProcessor.js";
|
||
|
|
import { itemRegistry } from "../../src/managers/ItemRegistry.js";
|
||
|
|
|
||
|
|
describe("Systems: Passive Item Effects", function () {
|
||
|
|
let gameLoop;
|
||
|
|
let grid;
|
||
|
|
let unitManager;
|
||
|
|
let effectProcessor;
|
||
|
|
let sourceUnit;
|
||
|
|
let targetUnit;
|
||
|
|
let mockRegistry;
|
||
|
|
|
||
|
|
beforeEach(async () => {
|
||
|
|
// 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 walkable floor
|
||
|
|
for (let x = 0; x < 20; x++) {
|
||
|
|
for (let z = 0; z < 20; z++) {
|
||
|
|
grid.setCell(x, 0, z, 1); // Floor
|
||
|
|
grid.setCell(x, 1, z, 0); // Air (walkable)
|
||
|
|
grid.setCell(x, 2, z, 0); // Headroom
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
effectProcessor = 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);
|
||
|
|
|
||
|
|
// Load items if needed
|
||
|
|
if (itemRegistry.items.size === 0) {
|
||
|
|
await itemRegistry.loadAll();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Integration Point 2: Passive Item Effects", () => {
|
||
|
|
it("should process ON_DAMAGED trigger when unit takes damage", () => {
|
||
|
|
// Create a mock item with ON_DAMAGED passive
|
||
|
|
const mockItem = {
|
||
|
|
id: "ITEM_THORNS",
|
||
|
|
name: "Thorns Armor",
|
||
|
|
type: "ARMOR",
|
||
|
|
passives: [
|
||
|
|
{
|
||
|
|
trigger: "ON_DAMAGED",
|
||
|
|
action: "DAMAGE",
|
||
|
|
params: {
|
||
|
|
power: 5,
|
||
|
|
element: "PHYSICAL",
|
||
|
|
target: "SOURCE",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
};
|
||
|
|
|
||
|
|
// Mock itemRegistry
|
||
|
|
const originalGet = itemRegistry.get;
|
||
|
|
itemRegistry.get = (id) => {
|
||
|
|
if (id === "ITEM_THORNS") return mockItem;
|
||
|
|
return originalGet.call(itemRegistry, id);
|
||
|
|
};
|
||
|
|
|
||
|
|
// Equip item to target unit
|
||
|
|
targetUnit.loadout = {
|
||
|
|
mainHand: null,
|
||
|
|
offHand: null,
|
||
|
|
body: { defId: "ITEM_THORNS", uid: "test-thorns" },
|
||
|
|
accessory: null,
|
||
|
|
belt: [null, null],
|
||
|
|
};
|
||
|
|
|
||
|
|
// Create GameLoop instance (simplified for testing)
|
||
|
|
const gameLoop = {
|
||
|
|
effectProcessor,
|
||
|
|
inventoryManager: {
|
||
|
|
itemRegistry,
|
||
|
|
},
|
||
|
|
processPassiveItemEffects: function (unit, trigger, context) {
|
||
|
|
if (!unit || !unit.loadout || !this.effectProcessor || !this.inventoryManager) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const equippedItems = [
|
||
|
|
unit.loadout.mainHand,
|
||
|
|
unit.loadout.offHand,
|
||
|
|
unit.loadout.body,
|
||
|
|
unit.loadout.accessory,
|
||
|
|
...(unit.loadout.belt || []),
|
||
|
|
].filter(Boolean);
|
||
|
|
|
||
|
|
for (const itemInstance of equippedItems) {
|
||
|
|
if (!itemInstance || !itemInstance.defId) continue;
|
||
|
|
|
||
|
|
const itemDef = this.inventoryManager.itemRegistry.get(itemInstance.defId);
|
||
|
|
if (!itemDef || !itemDef.passives) continue;
|
||
|
|
|
||
|
|
for (const passive of itemDef.passives) {
|
||
|
|
if (passive.trigger !== trigger) continue;
|
||
|
|
|
||
|
|
const effectDef = {
|
||
|
|
type: passive.action,
|
||
|
|
power: passive.params.power,
|
||
|
|
element: passive.params.element,
|
||
|
|
};
|
||
|
|
|
||
|
|
let target = context.target || context.source || unit;
|
||
|
|
if (passive.params.target === "SOURCE" && context.source) {
|
||
|
|
target = context.source;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.effectProcessor.process(effectDef, unit, target);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const initialSourceHP = sourceUnit.currentHealth;
|
||
|
|
const sourceDefense = sourceUnit.baseStats.defense || 0;
|
||
|
|
const expectedThornsDamage = Math.max(0, 5 - sourceDefense); // 5 thorns - defense
|
||
|
|
|
||
|
|
// Apply damage to target (this should trigger ON_DAMAGED)
|
||
|
|
const damageEffect = {
|
||
|
|
type: "DAMAGE",
|
||
|
|
power: 10,
|
||
|
|
};
|
||
|
|
effectProcessor.process(damageEffect, sourceUnit, targetUnit);
|
||
|
|
|
||
|
|
// Process passive effects
|
||
|
|
gameLoop.processPassiveItemEffects(targetUnit, "ON_DAMAGED", {
|
||
|
|
source: sourceUnit,
|
||
|
|
damageAmount: 10,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Source should have taken thorns damage (reduced by defense)
|
||
|
|
if (expectedThornsDamage > 0) {
|
||
|
|
expect(sourceUnit.currentHealth).to.be.lessThan(initialSourceHP);
|
||
|
|
expect(sourceUnit.currentHealth).to.equal(initialSourceHP - expectedThornsDamage);
|
||
|
|
} else {
|
||
|
|
// If defense blocks all damage, health should be unchanged
|
||
|
|
expect(sourceUnit.currentHealth).to.equal(initialSourceHP);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Restore original get method
|
||
|
|
itemRegistry.get = originalGet;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should process ON_DAMAGE_DEALT trigger when unit deals damage", () => {
|
||
|
|
// Create a mock item with ON_DAMAGE_DEALT passive (e.g., lifesteal)
|
||
|
|
const mockItem = {
|
||
|
|
id: "ITEM_LIFESTEAL",
|
||
|
|
name: "Lifesteal Blade",
|
||
|
|
type: "WEAPON",
|
||
|
|
passives: [
|
||
|
|
{
|
||
|
|
trigger: "ON_DAMAGE_DEALT",
|
||
|
|
action: "HEAL",
|
||
|
|
params: {
|
||
|
|
power: 2,
|
||
|
|
},
|
||
|
|
target: "SELF",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
};
|
||
|
|
|
||
|
|
const originalGet = itemRegistry.get;
|
||
|
|
itemRegistry.get = (id) => {
|
||
|
|
if (id === "ITEM_LIFESTEAL") return mockItem;
|
||
|
|
return originalGet.call(itemRegistry, id);
|
||
|
|
};
|
||
|
|
|
||
|
|
sourceUnit.loadout = {
|
||
|
|
mainHand: { defId: "ITEM_LIFESTEAL", uid: "test-lifesteal" },
|
||
|
|
offHand: null,
|
||
|
|
body: null,
|
||
|
|
accessory: null,
|
||
|
|
belt: [null, null],
|
||
|
|
};
|
||
|
|
|
||
|
|
const gameLoop = {
|
||
|
|
effectProcessor,
|
||
|
|
inventoryManager: {
|
||
|
|
itemRegistry,
|
||
|
|
},
|
||
|
|
processPassiveItemEffects: function (unit, trigger, context) {
|
||
|
|
if (!unit || !unit.loadout || !this.effectProcessor || !this.inventoryManager) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const equippedItems = [
|
||
|
|
unit.loadout.mainHand,
|
||
|
|
unit.loadout.offHand,
|
||
|
|
unit.loadout.body,
|
||
|
|
unit.loadout.accessory,
|
||
|
|
...(unit.loadout.belt || []),
|
||
|
|
].filter(Boolean);
|
||
|
|
|
||
|
|
for (const itemInstance of equippedItems) {
|
||
|
|
if (!itemInstance || !itemInstance.defId) continue;
|
||
|
|
|
||
|
|
const itemDef = this.inventoryManager.itemRegistry.get(itemInstance.defId);
|
||
|
|
if (!itemDef || !itemDef.passives) continue;
|
||
|
|
|
||
|
|
for (const passive of itemDef.passives) {
|
||
|
|
if (passive.trigger !== trigger) continue;
|
||
|
|
|
||
|
|
const effectDef = {
|
||
|
|
type: passive.action,
|
||
|
|
power: passive.params.power,
|
||
|
|
};
|
||
|
|
|
||
|
|
let target = context.target || context.source || unit;
|
||
|
|
if (passive.params.target === "SELF" || passive.target === "SELF") {
|
||
|
|
target = unit;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.effectProcessor.process(effectDef, unit, target);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const initialSourceHP = sourceUnit.currentHealth;
|
||
|
|
sourceUnit.currentHealth = 80; // Set to less than max
|
||
|
|
|
||
|
|
// Deal damage (this should trigger ON_DAMAGE_DEALT)
|
||
|
|
const damageEffect = {
|
||
|
|
type: "DAMAGE",
|
||
|
|
power: 10,
|
||
|
|
};
|
||
|
|
effectProcessor.process(damageEffect, sourceUnit, targetUnit);
|
||
|
|
|
||
|
|
// Process passive effects
|
||
|
|
gameLoop.processPassiveItemEffects(sourceUnit, "ON_DAMAGE_DEALT", {
|
||
|
|
target: targetUnit,
|
||
|
|
damageAmount: 10,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Source should have healed from lifesteal
|
||
|
|
expect(sourceUnit.currentHealth).to.be.greaterThan(80);
|
||
|
|
|
||
|
|
itemRegistry.get = originalGet;
|
||
|
|
});
|
||
|
|
|
||
|
|
it("should not process passive effects if trigger doesn't match", () => {
|
||
|
|
const mockItem = {
|
||
|
|
id: "ITEM_OTHER",
|
||
|
|
name: "Other Item",
|
||
|
|
type: "ARMOR",
|
||
|
|
passive_effects: [
|
||
|
|
{
|
||
|
|
trigger: "ON_HEAL_DEALT", // Different trigger
|
||
|
|
action: "HEAL",
|
||
|
|
params: { power: 5 },
|
||
|
|
},
|
||
|
|
],
|
||
|
|
};
|
||
|
|
|
||
|
|
const originalGet = itemRegistry.get;
|
||
|
|
itemRegistry.get = (id) => {
|
||
|
|
if (id === "ITEM_OTHER") return mockItem;
|
||
|
|
return originalGet.call(itemRegistry, id);
|
||
|
|
};
|
||
|
|
|
||
|
|
targetUnit.loadout = {
|
||
|
|
mainHand: null,
|
||
|
|
offHand: null,
|
||
|
|
body: { defId: "ITEM_OTHER", uid: "test-other" },
|
||
|
|
accessory: null,
|
||
|
|
belt: [null, null],
|
||
|
|
};
|
||
|
|
|
||
|
|
const gameLoop = {
|
||
|
|
effectProcessor,
|
||
|
|
inventoryManager: {
|
||
|
|
itemRegistry,
|
||
|
|
},
|
||
|
|
processPassiveItemEffects: function (unit, trigger, context) {
|
||
|
|
if (!unit || !unit.loadout || !this.effectProcessor || !this.inventoryManager) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const equippedItems = [
|
||
|
|
unit.loadout.mainHand,
|
||
|
|
unit.loadout.offHand,
|
||
|
|
unit.loadout.body,
|
||
|
|
unit.loadout.accessory,
|
||
|
|
...(unit.loadout.belt || []),
|
||
|
|
].filter(Boolean);
|
||
|
|
|
||
|
|
for (const itemInstance of equippedItems) {
|
||
|
|
if (!itemInstance || !itemInstance.defId) continue;
|
||
|
|
|
||
|
|
const itemDef = this.inventoryManager.itemRegistry.get(itemInstance.defId);
|
||
|
|
if (!itemDef || !itemDef.passives) continue;
|
||
|
|
|
||
|
|
for (const passive of itemDef.passives) {
|
||
|
|
if (passive.trigger !== trigger) continue;
|
||
|
|
// Should not reach here for ON_DAMAGED trigger
|
||
|
|
expect.fail("Should not process passive with different trigger");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Process ON_DAMAGED trigger (but item has ON_HEAL_DEALT)
|
||
|
|
gameLoop.processPassiveItemEffects(targetUnit, "ON_DAMAGED", {
|
||
|
|
source: sourceUnit,
|
||
|
|
damageAmount: 10,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should not have processed anything
|
||
|
|
itemRegistry.get = originalGet;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|