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.
This commit is contained in:
Matthew Mone 2025-12-30 20:50:11 -08:00
parent 178389309d
commit f04905044d
16 changed files with 4739 additions and 126 deletions

View file

@ -35,45 +35,239 @@ The Processor requires injection of:
Every effect in the game must adhere to this structure. Every effect in the game must adhere to this structure.
```typescript ```typescript
interface EffectDefinition { /**
type: EffectType; * Effects.ts
* Type definitions for the Game Logic Engine (Effects, Passives, and Triggers).
*/
// -- Magnitude -- // =============================================================================
// 1. EFFECT DEFINITIONS (The "What")
// =============================================================================
/**
* List of all valid actions the EffectProcessor can execute.
*/
export type EffectType =
// Combat
| "DAMAGE"
| "HEAL"
| "CHAIN_DAMAGE"
| "REDIRECT_DAMAGE"
| "PREVENT_DEATH"
// Status & Stats
| "APPLY_STATUS"
| "REMOVE_STATUS"
| "REMOVE_ALL_DEBUFFS"
| "APPLY_BUFF"
| "GIVE_AP"
| "ADD_CHARGE"
| "ADD_SHIELD"
| "CONVERT_DAMAGE_TO_HEAL"
| "DYNAMIC_BUFF"
// Movement & Physics
| "TELEPORT"
| "MOVE_TO_TARGET"
| "SWAP_POSITIONS"
| "PHYSICS_PULL"
| "PUSH"
// World & Spawning
| "SPAWN_OBJECT"
| "SPAWN_HAZARD"
| "SPAWN_LOOT"
| "MODIFY_TERRAIN"
| "DESTROY_VOXEL"
| "DESTROY_OBJECTS"
| "REVEAL_OBJECTS"
| "COLLECT_LOOT"
// Meta / Logic
| "REPEAT_SKILL"
| "CANCEL_EVENT"
| "REDUCE_COST"
| "BUFF_SPAWN"
| "MODIFY_AOE";
/**
* A generic container for parameters used by Effect Handlers.
* In a strict system, this would be a Union of specific interfaces,
* but for JSON deserialization, a loose interface is often more flexible.
*/
export interface EffectParams {
// -- Combat Magnitude --
power?: number; // Base amount (Damage/Heal) power?: number; // Base amount (Damage/Heal)
attribute?: string; // Stat to scale off (e.g., "strength", "magic") attribute?: string; // Stat to scale off (e.g., "strength", "magic")
scaling?: number; // Multiplier for attribute (Default: 1.0) scaling?: number; // Multiplier for attribute (Default: 1.0)
// -- Flavour --
element?: "PHYSICAL" | "FIRE" | "ICE" | "SHOCK" | "VOID" | "TECH"; element?: "PHYSICAL" | "FIRE" | "ICE" | "SHOCK" | "VOID" | "TECH";
// -- Chaining --
bounces?: number;
decay?: number;
synergy_trigger?: string; // Status ID that triggers bonus effect
// -- Status/Buffs -- // -- Status/Buffs --
status_id?: string; // For APPLY_STATUS status_id?: string;
duration?: number; // Turns duration?: number;
chance?: number; // 0.0 to 1.0 stat?: string; // For Buffs
value?: number; // For Buffs/Mods
chance?: number; // 0.0 to 1.0 (Proc chance)
// -- Movement/Physics -- // -- Physics --
force?: number; // Distance for Push/Pull force?: number; // Distance
destination?: "TARGET" | "BEHIND_TARGET"; // For Teleport destination?: "TARGET" | "BEHIND_TARGET" | "ADJACENT_TO_TARGET";
// -- Conditionals -- // -- World --
condition?: { object_id?: string; // Unit ID to spawn
target_tag?: string; // e.g. "MECHANICAL" hazard_id?: string;
target_status?: string; // e.g. "WET" tag?: string; // Filter for objects (e.g. "COVER")
hp_threshold?: number; // e.g. 0.3 (30%) range?: number; // AoE radius
// -- Logic --
percentage?: number; // 0.0 - 1.0
amount?: number; // Flat amount (AP/Charge)
amount_range?: [number, number]; // [min, max]
set_hp?: number; // Hard set HP value
shape?: "CIRCLE" | "LINE" | "CONE" | "SINGLE";
size?: number;
multiplier?: number;
}
/**
* The runtime payload passed to EffectProcessor.process().
* Combines the Type and the Params.
*/
export interface EffectDefinition extends EffectParams {
type: EffectType;
// Optional Override Condition for the Effect itself
// (Distinct from the Passive Trigger condition)
condition?: ConditionDefinition;
// Conditional Multiplier (e.g. Execute damage)
conditional_multiplier?: {
condition: string; // Condition Tag
value: number;
}; };
} }
type EffectType = // =============================================================================
| "DAMAGE" // 2. PASSIVE DEFINITIONS (The "When")
| "HEAL" // =============================================================================
| "APPLY_STATUS"
| "REMOVE_STATUS" /**
| "TELEPORT" * Triggers that the EventSystem listens for.
| "PUSH" */
| "PULL" export type TriggerType =
| "SPAWN_UNIT" // Stat Calculation Hooks
| "MODIFY_TERRAIN" // Destroy walls, create hazards | "ON_STAT_CALC"
| "CHAIN_DAMAGE"; // Bouncing projectiles | "STATIC_STAT_MOD"
| "ON_SKILL_COST_CALC"
| "ON_ATTACK_CALC"
| "ON_DAMAGE_CALC"
// Combat Events
| "ON_ATTACK_HIT"
| "ON_SKILL_HIT"
| "ON_SKILL_CAST"
| "ON_DAMAGED"
| "ON_ALLY_DAMAGED"
| "ON_DAMAGE_DEALT"
| "ON_HEAL_DEALT"
| "ON_HEAL_OVERFLOW"
| "ON_KILL"
| "ON_LETHAL_DAMAGE"
| "ON_SYNERGY_TRIGGER"
// Turn Lifecycle
| "ON_TURN_START"
| "ON_TURN_END"
| "ON_ACTION_COMPLETE"
// World Events
| "ON_MOVE_COMPLETE"
| "ON_HAZARD_TICK"
| "ON_TRAP_TRIGGER"
| "ON_OBJECT_DESTROYED"
| "ON_SPAWN_OBJECT"
| "ON_LEVEL_START"
// Meta / UI
| "GLOBAL_SHOP_PRICE"
| "ON_SKILL_TARGETING"
| "AURA_UPDATE";
/**
* How a stat modifier interacts with the base value.
*/
export type ModifierType =
| "ADD"
| "MULTIPLY"
| "ADD_PER_ADJACENT_ENEMY"
| "ADD_PER_DESTROYED_VOXEL"
| "ADD_STAT"
| "MULTIPLY_DAMAGE";
/**
* An entry in `passive_registry.json`.
* Defines a rule: "WHEN [Trigger] IF [Condition] THEN [Action]".
*/
export interface PassiveDefinition {
id: string;
name: string;
description: string;
// -- The Trigger --
trigger: TriggerType;
// -- The Check --
condition?: ConditionDefinition;
limit?: number; // Max times per run/turn
// -- The Effect (Action) --
// Maps to EffectDefinition.type
action?: EffectType;
// -- The Parameters --
// Merged into EffectDefinition
params?: EffectParams;
// -- For Stat Modifiers (simpler than full Effects) --
stat?: string;
modifier_type?: ModifierType;
value?: number;
range?: number; // For Aura/Per-Adjacent checks
// -- For Complex Stat Lists --
modifiers?: { stat: string; value: number }[];
// -- For Auras --
target?: "ALLIES" | "ENEMIES" | "SELF";
}
// =============================================================================
// 3. CONDITIONS (The "If")
// =============================================================================
export type ConditionType =
| "SOURCE_IS_ADJACENT"
| "IS_ADJACENT"
| "IS_BASIC_ATTACK"
| "SKILL_TAG" // value: string
| "DAMAGE_TYPE" // value: string
| "TARGET_TAG" // value: string
| "OWNER_IS_SELF"
| "IS_FLANKING"
| "TARGET_LOCKED"
| "DID_NOT_ATTACK"
| "TARGET_HP_LOW"
| "IS_MECHANICAL";
export interface ConditionDefinition {
type: ConditionType;
value?: string | number | boolean;
}
``` ```
## 4. Handler Specifications ## 4. Handler Specifications
@ -119,14 +313,3 @@ type EffectType =
**CoA 4: Physics Safety** **CoA 4: Physics Safety**
- When `PUSH` is executed, the system must check `VoxelGrid.isSolid()` behind the target. If a wall exists, the unit must **not** move into the wall (optionally take "Smash" damage instead). - When `PUSH` is executed, the system must check `VoxelGrid.isSolid()` behind the target. If a wall exists, the unit must **not** move into the wall (optionally take "Smash" damage instead).
---
## 6. Prompt for Coding Agent
> "Create `src/systems/EffectProcessor.js`.
>
> 1. **Constructor:** Accept `VoxelGrid` and `UnitManager`. Initialize a map of `handlers`.
> 2. **Process Method:** `process(effectDef, source, target)`. Look up handler by `effectDef.type`. Verify `checkConditions()`. Execute handler. Return `ResultObject`.
> 3. **Handlers:** Implement `handleDamage`, `handleHeal`, `handleStatus`, `handleMove`.
> 4. **Helper:** Implement `calculatePower(def, source)` to handle attribute scaling logic centrally."

View file

@ -0,0 +1,278 @@
{
"comment": "Registry of all Passive Skills and Item Effects. Referenced by ID in Skill Trees and Items.",
"passives": [
{
"id": "PASSIVE_IRON_SKIN",
"name": "Iron Skin",
"description": "Gain +1 Defense for each adjacent enemy.",
"trigger": "ON_STAT_CALC",
"stat": "defense",
"modifier_type": "ADD_PER_ADJACENT_ENEMY",
"value": 1
},
{
"id": "PASSIVE_THORNS",
"name": "Thorns",
"description": "Reflect 5 Physical Damage to attackers when hit by melee.",
"trigger": "ON_DAMAGED",
"condition": { "type": "SOURCE_IS_ADJACENT" },
"action": "DAMAGE",
"params": { "power": 5, "element": "PHYSICAL", "target": "SOURCE" }
},
{
"id": "PASSIVE_UNYIELDING",
"name": "Unyielding",
"description": "Prevents death once per run, setting HP to 1 instead.",
"trigger": "ON_LETHAL_DAMAGE",
"limit": 1,
"action": "PREVENT_DEATH",
"params": { "set_hp": 1 }
},
{
"id": "PASSIVE_RENDING_BLOW",
"name": "Rending Blow",
"description": "Basic Attacks reduce target Defense by 2 for 2 turns.",
"trigger": "ON_ATTACK_HIT",
"condition": { "type": "IS_BASIC_ATTACK" },
"action": "APPLY_BUFF",
"params": { "stat": "defense", "value": -2, "duration": 2 }
},
{
"id": "PASSIVE_BODYGUARD",
"name": "Bodyguard",
"description": "Absorb 50% of damage taken by adjacent allies.",
"trigger": "ON_ALLY_DAMAGED",
"condition": { "type": "IS_ADJACENT" },
"action": "REDIRECT_DAMAGE",
"params": { "percentage": 0.5 }
},
{
"id": "PASSIVE_GLASS_CANNON",
"name": "Glass Cannon",
"description": "Deal +20% Magic Damage but have -10% Max Health.",
"trigger": "STATIC_STAT_MOD",
"modifiers": [
{ "stat": "magic_damage_mult", "value": 0.2 },
{ "stat": "max_health_mult", "value": -0.1 }
]
},
{
"id": "PASSIVE_MANA_SYPHON",
"name": "Mana Syphon",
"description": "Killing an enemy restores 1 AP.",
"trigger": "ON_KILL",
"action": "GIVE_AP",
"params": { "amount": 1 }
},
{
"id": "PASSIVE_ELEMENTAL_AFFINITY",
"name": "Elemental Affinity",
"description": "Standing on Hazard tiles (Fire/Acid) heals you instead of dealing damage.",
"trigger": "ON_HAZARD_TICK",
"action": "CONVERT_DAMAGE_TO_HEAL",
"params": { "percentage": 1.0 }
},
{
"id": "PASSIVE_DOUBLE_CAST",
"name": "Double Cast",
"description": "10% Chance to cast a spell twice for free.",
"trigger": "ON_SKILL_CAST",
"condition": { "type": "SKILL_TAG", "value": "MAGIC" },
"chance": 0.1,
"action": "REPEAT_SKILL"
},
{
"id": "EFFECT_SEARING_BOLT",
"name": "Searing Bolt",
"description": "Spells have 100% chance to inflict Scorch.",
"trigger": "ON_SKILL_HIT",
"condition": { "type": "SKILL_TAG", "value": "MAGIC" },
"action": "APPLY_STATUS",
"params": { "status_id": "STATUS_SCORCH", "duration": 2 }
},
{
"id": "EFFECT_AETHER_SURGE",
"name": "Aether Surge",
"description": "Regain 2 AP when triggering a Synergy Chain.",
"trigger": "ON_SYNERGY_TRIGGER",
"action": "GIVE_AP",
"params": { "amount": 2 }
},
{
"id": "PASSIVE_LUCKY_FIND",
"name": "Lucky Find",
"description": "5% chance to find Aether Shards when moving.",
"trigger": "ON_MOVE_COMPLETE",
"chance": 0.05,
"action": "SPAWN_LOOT",
"params": { "type": "CURRENCY", "amount_range": [1, 5] }
},
{
"id": "PASSIVE_BACKSTAB",
"name": "Backstab",
"description": "+50% Critical Chance when attacking from Flanking position.",
"trigger": "ON_ATTACK_CALC",
"condition": { "type": "IS_FLANKING" },
"modifier_type": "ADD_STAT",
"stat": "crit_chance",
"value": 0.5
},
{
"id": "PASSIVE_LIGHT_STEP",
"name": "Light Step",
"description": "Traps do not trigger when stepping on them.",
"trigger": "ON_TRAP_TRIGGER",
"action": "CANCEL_EVENT"
},
{
"id": "PASSIVE_HAGGLER",
"name": "Haggler",
"description": "Merchant Guild prices reduced by 20%.",
"trigger": "GLOBAL_SHOP_PRICE",
"modifier_type": "MULTIPLY",
"value": 0.8
},
{
"id": "EFFECT_HIGH_SCROUNGE",
"name": "High Scrounge",
"description": "Higher chance to find loot.",
"trigger": "STATIC_STAT_MOD",
"modifiers": [{ "stat": "luck", "value": 5 }]
},
{
"id": "EFFECT_ARTIFACT_SENSE",
"name": "Artifact Sense",
"description": "Reveals Ancient Terminals on the map.",
"trigger": "ON_LEVEL_START",
"action": "REVEAL_OBJECTS",
"params": { "tag": "TERMINAL" }
},
{
"id": "PASSIVE_SCRAP_SHIELD",
"name": "Scrap Shield",
"description": "Gain +1 Defense for every destroyed voxel within 3 tiles.",
"trigger": "ON_STAT_CALC",
"stat": "defense",
"modifier_type": "ADD_PER_DESTROYED_VOXEL",
"range": 3
},
{
"id": "PASSIVE_EFFICIENT_BUILD",
"name": "Efficient Build",
"description": "Deployable skills cost 1 less AP.",
"trigger": "ON_SKILL_COST_CALC",
"condition": { "type": "SKILL_TAG", "value": "DEPLOY" },
"action": "REDUCE_COST",
"params": { "amount": 1 }
},
{
"id": "PASSIVE_RECYCLE",
"name": "Recycle",
"description": "Destroying a deployable restores 20 Health.",
"trigger": "ON_OBJECT_DESTROYED",
"condition": { "type": "OWNER_IS_SELF" },
"action": "HEAL",
"params": { "power": 20 }
},
{
"id": "PASSIVE_CONDUCTIVE",
"name": "Conductive",
"description": "Tech damage chains to adjacent Wet units.",
"trigger": "ON_DAMAGE_DEALT",
"condition": { "type": "DAMAGE_TYPE", "value": "TECH" },
"action": "CHAIN_DAMAGE",
"params": { "synergy_trigger": "STATUS_WET", "bounces": 1, "decay": 0.5 }
},
{
"id": "EFFECT_HARDENED_STRUCTURE",
"name": "Hardened Structure",
"description": "Deployed turrets have +50% Max HP.",
"trigger": "ON_SPAWN_OBJECT",
"action": "BUFF_SPAWN",
"params": { "stat": "max_health", "multiplier": 1.5 }
},
{
"id": "EFFECT_ANTI_MECH",
"name": "Anti-Mech",
"description": "Deal +50% damage to Mechanical enemies.",
"trigger": "ON_DAMAGE_CALC",
"condition": { "type": "TARGET_TAG", "value": "MECHANICAL" },
"modifier_type": "MULTIPLY_DAMAGE",
"value": 1.5
},
{
"id": "PASSIVE_AURA_OF_PEACE",
"name": "Aura of Peace",
"description": "Allies within 2 tiles get +1 Willpower.",
"trigger": "AURA_UPDATE",
"range": 2,
"target": "ALLIES",
"action": "APPLY_BUFF",
"params": { "stat": "willpower", "value": 1, "duration": 1 }
},
{
"id": "PASSIVE_FEEDBACK_LOOP",
"name": "Feedback Loop",
"description": "Healing allies heals self for 10% of amount.",
"trigger": "ON_HEAL_DEALT",
"action": "HEAL_SELF",
"params": { "percentage": 0.1 }
},
{
"id": "PASSIVE_OVERHEAL",
"name": "Overheal",
"description": "Healing above Max HP grants temporary Shield points.",
"trigger": "ON_HEAL_OVERFLOW",
"action": "ADD_SHIELD",
"params": { "ratio": 1.0 }
},
{
"id": "PASSIVE_PACIFIST",
"name": "Pacifist",
"description": "If you end turn without attacking, gain +20 Charge.",
"trigger": "ON_TURN_END",
"condition": { "type": "DID_NOT_ATTACK" },
"action": "ADD_CHARGE",
"params": { "amount": 20 }
},
{
"id": "EFFECT_AOE_HEAL",
"name": "Cleansing Wave",
"description": "Healing skills now target all adjacent allies around target.",
"trigger": "ON_SKILL_TARGETING",
"condition": { "type": "SKILL_TAG", "value": "HEAL" },
"action": "MODIFY_AOE",
"params": { "shape": "CIRCLE", "size": 1 }
},
{
"id": "PASSIVE_BATTLE_RHYTHM",
"name": "Battle Rhythm",
"description": "Attacking grants Magic Buff. Casting Spells grants Attack Buff.",
"trigger": "ON_ACTION_COMPLETE",
"action": "DYNAMIC_BUFF"
},
{
"id": "PASSIVE_DEMOLITIONIST",
"name": "Demolitionist",
"description": "Deal +100% damage to Structures and Cover.",
"trigger": "ON_DAMAGE_CALC",
"condition": { "type": "TARGET_TAG", "value": "STRUCTURE" },
"modifier_type": "MULTIPLY_DAMAGE",
"value": 2.0
},
{
"id": "PASSIVE_SHARD_MAGNET",
"name": "Shard Magnet",
"description": "Automatically collect Aether Shards within 3 tiles at start of turn.",
"trigger": "ON_TURN_START",
"action": "COLLECT_LOOT",
"params": { "range": 3, "type": "CURRENCY" }
}
]
}

