Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.nisovin.magicspells.castmodifiers.conditions;


import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.LivingEntity;
import org.bukkit.scoreboard.Objective;
import org.bukkit.scoreboard.Scoreboard;

import com.nisovin.magicspells.castmodifiers.Condition;
import com.nisovin.magicspells.util.Name;
Comment on lines +4 to +11
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Styling: Group & sort imports.


/**
* Usage: score <objective><operator><value> required/denied
* Example: score kills>3 required
*/
@Name("score")
public class ScoreCondition extends Condition {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to add the condition with ConditionManager.


private String objectiveName;
private String operator;
private int value;

@Override
public boolean initialize(String var) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the IDE not highlighting this as NotNull already? If you implement the method, it should automatically leave a NotNull and mark that the null check below is not needed.

if (var == null) return false;
var = var.trim();
String[] ops = {">=","<=","!=",">","<","="};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of implementing operators like this, the condition should extend OperatorCondition and use its method for comparison.

for (String op : ops) {
int idx = var.indexOf(op);
if (idx > 0) {
objectiveName = var.substring(0, idx).trim();
operator = op;
try {
value = Integer.parseInt(var.substring(idx + op.length()).trim());
} catch (NumberFormatException e) {
return false;
}
return true;
}
}
return false;
}

@Override
public boolean check(LivingEntity entity) {
return checkScore(entity);
}

@Override
public boolean check(LivingEntity caster, LivingEntity target) {
return checkScore(target);
}

@Override
public boolean check(LivingEntity caster, Location location) {
// Not applicable for locations, always false
return false;
}

private boolean checkScore(LivingEntity entity) {
Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
Objective obj = scoreboard.getObjective(objectiveName);
if (obj == null) return false;
int score = obj.getScoreFor(entity).getScore();
return compare(score, operator, value);
}

private boolean compare(int score, String op, int val) {
switch (op) {
case ">": return score > val;
case "<": return score < val;
case ">=": return score >= val;
case "<=": return score <= val;
case "=": return score == val;
case "!=": return score != val;
default: return false;
}
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace indents with tabs.

132 changes: 132 additions & 0 deletions core/src/main/java/com/nisovin/magicspells/spells/buff/ProxySpell.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package com.nisovin.magicspells.spells.buff;

import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

import org.jetbrains.annotations.NotNull;

import org.bukkit.entity.LivingEntity;
import org.bukkit.event.Listener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.entity.EntityDamageByEntityEvent;

import com.nisovin.magicspells.events.SpellTargetEvent;
import com.nisovin.magicspells.events.SpellPreImpactEvent;
import com.nisovin.magicspells.events.MagicSpellsEntityDamageByEntityEvent;
import com.nisovin.magicspells.spells.BuffSpell;
import com.nisovin.magicspells.spelleffects.EffectPosition;
import com.nisovin.magicspells.util.MagicConfig;
import com.nisovin.magicspells.util.SpellData;
Comment on lines +3 to +23
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Styling: Group & sort imports.


public class ProxySpell extends BuffSpell implements Listener {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spell already implements Listener.


private final Set<UUID> redirecting = new HashSet<>();
private final Map<UUID, SpellData> proxies = new HashMap<>();

public ProxySpell(MagicConfig config, String spellName) {
super(config, spellName);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Broke your build because of this:

Suggested change
}

}

@Override
public boolean castBuff(SpellData data) {
proxies.put(data.target().getUniqueId(), data);
return true;
}
Comment on lines +35 to +39
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This spell should fail if the caster is equal to the target, it seems?


@Override
public boolean recastBuff(SpellData data) {
stopEffects(data.target());
return castBuff(data);
}

@Override
public boolean isActive(LivingEntity entity) {
return proxies.containsKey(entity.getUniqueId());
}

@Override
public void turnOffBuff(LivingEntity entity) {
proxies.remove(entity.getUniqueId());
}

@Override
protected @NotNull Collection<UUID> getActiveEntities() {
return proxies.keySet();
}

@EventHandler(ignoreCancelled = true)
public void onSpellTarget(SpellTargetEvent event) {
LivingEntity target = event.getTarget();
if (target == null || !target.isValid()) return;

LivingEntity proxyTarget = getProxyTarget(target);
if (proxyTarget == null) return;

event.setTarget(proxyTarget);
playRedirectEffects(target, proxyTarget, event.getSpellData());

addUseAndChargeCost(target);
}

@EventHandler(ignoreCancelled = true)
public void onSpellPreImpact(SpellPreImpactEvent event) {
LivingEntity target = event.getTarget();
if (target == null || !target.isValid()) return;

LivingEntity proxyTarget = getProxyTarget(target);
if (proxyTarget == null) return;

SpellData subData = new SpellData(event.getCaster(), proxyTarget, event.getPower());
playRedirectEffects(target, proxyTarget, subData);

event.setRedirected(true);
addUseAndChargeCost(target);
}

@EventHandler(ignoreCancelled = true)
public void onEntityDamage(EntityDamageByEntityEvent event) {
if (!(event.getEntity() instanceof LivingEntity target)) return;

LivingEntity proxyTarget = getProxyTarget(target);
if (proxyTarget == null) return;
if (!redirecting.add(proxyTarget.getUniqueId())) return;

SpellData subData = new SpellData(event.getDamager() instanceof LivingEntity damager ? damager : null, proxyTarget);
playRedirectEffects(target, proxyTarget, subData);

event.setCancelled(true);
try {
proxyTarget.damage(event.getDamage(), event.getDamager());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should pass the DamageSource, probably with the modified direct damager, just to preserve damage type and other curent/future data.

addUseAndChargeCost(target);
} finally {
redirecting.remove(proxyTarget.getUniqueId());
}
}

@EventHandler(ignoreCancelled = true)
public void onLegacyDamage(MagicSpellsEntityDamageByEntityEvent event) {
onEntityDamage(event);
}

private LivingEntity getProxyTarget(LivingEntity target) {
SpellData proxyData = proxies.get(target.getUniqueId());
if (proxyData == null) return null;

LivingEntity proxyTarget = proxyData.caster();
if (proxyTarget != null && proxyTarget.isValid()) return proxyTarget;
Comment on lines +117 to +121
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be storing SpellData in the HashMap if all we need is the caster reference. But more importantly, we shouldn't be storing LivingEntity like we're sometimes used to in MS because we risk stale entities. We're still working out how to address this in a meaningful way.

If an entity's chunk becomes unloaded, its Entity instance no longer points to it and is invalid. This spell ends if that is the case, which is fine. However, if the entity is loaded again, the entity does exist, and is valid, but it's Entity instance still no longer points to it. This means that this spell might fail and turn itself off even if an entity is valid, but had been unloaded before being checked here. To avoid this issue, you can have the HasMap store the caster's UUID. Fetch with Bukkit#getEntity and keep the isValid check.


turnOff(target);
return null;
}

private void playRedirectEffects(LivingEntity target, LivingEntity proxyTarget, SpellData data) {
playSpellEffects(EffectPosition.TARGET, target, data);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imo, this should be START_POSITION.

playSpellEffects(EffectPosition.END_POSITION, proxyTarget, data);
}

}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace indents with tabs.

Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package com.nisovin.magicspells.spells.instant;

