diff --git a/specs/EffectProcessor.spec.md b/specs/EffectProcessor.spec.md index a39ad90..ff227aa 100644 --- a/specs/EffectProcessor.spec.md +++ b/specs/EffectProcessor.spec.md @@ -35,45 +35,239 @@ The Processor requires injection of: Every effect in the game must adhere to this structure. ```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) attribute?: string; // Stat to scale off (e.g., "strength", "magic") scaling?: number; // Multiplier for attribute (Default: 1.0) - - // -- Flavour -- 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; // For APPLY_STATUS - duration?: number; // Turns - chance?: number; // 0.0 to 1.0 + status_id?: string; + duration?: number; + stat?: string; // For Buffs + value?: number; // For Buffs/Mods + chance?: number; // 0.0 to 1.0 (Proc chance) - // -- Movement/Physics -- - force?: number; // Distance for Push/Pull - destination?: "TARGET" | "BEHIND_TARGET"; // For Teleport + // -- Physics -- + force?: number; // Distance + destination?: "TARGET" | "BEHIND_TARGET" | "ADJACENT_TO_TARGET"; - // -- Conditionals -- - condition?: { - target_tag?: string; // e.g. "MECHANICAL" - target_status?: string; // e.g. "WET" - hp_threshold?: number; // e.g. 0.3 (30%) + // -- 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; }; } -type EffectType = - | "DAMAGE" - | "HEAL" - | "APPLY_STATUS" - | "REMOVE_STATUS" - | "TELEPORT" - | "PUSH" - | "PULL" - | "SPAWN_UNIT" - | "MODIFY_TERRAIN" // Destroy walls, create hazards - | "CHAIN_DAMAGE"; // Bouncing projectiles +// ============================================================================= +// 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; +} ``` ## 4. Handler Specifications @@ -119,14 +313,3 @@ type EffectType = **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). - ---- - -## 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." diff --git a/src/assets/data/effects/effects.json b/src/assets/data/effects/effects.json new file mode 100644 index 0000000..499abd9 --- /dev/null +++ b/src/assets/data/effects/effects.json @@ -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" } + } + ] +} diff --git a/src/assets/skills/skill_fireball.json b/src/assets/skills/skill_fireball.json index 40bdae7..bcfc243 100644 --- a/src/assets/skills/skill_fireball.json +++ b/src/assets/skills/skill_fireball.json @@ -22,7 +22,9 @@ "type": "APPLY_STATUS", "status_id": "STATUS_SCORCH", "chance": 1.0, - "duration": 2 + "duration": 2, + "power": 3, + "element": "FIRE" } ] } diff --git a/src/assets/skills/skill_teleport.json b/src/assets/skills/skill_teleport.json index d50550b..9aa3808 100644 --- a/src/assets/skills/skill_teleport.json +++ b/src/assets/skills/skill_teleport.json @@ -1,14 +1,14 @@ { "id": "SKILL_TELEPORT", "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", "costs": { "ap": 2 }, "cooldown_turns": 4, "targeting": { - "range": 5, + "range": -1, "type": "EMPTY", - "line_of_sight": false + "line_of_sight": true }, "effects": [{ "type": "TELEPORT" }] } diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index e162e0d..effad00 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -19,6 +19,8 @@ import { MissionManager } from "../managers/MissionManager.js"; import { TurnSystem } from "../systems/TurnSystem.js"; import { MovementSystem } from "../systems/MovementSystem.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 { InventoryManager } from "../managers/InventoryManager.js"; import { InventoryContainer } from "../models/InventoryContainer.js"; @@ -73,6 +75,8 @@ export class GameLoop { this.movementSystem = null; /** @type {SkillTargetingSystem | null} */ this.skillTargetingSystem = null; + /** @type {EffectProcessor | null} */ + this.effectProcessor = null; // Inventory System /** @type {InventoryManager | null} */ @@ -548,15 +552,68 @@ export class GameLoop { this.combatState = "TARGETING_SKILL"; this.activeSkillId = skillId; - // Clear movement highlights and show skill range + // Clear movement highlights and show skill range (only valid targets) this.clearMovementHighlights(); const skillDef = this.skillTargetingSystem.getSkillDef(skillId); - if (skillDef && this.voxelManager) { - this.voxelManager.highlightRange( - activeUnit.position, - skillDef.range, - "RED_OUTLINE" - ); + if (skillDef && this.voxelManager && this.skillTargetingSystem) { + // Check if this is a teleport skill with unlimited range (range = -1) + const isTeleportSkill = skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT"); + const hasUnlimitedRange = skillDef.range === -1 || skillDef.range === Infinity; + + 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) @@ -619,20 +676,189 @@ export class GameLoop { } // 2. Get Targets (Units in AoE) - const targets = this.skillTargetingSystem.getUnitsInAoE( + let targets = this.skillTargetingSystem.getUnitsInAoE( activeUnit.position, targetPos, 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 - // TODO: Implement EffectProcessor - // const skillDef = this.skillTargetingSystem.getSkillDef(skillId); - // if (skillDef && skillDef.effects) { - // skillDef.effects.forEach(eff => { - // targets.forEach(t => this.effectProcessor.process(eff, activeUnit, t)); - // }); - // } + // 3. Process Effects using EffectProcessor + const skillDef = this.skillTargetingSystem.getSkillDef(skillId); + + // Process ON_SKILL_CAST passive effects + if (skillDef) { + 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( `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 this.movementSystem.setContext(this.grid, 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 // 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"); } + /** + * 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. * @private diff --git a/src/grid/VoxelManager.js b/src/grid/VoxelManager.js index 2ad3535..1a8173e 100644 --- a/src/grid/VoxelManager.js +++ b/src/grid/VoxelManager.js @@ -539,6 +539,213 @@ export class VoxelManager { 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). */ diff --git a/src/systems/EffectProcessor.js b/src/systems/EffectProcessor.js new file mode 100644 index 0000000..b88456e --- /dev/null +++ b/src/systems/EffectProcessor.js @@ -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 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 + */ diff --git a/src/systems/Effects.d.ts b/src/systems/Effects.d.ts new file mode 100644 index 0000000..49db4a2 --- /dev/null +++ b/src/systems/Effects.d.ts @@ -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; +} diff --git a/src/systems/MovementSystem.js b/src/systems/MovementSystem.js index 4181323..ecc35ed 100644 --- a/src/systems/MovementSystem.js +++ b/src/systems/MovementSystem.js @@ -196,6 +196,12 @@ export class MovementSystem { 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) const reachableTiles = this.getReachableTiles(unit); const isReachable = reachableTiles.some( @@ -205,16 +211,21 @@ export class MovementSystem { pos.z === finalTargetPos.z ); + // If not reachable, check if it's due to insufficient AP or out of range 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: [] }; } - // 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 if (unit.currentAP < movementCost) { return { valid: false, cost: movementCost, path: [] }; diff --git a/src/systems/SkillTargetingSystem.js b/src/systems/SkillTargetingSystem.js index a634769..4b0f03d 100644 --- a/src/systems/SkillTargetingSystem.js +++ b/src/systems/SkillTargetingSystem.js @@ -69,8 +69,8 @@ export class SkillTargetingSystem { target_type: targetType, ignore_cover: targeting.line_of_sight === false, // If line_of_sight is false, ignore cover aoe_type: aoe.shape || "SINGLE", - aoe_radius: aoe.shape === "CIRCLE" ? (aoe.size || 1) : undefined, - aoe_length: aoe.shape === "LINE" ? (aoe.size || 1) : undefined, + aoe_radius: aoe.shape === "CIRCLE" ? aoe.size || 1 : undefined, + aoe_length: aoe.shape === "LINE" ? aoe.size || 1 : undefined, costAP: skillDef.costs?.ap || 0, cooldown: skillDef.cooldown_turns || 0, effects: skillDef.effects || [], @@ -101,9 +101,16 @@ export class SkillTargetingSystem { * @returns {boolean} - True if line of sight is clear * @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) { if (ignoreCover) { - return true; + return { clear: true, obstruction: 0 }; } // 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) 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 dy = targetCenterY - sourceHeadY; const dz = targetPos.z - sourcePos.z; - // Number of steps (use the maximum dimension) - const steps = Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)); - if (steps === 0) return true; + // Step direction for each axis + const stepX = dx > 0 ? 1 : dx < 0 ? -1 : 0; + const stepY = dy > 0 ? 1 : dy < 0 ? -1 : 0; + const stepZ = dz > 0 ? 1 : dz < 0 ? -1 : 0; - const stepX = dx / steps; - const stepY = dy / steps; - const stepZ = dz / steps; + // Calculate delta distances (how far along the ray we must travel to cross one voxel boundary) + const deltaX = stepX !== 0 ? Math.abs(1 / dx) : Infinity; + 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) - for (let i = 1; i < steps; i++) { - const x = Math.round(sourcePos.x + stepX * i); - const y = Math.round(sourceHeadY + stepY * i); - const z = Math.round(sourcePos.z + stepZ * i); + // Calculate initial distances to next voxel boundary + let nextX = Infinity; + let nextY = Infinity; + let nextZ = Infinity; - // Check if this voxel is solid - if (this.grid.isSolid({ x, y, z })) { - return false; + if (stepX !== 0) { + nextX = + 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) { return { valid: false, reason: "Source unit has no position" }; } - const distance = this.manhattanDistance(sourceUnit.position, targetPos); - if (distance > skillDef.range) { - return { valid: false, reason: "Target out of range" }; + + // Check if this is a teleport skill (range = -1 means unlimited range, limited by LOS) + 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 const ignoreCover = skillDef.ignore_cover || false; - if (!this.hasLineOfSight(sourceUnit.position, targetPos, ignoreCover)) { - return { valid: false, reason: "No line of sight" }; + // All skills including teleport require line of sight for targeting + // (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) const targetUnit = this.grid.getUnitAt(targetPos); const targetType = skillDef.target_type; + const hasAoE = skillDef.aoe_type && skillDef.aoe_type !== "SINGLE"; if (targetType === "SELF") { // SELF skills target the caster's position @@ -179,15 +314,45 @@ export class SkillTargetingSystem { targetPos.y !== sourceUnit.position.y || 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") { - if (!targetUnit || targetUnit.team === sourceUnit.team) { - return { valid: false, reason: "Invalid target type: must be enemy" }; + // For AoE skills, allow targeting empty spaces (the AoE will find enemies) + // 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") { - if (!targetUnit || targetUnit.team !== sourceUnit.team) { - return { valid: false, reason: "Invalid target type: must be ally" }; + // For AoE skills, allow targeting empty spaces (the AoE will find allies) + 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") { 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 = []; // 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 y = cursorPos.y - radius; y <= cursorPos.y + radius; y++) { - for ( - let z = cursorPos.z - radius; - z <= cursorPos.z + radius; - z++ - ) { - const pos = { x, y, z }; - const dist = this.manhattanDistance(cursorPos, pos); - if (dist <= radius) { - tiles.push(pos); - } + for (let z = cursorPos.z - radius; z <= cursorPos.z + radius; z++) { + const pos = { x, y: cursorPos.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 units = []; + const unitIdsFound = new Set(); // Track found unit IDs to avoid duplicates for (const tile of aoeTiles) { + // Check unit at the exact tile position const unit = this.grid.getUnitAt(tile); - if (unit) { - // Avoid duplicates - if (!units.some((u) => u.id === unit.id)) { - units.push(unit); + if (unit && !unitIdsFound.has(unit.id)) { + units.push(unit); + unitIdsFound.add(unit.id); + } + + // 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; } } - diff --git a/src/systems/TurnSystem.js b/src/systems/TurnSystem.js index a12d0e1..c411d77 100644 --- a/src/systems/TurnSystem.js +++ b/src/systems/TurnSystem.js @@ -2,6 +2,8 @@ * @typedef {import("../units/Unit.js").Unit} Unit * @typedef {import("../managers/UnitManager.js").UnitManager} UnitManager * @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} */ this.unitManager = unitManager; + /** @type {VoxelGrid | null} */ + this.voxelGrid = null; + + /** @type {EffectProcessor | null} */ + this.effectProcessor = null; + /** @type {number} */ this.globalTick = 0; @@ -33,6 +41,9 @@ export class TurnSystem extends EventTarget { /** @type {string[]} */ this.turnQueue = []; + + /** @type {Set} - 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; } + /** + * 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. * @param {Unit[]} units - Array of units to include in combat @@ -56,6 +93,7 @@ export class TurnSystem extends EventTarget { this.globalTick = 0; this.round = 1; this.phase = "TURN_START"; + this.unitsActedThisRound = new Set(); // Initialize charge meters based on speed units.forEach((unit) => { @@ -82,6 +120,17 @@ export class TurnSystem extends EventTarget { startTurn(unit) { 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.phase = "WAITING_FOR_INPUT"; @@ -101,12 +150,16 @@ export class TurnSystem extends EventTarget { // Check for Stun BEFORE processing status effects // (so we can catch stuns that would expire this turn) - const isStunned = unit.statusEffects && unit.statusEffects.some( - (effect) => effect.id === "STUN" || effect.type === "STUN" || effect.id === "stun" - ); + const isStunned = + unit.statusEffects && + unit.statusEffects.some( + (effect) => + effect.id === "STUN" || effect.type === "STUN" || effect.id === "stun" + ); 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); // Skip action phase, immediately end turn this.phase = "TURN_END"; @@ -114,7 +167,11 @@ export class TurnSystem extends EventTarget { 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); // 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 if (effect.type === "DOT" || effect.damage) { 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) { const heal = effect.heal || 0; - unit.currentHealth = Math.min( - unit.maxHealth, - unit.currentHealth + heal - ); + if (heal > 0) { + const previousHP = unit.currentHealth; + 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 @@ -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) if (!skipAdvance) { this.advanceToNextTurn(); @@ -232,7 +383,7 @@ export class TurnSystem extends EventTarget { this.endCombat(); return; } - + // Safety check: if we're already in INIT or COMBAT_END, don't advance if (this.phase === "INIT" || this.phase === "COMBAT_END") { return; @@ -271,9 +422,11 @@ export class TurnSystem extends EventTarget { break; } } - + 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 this.endCombat(); } @@ -409,6 +562,7 @@ export class TurnSystem extends EventTarget { this.phase = "INIT"; this.round = 1; this.turnQueue = []; + this.unitsActedThisRound = new Set(); } /** @@ -420,4 +574,3 @@ export class TurnSystem extends EventTarget { this.dispatchEvent(new CustomEvent("combat-end")); } } - diff --git a/src/ui/components/SkillTreeUI.js b/src/ui/components/SkillTreeUI.js index 46e3652..a216b8d 100644 --- a/src/ui/components/SkillTreeUI.js +++ b/src/ui/components/SkillTreeUI.js @@ -339,7 +339,7 @@ export class SkillTreeUI extends LitElement { 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._scrollToAvailableTier(); } @@ -544,7 +544,7 @@ export class SkillTreeUI extends LitElement { // Determine line style based on child status 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) const midX = parentCenter.x; diff --git a/test/core/GameLoop/combat-skill-targeting.test.js b/test/core/GameLoop/combat-skill-targeting.test.js new file mode 100644 index 0000000..fa2e2a1 --- /dev/null +++ b/test/core/GameLoop/combat-skill-targeting.test.js @@ -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; + }); + }); +}); + diff --git a/test/systems/EffectProcessor.test.js b/test/systems/EffectProcessor.test.js new file mode 100644 index 0000000..6cf2888 --- /dev/null +++ b/test/systems/EffectProcessor.test.js @@ -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); + }); + }); +}); + diff --git a/test/systems/EnvironmentalHazards.test.js b/test/systems/EnvironmentalHazards.test.js new file mode 100644 index 0000000..f1f6302 --- /dev/null +++ b/test/systems/EnvironmentalHazards.test.js @@ -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); + }); + }); +}); + diff --git a/test/systems/PassiveItemEffects.test.js b/test/systems/PassiveItemEffects.test.js new file mode 100644 index 0000000..2e9d85a --- /dev/null +++ b/test/systems/PassiveItemEffects.test.js @@ -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; + }); + }); +}); +