View file

@ -22,7 +22,9 @@
"type": "APPLY_STATUS", "type": "APPLY_STATUS",
"status_id": "STATUS_SCORCH", "status_id": "STATUS_SCORCH",
"chance": 1.0, "chance": 1.0,
"duration": 2 "duration": 2,
"power": 3,
"element": "FIRE"
} }
] ]
} }

View file

@ -1,14 +1,14 @@
{ {
"id": "SKILL_TELEPORT", "id": "SKILL_TELEPORT",
"name": "Phase Shift", "name": "Phase Shift",
"description": "Instantly move to any target tile within range, ignoring obstacles.", "description": "Instantly move to any walkable surface within line of sight.",
"type": "ACTIVE", "type": "ACTIVE",
"costs": { "ap": 2 }, "costs": { "ap": 2 },
"cooldown_turns": 4, "cooldown_turns": 4,
"targeting": { "targeting": {
"range": 5, "range": -1,
"type": "EMPTY", "type": "EMPTY",
"line_of_sight": false "line_of_sight": true
}, },
"effects": [{ "type": "TELEPORT" }] "effects": [{ "type": "TELEPORT" }]
} }

View file

@ -19,6 +19,8 @@ import { MissionManager } from "../managers/MissionManager.js";
import { TurnSystem } from "../systems/TurnSystem.js"; import { TurnSystem } from "../systems/TurnSystem.js";
import { MovementSystem } from "../systems/MovementSystem.js"; import { MovementSystem } from "../systems/MovementSystem.js";
import { SkillTargetingSystem } from "../systems/SkillTargetingSystem.js"; import { SkillTargetingSystem } from "../systems/SkillTargetingSystem.js";
import { EffectProcessor } from "../systems/EffectProcessor.js";
import { SeededRandom } from "../utils/SeededRandom.js";
import { skillRegistry } from "../managers/SkillRegistry.js"; import { skillRegistry } from "../managers/SkillRegistry.js";
import { InventoryManager } from "../managers/InventoryManager.js"; import { InventoryManager } from "../managers/InventoryManager.js";
import { InventoryContainer } from "../models/InventoryContainer.js"; import { InventoryContainer } from "../models/InventoryContainer.js";
@ -73,6 +75,8 @@ export class GameLoop {
this.movementSystem = null; this.movementSystem = null;
/** @type {SkillTargetingSystem | null} */ /** @type {SkillTargetingSystem | null} */
this.skillTargetingSystem = null; this.skillTargetingSystem = null;
/** @type {EffectProcessor | null} */
this.effectProcessor = null;
// Inventory System // Inventory System
/** @type {InventoryManager | null} */ /** @type {InventoryManager | null} */
@ -548,15 +552,68 @@ export class GameLoop {
this.combatState = "TARGETING_SKILL"; this.combatState = "TARGETING_SKILL";
this.activeSkillId = skillId; this.activeSkillId = skillId;
// Clear movement highlights and show skill range // Clear movement highlights and show skill range (only valid targets)
this.clearMovementHighlights(); this.clearMovementHighlights();
const skillDef = this.skillTargetingSystem.getSkillDef(skillId); const skillDef = this.skillTargetingSystem.getSkillDef(skillId);
if (skillDef && this.voxelManager) { if (skillDef && this.voxelManager && this.skillTargetingSystem) {
this.voxelManager.highlightRange( // Check if this is a teleport skill with unlimited range (range = -1)
activeUnit.position, const isTeleportSkill = skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT");
skillDef.range, const hasUnlimitedRange = skillDef.range === -1 || skillDef.range === Infinity;
"RED_OUTLINE"
); let allTilesInRange = [];
if (isTeleportSkill && hasUnlimitedRange) {
// For teleport with unlimited range, scan all valid tiles in the grid
// Range is limited only by line of sight
if (this.grid && this.grid.size) {
for (let x = 0; x < this.grid.size.x; x++) {
for (let y = 0; y < this.grid.size.y; y++) {
for (let z = 0; z < this.grid.size.z; z++) {
if (this.grid.isValidBounds({ x, y, z })) {
allTilesInRange.push({ x, y, z });
}
}
}
}
}
} else {
// For normal skills, get tiles within range
for (let x = activeUnit.position.x - skillDef.range; x <= activeUnit.position.x + skillDef.range; x++) {
for (let y = activeUnit.position.y - skillDef.range; y <= activeUnit.position.y + skillDef.range; y++) {
for (let z = activeUnit.position.z - skillDef.range; z <= activeUnit.position.z + skillDef.range; z++) {
const dist =
Math.abs(x - activeUnit.position.x) +
Math.abs(y - activeUnit.position.y) +
Math.abs(z - activeUnit.position.z);
if (dist <= skillDef.range) {
// Check if position is valid bounds
if (this.grid && this.grid.isValidBounds({ x, y, z })) {
allTilesInRange.push({ x, y, z });
}
}
}
}
}
}
// Filter to only valid targets using validation and collect obstruction data
const validTilesWithObstruction = [];
allTilesInRange.forEach((tilePos) => {
const validation = this.skillTargetingSystem.validateTarget(
activeUnit,
tilePos,
skillId
);
if (validation.valid) {
validTilesWithObstruction.push({
pos: tilePos,
obstruction: validation.obstruction || 0
});
}
});
// Highlight only valid targets with obstruction-based dimming
this.voxelManager.highlightTilesWithObstruction(validTilesWithObstruction, "RED_OUTLINE");
} }
// Update combat state to refresh UI (show cancel button) // Update combat state to refresh UI (show cancel button)
@ -619,20 +676,189 @@ export class GameLoop {
} }
// 2. Get Targets (Units in AoE) // 2. Get Targets (Units in AoE)
const targets = this.skillTargetingSystem.getUnitsInAoE( let targets = this.skillTargetingSystem.getUnitsInAoE(
activeUnit.position, activeUnit.position,
targetPos, targetPos,
skillId skillId
); );
console.log(`AoE found ${targets.length} targets at ${targetPos.x},${targetPos.y},${targetPos.z}`);
if (targets.length > 0) {
targets.forEach((t) => {
console.log(` - Target: ${t.name} at ${t.position.x},${t.position.y},${t.position.z}`);
});
}
// Fallback: If no targets found but there's a unit at the target position, include it
// This handles cases where the AoE calculation might miss the exact target
if (targets.length === 0 && this.grid) {
const unitAtTarget = this.grid.getUnitAt(targetPos);
if (unitAtTarget) {
targets = [unitAtTarget];
console.log(`Fallback: Added unit at target position: ${unitAtTarget.name}`);
}
}
// 3. Process Effects (Damage, Status) via EffectProcessor // 3. Process Effects using EffectProcessor
// TODO: Implement EffectProcessor const skillDef = this.skillTargetingSystem.getSkillDef(skillId);
// const skillDef = this.skillTargetingSystem.getSkillDef(skillId);
// if (skillDef && skillDef.effects) { // Process ON_SKILL_CAST passive effects
// skillDef.effects.forEach(eff => { if (skillDef) {
// targets.forEach(t => this.effectProcessor.process(eff, activeUnit, t)); this.processPassiveItemEffects(activeUnit, "ON_SKILL_CAST", {
// }); skillId: skillId,
// } skillDef: skillDef,
});
}
if (skillDef && skillDef.effects && this.effectProcessor) {
for (const effect of skillDef.effects) {
// Special handling for TELEPORT - teleports the source unit, not targets
if (effect.type === "TELEPORT") {
// Check line of sight - if obstructed, teleport has a chance to fail
const losResult = this.skillTargetingSystem.hasLineOfSight(
activeUnit.position,
targetPos,
skillDef.ignore_cover || false
);
// Calculate failure chance based on obstruction level
// Obstruction of 0 = 0% failure, obstruction of 1.0 = 100% failure
const failureChance = losResult.obstruction || 0;
if (Math.random() < failureChance) {
console.warn(
`${activeUnit.name}'s teleport failed due to obstructed line of sight! (${Math.round(failureChance * 100)}% obstruction)`
);
// Teleport failed - unit stays in place, but AP was already deducted
// Could optionally refund AP here, but for now we'll just log the failure
continue; // Skip teleport execution
}
// Find walkable Y level for target position
let walkableY = targetPos.y;
if (this.movementSystem) {
const foundY = this.movementSystem.findWalkableY(
targetPos.x,
targetPos.z,
targetPos.y
);
if (foundY !== null) {
walkableY = foundY;
} else {
// No walkable position found - teleport fails
console.warn(
`${activeUnit.name}'s teleport failed: target position is not walkable`
);
continue; // Skip teleport execution
}
}
const teleportDestination = { x: targetPos.x, y: walkableY, z: targetPos.z };
// Process teleport effect - source unit is teleported to destination
const result = this.effectProcessor.process(effect, activeUnit, teleportDestination);
if (result.success && result.data) {
// Update unit mesh position after teleport
const mesh = this.unitMeshes.get(activeUnit.id);
if (mesh) {
// Floor surface is at pos.y - 0.5, unit should be 0.6 above: pos.y + 0.1
mesh.position.set(
activeUnit.position.x,
activeUnit.position.y + 0.1,
activeUnit.position.z
);
}
console.log(
`Teleported ${activeUnit.name} to ${activeUnit.position.x},${activeUnit.position.y},${activeUnit.position.z}`
);
} else {
console.warn(`Teleport failed: ${result.error || "Unknown error"}`);
}
continue; // Skip normal target processing for TELEPORT
}
// Process effect for all targets (for non-TELEPORT effects)
for (const target of targets) {
if (!target) continue;
// Check if unit is alive
if (typeof target.isAlive === "function" && !target.isAlive()) continue;
if (target.currentHealth <= 0) continue;
// Process ON_SKILL_HIT passive effects (before processing effect)
this.processPassiveItemEffects(activeUnit, "ON_SKILL_HIT", {
skillId: skillId,
skillDef: skillDef,
target: target,
effect: effect,
});
// Process effect through EffectProcessor
const result = this.effectProcessor.process(effect, activeUnit, target);
if (result.success) {
// Log success messages based on effect type
if (result.data) {
if (result.data.type === "DAMAGE") {
console.log(
`${activeUnit.name} dealt ${result.data.amount} damage to ${target.name} (${result.data.currentHP}/${target.maxHealth} HP)`
);
if (result.data.currentHP <= 0) {
console.log(`${target.name} has been defeated!`);
// Process ON_KILL passive effects (on source)
this.processPassiveItemEffects(activeUnit, "ON_KILL", {
target: target,
killedUnit: target,
});
// TODO: Handle unit death (remove from grid, trigger death effects, etc.)
}
// Process passive item effects for ON_DAMAGED trigger (on target)
this.processPassiveItemEffects(target, "ON_DAMAGED", {
source: activeUnit,
damageAmount: result.data.amount,
});
// Process passive item effects for ON_DAMAGE_DEALT trigger (on source)
this.processPassiveItemEffects(activeUnit, "ON_DAMAGE_DEALT", {
target: target,
damageAmount: result.data.amount,
});
} else if (result.data.type === "HEAL") {
if (result.data.amount > 0) {
console.log(
`${activeUnit.name} healed ${target.name} for ${result.data.amount} HP (${result.data.currentHP}/${target.maxHealth} HP)`
);
}
// Process passive item effects for ON_HEAL_DEALT trigger (on source)
this.processPassiveItemEffects(activeUnit, "ON_HEAL_DEALT", {
target: target,
healAmount: result.data.amount,
});
} else if (result.data.type === "APPLY_STATUS") {
console.log(
`${activeUnit.name} applied ${result.data.statusId} to ${target.name} for ${result.data.duration} turns`
);
} else if (result.data.type === "CHAIN_DAMAGE") {
// Log chain damage results
if (result.data.results && result.data.results.length > 0) {
const primaryResult = result.data.results[0];
console.log(
`${activeUnit.name} dealt ${primaryResult.amount} damage to ${target.name} (chain lightning)`
);
if (result.data.chainTargets && result.data.chainTargets.length > 0) {
console.log(
`Chain lightning bounced to ${result.data.chainTargets.length} additional targets`
);
}
}
}
}
} else {
// Log warnings for failed effects (but don't block other effects)
if (result.error && result.error !== "Conditions not met") {
console.warn(`Effect ${effect.type} failed: ${result.error}`);
}
}
}
}
}
console.log( console.log(
`Executed skill ${skillId} at ${targetPos.x},${targetPos.y},${targetPos.z}, hit ${targets.length} targets` `Executed skill ${skillId} at ${targetPos.x},${targetPos.y},${targetPos.z}, hit ${targets.length} targets`
@ -835,6 +1061,14 @@ export class GameLoop {
// WIRING: Connect Systems to Data // WIRING: Connect Systems to Data
this.movementSystem.setContext(this.grid, this.unitManager); this.movementSystem.setContext(this.grid, this.unitManager);
this.turnSystem.setContext(this.unitManager); this.turnSystem.setContext(this.unitManager);
// Initialize EffectProcessor with grid, unitManager, and optional RNG (using seed from runData)
this.effectProcessor = new EffectProcessor(
this.grid,
this.unitManager,
runData.seed ? new SeededRandom(runData.seed) : null
);
// Set hazard context for TurnSystem (for environmental hazard processing)
this.turnSystem.setHazardContext(this.grid, this.effectProcessor);
// Load skills and initialize SkillTargetingSystem // Load skills and initialize SkillTargetingSystem
// Skip skill loading in test mode (when startAnimation is false) to avoid fetch timeouts // Skip skill loading in test mode (when startAnimation is false) to avoid fetch timeouts
@ -2011,6 +2245,219 @@ export class GameLoop {
console.log("TurnSystem: Combat started"); console.log("TurnSystem: Combat started");
} }
/**
* Processes passive item effects for a unit when a trigger event occurs.
* Integration Point 2: EventSystem for Passive Items
* @param {Unit} unit - Unit whose items should be checked
* @param {string} trigger - Trigger event type (e.g., "ON_DAMAGED", "ON_DAMAGE_DEALT", "ON_HEAL_DEALT")
* @param {Object} context - Event context (source, target, damageAmount, etc.)
* @private
*/
processPassiveItemEffects(unit, trigger, context = {}) {
if (!unit || !unit.loadout || !this.effectProcessor || !this.inventoryManager) {
return;
}
// Get all equipped items from loadout
const equippedItems = [
unit.loadout.mainHand,
unit.loadout.offHand,
unit.loadout.body,
unit.loadout.accessory,
...(unit.loadout.belt || []),
].filter(Boolean); // Remove nulls
// Process each equipped item's passive effects
for (const itemInstance of equippedItems) {
if (!itemInstance || !itemInstance.defId) continue;
const itemDef = this.inventoryManager.itemRegistry.get(itemInstance.defId);
if (!itemDef || !itemDef.passives) continue;
// Check each passive effect
for (const passive of itemDef.passives) {
if (passive.trigger !== trigger) continue;
// Check conditions before processing
if (!this._checkPassiveConditions(passive, context)) continue;
// Check chance if present
if (passive.chance !== undefined) {
if (!this.rng || Math.random() > passive.chance) continue;
}
// Convert passive effect to EffectDefinition format
const effectDef = this._convertPassiveToEffectDef(passive, context);
if (!effectDef) continue;
// Determine target based on passive action
let target = context.target || context.source || unit;
if (passive.params && passive.params.target === "SOURCE" && context.source) {
target = context.source;
} else if (passive.params && passive.params.target === "SELF") {
target = unit;
}
// Process the effect through EffectProcessor
const result = this.effectProcessor.process(effectDef, unit, target);
if (result.success && result.data) {
console.log(
`Passive effect ${passive.id || "unknown"} triggered on ${unit.name} (${trigger})`
);
}
}
}
}
/**
* Converts a passive effect definition to an EffectDefinition format.
* @param {Object} passive - Passive effect definition
* @param {Object} context - Event context
* @returns {Object | null} - EffectDefinition or null if conversion not supported
* @private
*/
_convertPassiveToEffectDef(passive, context) {
if (!passive.action || !passive.params) return null;
// Map passive actions to EffectProcessor effect types
const actionMap = {
DAMAGE: "DAMAGE",
APPLY_STATUS: "APPLY_STATUS",
HEAL: "HEAL",
CHAIN_DAMAGE: "CHAIN_DAMAGE",
GIVE_AP: "GIVE_AP",
HEAL_SELF: "HEAL_SELF",
APPLY_BUFF: "APPLY_BUFF",
};
const effectType = actionMap[passive.action];
if (!effectType) return null; // Unsupported action
const effectDef = {
type: effectType,
};
// Copy relevant params
if (passive.params.power !== undefined) {
effectDef.power = passive.params.power;
}
if (passive.params.element) {
effectDef.element = passive.params.element;
}
if (passive.params.status_id) {
effectDef.status_id = passive.params.status_id;
}
if (passive.params.duration !== undefined) {
effectDef.duration = passive.params.duration;
}
if (passive.params.chance !== undefined) {
effectDef.chance = passive.params.chance;
}
// CHAIN_DAMAGE specific params
if (passive.params.bounces !== undefined) {
effectDef.bounces = passive.params.bounces;
}
if (passive.params.chainRange !== undefined) {
effectDef.chainRange = passive.params.chainRange;
}
if (passive.params.decay !== undefined) {
effectDef.decay = passive.params.decay;
}
if (passive.params.synergy_trigger) {
effectDef.synergy_trigger = passive.params.synergy_trigger;
}
if (passive.condition) {
effectDef.condition = passive.condition;
}
// GIVE_AP specific params
if (passive.params.amount !== undefined) {
effectDef.amount = passive.params.amount;
}
// HEAL_SELF specific params
if (passive.params.percentage !== undefined) {
effectDef.percentage = passive.params.percentage;
}
if (context.healAmount !== undefined) {
effectDef.healAmount = context.healAmount;
}
// APPLY_BUFF specific params
if (passive.params.stat !== undefined) {
effectDef.stat = passive.params.stat;
}
if (passive.params.value !== undefined) {
effectDef.value = passive.params.value;
}
return effectDef;
}
/**
* Checks if passive effect conditions are met.
* @param {Object} passive - Passive effect definition
* @param {Object} context - Event context
* @returns {boolean} - True if conditions are met
* @private
*/
_checkPassiveConditions(passive, context) {
if (!passive.condition) return true; // No conditions means always execute
const condition = passive.condition;
// Check SKILL_TAG condition
if (condition.type === "SKILL_TAG" && condition.value) {
const skillDef = context.skillDef;
if (!skillDef || !skillDef.tags) return false;
if (!skillDef.tags.includes(condition.value)) return false;
}
// Check DAMAGE_TYPE condition
if (condition.type === "DAMAGE_TYPE" && condition.value) {
const effect = context.effect;
if (!effect || effect.element !== condition.value) return false;
}
// Check TARGET_TAG condition
if (condition.type === "TARGET_TAG" && condition.value) {
const target = context.target;
if (!target || !target.tags) return false;
if (!target.tags.includes(condition.value)) return false;
}
// Check SOURCE_IS_ADJACENT condition
if (condition.type === "SOURCE_IS_ADJACENT") {
const source = context.source;
const target = context.target || context.unit;
if (!source || !target || !source.position || !target.position) return false;
const dist = Math.abs(source.position.x - target.position.x) +
Math.abs(source.position.y - target.position.y) +
Math.abs(source.position.z - target.position.z);
if (dist > 1) return false; // Not adjacent (Manhattan distance > 1)
}
// Check IS_ADJACENT condition
if (condition.type === "IS_ADJACENT") {
const source = context.source || context.unit;
const target = context.target;
if (!source || !target || !source.position || !target.position) return false;
const dist = Math.abs(source.position.x - target.position.x) +
Math.abs(source.position.y - target.position.y) +
Math.abs(source.position.z - target.position.z);
if (dist > 1) return false; // Not adjacent
}
// Check DID_NOT_ATTACK condition (for ON_TURN_END)
if (condition.type === "DID_NOT_ATTACK") {
// This would need to track if the unit attacked this turn
// For now, we'll assume it's tracked in context
if (context.didAttack === true) return false;
}
return true;
}
/** /**
* Event handler for combat-end event from TurnSystem. * Event handler for combat-end event from TurnSystem.
* @private * @private

View file

@ -539,6 +539,213 @@ export class VoxelManager {
this.aoeReticle.clear(); this.aoeReticle.clear();
} }
/**
* Highlights specific tiles with obstruction-based dimming.
* @param {Array<{pos: import("./types.js").Position, obstruction: number}>} tilesWithObstruction - Array of tile positions with obstruction levels (0-1)
* @param {string} style - Highlight style (e.g., 'RED_OUTLINE')
*/
highlightTilesWithObstruction(tilesWithObstruction, style = "RED_OUTLINE") {
if (!this.rangeHighlights) return;
// Clear existing range highlights
this.clearRangeHighlights();
// Create highlights for each tile with obstruction-based opacity
tilesWithObstruction.forEach(({ pos, obstruction }) => {
// Calculate opacity based on obstruction (0 obstruction = full brightness, 1 obstruction = dim)
// Success chance = 1 - obstruction, so opacity should reflect that
const baseOpacity = 1.0;
const dimmedOpacity = baseOpacity * (1 - obstruction * 0.7); // Dim up to 70% based on obstruction
// Create materials with obstruction-based opacity
const outerGlowMaterial = new THREE.LineBasicMaterial({
color: 0x660000,
transparent: true,
opacity: 0.3 * dimmedOpacity,
});
const midGlowMaterial = new THREE.LineBasicMaterial({
color: 0x880000,
transparent: true,
opacity: 0.5 * dimmedOpacity,
});
const highlightMaterial = new THREE.LineBasicMaterial({
color: 0xff0000, // Bright red
transparent: true,
opacity: dimmedOpacity,
});
const thickMaterial = new THREE.LineBasicMaterial({
color: 0xcc0000,
transparent: true,
opacity: 0.8 * dimmedOpacity,
});
// Find walkable Y level (similar to movement highlights)
let walkableY = pos.y;
// Check if there's a floor at this position
if (this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) {
// No floor, try to find walkable level
for (let checkY = pos.y; checkY >= 0; checkY--) {
if (this.grid.getCell(pos.x, checkY - 1, pos.z) !== 0) {
walkableY = checkY;
break;
}
}
}
const floorSurfaceY = walkableY - 0.5;
// Outer glow
const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15);
outerGlowGeometry.rotateX(-Math.PI / 2);
const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry);
const outerGlowLines = new THREE.LineSegments(
outerGlowEdges,
outerGlowMaterial
);
outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z);
this.scene.add(outerGlowLines);
this.rangeHighlights.add(outerGlowLines);
// Mid glow
const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08);
midGlowGeometry.rotateX(-Math.PI / 2);
const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry);
const midGlowLines = new THREE.LineSegments(
midGlowEdges,
midGlowMaterial
);
midGlowLines.position.set(pos.x, floorSurfaceY + 0.004, pos.z);
this.scene.add(midGlowLines);
this.rangeHighlights.add(midGlowLines);
// Main highlight
const baseGeometry = new THREE.PlaneGeometry(1, 1);
baseGeometry.rotateX(-Math.PI / 2);
const highlightEdges = new THREE.EdgesGeometry(baseGeometry);
const highlightLines = new THREE.LineSegments(
highlightEdges,
highlightMaterial
);
highlightLines.position.set(pos.x, floorSurfaceY + 0.005, pos.z);
this.scene.add(highlightLines);
this.rangeHighlights.add(highlightLines);
// Thick border
const thickGeometry = new THREE.PlaneGeometry(1.02, 1.02);
thickGeometry.rotateX(-Math.PI / 2);
const thickEdges = new THREE.EdgesGeometry(thickGeometry);
const thickLines = new THREE.LineSegments(thickEdges, thickMaterial);
thickLines.position.set(pos.x, floorSurfaceY + 0.006, pos.z);
this.scene.add(thickLines);
this.rangeHighlights.add(thickLines);
});
}
/**
* Highlights specific tiles (used for skill targeting with validation).
* @param {import("./types.js").Position[]} tiles - Array of tile positions to highlight
* @param {string} style - Highlight style (e.g., 'RED_OUTLINE')
*/
highlightTiles(tiles, style = "RED_OUTLINE") {
if (!this.rangeHighlights) return;
// Clear existing range highlights
this.clearRangeHighlights();
// Create red outline materials (similar to movement highlights but red)
const outerGlowMaterial = new THREE.LineBasicMaterial({
color: 0x660000,
transparent: true,
opacity: 0.3,
});
const midGlowMaterial = new THREE.LineBasicMaterial({
color: 0x880000,
transparent: true,
opacity: 0.5,
});
const highlightMaterial = new THREE.LineBasicMaterial({
color: 0xff0000, // Bright red
transparent: true,
opacity: 1.0,
});
const thickMaterial = new THREE.LineBasicMaterial({
color: 0xcc0000,
transparent: true,
opacity: 0.8,
});
// Create base plane geometry
const baseGeometry = new THREE.PlaneGeometry(1, 1);
baseGeometry.rotateX(-Math.PI / 2);
// Create highlights for each tile
tiles.forEach((pos) => {
// Find walkable Y level (similar to movement highlights)
let walkableY = pos.y;
// Check if there's a floor at this position
if (this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) {
// No floor, try to find walkable level
for (let checkY = pos.y; checkY >= 0; checkY--) {
if (this.grid.getCell(pos.x, checkY - 1, pos.z) !== 0) {
walkableY = checkY;
break;
}
}
}
const floorSurfaceY = walkableY - 0.5;
// Outer glow
const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15);
outerGlowGeometry.rotateX(-Math.PI / 2);
const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry);
const outerGlowLines = new THREE.LineSegments(
outerGlowEdges,
outerGlowMaterial
);
outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z);
this.scene.add(outerGlowLines);
this.rangeHighlights.add(outerGlowLines);
// Mid glow
const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08);
midGlowGeometry.rotateX(-Math.PI / 2);
const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry);
const midGlowLines = new THREE.LineSegments(
midGlowEdges,
midGlowMaterial
);
midGlowLines.position.set(pos.x, floorSurfaceY + 0.004, pos.z);
this.scene.add(midGlowLines);
this.rangeHighlights.add(midGlowLines);
// Main highlight
const highlightEdges = new THREE.EdgesGeometry(baseGeometry);
const highlightLines = new THREE.LineSegments(
highlightEdges,
highlightMaterial
);
highlightLines.position.set(pos.x, floorSurfaceY + 0.005, pos.z);
this.scene.add(highlightLines);
this.rangeHighlights.add(highlightLines);
// Thick border
const thickGeometry = new THREE.PlaneGeometry(1.02, 1.02);
thickGeometry.rotateX(-Math.PI / 2);
const thickEdges = new THREE.EdgesGeometry(thickGeometry);
const thickLines = new THREE.LineSegments(thickEdges, thickMaterial);
thickLines.position.set(pos.x, floorSurfaceY + 0.006, pos.z);
this.scene.add(thickLines);
this.rangeHighlights.add(thickLines);
});
}
/** /**
* Clears all highlights (range and AoE). * Clears all highlights (range and AoE).
*/ */