import java.util.*;

import org.bukkit.Location;
import org.bukkit.util.Vector;
import org.bukkit.entity.LivingEntity;
import org.bukkit.scheduler.BukkitRunnable;

import com.nisovin.magicspells.Subspell;
import com.nisovin.magicspells.MagicSpells;
import com.nisovin.magicspells.util.SpellData;
import com.nisovin.magicspells.util.CastResult;
import com.nisovin.magicspells.util.MagicConfig;
import com.nisovin.magicspells.spells.InstantSpell;
import com.nisovin.magicspells.util.config.ConfigData;
import com.nisovin.magicspells.spelleffects.EffectPosition;

public class BranchingProjectileSpell extends InstantSpell {

private final ConfigData<Double> maxDistance;
private final ConfigData<Double> hitRadius;
private final ConfigData<Double> stepLength;
private final ConfigData<Double> branchAngle;
private final ConfigData<Double> branchProbability;

private final ConfigData<Integer> maxDuration;
private final ConfigData<Integer> minBranchLength;
private final ConfigData<Integer> maxBranchLength;

private final ConfigData<String> spellToCastName;
private Subspell spellToCast;

public BranchingProjectileSpell(MagicConfig config, String spellName) {
super(config, spellName);

maxDuration = getConfigDataInt("max-duration", 40);

minBranchLength = getConfigDataInt("min-branch-length", 3);
maxBranchLength = getConfigDataInt("max-branch-length", 8);

hitRadius = getConfigDataDouble("hit-radius", 1.5);
stepLength = getConfigDataDouble("step-length", 0.8);
maxDistance = getConfigDataDouble("max-distance", 20.0);
branchAngle = getConfigDataDouble("branch-angle", 30.0);
branchProbability = getConfigDataDouble("branch-probability", 0.3);

spellToCastName = getConfigDataString("spell", "");
}

@Override
public void initialize() {
super.initialize();
spellToCast = initSubspell(spellToCastName.get(null), "BranchingProjectileSpell '" + internalName + "' has an invalid spell: '" + spellToCastName.get(null) + "' defined!");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's invalid to call .get(null) - it's marked NotNull in your IDE. Leave sub-spells without expression support for now until we do cast-time sub-spell parsing - your way doesn't support cast-time eval anyway, since it never uses SpellData. Also, add a newline after super call.

}

@Override
public CastResult cast(SpellData data) {
Location start = data.caster().getEyeLocation();
Vector direction = start.getDirection().normalize();
Comment thread
DragonsAscent marked this conversation as resolved.

double maxDist = maxDistance.get(data);
int maxTicks = maxDuration.get(data);
Comment on lines +62 to +63
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to pass these to the constructor since they are visible within BranchTask.

new BranchTask(start, direction, maxDist, maxTicks, data, 0, false).runTaskTimer(MagicSpells.plugin, 0, 1);
return new CastResult(PostCastAction.HANDLE_NORMALLY, data);
Comment thread
JasperLorelai marked this conversation as resolved.
}

private class BranchTask extends BukkitRunnable {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should extend Runnable instead and be scheduled with MagicSpells#scheduleRepeatingTask, cancelled with MagicSpells#cancelTask. We handle logging BukkitRunnable does not. But it does remind me we should eventually make our own runnable for convenience.


private final Location current;
Comment thread
DragonsAscent marked this conversation as resolved.
private final Vector direction;
private final double maxDist;
private final int maxTicks;
private final SpellData data;
private final int branchDepth;
private final boolean isBranch;
private double traveled = 0;
private int ticks = 0;
private int branchLength = 0;
private final int branchMaxLength;

BranchTask(Location start, Vector direction, double maxDist, int maxTicks, SpellData data, int branchDepth, boolean isBranch) {
this.current = start.clone();
this.direction = direction.clone();
this.maxDist = maxDist;
this.maxTicks = maxTicks;
this.data = data;
this.branchDepth = branchDepth;
this.isBranch = isBranch;
this.branchMaxLength = isBranch ? random.nextInt(minBranchLength.get(data), maxBranchLength.get(data)) : Integer.MAX_VALUE;
}

@Override
public void run() {
boolean finished = (traveled >= maxDist || ticks >= maxTicks || branchLength >= branchMaxLength);
if (finished) {
// Play DELAYED effect at the end of the branch/trunk
playSpellEffects(EffectPosition.DELAYED, current, data.location(current));
cancel();
return;
}
// Add a small random curve to the direction for organic movement (branches only)
if (isBranch) {
double curveStrength = 0.15; // tweak for more/less curve
Vector curve = new Vector(
(Math.random() - 0.5) * curveStrength,
(Math.random() - 0.5) * curveStrength,
(Math.random() - 0.5) * curveStrength
);
direction.add(curve);
direction.normalize();
}
// Move forward
current.add(direction.clone().multiply(stepLength.get(data)));
traveled += stepLength.get(data);
branchLength++;
ticks++;
// Play particle effect using appropriate EffectPosition
EffectPosition pos = isBranch ? EffectPosition.SPECIAL : EffectPosition.PROJECTILE;
playSpellEffects(pos, current, data.location(current));
// Hit detection
for (LivingEntity entity : current.getNearbyLivingEntities(hitRadius.get(data))) {
if (entity.equals(data.caster())) continue;
if (spellToCast != null) spellToCast.subcast(data.retarget(entity, current));
}
// Branching
if (!isBranch && Math.random() < branchProbability.get(data)) {
Vector branchDir = getBranchDirection(direction, branchAngle.get(data));
new BranchTask(current.clone(), branchDir, maxDist, maxTicks, data, branchDepth + 1, true).runTaskTimer(MagicSpells.plugin, 0, 1);
}
Comment on lines +126 to +130
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Branching currently has no cap: the trunk can spawn a new BranchTask every tick while the random check passes, and branchDepth is incremented but never used to limit recursion/branch count. This can create many scheduled tasks and cause server lag; consider adding a configurable max branch count/depth (or ensure only one branch can spawn per trunk) and either remove or use branchDepth.

Copilot uses AI. Check for mistakes.
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments are a bit redundant. Ideally they should be suplimented/replaced by newlines.


private Vector getBranchDirection(Vector base, double angleDeg) {
double angleRad = Math.toRadians(angleDeg);
double yaw = Math.atan2(base.getZ(), base.getX());
double pitch = Math.asin(base.getY());
double branchYaw = yaw + (Math.random() - 0.5) * angleRad;
double branchPitch = pitch + (Math.random() - 0.5) * (angleRad / 2);
double x = Math.cos(branchYaw) * Math.cos(branchPitch);
double y = Math.sin(branchPitch);
double z = Math.sin(branchYaw) * Math.cos(branchPitch);
return new Vector(x, y, z).normalize();
}


}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace indents with tabs.

Loading
Loading