View file

@ -0,0 +1,931 @@
/**
* @typedef {import("../grid/VoxelGrid.js").VoxelGrid} VoxelGrid
* @typedef {import("../managers/UnitManager.js").UnitManager} UnitManager
* @typedef {import("../units/Unit.js").Unit} Unit
* @typedef {import("../grid/types.js").Position} Position
* @typedef {import("../utils/SeededRandom.js").SeededRandom} SeededRandom
* @typedef {import("./Effects.d.ts").EffectType} EffectType
* @typedef {import("./Effects.d.ts").EffectParams} EffectParams
* @typedef {import("./Effects.d.ts").EffectDefinition} EffectDefinition
* @typedef {import("./Effects.d.ts").ConditionDefinition} ConditionDefinition
*/
/**
* EffectProcessor.js
* The central system responsible for executing all changes to the game state.
* Implements the specifications from EffectProcessor.spec.md
* @class
*/
export class EffectProcessor {
/**
* @param {VoxelGrid} voxelGrid - Voxel grid instance
* @param {UnitManager} unitManager - Unit manager instance
* @param {SeededRandom} [rng] - Optional seeded random number generator
*/
constructor(voxelGrid, unitManager, rng = null) {
/** @type {VoxelGrid} */
this.voxelGrid = voxelGrid;
/** @type {UnitManager} */
this.unitManager = unitManager;
/** @type {SeededRandom | null} */
this.rng = rng;
/**
* Map of effect type to handler function
* @type {Map<string, (def: EffectDefinition, source: Unit, target: Unit | Position) => EffectResult>}
*/
this.handlers = new Map();
// Register handlers
this.handlers.set("DAMAGE", this.handleDamage.bind(this));
this.handlers.set("HEAL", this.handleHeal.bind(this));
this.handlers.set("APPLY_STATUS", this.handleStatus.bind(this));
this.handlers.set("REMOVE_STATUS", this.handleStatus.bind(this));
this.handlers.set("PUSH", this.handleMove.bind(this));
this.handlers.set("PULL", this.handleMove.bind(this));
this.handlers.set("TELEPORT", this.handleMove.bind(this));
this.handlers.set("CHAIN_DAMAGE", this.handleChainDamage.bind(this));
this.handlers.set("GIVE_AP", this.handleGiveAP.bind(this));
this.handlers.set("HEAL_SELF", this.handleHealSelf.bind(this));
this.handlers.set("APPLY_BUFF", this.handleApplyBuff.bind(this));
}
/**
* Processes an effect definition and applies it to the target.
* @param {EffectDefinition} effectDef - Effect definition (matches Effects.d.ts)
* @param {Unit | null} source - Source unit (null for environmental effects)
* @param {Unit | Position} target - Target unit or position
* @returns {EffectResult} - Result object with success status and data
*/
process(effectDef, source, target) {
// Validate inputs
if (!effectDef || !effectDef.type) {
return {
success: false,
error: "Invalid effect definition",
data: null,
};
}
// Check conditions before processing
if (!this.checkConditions(effectDef, source, target)) {
return {
success: false,
error: "Conditions not met",
data: null,
};
}
// Look up handler
const handler = this.handlers.get(effectDef.type);
if (!handler) {
return {
success: false,
error: `Unknown effect type: ${effectDef.type}`,
data: null,
};
}
// Execute handler
try {
return handler(effectDef, source, target);
} catch (error) {
return {
success: false,
error: error.message || "Handler execution failed",
data: null,
};
}
}
/**
* Checks if conditions are met for the effect to execute.
* Uses ConditionDefinition structure: { type: ConditionType, value?: string | number | boolean }
* @param {EffectDefinition} effectDef - Effect definition
* @param {Unit | null} source - Source unit
* @param {Unit | Position} target - Target unit or position
* @returns {boolean} - True if conditions are met
*/
checkConditions(effectDef, source, target) {
if (!effectDef.condition) {
return true; // No conditions means always execute
}
const condition = /** @type {ConditionDefinition} */ (effectDef.condition);
// Handle condition based on type (matching ConditionDefinition structure)
switch (condition.type) {
case "TARGET_TAG":
if (target && typeof target === "object" && "tags" in target) {
const unit = /** @type {Unit} */ (target);
const tagValue =
typeof condition.value === "string" ? condition.value : null;
if (!tagValue || !unit.tags || !unit.tags.includes(tagValue)) {
return false;
}
}
break;
case "TARGET_HP_LOW":
if (target && typeof target === "object" && "currentHealth" in target) {
const unit = /** @type {Unit} */ (target);
const threshold =
typeof condition.value === "number" ? condition.value : 0.5;
const hpPercentage = unit.currentHealth / (unit.maxHealth || 1);
if (hpPercentage > threshold) {
return false;
}
}
break;
case "IS_MECHANICAL":
if (target && typeof target === "object" && "tags" in target) {
const unit = /** @type {Unit} */ (target);
if (!unit.tags || !unit.tags.includes("MECHANICAL")) {
return false;
}
}
break;
// Legacy support: old format with direct properties (for backward compatibility)
default:
// Check target_tag (legacy format)
if (
condition.target_tag &&
target &&
typeof target === "object" &&
"tags" in target
) {
const unit = /** @type {Unit} */ (target);
if (unit.tags && !unit.tags.includes(condition.target_tag)) {
return false;
}
}
// Check target_status (legacy format - could be condition.type === "TARGET_STATUS" in future)
if (
condition.target_status &&
target &&
typeof target === "object" &&
"statusEffects" in target
) {
const unit = /** @type {Unit} */ (target);
const hasStatus = unit.statusEffects?.some(
(effect) => effect.id === condition.target_status
);
if (!hasStatus) {
return false;
}
}
// Check hp_threshold (legacy format)
if (
condition.hp_threshold !== undefined &&
target &&
typeof target === "object" &&
"currentHealth" in target
) {
const unit = /** @type {Unit} */ (target);
const hpPercentage = unit.currentHealth / (unit.maxHealth || 1);
if (hpPercentage > condition.hp_threshold) {
return false;
}
}
break;
}
return true;
}
/**
* Calculates the final power value based on base power and attribute scaling.
* @param {EffectDefinition} effectDef - Effect definition
* @param {Unit} source - Source unit
* @returns {number} - Calculated power value
*/
calculatePower(effectDef, source) {
const basePower = effectDef.power || 0;
const attribute = effectDef.attribute;
const scaling = effectDef.scaling || 1.0;
if (!attribute || !source.baseStats) {
return basePower;
}
// Get attribute value from source's baseStats
const attributeValue = source.baseStats[attribute] || 0;
const scaledValue = attributeValue * scaling;
return basePower + scaledValue;
}
/**
* Handles DAMAGE effect.
* @param {EffectDefinition} effectDef - Effect definition
* @param {Unit} source - Source unit
* @param {Unit | Position} target - Target unit or position
* @returns {EffectResult} - Result object
*/
handleDamage(effectDef, source, target) {
if (!target || typeof target !== "object" || !("currentHealth" in target)) {
return {
success: false,
error: "Damage target must be a Unit",
data: null,
};
}
const unit = /** @type {Unit} */ (target);
// Calculate base damage
let baseDamage = this.calculatePower(effectDef, source);
// Apply conditional multiplier if condition is met
if (effectDef.conditional_multiplier) {
const condMult = effectDef.conditional_multiplier;
// Check if condition is met (e.g., target has specific tag)
if (
condMult.condition &&
target &&
typeof target === "object" &&
"tags" in target
) {
const unit = /** @type {Unit} */ (target);
if (unit.tags && unit.tags.includes(condMult.condition)) {
baseDamage *= condMult.value;
}
}
}
// Apply defense reduction
const defense = unit.baseStats?.defense || 0;
let finalDamage = Math.max(0, baseDamage - defense);
// TODO: Apply element resistance/weakness if element is specified
// For now, we'll skip element checks
// Apply damage
const previousHP = unit.currentHealth;
unit.currentHealth = Math.max(0, unit.currentHealth - finalDamage);
return {
success: true,
error: null,
data: {
type: "DAMAGE",
amount: finalDamage,
previousHP,
currentHP: unit.currentHealth,
target: unit.id,
},
};
}
/**
* Handles HEAL effect.
* @param {EffectDefinition} effectDef - Effect definition
* @param {Unit} source - Source unit
* @param {Unit | Position} target - Target unit or position
* @returns {EffectResult} - Result object
*/
handleHeal(effectDef, source, target) {
if (!target || typeof target !== "object" || !("currentHealth" in target)) {
return {
success: false,
error: "Heal target must be a Unit",
data: null,
};
}
const unit = /** @type {Unit} */ (target);
// Calculate heal amount
const healAmount = this.calculatePower(effectDef, source);
// Apply healing
const previousHP = unit.currentHealth;
unit.currentHealth = Math.min(
unit.maxHealth || unit.currentHealth,
unit.currentHealth + healAmount
);
const actualHeal = unit.currentHealth - previousHP;
return {
success: true,
error: null,
data: {
type: "HEAL",
amount: actualHeal,
previousHP,
currentHP: unit.currentHealth,
target: unit.id,
},
};
}
/**
* Handles APPLY_STATUS and REMOVE_STATUS effects.
* @param {EffectDefinition} effectDef - Effect definition
* @param {Unit} source - Source unit
* @param {Unit | Position} target - Target unit or position
* @returns {EffectResult} - Result object
*/
handleStatus(effectDef, source, target) {
if (!target || typeof target !== "object" || !("statusEffects" in target)) {
return {
success: false,
error: "Status target must be a Unit",
data: null,
};
}
const unit = /** @type {Unit} */ (target);
const statusId = effectDef.status_id;
if (!statusId) {
return {
success: false,
error: "Status effect missing status_id",
data: null,
};
}
// Initialize statusEffects array if needed
if (!unit.statusEffects) {
unit.statusEffects = [];
}
if (effectDef.type === "APPLY_STATUS") {
// Check chance (default 1.0 = 100%)
const chance = effectDef.chance !== undefined ? effectDef.chance : 1.0;
const roll = this.rng ? this.rng.next() : Math.random();
if (roll >= chance) {
return {
success: false,
error: "Status effect failed chance roll",
data: null,
};
}
const duration = effectDef.duration || 1;
// Check if status already exists
const existingStatus = unit.statusEffects.find((s) => s.id === statusId);
// Build status effect object with any DoT/HoT properties
const statusEffect = {
id: statusId,
type: "STATUS",
duration: duration,
};
// Store DoT damage if provided (for damage over time effects)
if (effectDef.power !== undefined && effectDef.power > 0) {
statusEffect.damage = effectDef.power;
statusEffect.type = "DOT"; // Mark as DoT for processing
statusEffect.element = effectDef.element; // Store element for display
}
// Store HoT heal if provided (for heal over time effects)
if (effectDef.power !== undefined && effectDef.power < 0) {
statusEffect.heal = Math.abs(effectDef.power);
statusEffect.type = "HOT"; // Mark as HoT for processing
}
if (existingStatus) {
// Refresh duration if status already exists
existingStatus.duration = Math.max(existingStatus.duration, duration);
// Update damage/heal if provided
if (statusEffect.damage !== undefined) {
existingStatus.damage = statusEffect.damage;
existingStatus.type = "DOT";
existingStatus.element = statusEffect.element;
}
if (statusEffect.heal !== undefined) {
existingStatus.heal = statusEffect.heal;
existingStatus.type = "HOT";
}
} else {
// Add new status effect
unit.statusEffects.push(statusEffect);
}
return {
success: true,
error: null,
data: {
type: "APPLY_STATUS",
statusId,
duration,
target: unit.id,
},
};
} else if (effectDef.type === "REMOVE_STATUS") {
// Remove status effect
const index = unit.statusEffects.findIndex((s) => s.id === statusId);
if (index !== -1) {
unit.statusEffects.splice(index, 1);
return {
success: true,
error: null,
data: {
type: "REMOVE_STATUS",
statusId,
target: unit.id,
},
};
} else {
return {
success: false,
error: "Status effect not found on target",
data: null,
};
}
}
return {
success: false,
error: "Invalid status effect type",
data: null,
};
}
/**
* Handles CHAIN_DAMAGE effect.
* Applies damage to primary target, then chains to nearest enemies with decay.
* @param {EffectDefinition} effectDef - Effect definition
* @param {Unit} source - Source unit
* @param {Unit | Position} target - Target unit or position
* @returns {EffectResult} - Result object
*/
handleChainDamage(effectDef, source, target) {
if (!target || typeof target !== "object" || !("currentHealth" in target)) {
return {
success: false,
error: "Chain damage target must be a Unit",
data: null,
};
}
const primaryTarget = /** @type {Unit} */ (target);
const results = [];
// Step 1: Apply damage to primary target
const primaryDamageDef = {
type: "DAMAGE",
power: effectDef.power,
attribute: effectDef.attribute,
scaling: effectDef.scaling,
element: effectDef.element,
};
const primaryResult = this.handleDamage(
primaryDamageDef,
source,
primaryTarget
);
if (primaryResult.success) {
results.push(primaryResult.data);
}
// Step 2: Find nearest enemies for chaining
const bounces = effectDef.bounces || 2;
const chainRange = effectDef.chainRange || 3;
const decay = effectDef.decay !== undefined ? effectDef.decay : 0.5; // Default 50% damage per bounce
const synergyTrigger = effectDef.synergy_trigger; // e.g., "STATUS_WET"
// Get all enemy units in range (excluding primary target)
const enemyTeam = source
? source.team === "PLAYER"
? "ENEMY"
: "PLAYER"
: "ENEMY";
const allEnemies = this.unitManager
.getUnitsInRange(primaryTarget.position, chainRange, enemyTeam)
.filter((unit) => {
if (unit.id === primaryTarget.id) return false;
// Check if unit is alive
if (typeof unit.isAlive === "function") {
return unit.isAlive();
}
return unit.currentHealth > 0;
});
// Sort by distance to primary target
allEnemies.sort((a, b) => {
const distA =
Math.abs(a.position.x - primaryTarget.position.x) +
Math.abs(a.position.y - primaryTarget.position.y) +
Math.abs(a.position.z - primaryTarget.position.z);
const distB =
Math.abs(b.position.x - primaryTarget.position.x) +
Math.abs(b.position.y - primaryTarget.position.y) +
Math.abs(b.position.z - primaryTarget.position.z);
return distA - distB;
});
// Take up to 'bounces' nearest enemies
const chainTargets = allEnemies.slice(0, bounces);
// Step 3: Apply chained damage with decay
let currentDecay = decay;
for (const chainTarget of chainTargets) {
// Check for synergy trigger (e.g., WET status)
let damageMultiplier = 1.0;
if (synergyTrigger && chainTarget.statusEffects) {
const hasSynergy = chainTarget.statusEffects.some(
(effect) => effect.id === synergyTrigger
);
if (hasSynergy) {
damageMultiplier = 2.0; // Double damage for synergy
}
}
// Calculate chained damage: base damage * decay * synergy multiplier
const chainDamageDef = {
type: "DAMAGE",
power: (effectDef.power || 0) * currentDecay * damageMultiplier,
attribute: effectDef.attribute,
scaling: effectDef.scaling,
element: effectDef.element,
};
const chainResult = this.handleDamage(
chainDamageDef,
source,
chainTarget
);
if (chainResult.success) {
results.push(chainResult.data);
}
// Apply further decay for next bounce (optional - could be cumulative or fixed)
currentDecay *= decay;
}
return {
success: true,
error: null,
data: {
type: "CHAIN_DAMAGE",
primaryTarget: primaryTarget.id,
chainTargets: chainTargets.map((u) => u.id),
results: results,
},
};
}
/**
* Handles PUSH, PULL, and TELEPORT effects.
* @param {EffectDefinition} effectDef - Effect definition
* @param {Unit} source - Source unit
* @param {Unit | Position} target - Target unit or position
* @returns {EffectResult} - Result object
*/
handleMove(effectDef, source, target) {
if (effectDef.type === "TELEPORT") {
// For TELEPORT, source is the unit being teleported, target is destination or reference
if (!source || !source.position) {
return {
success: false,
error: "TELEPORT requires a source unit",
data: null,
};
}
const unit = source; // Source unit is the one being teleported
const oldPosition = { ...unit.position };
// Determine destination
let destination;
if (effectDef.destination === "BEHIND_TARGET") {
// Calculate position behind target relative to source
if (!target || typeof target !== "object" || !("position" in target)) {
return {
success: false,
error: "TELEPORT BEHIND_TARGET requires a target unit",
data: null,
};
}
const targetUnit = /** @type {Unit} */ (target);
const dx = targetUnit.position.x - unit.position.x;
const dz = targetUnit.position.z - unit.position.z;
destination = {
x: targetUnit.position.x + dx,
y: targetUnit.position.y,
z: targetUnit.position.z + dz,
};
} else {
// Default: target is the destination position
if (!target || typeof target !== "object" || !("x" in target)) {
return {
success: false,
error: "TELEPORT requires a destination position",
data: null,
};
}
destination = /** @type {Position} */ (target);
}
// Validate destination (must be Air and Unoccupied)
if (this.voxelGrid.isSolid(destination)) {
return {
success: false,
error: "Teleport destination is solid",
data: null,
};
}
if (this.voxelGrid.isOccupied(destination)) {
return {
success: false,
error: "Teleport destination is occupied",
data: null,
};
}
// Move unit
if (this.voxelGrid.moveUnit(unit, destination, { force: true })) {
return {
success: true,
error: null,
data: {
type: "TELEPORT",
oldPosition,
newPosition: destination,
target: unit.id,
},
};
}
return {
success: false,
error: "Failed to move unit",
data: null,
};
} else if (effectDef.type === "PUSH" || effectDef.type === "PULL") {
// For PUSH/PULL, target is the unit being moved
if (!target || typeof target !== "object" || !("position" in target)) {
return {
success: false,
error: "PUSH/PULL target must be a Unit",
data: null,
};
}
const unit = /** @type {Unit} */ (target);
const oldPosition = { ...unit.position };
// Calculate direction from source to target (or opposite for PULL)
if (!source || !source.position) {
return {
success: false,
error: "PUSH/PULL requires a source unit",
data: null,
};
}
const force = effectDef.force || 1;
// Calculate direction vector from source to target
const dx = unit.position.x - source.position.x;
const dz = unit.position.z - source.position.z;
// Calculate distance
const distance = Math.sqrt(dx * dx + dz * dz);
if (distance === 0) {
return {
success: false,
error: "Source and target are at same position",
data: null,
};
}
// Normalize direction
const normalizedX = dx / distance;
const normalizedZ = dz / distance;
// For PUSH: move away from source (same direction as vector)
// For PULL: move toward source (opposite direction)
const pushDirectionX =
effectDef.type === "PULL" ? -normalizedX : normalizedX;
const pushDirectionZ =
effectDef.type === "PULL" ? -normalizedZ : normalizedZ;
// Calculate new position
const newPosition = {
x: Math.round(unit.position.x + pushDirectionX * force),
y: unit.position.y,
z: Math.round(unit.position.z + pushDirectionZ * force),
};
// Check if destination or any intermediate position is solid (CoA 4: Physics Safety)
// Check all positions from current to destination
for (let i = 1; i <= force; i++) {
const checkPos = {
x: Math.round(unit.position.x + pushDirectionX * i),
y: unit.position.y,
z: Math.round(unit.position.z + pushDirectionZ * i),
};
if (this.voxelGrid.isSolid(checkPos)) {
// Optionally apply "Smash" damage (for now, just fail)
return {
success: false,
error: "Push/Pull blocked by solid terrain",
data: null,
};
}
}
// Check if destination is occupied
if (this.voxelGrid.isOccupied(newPosition)) {
return {
success: false,
error: "Push/Pull destination is occupied",
data: null,
};
}
// Move unit
if (this.voxelGrid.moveUnit(unit, newPosition)) {
return {
success: true,
error: null,
data: {
type: effectDef.type,
oldPosition,
newPosition,
target: unit.id,
},
};
}
return {
success: false,
error: "Failed to move unit",
data: null,
};
}
return {
success: false,
error: "Invalid move effect type",
data: null,
};
}
/**
* Handles GIVE_AP effect.
* Restores Action Points to the target unit.
* @param {EffectDefinition} effectDef - Effect definition
* @param {Unit} source - Source unit
* @param {Unit} target - Target unit
* @returns {EffectResult} - Result object
*/
handleGiveAP(effectDef, source, target) {
if (!target || typeof target !== "object" || !("currentAP" in target)) {
return {
success: false,
error: "GIVE_AP target must be a Unit",
data: null,
};
}
const unit = /** @type {Unit} */ (target);
const amount = effectDef.amount || effectDef.power || 0;
const previousAP = unit.currentAP;
unit.currentAP = (unit.currentAP || 0) + amount;
return {
success: true,
error: null,
data: {
type: "GIVE_AP",
amount: amount,
previousAP,
currentAP: unit.currentAP,
target: unit.id,
},
};
}
/**
* Handles HEAL_SELF effect.
* Heals the source unit for a percentage of healing dealt.
* @param {EffectDefinition} effectDef - Effect definition
* @param {Unit} source - Source unit
* @param {Unit} target - Target unit (usually ignored, heals source)
* @returns {EffectResult} - Result object
*/
handleHealSelf(effectDef, source, target) {
if (!source || typeof source !== "object" || !("currentHealth" in source)) {
return {
success: false,
error: "HEAL_SELF requires a source unit",
data: null,
};
}
const unit = source; // Heal the source unit
const percentage = effectDef.percentage || 0.1;
// Get heal amount from context (should be passed via effectDef.healAmount)
const healAmount = effectDef.healAmount || 0;
const healSelfAmount = Math.floor(healAmount * percentage);
if (healSelfAmount <= 0) {
return {
success: false,
error: "No healing to reflect",
data: null,
};
}
const previousHP = unit.currentHealth;
unit.currentHealth = Math.min(
unit.maxHealth,
unit.currentHealth + healSelfAmount
);
return {
success: true,
error: null,
data: {
type: "HEAL_SELF",
amount: healSelfAmount,
previousHP,
currentHP: unit.currentHealth,
target: unit.id,
},
};
}
/**
* Handles APPLY_BUFF effect.
* Applies a temporary stat modification to the target unit.
* @param {EffectDefinition} effectDef - Effect definition
* @param {Unit} source - Source unit
* @param {Unit} target - Target unit
* @returns {EffectResult} - Result object
*/
handleApplyBuff(effectDef, source, target) {
if (!target || typeof target !== "object" || !("baseStats" in target)) {
return {
success: false,
error: "APPLY_BUFF target must be a Unit",
data: null,
};
}
const unit = /** @type {Unit} */ (target);
const stat = effectDef.stat;
const value = effectDef.value || 0;
const duration = effectDef.duration || 1;
if (!stat) {
return {
success: false,
error: "APPLY_BUFF requires a stat name",
data: null,
};
}
// Initialize buffs array if it doesn't exist
if (!unit.buffs) {
unit.buffs = [];
}
// Add buff to unit
const buff = {
id: `buff_${stat}_${Date.now()}`,
stat: stat,
value: value,
duration: duration,
source: source ? source.id : null,
};
unit.buffs.push(buff);
return {
success: true,
error: null,
data: {
type: "APPLY_BUFF",
stat: stat,
value: value,
duration: duration,
target: unit.id,
},
};
}
}
/**
* @typedef {Object} EffectResult
* @property {boolean} success - Whether the effect was successful
* @property {string | null} error - Error message if failed
* @property {Object | null} data - Result data
*/

233
src/systems/Effects.d.ts vendored Normal file
View file

@ -0,0 +1,233 @@
/**
* Effects.ts
* Type definitions for the Game Logic Engine (Effects, Passives, and Triggers).
*/
// =============================================================================
// 1. EFFECT DEFINITIONS (The "What")
// =============================================================================
/**
* List of all valid actions the EffectProcessor can execute.
*/
export type EffectType =
// Combat
| "DAMAGE"
| "HEAL"
| "CHAIN_DAMAGE"
| "REDIRECT_DAMAGE"
| "PREVENT_DEATH"
// Status & Stats
| "APPLY_STATUS"
| "REMOVE_STATUS"
| "REMOVE_ALL_DEBUFFS"
| "APPLY_BUFF"
| "GIVE_AP"
| "ADD_CHARGE"
| "ADD_SHIELD"
| "CONVERT_DAMAGE_TO_HEAL"
| "DYNAMIC_BUFF"
// Movement & Physics
| "TELEPORT"
| "MOVE_TO_TARGET"
| "SWAP_POSITIONS"
| "PHYSICS_PULL"
| "PUSH"
// World & Spawning
| "SPAWN_OBJECT"
| "SPAWN_HAZARD"
| "SPAWN_LOOT"
| "MODIFY_TERRAIN"
| "DESTROY_VOXEL"
| "DESTROY_OBJECTS"
| "REVEAL_OBJECTS"
| "COLLECT_LOOT"
// Meta / Logic
| "REPEAT_SKILL"
| "CANCEL_EVENT"
| "REDUCE_COST"
| "BUFF_SPAWN"
| "MODIFY_AOE";
/**
* A generic container for parameters used by Effect Handlers.
* In a strict system, this would be a Union of specific interfaces,
* but for JSON deserialization, a loose interface is often more flexible.
*/
export interface EffectParams {
// -- Combat Magnitude --
power?: number; // Base amount (Damage/Heal)
attribute?: string; // Stat to scale off (e.g., "strength", "magic")
scaling?: number; // Multiplier for attribute (Default: 1.0)
element?: "PHYSICAL" | "FIRE" | "ICE" | "SHOCK" | "VOID" | "TECH";
// -- Chaining --
bounces?: number;
decay?: number;
synergy_trigger?: string; // Status ID that triggers bonus effect
// -- Status/Buffs --
status_id?: string;
duration?: number;
stat?: string; // For Buffs
value?: number; // For Buffs/Mods
chance?: number; // 0.0 to 1.0 (Proc chance)
// -- Physics --
force?: number; // Distance
destination?: "TARGET" | "BEHIND_TARGET" | "ADJACENT_TO_TARGET";
// -- World --
object_id?: string; // Unit ID to spawn
hazard_id?: string;
tag?: string; // Filter for objects (e.g. "COVER")
range?: number; // AoE radius
// -- Logic --
percentage?: number; // 0.0 - 1.0
amount?: number; // Flat amount (AP/Charge)
amount_range?: [number, number]; // [min, max]
set_hp?: number; // Hard set HP value
shape?: "CIRCLE" | "LINE" | "CONE" | "SINGLE";
size?: number;
multiplier?: number;
}
/**
* The runtime payload passed to EffectProcessor.process().
* Combines the Type and the Params.
*/
export interface EffectDefinition extends EffectParams {
type: EffectType;
// Optional Override Condition for the Effect itself
// (Distinct from the Passive Trigger condition)
condition?: ConditionDefinition;
// Conditional Multiplier (e.g. Execute damage)
conditional_multiplier?: {
condition: string; // Condition Tag
value: number;
};
}
// =============================================================================
// 2. PASSIVE DEFINITIONS (The "When")
// =============================================================================
/**
* Triggers that the EventSystem listens for.
*/
export type TriggerType =
// Stat Calculation Hooks
| "ON_STAT_CALC"
| "STATIC_STAT_MOD"
| "ON_SKILL_COST_CALC"
| "ON_ATTACK_CALC"
| "ON_DAMAGE_CALC"
// Combat Events
| "ON_ATTACK_HIT"
| "ON_SKILL_HIT"
| "ON_SKILL_CAST"
| "ON_DAMAGED"
| "ON_ALLY_DAMAGED"
| "ON_DAMAGE_DEALT"
| "ON_HEAL_DEALT"
| "ON_HEAL_OVERFLOW"
| "ON_KILL"
| "ON_LETHAL_DAMAGE"
| "ON_SYNERGY_TRIGGER"
// Turn Lifecycle
| "ON_TURN_START"
| "ON_TURN_END"
| "ON_ACTION_COMPLETE"
// World Events
| "ON_MOVE_COMPLETE"
| "ON_HAZARD_TICK"
| "ON_TRAP_TRIGGER"
| "ON_OBJECT_DESTROYED"
| "ON_SPAWN_OBJECT"
| "ON_LEVEL_START"
// Meta / UI
| "GLOBAL_SHOP_PRICE"
| "ON_SKILL_TARGETING"
| "AURA_UPDATE";
/**
* How a stat modifier interacts with the base value.
*/
export type ModifierType =
| "ADD"
| "MULTIPLY"
| "ADD_PER_ADJACENT_ENEMY"
| "ADD_PER_DESTROYED_VOXEL"
| "ADD_STAT"
| "MULTIPLY_DAMAGE";
/**
* An entry in `passive_registry.json`.
* Defines a rule: "WHEN [Trigger] IF [Condition] THEN [Action]".
*/
export interface PassiveDefinition {
id: string;
name: string;
description: string;
// -- The Trigger --
trigger: TriggerType;
// -- The Check --
condition?: ConditionDefinition;
limit?: number; // Max times per run/turn
// -- The Effect (Action) --
// Maps to EffectDefinition.type
action?: EffectType;
// -- The Parameters --
// Merged into EffectDefinition
params?: EffectParams;
// -- For Stat Modifiers (simpler than full Effects) --
stat?: string;
modifier_type?: ModifierType;
value?: number;
range?: number; // For Aura/Per-Adjacent checks
// -- For Complex Stat Lists --
modifiers?: { stat: string; value: number }[];
// -- For Auras --
target?: "ALLIES" | "ENEMIES" | "SELF";
}
// =============================================================================
// 3. CONDITIONS (The "If")
// =============================================================================
export type ConditionType =
| "SOURCE_IS_ADJACENT"
| "IS_ADJACENT"
| "IS_BASIC_ATTACK"
| "SKILL_TAG" // value: string
| "DAMAGE_TYPE" // value: string
| "TARGET_TAG" // value: string
| "OWNER_IS_SELF"
| "IS_FLANKING"
| "TARGET_LOCKED"
| "DID_NOT_ATTACK"
| "TARGET_HP_LOW"
| "IS_MECHANICAL";
export interface ConditionDefinition {
type: ConditionType;
value?: string | number | boolean;
}

View file

@ -196,6 +196,12 @@ export class MovementSystem {
return { valid: false, cost: 0, path: [] }; return { valid: false, cost: 0, path: [] };
} }
// Calculate movement cost first (horizontal Manhattan distance)
const horizontalDistance =
Math.abs(finalTargetPos.x - unit.position.x) +
Math.abs(finalTargetPos.z - unit.position.z);
const movementCost = Math.max(1, horizontalDistance);
// Check if target is reachable (path exists) // Check if target is reachable (path exists)
const reachableTiles = this.getReachableTiles(unit); const reachableTiles = this.getReachableTiles(unit);
const isReachable = reachableTiles.some( const isReachable = reachableTiles.some(
@ -205,16 +211,21 @@ export class MovementSystem {
pos.z === finalTargetPos.z pos.z === finalTargetPos.z
); );
// If not reachable, check if it's due to insufficient AP or out of range
if (!isReachable) { if (!isReachable) {
// Check if it's within movement range but just not affordable
const baseMovement = unit.baseStats?.movement || 4;
const inMovementRange = horizontalDistance <= baseMovement;
// If within movement range but not reachable, it's likely due to insufficient AP
if (inMovementRange && unit.currentAP < movementCost) {
return { valid: false, cost: movementCost, path: [] };
}
// Otherwise, it's out of range
return { valid: false, cost: 0, path: [] }; return { valid: false, cost: 0, path: [] };
} }
// Calculate movement cost (horizontal Manhattan distance)
const horizontalDistance =
Math.abs(finalTargetPos.x - unit.position.x) +
Math.abs(finalTargetPos.z - unit.position.z);
const movementCost = Math.max(1, horizontalDistance);
// Check if unit has sufficient AP // Check if unit has sufficient AP
if (unit.currentAP < movementCost) { if (unit.currentAP < movementCost) {
return { valid: false, cost: movementCost, path: [] }; return { valid: false, cost: movementCost, path: [] };

View file

@ -69,8 +69,8 @@ export class SkillTargetingSystem {
target_type: targetType, target_type: targetType,
ignore_cover: targeting.line_of_sight === false, // If line_of_sight is false, ignore cover ignore_cover: targeting.line_of_sight === false, // If line_of_sight is false, ignore cover
aoe_type: aoe.shape || "SINGLE", aoe_type: aoe.shape || "SINGLE",
aoe_radius: aoe.shape === "CIRCLE" ? (aoe.size || 1) : undefined, aoe_radius: aoe.shape === "CIRCLE" ? aoe.size || 1 : undefined,
aoe_length: aoe.shape === "LINE" ? (aoe.size || 1) : undefined, aoe_length: aoe.shape === "LINE" ? aoe.size || 1 : undefined,
costAP: skillDef.costs?.ap || 0, costAP: skillDef.costs?.ap || 0,
cooldown: skillDef.cooldown_turns || 0, cooldown: skillDef.cooldown_turns || 0,
effects: skillDef.effects || [], effects: skillDef.effects || [],
@ -101,9 +101,16 @@ export class SkillTargetingSystem {
* @returns {boolean} - True if line of sight is clear * @returns {boolean} - True if line of sight is clear
* @private * @private
*/ */
/**
* Checks line of sight and calculates obstruction level.
* @param {Position} sourcePos - Source position
* @param {Position} targetPos - Target position
* @param {boolean} ignoreCover - Whether to ignore cover
* @returns {{ clear: boolean; obstruction: number }} - LOS result with obstruction level (0-1, where 1 = fully blocked)
*/
hasLineOfSight(sourcePos, targetPos, ignoreCover = false) { hasLineOfSight(sourcePos, targetPos, ignoreCover = false) {
if (ignoreCover) { if (ignoreCover) {
return true; return { clear: true, obstruction: 0 };
} }
// Source head height (assuming unit is 1.5 voxels tall, head at y + 1.5) // Source head height (assuming unit is 1.5 voxels tall, head at y + 1.5)
@ -111,32 +118,135 @@ export class SkillTargetingSystem {
// Target center (assuming target is at y + 0.5) // Target center (assuming target is at y + 0.5)
const targetCenterY = targetPos.y + 0.5; const targetCenterY = targetPos.y + 0.5;
// Raycast from source to target // Use 3D DDA (Digital Differential Analyzer) algorithm for accurate voxel traversal
// This ensures we check every voxel the ray passes through
let x = Math.floor(sourcePos.x);
let y = Math.floor(sourceHeadY);
let z = Math.floor(sourcePos.z);
const endX = Math.floor(targetPos.x);
const endY = Math.floor(targetCenterY);
const endZ = Math.floor(targetPos.z);
// Track obstruction: count solid voxels and total voxels checked
let solidVoxels = 0;
let totalVoxels = 0;
// If we're already at the target, LOS is clear
if (x === endX && y === endY && z === endZ) {
return { clear: true, obstruction: 0 };
}
// Calculate direction
const dx = targetPos.x - sourcePos.x; const dx = targetPos.x - sourcePos.x;
const dy = targetCenterY - sourceHeadY; const dy = targetCenterY - sourceHeadY;
const dz = targetPos.z - sourcePos.z; const dz = targetPos.z - sourcePos.z;
// Number of steps (use the maximum dimension) // Step direction for each axis
const steps = Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)); const stepX = dx > 0 ? 1 : dx < 0 ? -1 : 0;
if (steps === 0) return true; const stepY = dy > 0 ? 1 : dy < 0 ? -1 : 0;
const stepZ = dz > 0 ? 1 : dz < 0 ? -1 : 0;
const stepX = dx / steps; // Calculate delta distances (how far along the ray we must travel to cross one voxel boundary)
const stepY = dy / steps; const deltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity;
const stepZ = dz / steps; const deltaY = stepY !== 0 ? Math.abs(1 / dy) : Infinity;
const deltaZ = stepZ !== 0 ? Math.abs(1 / dz) : Infinity;
// Check each step along the ray (skip first and last to avoid edge cases) // Calculate initial distances to next voxel boundary
for (let i = 1; i < steps; i++) { let nextX = Infinity;
const x = Math.round(sourcePos.x + stepX * i); let nextY = Infinity;
const y = Math.round(sourceHeadY + stepY * i); let nextZ = Infinity;
const z = Math.round(sourcePos.z + stepZ * i);
// Check if this voxel is solid if (stepX !== 0) {
if (this.grid.isSolid({ x, y, z })) { nextX =
return false; stepX > 0
? (Math.floor(sourcePos.x) + 1 - sourcePos.x) / dx
: (sourcePos.x - Math.floor(sourcePos.x)) / -dx;
if (isNaN(nextX) || !isFinite(nextX)) nextX = Infinity;
}
if (stepY !== 0) {
nextY =
stepY > 0
? (Math.floor(sourceHeadY) + 1 - sourceHeadY) / dy
: (sourceHeadY - Math.floor(sourceHeadY)) / -dy;
if (isNaN(nextY) || !isFinite(nextY)) nextY = Infinity;
}
if (stepZ !== 0) {
nextZ =
stepZ > 0
? (Math.floor(sourcePos.z) + 1 - sourcePos.z) / dz
: (sourcePos.z - Math.floor(sourcePos.z)) / -dz;
if (isNaN(nextZ) || !isFinite(nextZ)) nextZ = Infinity;
}
// Track previous position to avoid checking the same voxel twice
let prevX = null;
let prevY = null;
let prevZ = null;
// Maximum iterations to prevent infinite loops
const maxIterations =
Math.abs(endX - x) + Math.abs(endY - y) + Math.abs(endZ - z) + 10;
let iterations = 0;
// Traverse voxels along the ray
while (iterations < maxIterations) {
iterations++;
// Skip if we've already checked this voxel
if (x !== prevX || y !== prevY || z !== prevZ) {
// Check if we've reached the target
if (x === endX && y === endY && z === endZ) {
// Calculate obstruction level (0 = clear, 1 = fully blocked)
const obstruction = totalVoxels > 0 ? solidVoxels / totalVoxels : 0;
return {
clear: obstruction < 1.0,
obstruction: Math.min(1.0, obstruction),
};
}
// Check if this voxel is solid (but skip source position)
if (
!(
x === Math.floor(sourcePos.x) &&
y === Math.floor(sourceHeadY) &&
z === Math.floor(sourcePos.z)
)
) {
totalVoxels++;
if (this.grid.isSolid({ x, y, z })) {
solidVoxels++;
// If fully blocked, return immediately
return { clear: false, obstruction: 1.0 };
}
}
prevX = x;
prevY = y;
prevZ = z;
}
// Determine which axis to step along (the one with the smallest next boundary distance)
if (nextX < nextY && nextX < nextZ) {
nextX += deltaX;
x += stepX;
} else if (nextY < nextZ) {
nextY += deltaY;
y += stepY;
} else {
nextZ += deltaZ;
z += stepZ;
} }
} }
return true; // If we exit the loop, calculate obstruction and return
const obstruction = totalVoxels > 0 ? solidVoxels / totalVoxels : 0;
return {
clear: obstruction < 1.0,
obstruction: Math.min(1.0, obstruction),
};
} }
/** /**
@ -157,20 +267,45 @@ export class SkillTargetingSystem {
if (!sourceUnit.position) { if (!sourceUnit.position) {
return { valid: false, reason: "Source unit has no position" }; return { valid: false, reason: "Source unit has no position" };
} }
const distance = this.manhattanDistance(sourceUnit.position, targetPos);
if (distance > skillDef.range) { // Check if this is a teleport skill (range = -1 means unlimited range, limited by LOS)
return { valid: false, reason: "Target out of range" }; const isTeleportSkill =
skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT");
const hasUnlimitedRange =
skillDef.range === -1 || skillDef.range === Infinity;
// For teleport skills with unlimited range, skip distance check (only LOS matters)
if (!isTeleportSkill || !hasUnlimitedRange) {
const distance = this.manhattanDistance(sourceUnit.position, targetPos);
if (distance > skillDef.range) {
return { valid: false, reason: "Target out of range" };
}
} }
// 2. Line of Sight Check // 2. Line of Sight Check
const ignoreCover = skillDef.ignore_cover || false; const ignoreCover = skillDef.ignore_cover || false;
if (!this.hasLineOfSight(sourceUnit.position, targetPos, ignoreCover)) { // All skills including teleport require line of sight for targeting
return { valid: false, reason: "No line of sight" }; // (Teleport failure chance is handled during execution if LOS becomes obstructed)
const losResult = this.hasLineOfSight(
sourceUnit.position,
targetPos,
ignoreCover
);
if (!losResult.clear) {
return {
valid: false,
reason: "No line of sight",
obstruction: losResult.obstruction,
};
} }
// Store obstruction level for highlighting (even if clear, partial obstruction affects success chance)
const obstruction = losResult.obstruction || 0;
// 3. Content Check (Target Type) // 3. Content Check (Target Type)
const targetUnit = this.grid.getUnitAt(targetPos); const targetUnit = this.grid.getUnitAt(targetPos);
const targetType = skillDef.target_type; const targetType = skillDef.target_type;
const hasAoE = skillDef.aoe_type && skillDef.aoe_type !== "SINGLE";
if (targetType === "SELF") { if (targetType === "SELF") {
// SELF skills target the caster's position // SELF skills target the caster's position
@ -179,15 +314,45 @@ export class SkillTargetingSystem {
targetPos.y !== sourceUnit.position.y || targetPos.y !== sourceUnit.position.y ||
targetPos.z !== sourceUnit.position.z targetPos.z !== sourceUnit.position.z
) { ) {
return { valid: false, reason: "Invalid target type: must target self" }; return {
valid: false,
reason: "Invalid target type: must target self",
};
} }
} else if (targetType === "ENEMY") { } else if (targetType === "ENEMY") {
if (!targetUnit || targetUnit.team === sourceUnit.team) { // For AoE skills, allow targeting empty spaces (the AoE will find enemies)
return { valid: false, reason: "Invalid target type: must be enemy" }; // For single-target skills, must target an enemy unit
if (hasAoE) {
// AoE skills can target empty spaces or enemy units
if (targetUnit && targetUnit.team === sourceUnit.team) {
return {
valid: false,
reason: "Invalid target type: cannot target ally",
};
}
// Allow empty spaces or enemy units for AoE
} else {
// Single-target skills must target an enemy unit
if (!targetUnit || targetUnit.team === sourceUnit.team) {
return { valid: false, reason: "Invalid target type: must be enemy" };
}
} }
} else if (targetType === "ALLY") { } else if (targetType === "ALLY") {
if (!targetUnit || targetUnit.team !== sourceUnit.team) { // For AoE skills, allow targeting empty spaces (the AoE will find allies)
return { valid: false, reason: "Invalid target type: must be ally" }; if (hasAoE) {
// AoE skills can target empty spaces or ally units
if (targetUnit && targetUnit.team !== sourceUnit.team) {
return {
valid: false,
reason: "Invalid target type: cannot target enemy",
};
}
// Allow empty spaces or ally units for AoE
} else {
// Single-target skills must target an ally unit
if (!targetUnit || targetUnit.team !== sourceUnit.team) {
return { valid: false, reason: "Invalid target type: must be ally" };
}
} }
} else if (targetType === "EMPTY") { } else if (targetType === "EMPTY") {
if (targetUnit) { if (targetUnit) {
@ -195,7 +360,103 @@ export class SkillTargetingSystem {
} }
} }
return { valid: true, reason: "" }; // 4. Surface Check - ensure the target position is a valid surface (has a floor)
// This is important for AoE skills and teleport skills that can target empty spaces
const hasTeleportEffect =
skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT");
const needsSurfaceCheck = (hasAoE || hasTeleportEffect) && !targetUnit;
if (needsSurfaceCheck) {
if (hasTeleportEffect) {
// For teleport skills, require a walkable position (air + floor + air above)
// First, check if the target position itself is solid (inside a wall)
const targetCell = this.grid.getCell(
targetPos.x,
targetPos.y,
targetPos.z
);
if (targetCell !== 0) {
// Target is inside a solid block (wall)
return {
valid: false,
reason: "Invalid target: position is inside a wall",
};
}
// Check a range of Y levels (same as movement system does)
const yLevelsToCheck = [
targetPos.y,
targetPos.y + 1,
targetPos.y - 1,
targetPos.y - 2,
];
let foundWalkable = false;
for (const checkY of yLevelsToCheck) {
if (checkY < 0) continue;
// Check if this position is walkable (air at Y, floor below, air above)
const cellAtY = this.grid.getCell(targetPos.x, checkY, targetPos.z);
// Skip if this Y level is solid (inside a wall)
if (cellAtY !== 0) continue;
const floorY = checkY - 1;
if (floorY < 0) continue;
const floorCell = this.grid.getCell(targetPos.x, floorY, targetPos.z);
const cellAbove = this.grid.getCell(
targetPos.x,
checkY + 1,
targetPos.z
);
if (
floorCell !== 0 && // Floor below
cellAbove === 0 // Air above
) {
foundWalkable = true;
break;
}
}
if (!foundWalkable) {
return {
valid: false,
reason: "Invalid target: position is not walkable",
};
}
} else {
// For AoE skills, just check for a valid surface (floor below)
let foundValidSurface = false;
for (let checkY = targetPos.y; checkY >= 0; checkY--) {
const cellAtY = this.grid.getCell(targetPos.x, checkY, targetPos.z);
if (cellAtY === 0) {
const floorY = checkY - 1;
if (floorY >= 0) {
const floorCell = this.grid.getCell(
targetPos.x,
floorY,
targetPos.z
);
if (floorCell !== 0) {
foundValidSurface = true;
break;
}
}
}
}
if (!foundValidSurface) {
return {
valid: false,
reason: "Invalid target: no valid surface at this position",
};
}
}
}
return { valid: true, reason: "", obstruction: obstruction };
} }
/** /**
@ -222,18 +483,13 @@ export class SkillTargetingSystem {
const tiles = []; const tiles = [];
// Generate all tiles within Manhattan distance radius // Generate all tiles within Manhattan distance radius
// Only iterate X and Z (horizontal plane), use cursorPos.y for all tiles
for (let x = cursorPos.x - radius; x <= cursorPos.x + radius; x++) { for (let x = cursorPos.x - radius; x <= cursorPos.x + radius; x++) {
for (let y = cursorPos.y - radius; y <= cursorPos.y + radius; y++) { for (let z = cursorPos.z - radius; z <= cursorPos.z + radius; z++) {
for ( const pos = { x, y: cursorPos.y, z };
let z = cursorPos.z - radius; const dist = this.manhattanDistance(cursorPos, pos);
z <= cursorPos.z + radius; if (dist <= radius) {
z++ tiles.push(pos);
) {
const pos = { x, y, z };
const dist = this.manhattanDistance(cursorPos, pos);
if (dist <= radius) {
tiles.push(pos);
}
} }
} }
} }
@ -294,18 +550,34 @@ export class SkillTargetingSystem {
const aoeTiles = this.getAoETiles(sourcePos, targetPos, skillId); const aoeTiles = this.getAoETiles(sourcePos, targetPos, skillId);
const units = []; const units = [];
const unitIdsFound = new Set(); // Track found unit IDs to avoid duplicates
for (const tile of aoeTiles) { for (const tile of aoeTiles) {
// Check unit at the exact tile position
const unit = this.grid.getUnitAt(tile); const unit = this.grid.getUnitAt(tile);
if (unit) { if (unit && !unitIdsFound.has(unit.id)) {
// Avoid duplicates units.push(unit);
if (!units.some((u) => u.id === unit.id)) { unitIdsFound.add(unit.id);
units.push(unit); }
// Also check adjacent Y levels (units might be on different floors)
// Check one level above and below
if (tile.y > 0) {
const tileBelow = { x: tile.x, y: tile.y - 1, z: tile.z };
const unitBelow = this.grid.getUnitAt(tileBelow);
if (unitBelow && !unitIdsFound.has(unitBelow.id)) {
units.push(unitBelow);
unitIdsFound.add(unitBelow.id);
} }
} }
const tileAbove = { x: tile.x, y: tile.y + 1, z: tile.z };
const unitAbove = this.grid.getUnitAt(tileAbove);
if (unitAbove && !unitIdsFound.has(unitAbove.id)) {
units.push(unitAbove);
unitIdsFound.add(unitAbove.id);
}
} }
return units; return units;
} }
} }

View file

@ -2,6 +2,8 @@
* @typedef {import("../units/Unit.js").Unit} Unit * @typedef {import("../units/Unit.js").Unit} Unit
* @typedef {import("../managers/UnitManager.js").UnitManager} UnitManager * @typedef {import("../managers/UnitManager.js").UnitManager} UnitManager
* @typedef {import("../core/types.d.ts").CombatPhase} CombatPhase * @typedef {import("../core/types.d.ts").CombatPhase} CombatPhase
* @typedef {import("../grid/VoxelGrid.js").VoxelGrid} VoxelGrid
* @typedef {import("../systems/EffectProcessor.js").EffectProcessor} EffectProcessor
*/ */
/** /**
@ -19,6 +21,12 @@ export class TurnSystem extends EventTarget {
/** @type {UnitManager | null} */ /** @type {UnitManager | null} */
this.unitManager = unitManager; this.unitManager = unitManager;
/** @type {VoxelGrid | null} */
this.voxelGrid = null;
/** @type {EffectProcessor | null} */
this.effectProcessor = null;
/** @type {number} */ /** @type {number} */
this.globalTick = 0; this.globalTick = 0;
@ -33,6 +41,9 @@ export class TurnSystem extends EventTarget {
/** @type {string[]} */ /** @type {string[]} */
this.turnQueue = []; this.turnQueue = [];
/** @type {Set<string>} - Track which units have acted in the current round */
this.unitsActedThisRound = new Set();
} }
/** /**
@ -43,6 +54,32 @@ export class TurnSystem extends EventTarget {
this.unitManager = unitManager; this.unitManager = unitManager;
} }
/**
* Sets the voxel grid and effect processor context for environmental hazards.
* @param {VoxelGrid} voxelGrid - Voxel grid instance
* @param {EffectProcessor} effectProcessor - Effect processor instance
*/
setHazardContext(voxelGrid, effectProcessor) {
this.voxelGrid = voxelGrid;
this.effectProcessor = effectProcessor;
}
/**
* Sets callback for processing ON_TURN_START passive effects.
* @param {Function} callback - Callback function(unit)
*/
setTurnStartCallback(callback) {
this.onTurnStartCallback = callback;
}
/**
* Sets callback for processing ON_TURN_END passive effects.
* @param {Function} callback - Callback function(unit)
*/
setTurnEndCallback(callback) {
this.onTurnEndCallback = callback;
}
/** /**
* Starts combat with the given units. * Starts combat with the given units.
* @param {Unit[]} units - Array of units to include in combat * @param {Unit[]} units - Array of units to include in combat
@ -56,6 +93,7 @@ export class TurnSystem extends EventTarget {
this.globalTick = 0; this.globalTick = 0;
this.round = 1; this.round = 1;
this.phase = "TURN_START"; this.phase = "TURN_START";
this.unitsActedThisRound = new Set();
// Initialize charge meters based on speed // Initialize charge meters based on speed
units.forEach((unit) => { units.forEach((unit) => {
@ -82,6 +120,17 @@ export class TurnSystem extends EventTarget {
startTurn(unit) { startTurn(unit) {
if (!unit) return; if (!unit) return;
// Check if we need to increment the round
// If this unit has already acted this round, we've completed a full cycle
if (this.unitsActedThisRound.has(unit.id)) {
// All units have acted at least once, increment round
this.round += 1;
this.unitsActedThisRound.clear();
}
// Mark this unit as having acted this round
this.unitsActedThisRound.add(unit.id);
this.activeUnitId = unit.id; this.activeUnitId = unit.id;
this.phase = "WAITING_FOR_INPUT"; this.phase = "WAITING_FOR_INPUT";
@ -101,12 +150,16 @@ export class TurnSystem extends EventTarget {
// Check for Stun BEFORE processing status effects // Check for Stun BEFORE processing status effects
// (so we can catch stuns that would expire this turn) // (so we can catch stuns that would expire this turn)
const isStunned = unit.statusEffects && unit.statusEffects.some( const isStunned =
(effect) => effect.id === "STUN" || effect.type === "STUN" || effect.id === "stun" unit.statusEffects &&
); unit.statusEffects.some(
(effect) =>
effect.id === "STUN" || effect.type === "STUN" || effect.id === "stun"
);
if (isStunned) { if (isStunned) {
// Process status effects first (to apply DoT/HoT and decrement durations) // Process hazards first, then status effects
this.processEnvironmentalHazards(unit);
this.processStatusEffects(unit); this.processStatusEffects(unit);
// Skip action phase, immediately end turn // Skip action phase, immediately end turn
this.phase = "TURN_END"; this.phase = "TURN_END";
@ -114,7 +167,11 @@ export class TurnSystem extends EventTarget {
return; return;
} }
// C. Status Effect Tick (The "Upkeep" Step) // C. Environmental Hazard Processing (Integration Point 3)
// Process hazards BEFORE status effects (hazards are environmental, status effects are on the unit)
this.processEnvironmentalHazards(unit);
// D. Status Effect Tick (The "Upkeep" Step)
this.processStatusEffects(unit); this.processStatusEffects(unit);
// Dispatch turn-start event // Dispatch turn-start event
@ -126,6 +183,75 @@ export class TurnSystem extends EventTarget {
}, },
}) })
); );
// E. Process ON_TURN_START passive effects (if GameLoop is available)
if (this.onTurnStartCallback) {
this.onTurnStartCallback(unit);
}
}
/**
* Processes environmental hazards at the unit's position.
* Integration Point 3: Environmental Hazard
* @param {Unit} unit - The unit to check hazards for
* @private
*/
processEnvironmentalHazards(unit) {
if (!this.voxelGrid || !this.effectProcessor || !unit.position) {
return;
}
const hazard = this.voxelGrid.getHazardAt(unit.position);
if (!hazard) {
return;
}
// Map hazard IDs to effect definitions
const hazardEffects = {
HAZARD_FIRE: {
type: "DAMAGE",
power: 5,
element: "FIRE",
},
HAZARD_ACID: {
type: "DAMAGE",
power: 5,
element: "TECH",
},
HAZARD_ELECTRICITY: {
type: "DAMAGE",
power: 5,
element: "SHOCK",
},
};
let effectDef = hazardEffects[hazard.id];
if (!effectDef) {
// Unknown hazard type, use default damage
effectDef = {
type: "DAMAGE",
power: 5,
};
}
// Process hazard damage through EffectProcessor
// Source is null (environmental), target is the unit
const result = this.effectProcessor.process(effectDef, null, unit);
if (result.success && result.data && result.data.type === "DAMAGE") {
console.log(
`${unit.name} took ${result.data.amount} ${
effectDef.element || "damage"
} from ${hazard.id}`
);
}
// Decrement hazard duration
hazard.duration -= 1;
if (hazard.duration <= 0) {
// Remove expired hazard
const key = `${unit.position.x},${unit.position.y},${unit.position.z}`;
this.voxelGrid.hazardMap.delete(key);
}
} }
/** /**
@ -141,13 +267,33 @@ export class TurnSystem extends EventTarget {
// Apply DoT/HoT if applicable // Apply DoT/HoT if applicable
if (effect.type === "DOT" || effect.damage) { if (effect.type === "DOT" || effect.damage) {
const damage = effect.damage || 0; const damage = effect.damage || 0;
unit.currentHealth = Math.max(0, unit.currentHealth - damage); if (damage > 0) {
const previousHP = unit.currentHealth;
unit.currentHealth = Math.max(0, unit.currentHealth - damage);
const actualDamage = previousHP - unit.currentHealth;
if (actualDamage > 0) {
console.log(
`${unit.name} took ${actualDamage} ${
effect.element || ""
} damage from ${effect.id} (${effect.duration} turns remaining)`
);
}
}
} else if (effect.type === "HOT" || effect.heal) { } else if (effect.type === "HOT" || effect.heal) {
const heal = effect.heal || 0; const heal = effect.heal || 0;
unit.currentHealth = Math.min( if (heal > 0) {
unit.maxHealth, const previousHP = unit.currentHealth;
unit.currentHealth + heal unit.currentHealth = Math.min(
); unit.maxHealth,
unit.currentHealth + heal
);
const actualHeal = unit.currentHealth - previousHP;
if (actualHeal > 0) {
console.log(
`${unit.name} healed ${actualHeal} HP from ${effect.id} (${effect.duration} turns remaining)`
);
}
}
} }
// Decrement duration // Decrement duration
@ -197,6 +343,11 @@ export class TurnSystem extends EventTarget {
}) })
); );
// Process ON_TURN_END passive effects (if callback is available)
if (this.onTurnEndCallback) {
this.onTurnEndCallback(unit);
}
// Advance to next turn (unless we're skipping for cleanup) // Advance to next turn (unless we're skipping for cleanup)
if (!skipAdvance) { if (!skipAdvance) {
this.advanceToNextTurn(); this.advanceToNextTurn();
@ -232,7 +383,7 @@ export class TurnSystem extends EventTarget {
this.endCombat(); this.endCombat();
return; return;
} }
// Safety check: if we're already in INIT or COMBAT_END, don't advance // Safety check: if we're already in INIT or COMBAT_END, don't advance
if (this.phase === "INIT" || this.phase === "COMBAT_END") { if (this.phase === "INIT" || this.phase === "COMBAT_END") {
return; return;
@ -271,9 +422,11 @@ export class TurnSystem extends EventTarget {
break; break;
} }
} }
if (tickLimit === 0) { if (tickLimit === 0) {
console.error("TurnSystem: advanceToNextTurn() hit safety limit - no unit reached 100 charge"); console.error(
"TurnSystem: advanceToNextTurn() hit safety limit - no unit reached 100 charge"
);
// End combat if we can't advance // End combat if we can't advance
this.endCombat(); this.endCombat();
} }
@ -409,6 +562,7 @@ export class TurnSystem extends EventTarget {
this.phase = "INIT"; this.phase = "INIT";
this.round = 1; this.round = 1;
this.turnQueue = []; this.turnQueue = [];
this.unitsActedThisRound = new Set();
} }
/** /**
@ -420,4 +574,3 @@ export class TurnSystem extends EventTarget {
this.dispatchEvent(new CustomEvent("combat-end")); this.dispatchEvent(new CustomEvent("combat-end"));
} }
} }

View file

@ -339,7 +339,7 @@ export class SkillTreeUI extends LitElement {
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has("unit") || changedProperties.has("treeDef")) { if (changedProperties.has("unit") || changedProperties.has("treeDef") || changedProperties.has("updateTrigger")) {
this._updateConnections(); this._updateConnections();
this._scrollToAvailableTier(); this._scrollToAvailableTier();
} }
@ -544,7 +544,7 @@ export class SkillTreeUI extends LitElement {
// Determine line style based on child status // Determine line style based on child status
const childStatus = this._calculateNodeStatus(childId, tree.nodes[childId]); const childStatus = this._calculateNodeStatus(childId, tree.nodes[childId]);
const pathClass = `connection-line ${childStatus}`; const pathClass = `connection-line ${childStatus.toLowerCase()}`;
// Create path with 90-degree bends (circuit board style) // Create path with 90-degree bends (circuit board style)
const midX = parentCenter.x; const midX = parentCenter.x;

View file

@ -0,0 +1,556 @@
import { expect } from "@esm-bundle/chai";
import sinon from "sinon";
import * as THREE from "three";
import { GameLoop } from "../../../src/core/GameLoop.js";
import { skillRegistry } from "../../../src/managers/SkillRegistry.js";
import {
createGameLoopSetup,
cleanupGameLoop,
createRunData,
createMockGameStateManagerForCombat,
setupCombatUnits,
cleanupTurnSystem,
} from "./helpers.js";
describe("Core: GameLoop - Combat Skill Targeting and Execution", function () {
this.timeout(30000);
let gameLoop;
let container;
let mockGameStateManager;
let playerUnit;
let enemyUnit;
beforeEach(async () => {
const setup = createGameLoopSetup();
gameLoop = setup.gameLoop;
container = setup.container;
gameLoop.stop();
if (
gameLoop.turnSystem &&
typeof gameLoop.turnSystem.reset === "function"
) {
gameLoop.turnSystem.reset();
}
gameLoop.init(container);
mockGameStateManager = createMockGameStateManagerForCombat();
gameLoop.gameStateManager = mockGameStateManager;
const runData = createRunData({
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
});
await gameLoop.startLevel(runData, { startAnimation: false });
const units = setupCombatUnits(gameLoop);
playerUnit = units.playerUnit;
enemyUnit = units.enemyUnit;
// Start combat and set player unit as active
if (gameLoop.turnSystem) {
gameLoop.turnSystem.startCombat([playerUnit, enemyUnit]);
// Manually set player unit as active for testing
gameLoop.turnSystem.activeUnitId = playerUnit.id;
}
});
afterEach(() => {
gameLoop.clearMovementHighlights();
gameLoop.clearSpawnZoneHighlights();
cleanupTurnSystem(gameLoop);
cleanupGameLoop(gameLoop, container);
});
describe("Skill Targeting Validation", () => {
it("should only highlight valid targets when entering targeting mode", async () => {
// Add a skill to the player unit
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
// Add skill to unit actions
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
// Enter targeting mode
gameLoop.onSkillClicked(skillId);
// Verify we're in targeting mode
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
expect(gameLoop.activeSkillId).to.equal(skillId);
// Check that highlights were created (valid targets only)
if (gameLoop.voxelManager && gameLoop.voxelManager.rangeHighlights) {
const highlightCount = gameLoop.voxelManager.rangeHighlights.size;
// Should have highlights for valid empty tiles within range
expect(highlightCount).to.be.greaterThan(0);
}
});
it("should not highlight invalid targets (out of range)", async () => {
const skillId = "SKILL_SHORT_RANGE";
const skillDef = {
id: skillId,
name: "Short Range",
costs: { ap: 1 },
targeting: {
range: 2, // Very short range
type: "ENEMY",
line_of_sight: true,
},
effects: [],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
// Add skill to unit actions
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Short Range",
costAP: 1,
cooldown: 0,
});
// Place enemy far away
const farAwayPos = {
x: playerUnit.position.x + 10,
y: playerUnit.position.y,
z: playerUnit.position.z + 10,
};
gameLoop.grid.moveUnit(enemyUnit, farAwayPos, { force: true });
// Enter targeting mode
gameLoop.onSkillClicked(skillId);
// Verify we're in targeting mode
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
// The far away enemy should not be highlighted
// (validation happens when checking individual tiles)
expect(gameLoop.activeSkillId).to.equal(skillId);
});
});
describe("Movement Button Toggle", () => {
it("should return to movement mode when movement button is clicked", () => {
// First enter skill targeting mode
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
if (gameLoop.skillTargetingSystem) {
const registry = gameLoop.skillTargetingSystem.skillRegistry;
if (registry instanceof Map) {
registry.set(skillId, skillDef);
} else if (registry.set) {
registry.set(skillId, skillDef);
} else {
registry[skillId] = skillDef;
}
}
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
gameLoop.onSkillClicked(skillId);
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
// Click movement button
gameLoop.onMovementClicked();
// Should return to IDLE/movement mode
expect(gameLoop.combatState).to.equal("IDLE");
expect(gameLoop.activeSkillId).to.be.null;
});
it("should toggle skill off when clicking the same skill again", () => {
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
if (gameLoop.skillTargetingSystem) {
const registry = gameLoop.skillTargetingSystem.skillRegistry;
if (registry instanceof Map) {
registry.set(skillId, skillDef);
} else if (registry.set) {
registry.set(skillId, skillDef);
} else {
registry[skillId] = skillDef;
}
}
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
// First click - enter targeting mode
gameLoop.onSkillClicked(skillId);
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
// Second click - should cancel targeting
gameLoop.onSkillClicked(skillId);
expect(gameLoop.combatState).to.equal("IDLE");
expect(gameLoop.activeSkillId).to.be.null;
});
});
describe("Hotkey Support", () => {
it("should trigger skill when number key is pressed", () => {
const skillId = "SKILL_TEST";
const skillDef = {
id: skillId,
name: "Test Skill",
costs: { ap: 2 },
targeting: {
range: 5,
type: "ENEMY",
line_of_sight: true,
},
effects: [],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Test Skill",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
// Press number key 1 (first skill)
gameLoop.handleKeyInput("Digit1");
// Should enter targeting mode
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
expect(gameLoop.activeSkillId).to.equal(skillId);
});
it("should trigger movement mode when M key is pressed", () => {
// First enter skill targeting mode
const skillId = "SKILL_TEST";
const skillDef = {
id: skillId,
name: "Test Skill",
costs: { ap: 2 },
targeting: {
range: 5,
type: "ENEMY",
line_of_sight: true,
},
effects: [],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Test Skill",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
gameLoop.onSkillClicked(skillId);
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
// Press M key
gameLoop.handleKeyInput("KeyM");
// Should return to movement mode
expect(gameLoop.combatState).to.equal("IDLE");
});
});
describe("TELEPORT Effect Execution", () => {
it("should teleport unit to target position when TELEPORT effect is executed", async () => {
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
// Add skill to unit actions
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
const originalPos = { ...playerUnit.position };
const targetPos = {
x: originalPos.x + 3,
y: originalPos.y,
z: originalPos.z + 3,
};
// Ensure target position is valid and empty
if (gameLoop.grid.isOccupied(targetPos)) {
// Clear it if occupied
const unitAtPos = gameLoop.grid.getUnitAt(targetPos);
if (unitAtPos) {
gameLoop.grid.removeUnit(unitAtPos);
}
}
// Ensure target position is walkable (floor at y=0, air at y=1 and y=2)
gameLoop.grid.setCell(targetPos.x, 0, targetPos.z, 1); // Floor
gameLoop.grid.setCell(targetPos.x, targetPos.y, targetPos.z, 0); // Air at y=1
gameLoop.grid.setCell(targetPos.x, targetPos.y + 1, targetPos.z, 0); // Air at y=2 (headroom)
// Execute the skill
await gameLoop.executeSkill(skillId, targetPos);
// Unit should be at target position
expect(playerUnit.position.x).to.equal(targetPos.x);
expect(playerUnit.position.z).to.equal(targetPos.z);
// Y might be adjusted to walkable level
expect(playerUnit.position.y).to.be.a("number");
// Unit mesh should be updated
const mesh = gameLoop.unitMeshes.get(playerUnit.id);
if (mesh) {
expect(mesh.position.x).to.equal(playerUnit.position.x);
expect(mesh.position.z).to.equal(playerUnit.position.z);
}
});
it("should deduct AP when executing TELEPORT skill", async () => {
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
const initialAP = playerUnit.currentAP;
const targetPos = {
x: playerUnit.position.x + 2,
y: playerUnit.position.y,
z: playerUnit.position.z + 2,
};
// Ensure target is empty
if (gameLoop.grid.isOccupied(targetPos)) {
const unitAtPos = gameLoop.grid.getUnitAt(targetPos);
if (unitAtPos) {
gameLoop.grid.removeUnit(unitAtPos);
}
}
await gameLoop.executeSkill(skillId, targetPos);
// AP should be deducted
expect(playerUnit.currentAP).to.equal(initialAP - 2);
});
it("should set cooldown when executing skill", async () => {
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
cooldown_turns: 4,
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
if (!playerUnit.actions) {
playerUnit.actions = [];
}
const skillAction = {
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
};
playerUnit.actions.push(skillAction);
// Ensure unit has enough AP
playerUnit.currentAP = 10;
const targetPos = {
x: playerUnit.position.x + 2,
y: playerUnit.position.y,
z: playerUnit.position.z + 2,
};
// Ensure target is empty
if (gameLoop.grid.isOccupied(targetPos)) {
const unitAtPos = gameLoop.grid.getUnitAt(targetPos);
if (unitAtPos) {
gameLoop.grid.removeUnit(unitAtPos);
}
}
await gameLoop.executeSkill(skillId, targetPos);
// Cooldown should be set
expect(skillAction.cooldown).to.equal(1);
});
});
describe("Skill Targeting State Management", () => {
it("should clear highlights when ending turn with skill active", () => {
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
// Enter targeting mode
gameLoop.onSkillClicked(skillId);
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
// End turn
gameLoop.endTurn();
// Should clear targeting state
expect(gameLoop.combatState).to.equal("IDLE");
expect(gameLoop.activeSkillId).to.be.null;
});
});
});

View file

@ -0,0 +1,814 @@
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);
});
});
});

View file

@ -0,0 +1,168 @@
import { expect } from "@esm-bundle/chai";
import { TurnSystem } from "../../src/systems/TurnSystem.js";
import { VoxelGrid } from "../../src/grid/VoxelGrid.js";
import { UnitManager } from "../../src/managers/UnitManager.js";
import { EffectProcessor } from "../../src/systems/EffectProcessor.js";
describe("Systems: Environmental Hazards", function () {
let turnSystem;
let grid;
let unitManager;
let effectProcessor;
let unit;
let mockRegistry;
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,
speed: 10,
movement: 4,
},
});
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);
turnSystem = new TurnSystem(unitManager);
turnSystem.setHazardContext(grid, effectProcessor);
// Create test unit
unit = unitManager.createUnit("CLASS_VANGUARD", "PLAYER");
unit.position = { x: 5, y: 1, z: 5 };
unit.currentHealth = 100;
unit.maxHealth = 100;
grid.placeUnit(unit, unit.position);
});
describe("Integration Point 3: Environmental Hazards", () => {
it("should apply fire hazard damage when unit starts turn on fire", () => {
// Place fire hazard at unit's position
grid.addHazard(unit.position, "HAZARD_FIRE", 3);
const initialHP = unit.currentHealth;
const defense = unit.baseStats.defense || 0;
const expectedDamage = Math.max(0, 5 - defense); // 5 fire damage - defense
// Start turn (should process hazards)
turnSystem.startTurn(unit);
// Unit should have taken fire damage (reduced by defense)
expect(unit.currentHealth).to.be.lessThanOrEqual(initialHP);
expect(unit.currentHealth).to.equal(initialHP - expectedDamage);
});
it("should apply acid hazard damage when unit starts turn on acid", () => {
// Place acid hazard at unit's position
grid.addHazard(unit.position, "HAZARD_ACID", 2);
const initialHP = unit.currentHealth;
const defense = unit.baseStats.defense || 0;
const expectedDamage = Math.max(0, 5 - defense); // 5 acid damage - defense
// Start turn
turnSystem.startTurn(unit);
// Unit should have taken acid damage (reduced by defense)
expect(unit.currentHealth).to.be.lessThanOrEqual(initialHP);
expect(unit.currentHealth).to.equal(initialHP - expectedDamage);
});
it("should decrement hazard duration after processing", () => {
// Place hazard with duration 2
grid.addHazard(unit.position, "HAZARD_FIRE", 2);
const hazard = grid.getHazardAt(unit.position);
expect(hazard.duration).to.equal(2);
// Start turn (should decrement duration)
turnSystem.startTurn(unit);
const hazardAfter = grid.getHazardAt(unit.position);
expect(hazardAfter.duration).to.equal(1);
});
it("should remove hazard when duration reaches 0", () => {
// Place hazard with duration 1
grid.addHazard(unit.position, "HAZARD_FIRE", 1);
expect(grid.getHazardAt(unit.position)).to.exist;
// Start turn (should remove hazard)
turnSystem.startTurn(unit);
// Hazard should be removed
expect(grid.getHazardAt(unit.position)).to.be.undefined;
});
it("should not process hazards if unit is not on a hazard tile", () => {
const initialHP = unit.currentHealth;
// Start turn without any hazard
turnSystem.startTurn(unit);
// Health should be unchanged (except for status effects if any)
expect(unit.currentHealth).to.equal(initialHP);
});
it("should handle unknown hazard types with default damage", () => {
// Place unknown hazard type
grid.addHazard(unit.position, "HAZARD_UNKNOWN", 1);
const initialHP = unit.currentHealth;
const defense = unit.baseStats.defense || 0;
const expectedDamage = Math.max(0, 5 - defense); // Default 5 damage - defense
// Start turn
turnSystem.startTurn(unit);
// Unit should still take damage (default 5, reduced by defense)
if (expectedDamage > 0) {
expect(unit.currentHealth).to.be.lessThan(initialHP);
}
expect(unit.currentHealth).to.equal(initialHP - expectedDamage);
});
it("should process hazards before status effects in turn order", () => {
// Add hazard and status effect
grid.addHazard(unit.position, "HAZARD_FIRE", 1);
unit.statusEffects = [
{
id: "poison",
type: "DOT",
damage: 3,
duration: 1,
},
];
const initialHP = unit.currentHealth;
const defense = unit.baseStats.defense || 0;
const hazardDamage = Math.max(0, 5 - defense); // 5 fire damage - defense
const poisonDamage = 3; // Poison damage (not reduced by defense in current implementation)
// Start turn
turnSystem.startTurn(unit);
// Should have taken both hazard damage and poison damage
expect(unit.currentHealth).to.equal(initialHP - hazardDamage - poisonDamage);
});
});
});

View file

@ -0,0 +1,358 @@
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;
});
});
});