Compare commits

...
3 Commits
Author SHA1 Message Date
dmg d890fb8915 feat(abilities): add chain lightning
Release / release (push) Successful in 8m37s
2026-09-20 18:56:58 -04:00
dmg 683c4ed312 feat(abilities): add frost nova
Release / release (push) Successful in 8m7s
2026-09-20 18:22:03 -04:00
dmg c80c088945 feat(ability): add rallying cry
Release / release (push) Successful in 7m59s
2026-09-20 17:49:12 -04:00
20 changed files with 2713 additions and 3 deletions

No files matched your search

@@ -74,6 +74,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
private games.dmg.fantasystick.ability.GuardianBond guardianBond;
private games.dmg.fantasystick.persistence.GuardianBondSettings guardianBondSettings;
private games.dmg.fantasystick.ability.OutgoingDamageBonus outgoingDamageBonus;
private games.dmg.fantasystick.ability.RallyingCry rallyingCry;
private games.dmg.fantasystick.persistence.RallyingCrySettings rallyingCrySettings;
private games.dmg.fantasystick.ability.FrostNova frostNova;
private games.dmg.fantasystick.persistence.FrostNovaSettings frostNovaSettings;
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
@@ -146,6 +153,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var purifyInitial = games.dmg.fantasystick.persistence.PurifySettings.parse(purifyConfiguration);
var guardianBondConfiguration = guardianBondConfiguration();
var guardianBondInitial = games.dmg.fantasystick.persistence.GuardianBondSettings.parse(guardianBondConfiguration);
var rallyingCryConfiguration = rallyingCryConfiguration();
var rallyingCryInitial = games.dmg.fantasystick.persistence.RallyingCrySettings.parse(rallyingCryConfiguration);
var frostNovaConfiguration = frostNovaConfiguration();
var frostNovaInitial = games.dmg.fantasystick.persistence.FrostNovaSettings.parse(frostNovaConfiguration);
var chainLightningConfiguration = chainLightningConfiguration();
var chainLightningInitial = games.dmg.fantasystick.persistence.ChainLightningSettings.parse(chainLightningConfiguration);
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
var factory = new CharacterFactory();
@@ -248,6 +261,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
getDataFolder().toPath(), guardianBondConfiguration);
guardianBond = new games.dmg.fantasystick.ability.GuardianBond(guardianBondInitial, sessions, parties,
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
outgoingDamageBonus = new games.dmg.fantasystick.ability.OutgoingDamageBonus(sessions, parties,
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
rallyingCrySettings = new games.dmg.fantasystick.persistence.RallyingCrySettings(
getDataFolder().toPath(), rallyingCryConfiguration);
rallyingCry = new games.dmg.fantasystick.ability.RallyingCry(
rallyingCryInitial, sessions, parties, outgoingDamageBonus);
frostNovaSettings = new games.dmg.fantasystick.persistence.FrostNovaSettings(
getDataFolder().toPath(), frostNovaConfiguration);
frostNova = new games.dmg.fantasystick.ability.FrostNova(frostNovaInitial, sessions, parties);
chainLightningSettings = new games.dmg.fantasystick.persistence.ChainLightningSettings(
getDataFolder().toPath(), chainLightningConfiguration);
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(chainLightningInitial, sessions, parties);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
@@ -265,6 +290,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityRegistry.register(mendingLight.definition());
abilityRegistry.register(purify.definition());
abilityRegistry.register(guardianBond.definition());
abilityRegistry.register(rallyingCry.definition());
abilityRegistry.register(frostNova.definition());
abilityRegistry.register(chainLightning.definition());
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
@@ -310,6 +338,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachMendingLight(mendingLightSettings, mendingLight);
admin.attachPurify(purifySettings, purify);
admin.attachGuardianBond(guardianBondSettings, guardianBond);
admin.attachRallyingCry(rallyingCrySettings, rallyingCry);
admin.attachFrostNova(frostNovaSettings, frostNova);
admin.attachChainLightning(chainLightningSettings, chainLightning);
admin.attachAdminRevival(adminRevival, adminReviveSettings);
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
characters, rules, sessions, nextTick, getServer()::getPlayer,
@@ -338,6 +369,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatExperience.install(this);
getServer().getPluginManager().registerEvents(fireball, this);
getServer().getPluginManager().registerEvents(frostShard, this);
getServer().getPluginManager().registerEvents(frostNova, this);
getServer().getPluginManager().registerEvents(chainLightning, this);
getServer().getPluginManager().registerEvents(longbow, this);
getServer().getPluginManager().registerEvents(quickstep, this);
getServer().getPluginManager().registerEvents(combatMitigation, this);
@@ -345,6 +378,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
guardianBond.install(this);
getServer().getPluginManager().registerEvents(shieldBash, this);
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
getServer().getPluginManager().registerEvents(outgoingDamageBonus, this);
rallyingCry.install(this);
getServer().getPluginManager().registerEvents(temporaryHealth, this);
getServer().getPluginManager().registerEvents(greatSword, this);
getServer().getPluginManager().registerEvents(revive, this);
@@ -417,6 +452,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingPurifySettings = purifySettings; purifySettings = null;
var closingGuardianBond = guardianBond; guardianBond = null;
var closingGuardianBondSettings = guardianBondSettings; guardianBondSettings = null;
var closingRallyingCry = rallyingCry; rallyingCry = null;
var closingRallyingCrySettings = rallyingCrySettings; rallyingCrySettings = null;
var closingFrostNova = frostNova; frostNova = null;
var closingFrostNovaSettings = frostNovaSettings; frostNovaSettings = null;
var closingChainLightning = chainLightning; chainLightning = null;
var closingChainLightningSettings = chainLightningSettings; chainLightningSettings = null;
var closingOutgoingDamageBonus = outgoingDamageBonus; outgoingDamageBonus = null;
var closingAdminRevival = adminRevival; adminRevival = null;
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
@@ -453,6 +495,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingPurifySettings != null) cleanup("Purify configuration", closingPurifySettings::close);
if (closingGuardianBond != null) cleanup("Guardian Bond", closingGuardianBond::close);
if (closingGuardianBondSettings != null) cleanup("Guardian Bond configuration", closingGuardianBondSettings::close);
if (closingRallyingCry != null) cleanup("Rallying Cry", closingRallyingCry::close);
if (closingRallyingCrySettings != null) cleanup("Rallying Cry configuration", closingRallyingCrySettings::close);
if (closingFrostNova != null) cleanup("Frost Nova", closingFrostNova::close);
if (closingFrostNovaSettings != null) cleanup("Frost Nova configuration", closingFrostNovaSettings::close);
if (closingChainLightning != null) cleanup("Chain Lightning", closingChainLightning::close);
if (closingChainLightningSettings != null) cleanup("Chain Lightning configuration", closingChainLightningSettings::close);
if (closingOutgoingDamageBonus != null) cleanup("outgoing damage bonuses", closingOutgoingDamageBonus::close);
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
@@ -484,6 +533,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
: closingPurifySettings.drained().toCompletableFuture(),
closingGuardianBondSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingGuardianBondSettings.drained().toCompletableFuture(),
closingRallyingCrySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingRallyingCrySettings.drained().toCompletableFuture(),
closingFrostNovaSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFrostNovaSettings.drained().toCompletableFuture(),
closingChainLightningSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingChainLightningSettings.drained().toCompletableFuture(),
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdminReviveSettings.drained().toCompletableFuture(),
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -714,6 +769,36 @@ public final class FantasyStickPlugin extends JavaPlugin {
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> rallyingCryConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.RallyingCrySettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.RallyingCrySettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.RallyingCrySettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> frostNovaConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.FrostNovaSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.FrostNovaSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.FrostNovaSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> chainLightningConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.ChainLightningSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.ChainLightningSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.ChainLightningSettings.parse(values);
return java.util.Map.copyOf(values);
}
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
@@ -0,0 +1,203 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
/** Deterministic bounded four-target lightning chain with exact decimal decay. */
public final class ChainLightning implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double initialRange, double jumpRange,
int additionalTargets, BigDecimal jumpMultiplier) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(jumpMultiplier);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(initialRange) || initialRange <= 0 || initialRange > 64
|| !Double.isFinite(jumpRange) || jumpRange <= 0 || jumpRange > 64
|| additionalTargets < 0 || additionalTargets > 63
|| jumpMultiplier.signum() <= 0 || jumpMultiplier.compareTo(BigDecimal.ONE) > 0) {
throw new IllegalArgumentException("Invalid Chain Lightning settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(8), Duration.ofSeconds(18), 16, 6, 3, new BigDecimal("0.75"));
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
}
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private Attempt damaging;
private boolean closed;
public ChainLightning(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("chain_lightning", "Chain Lightning", Material.LIGHTNING_ROD,
List.of("Strike for " + current.baseDamage() + " + INT modifier damage, then jump up to "
+ current.additionalTargets() + " times.",
"Each jump deals " + decimal(current.jumpMultiplier().movePointRight(2))
+ "% of the previous defined damage within " + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
"Use while aiming at the first visible hostile within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks.",
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
"jump-range", BigDecimal.valueOf(current.jumpRange()).stripTrailingZeros(),
"additional-targets", BigDecimal.valueOf(current.additionalTargets()),
"jump-multiplier", current.jumpMultiplier()), AbilityDefinition.TargetPolicy.SELECTION, this);
}
public static BigInteger initialDamage(BigInteger intelligence) { return initialDamage(BigInteger.valueOf(8), intelligence); }
static BigInteger initialDamage(BigInteger base, BigInteger intelligence) {
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning damage input");
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public boolean accepts(ControlInput input) {
return !closed && selectInitial(input, settings.initialRange()) != null;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var captured = from(execution.definition()); var caster = execution.input().player();
var initial = selectInitial(execution.input(), captured.initialRange());
if (initial == null) return;
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
var visited = new HashSet<UUID>(); LivingEntity current = initial;
int maximum = Math.min(64, 1 + captured.additionalTargets());
for (int strike = 0; strike < maximum && current != null && execution.stillCurrent(); strike++) {
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
strike(execution, caster, current, defined); visual(caster, current);
if (strike + 1 == maximum) break;
defined = defined.multiply(captured.jumpMultiplier());
current = next(caster, current, visited, captured.jumpRange());
}
}
private void strike(AbilityExecution execution, Player caster, LivingEntity target, BigDecimal defined) {
double amount = finite(definiteNonnegative(defined));
var origin = caster.getEyeLocation().clone();
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
.withDirectEntity(caster).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
try { target.damage(amount, source); }
finally { if (damaging == attempt) damaging = null; }
}
private LivingEntity selectInitial(ControlInput input, double range) {
var caster = input.player();
if (input.target() instanceof LivingEntity supplied && eligible(caster, supplied)
&& inRangeAndVisible(caster, supplied, range)) return supplied;
try {
var eye = caster.getEyeLocation();
var hit = caster.getWorld().rayTraceEntities(eye, eye.getDirection(), range, entity ->
entity instanceof LivingEntity target && eligible(caster, target) && inRangeAndVisible(caster, target, range));
if (hit == null || !(hit.getHitEntity() instanceof LivingEntity target)) return null;
return eligible(caster, target) && inRangeAndVisible(caster, target, range) ? target : null;
} catch (RuntimeException unavailable) { return null; }
}
private LivingEntity next(Player caster, LivingEntity previous, Set<UUID> visited, double range) {
List<Entity> nearby;
try { nearby = previous.getNearbyEntities(range, range, range); }
catch (RuntimeException unavailable) { return null; }
var candidates = new ArrayList<LivingEntity>(); var seen = new HashSet<UUID>(); int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256) break;
if (entity instanceof LivingEntity target && seen.add(target.getUniqueId()) && !visited.contains(target.getUniqueId())
&& eligible(caster, target) && inRangeAndVisible(previous, target, range)) candidates.add(target);
}
var origin = previous.getLocation();
candidates.sort(Comparator.comparingDouble((LivingEntity target) -> distance(origin, target))
.thenComparing(LivingEntity::getUniqueId));
return candidates.isEmpty() ? null : candidates.getFirst();
}
private boolean eligible(Player caster, LivingEntity target) {
if (target == caster) return false;
if (!(target instanceof Player player)) return true;
if (sessions.active(player.getUniqueId()).filter(sessions::current).isEmpty()) return false;
var sourceParty = parties.partyOf(caster.getUniqueId()).orElse(null);
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
return sourceParty == null || targetParty == null || !sourceParty.id().equals(targetParty.id());
}
private static boolean inRangeAndVisible(LivingEntity source, LivingEntity target, double range) {
try {
var from = source.getLocation(); var to = target.getLocation();
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
} catch (RuntimeException invalid) { return false; }
}
private static double distance(Location origin, LivingEntity target) {
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
? origin.distanceSquared(location) : Double.POSITIVE_INFINITY; }
catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
private static void visual(Player caster, LivingEntity target) {
try {
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
caster.getWorld().spawnParticle(Particle.ELECTRIC_SPARK, location, 18, 0.3, 0.5, 0.3, 0.02);
caster.getWorld().playSound(location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.35f, 1.7f);
} catch (RuntimeException ignored) { }
}
private static BigDecimal definiteNonnegative(BigDecimal value) { return value.signum() < 0 ? BigDecimal.ZERO : value; }
private static double finite(BigDecimal value) {
if (value.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0) return Double.MAX_VALUE;
double result = value.doubleValue(); return Double.isFinite(result) ? result : Double.MAX_VALUE;
}
private static Settings from(AbilityDefinition definition) {
var p = definition.parameters();
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("initial-range").doubleValue(),
p.get("jump-range").doubleValue(), p.get("additional-targets").intValueExact(), p.get("jump-multiplier"));
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
@Override public void close() { closed = true; damaging = null; }
}
@@ -0,0 +1,234 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
/** Bounded instantaneous Frost Nova damage and ownership-aware Slowness. */
public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double radius,
int slownessAmplifier, Duration slownessDuration) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(slownessDuration);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|| slownessAmplifier < 0 || slownessAmplifier > 255
|| slownessDuration.isNegative() || slownessDuration.isZero()
|| slownessDuration.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("Invalid Frost Nova settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(5), Duration.ofSeconds(12), 5, 1, Duration.ofSeconds(3));
}
}
private static final class SlowLease implements AutoCloseable {
final LivingEntity target; final PotionEffect previous; final PotionEffect applied;
final long started = System.nanoTime(); boolean owned; boolean closed;
SlowLease(LivingEntity target, PotionEffect applied) {
this.target = target; previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
}
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
@Override public void close() {
if (closed) return; closed = true; if (!owned) return;
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
if (current == null || current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2) return;
target.removePotionEffect(PotionEffectType.SLOWNESS);
if (previous != null) {
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
}
}
}
private final class LeaseGroup implements AutoCloseable {
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>(); boolean closed;
LeaseGroup(AbilityExecution execution) { this.execution = execution; }
@Override public void close() {
if (closed) return; closed = true; active.remove(execution, this);
for (var lease : List.copyOf(leases)) lease.close(); leases.clear();
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
}
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Map<AbilityExecution, LeaseGroup> active = new java.util.IdentityHashMap<>();
private Attempt damaging;
private boolean closed;
public FrostNova(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("frost_nova", "Frost Nova", Material.SNOWBALL,
List.of("Burst in a " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere for "
+ current.baseDamage() + " + INT modifier damage.",
"Applies Slowness " + level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + " seconds."),
"Use to affect exposed enemies; caster, party and ordinary players are excluded.",
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"slowness-amplifier", BigDecimal.valueOf(current.slownessAmplifier()),
"slowness-duration-seconds", BigDecimal.valueOf(current.slownessDuration().toNanos()).movePointLeft(9)),
AbilityDefinition.TargetPolicy.AREA, this);
}
public static BigInteger damage(BigInteger intelligence) { return damage(BigInteger.valueOf(5), intelligence); }
static BigInteger damage(BigInteger base, BigInteger intelligence) {
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Frost Nova damage input");
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var captured = from(execution.definition()); var source = execution.input().player();
Location origin = source.getEyeLocation().clone();
var group = new LeaseGroup(execution); active.put(execution, group); execution.own(group);
BigInteger exact = damage(captured.baseDamage(), execution.character().stats().get(Stat.INT));
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
int affected = 0;
for (var target : candidates(source, origin, captured.radius())) {
if (affected == 64 || !execution.stillCurrent()) break;
if (!eligible(execution, target, origin, captured.radius())) continue;
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
.withDirectEntity(source).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
try { target.damage(amount, sourceDamage); }
finally { if (damaging == attempt) damaging = null; }
affected++;
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
int ticks = Math.toIntExact(Math.max(1,
(captured.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
captured.slownessAmplifier(), false, true, true));
if (lease.apply()) group.leases.add(lease);
}
try { source.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 32,
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
catch (RuntimeException ignored) { }
if (group.leases.isEmpty()) execution.finish();
else execution.later(captured.slownessDuration(), execution::finish);
}
private List<LivingEntity> candidates(Player source, Location origin, double radius) {
List<Entity> nearby;
try { nearby = source.getNearbyEntities(radius, radius, radius); }
catch (RuntimeException unavailable) { return List.of(); }
var unique = new LinkedHashMap<UUID, LivingEntity>(); int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256) break;
if (entity instanceof LivingEntity target && target != source) unique.putIfAbsent(target.getUniqueId(), target);
}
var result = new ArrayList<>(unique.values());
result.sort(Comparator.comparingDouble((LivingEntity target) -> distance(origin, target))
.thenComparing(LivingEntity::getUniqueId));
return result;
}
private boolean eligible(AbilityExecution execution, LivingEntity target, Location origin, double radius) {
if (target == execution.input().player() || !inside(origin, target, radius)) return false;
try {
if (!execution.input().player().hasLineOfSight(target) || !unobstructed(origin, target)) return false;
} catch (RuntimeException unavailable) { return false; }
if (!(target instanceof Player player)) return true;
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
if (active == null) return false;
var sourceParty = parties.partyOf(execution.input().player().getUniqueId()).orElse(null);
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
return sourceParty == null || targetParty == null || !sourceParty.id().equals(targetParty.id());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
private static boolean unobstructed(Location origin, LivingEntity target) {
var world = origin.getWorld(); if (world == null) return false;
var location = target.getLocation(); if (location.getWorld() != world) return false;
var bounds = target.getBoundingBox();
var center = bounds == null ? location.toVector().add(new org.bukkit.util.Vector(0, target.getHeight() / 2, 0))
: bounds.getCenter();
var offset = center.clone().subtract(origin.toVector()); double distance = offset.length();
if (!Double.isFinite(distance) || distance == 0) return distance == 0;
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance,
org.bukkit.FluidCollisionMode.NEVER, true) == null;
}
private static boolean inside(Location origin, LivingEntity target, double radius) {
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
&& origin.distanceSquared(location) <= radius * radius; }
catch (RuntimeException invalid) { return false; }
}
private static double distance(Location origin, LivingEntity target) {
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
? origin.distanceSquared(location) : Double.POSITIVE_INFINITY; }
catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
}
private static Settings from(AbilityDefinition definition) {
var parameters = definition.parameters();
return new Settings(parameters.get("base-damage").toBigIntegerExact(), definition.cooldown(),
parameters.get("radius").doubleValue(), parameters.get("slowness-amplifier").intValueExact(),
Duration.ofNanos(parameters.get("slowness-duration-seconds").movePointRight(9).longValueExact()));
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
private static String seconds(Duration duration) {
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
}
private static String level(int amplifier) {
return switch (amplifier + 1) { case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; default -> Integer.toString(amplifier + 1); };
}
@Override public void close() {
if (closed) return; closed = true; damaging = null;
for (var group : List.copyOf(active.values())) { group.close(); group.execution.finish(); }
active.clear();
}
}
@@ -0,0 +1,114 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.projectiles.ProjectileSource;
/** Strongest deterministic Fantasy Stick outgoing combat bonus stage. */
public final class OutgoingDamageBonus implements Listener, AutoCloseable {
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
private final class Grant implements AutoCloseable {
final long sequence; final AbilityExecution execution; final Player recipient;
final BigDecimal percent; final BooleanSupplier current; boolean closed;
Grant(AbilityExecution execution, Player recipient, BigDecimal percent, BooleanSupplier current) {
sequence = ++sequences; this.execution = execution; this.recipient = recipient;
this.percent = percent; this.current = current;
}
boolean valid() {
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
catch (RuntimeException invalid) { return false; }
}
@Override public void close() {
if (closed) return; closed = true;
var owned = grants.get(recipient.getUniqueId());
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
}
}
private record Used(Grant grant, Entity target, BigDecimal amount) { }
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Consumer<Support> supports;
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
private final Map<EntityDamageEvent, Used> pending = new IdentityHashMap<>();
private long sequences;
private boolean closed;
public OutgoingDamageBonus(EquipmentSessions sessions, PartyRoster parties, Consumer<Support> supports) {
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.supports = Objects.requireNonNull(supports);
}
public AutoCloseable grant(AbilityExecution execution, Player recipient, BigDecimal percent, BooleanSupplier current) {
Objects.requireNonNull(execution); Objects.requireNonNull(recipient);
Objects.requireNonNull(percent); Objects.requireNonNull(current);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(1000)) > 0) {
throw new IllegalArgumentException("Invalid outgoing damage bonus");
}
var grant = new Grant(execution, recipient, percent, current);
grants.computeIfAbsent(recipient.getUniqueId(), ignored -> new ArrayList<>()).add(grant); return grant;
}
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void increase(EntityDamageByEntityEvent event) {
if (closed || GuardianBond.redirectedDamage()) return;
Entity attacker = attacker(event); if (!(attacker instanceof Player player)) return;
var owned = grants.get(player.getUniqueId()); if (owned == null) return;
Grant strongest = null;
for (var grant : List.copyOf(owned)) {
if (grant.recipient != player || !grant.valid()) continue;
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0
|| grant.percent.compareTo(strongest.percent) == 0 && grant.sequence < strongest.sequence) strongest = grant;
}
double original = event.getDamage();
if (strongest == null || !Double.isFinite(original) || original <= 0) return;
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
}
@EventHandler(priority = EventPriority.MONITOR)
public void accepted(EntityDamageEvent event) {
var used = pending.remove(event);
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
}
private static Entity attacker(EntityDamageByEntityEvent event) {
Entity causing = event.getDamageSource().getCausingEntity(); if (causing != null) return causing;
if (event.getDamager() instanceof Projectile projectile) {
ProjectileSource shooter = projectile.getShooter(); if (shooter instanceof Entity entity) return entity;
}
return event.getDamager();
}
@Override public void close() {
if (closed) return; closed = true;
for (var owned : List.copyOf(grants.values())) for (var grant : List.copyOf(owned)) grant.close();
grants.clear(); pending.clear();
}
}
@@ -0,0 +1,227 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
/** Dynamic owned party sphere for Rallying Cry movement and outgoing damage benefits. */
public final class RallyingCry implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, double radius, Duration duration,
int speedAmplifier, BigDecimal damageBonusPercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(damageBonusPercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|| speedAmplifier < 0 || speedAmplifier > 255
|| damageBonusPercent.signum() < 0 || damageBonusPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new IllegalArgumentException("Invalid Rallying Cry settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(25), 8, Duration.ofSeconds(6), 0, BigDecimal.valueOf(20));
}
}
private final class Area implements AutoCloseable {
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
Area(AbilityExecution execution, Settings settings) {
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
}
void refresh() {
if (closed) return;
if (!sourceCurrent()) { breakArea(this); return; }
var eligible = candidates();
for (var entry : List.copyOf(recipients.entrySet())) {
if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
}
for (var entry : eligible.entrySet()) {
if (!recipients.containsKey(entry.getKey())) {
var benefit = new Benefit(this, entry.getValue()); recipients.put(entry.getKey(), benefit);
}
}
}
boolean sourceCurrent() {
return execution.stillCurrent() && sessions.active(source.getUniqueId())
.filter(active -> active.equals(execution.input().session()) && sessions.current(active)).isPresent();
}
boolean eligible(Player player) {
if (!sourceCurrent()) return false;
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
if (active == null || !inside(source, player, settings.radius())) return false;
if (player == source) return active.equals(execution.input().session());
var sourceParty = parties.partyOf(source.getUniqueId()).orElse(null);
var recipientParty = parties.partyOf(player.getUniqueId()).orElse(null);
return sourceParty != null && recipientParty != null && sourceParty.id().equals(recipientParty.id());
}
Map<UUID, Player> candidates() {
var result = new LinkedHashMap<UUID, Player>();
if (eligible(source)) result.put(source.getUniqueId(), source);
List<Entity> nearby;
try { nearby = source.getNearbyEntities(settings.radius(), settings.radius(), settings.radius()); }
catch (RuntimeException unavailable) { nearby = List.of(); }
int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256 || result.size() == 64) break;
if (entity instanceof Player player && eligible(player)) result.putIfAbsent(player.getUniqueId(), player);
}
return result;
}
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
speed = speed(player, area.settings.speedAmplifier());
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(speed);
}
}
private final class SpeedGrant implements AutoCloseable {
final SpeedState state; final long sequence; final int amplifier; boolean closed;
SpeedGrant(SpeedState state, int amplifier) { this.state = state; sequence = ++speedSequences; this.amplifier = amplifier; }
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
}
private final class SpeedState {
final Player player; final List<SpeedGrant> grants = new ArrayList<>(); PotionEffect owned;
SpeedState(Player player) { this.player = player; }
void refresh() {
PotionEffect current;
try { current = player.getPotionEffect(PotionEffectType.SPEED); }
catch (RuntimeException unavailable) { return; }
if (owned != null && !same(current, owned)) owned = null;
SpeedGrant strongest = null;
for (var grant : List.copyOf(grants)) if (!grant.closed && (strongest == null
|| grant.amplifier > strongest.amplifier
|| grant.amplifier == strongest.amplifier && grant.sequence < strongest.sequence)) strongest = grant;
if (strongest == null) {
if (owned != null && same(current, owned) && !removeOwned()) return;
owned = null; speeds.remove(player.getUniqueId(), this); return;
}
if (owned != null && same(current, owned) && owned.getAmplifier() != strongest.amplifier) {
if (!removeOwned()) return;
current = null;
}
if (current != null && current.getAmplifier() >= strongest.amplifier) return;
var effect = new PotionEffect(PotionEffectType.SPEED, PotionEffect.INFINITE_DURATION,
strongest.amplifier, false, false, true);
try { if (player.addPotionEffect(effect, true)) owned = effect; }
catch (RuntimeException ignored) { owned = null; }
}
boolean removeOwned() {
try {
player.removePotionEffect(PotionEffectType.SPEED);
if (same(player.getPotionEffect(PotionEffectType.SPEED), owned)) return false;
} catch (RuntimeException failure) { return false; }
owned = null; return true;
}
}
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final OutgoingDamageBonus bonuses;
private final Map<Long, Area> areas = new java.util.HashMap<>();
private final Map<UUID, SpeedState> speeds = new java.util.HashMap<>();
private long sequences; private long speedSequences;
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
public RallyingCry(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
if (closed || sweepTask != null) throw new IllegalStateException("Rallying Cry sweep already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
public void sweep() {
if (closed) return; int checked = 0;
for (var area : List.copyOf(areas.values())) { area.refresh(); if (++checked == 64) break; }
for (var state : List.copyOf(speeds.values())) state.refresh();
}
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("rallying_cry", "Rallying Cry", Material.GOAT_HORN,
List.of("Grant nearby active party members Speed " + (current.speedAmplifier() + 1)
+ " and " + decimal(current.damageBonusPercent()) + "% combat damage.",
"A " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere lasting " + seconds(current.duration()) + " seconds."),
"Use to rally yourself and nearby active party members; copies do not stack.",
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
"speed-amplifier", BigDecimal.valueOf(current.speedAmplifier()),
"damage-bonus-percent", current.damageBonusPercent()), AbilityDefinition.TargetPolicy.AREA, this);
}
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
}
private AutoCloseable speed(Player player, int amplifier) {
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
}
private void breakArea(Area area) { area.close(); area.execution.finish(); }
private static boolean inside(Player source, Player target, double radius) {
try { var a = source.getLocation(); var b = target.getLocation();
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
} catch (RuntimeException invalid) { return false; }
}
private static boolean same(PotionEffect first, PotionEffect second) {
return first != null && second != null && first.getType().equals(second.getType())
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite();
}
private static void closeResource(AutoCloseable resource) {
try { resource.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
}
private static Settings from(AbilityDefinition definition) {
var parameters = definition.parameters();
return new Settings(definition.cooldown(), parameters.get("radius").doubleValue(),
Duration.ofNanos(parameters.get("duration-seconds").movePointRight(9).longValueExact()),
parameters.get("speed-amplifier").intValueExact(), parameters.get("damage-bonus-percent"));
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
private static String seconds(Duration duration) {
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
}
@Override public void close() {
if (closed) return; closed = true; if (sweepTask != null) sweepTask.cancel();
for (var area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
speeds.clear();
}
}
@@ -0,0 +1,173 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.ChainLightning;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Chain Lightning settings; publication remains a server-thread caller decision. */
public final class ChainLightningSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE = "chain-lightning-base-damage";
public static final String COOLDOWN = "chain-lightning-cooldown-seconds";
public static final String INITIAL_RANGE = "chain-lightning-initial-range-blocks";
public static final String JUMP_RANGE = "chain-lightning-jump-range-blocks";
public static final String ADDITIONAL = "chain-lightning-additional-targets";
public static final String MULTIPLIER = "chain-lightning-jump-multiplier";
private static final List<String> KEYS = List.of(BASE, COOLDOWN, INITIAL_RANGE, JUMP_RANGE, ADDITIONAL, MULTIPLIER);
private static final Map<String, String> DEFAULTS = Map.of(
BASE, "8", COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", ADDITIONAL, "3", MULTIPLIER, "0.75");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, ChainLightning.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-chain-lightning-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public ChainLightningSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
ChainLightningSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public ChainLightning.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Chain Lightning configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static ChainLightning.Settings parse(Map<String, String> values) {
try { return new ChainLightning.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
seconds(values.get(COOLDOWN)), finite(values.get(INITIAL_RANGE)), finite(values.get(JUMP_RANGE)),
new BigDecimal(values.get(ADDITIONAL)).intValueExact(), new BigDecimal(values.get(MULTIPLIER))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Chain Lightning settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,172 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.FrostNova;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Frost Nova settings; publication remains a server-thread caller decision. */
public final class FrostNovaSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE = "frost-nova-base-damage";
public static final String COOLDOWN = "frost-nova-cooldown-seconds";
public static final String RADIUS = "frost-nova-radius-blocks";
public static final String SLOWNESS = "frost-nova-slowness-amplifier";
public static final String DURATION = "frost-nova-slowness-duration-seconds";
private static final List<String> KEYS = List.of(BASE, COOLDOWN, RADIUS, SLOWNESS, DURATION);
private static final Map<String, String> DEFAULTS = Map.of(
BASE, "5", COOLDOWN, "12", RADIUS, "5", SLOWNESS, "1", DURATION, "3");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, FrostNova.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-frost-nova-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public FrostNovaSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
FrostNovaSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public FrostNova.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Frost Nova configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static FrostNova.Settings parse(Map<String, String> values) {
try { return new FrostNova.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
new BigDecimal(values.get(SLOWNESS)).intValueExact(), seconds(values.get(DURATION))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Frost Nova settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,172 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.RallyingCry;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Rallying Cry settings; publication remains a server-thread caller decision. */
public final class RallyingCrySettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "rallying-cry-cooldown-seconds";
public static final String RADIUS = "rallying-cry-radius-blocks";
public static final String DURATION = "rallying-cry-duration-seconds";
public static final String SPEED = "rallying-cry-speed-amplifier";
public static final String BONUS = "rallying-cry-damage-bonus-percent";
private static final List<String> KEYS = List.of(COOLDOWN, BONUS, DURATION, RADIUS, SPEED);
private static final Map<String, String> DEFAULTS = Map.of(
COOLDOWN, "25", RADIUS, "8", DURATION, "6", SPEED, "0", BONUS, "20");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, RallyingCry.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-rallying-cry-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public RallyingCrySettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
RallyingCrySettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public RallyingCry.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Rallying Cry configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static RallyingCry.Settings parse(Map<String, String> values) {
try { return new RallyingCry.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
seconds(values.get(DURATION)), new BigDecimal(values.get(SPEED)).intValueExact(),
new BigDecimal(values.get(BONUS))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Rallying Cry settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -45,6 +45,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.ability.Purify purify;
private games.dmg.fantasystick.persistence.GuardianBondSettings guardianBondSettings;
private games.dmg.fantasystick.ability.GuardianBond guardianBond;
private games.dmg.fantasystick.persistence.RallyingCrySettings rallyingCrySettings;
private games.dmg.fantasystick.ability.RallyingCry rallyingCry;
private games.dmg.fantasystick.persistence.FrostNovaSettings frostNovaSettings;
private games.dmg.fantasystick.ability.FrostNova frostNova;
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
private AdminCharacterIssuer characterIssuer;
private AdminRevival adminRevival;
private AdminExperienceGrant experienceGrant;
@@ -241,6 +247,24 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
guardianBondSettings = Objects.requireNonNull(settings); guardianBond = Objects.requireNonNull(effect);
}
public void attachRallyingCry(games.dmg.fantasystick.persistence.RallyingCrySettings settings,
games.dmg.fantasystick.ability.RallyingCry effect) {
if (closed || rallyingCrySettings != null) throw new IllegalStateException("Rallying Cry already attached or command closed");
rallyingCrySettings = Objects.requireNonNull(settings); rallyingCry = Objects.requireNonNull(effect);
}
public void attachFrostNova(games.dmg.fantasystick.persistence.FrostNovaSettings settings,
games.dmg.fantasystick.ability.FrostNova effect) {
if (closed || frostNovaSettings != null) throw new IllegalStateException("Frost Nova already attached or command closed");
frostNovaSettings = Objects.requireNonNull(settings); frostNova = Objects.requireNonNull(effect);
}
public void attachChainLightning(games.dmg.fantasystick.persistence.ChainLightningSettings settings,
games.dmg.fantasystick.ability.ChainLightning effect) {
if (closed || chainLightningSettings != null) throw new IllegalStateException("Chain Lightning already attached or command closed");
chainLightningSettings = Objects.requireNonNull(settings); chainLightning = Objects.requireNonNull(effect);
}
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
@@ -453,6 +477,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (guardianBondSettings != null && games.dmg.fantasystick.persistence.GuardianBondSettings.keys().contains(args[1])) {
guardianBondConfiguration(sender, args); return;
}
if (rallyingCrySettings != null && games.dmg.fantasystick.persistence.RallyingCrySettings.keys().contains(args[1])) {
rallyingCryConfiguration(sender, args); return;
}
if (frostNovaSettings != null && games.dmg.fantasystick.persistence.FrostNovaSettings.keys().contains(args[1])) {
frostNovaConfiguration(sender, args); return;
}
if (chainLightningSettings != null && games.dmg.fantasystick.persistence.ChainLightningSettings.keys().contains(args[1])) {
chainLightningConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -634,6 +667,87 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void rallyingCryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + rallyingCrySettings.value(key) + " (applies to future Rallying Cry casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RallyingCrySettings.Update> operation;
try { operation = rallyingCrySettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Rallying Cry setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition()); rallyingCrySettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Rallying Cry casts; active cries and deadlines are unchanged."
: "Rallying Cry configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void frostNovaConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + frostNovaSettings.value(key) + " (applies to future Frost Nova casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostNovaSettings.Update> operation;
try { operation = frostNovaSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Frost Nova setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition()); frostNovaSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Frost Nova casts; active slows and deadlines are unchanged."
: "Frost Nova configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void chainLightningConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + chainLightningSettings.value(key) + " (applies to future Chain Lightning casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ChainLightningSettings.Update> operation;
try { operation = chainLightningSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Chain Lightning setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition()); chainLightningSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Chain Lightning casts; active chains and deadlines are unchanged."
: "Chain Lightning configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void hexConfiguration(CommandSender sender, String[] args) {
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
@@ -791,6 +905,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
keys.addAll(games.dmg.fantasystick.persistence.MendingLightSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.PurifySettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.GuardianBondSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.RallyingCrySettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.FrostNovaSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.ChainLightningSettings.keys());
return matching(keys, args[1]);
}
if (args.length == 3 && args[0].equals("config")) {
@@ -851,6 +968,24 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
String defaults = games.dmg.fantasystick.persistence.GuardianBondSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (rallyingCrySettings != null
&& games.dmg.fantasystick.persistence.RallyingCrySettings.keys().contains(args[1])) {
String current = rallyingCrySettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.RallyingCrySettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (frostNovaSettings != null
&& games.dmg.fantasystick.persistence.FrostNovaSettings.keys().contains(args[1])) {
String current = frostNovaSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.FrostNovaSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (chainLightningSettings != null
&& games.dmg.fantasystick.persistence.ChainLightningSettings.keys().contains(args[1])) {
String current = chainLightningSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.ChainLightningSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
}
return List.of();
}
+25
View File
@@ -107,3 +107,28 @@ guardian-bond-cooldown-seconds: 20
guardian-bond-range-blocks: 10
guardian-bond-duration-seconds: 6
guardian-bond-redirect-percent: 30
# Rallying Cry defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
rallying-cry-cooldown-seconds: 25
rallying-cry-radius-blocks: 8
rallying-cry-duration-seconds: 6
rallying-cry-speed-amplifier: 0
rallying-cry-damage-bonus-percent: 20
# Frost Nova defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
frost-nova-base-damage: 5
frost-nova-cooldown-seconds: 12
frost-nova-radius-blocks: 5
frost-nova-slowness-amplifier: 1
frost-nova-slowness-duration-seconds: 3
# Chain Lightning defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
chain-lightning-base-damage: 8
chain-lightning-cooldown-seconds: 18
chain-lightning-initial-range-blocks: 16
chain-lightning-jump-range-blocks: 6
chain-lightning-additional-targets: 3
chain-lightning-jump-multiplier: 0.75
@@ -0,0 +1,240 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.ability.*;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.bukkit.Location;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChainLightningNativeTest {
@TempDir Path data;
@BeforeAll static void nativeRuntime() throws Exception { NativeRuntime.bootstrap(); }
@Test void characterBookPurchaseAssignmentAndEquippedControlExecuteNativeChain() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-cooldown-seconds: 0\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, false);
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
assertTrue(text.contains("ChainLightning")); assertTrue(text.contains("INT20")); assertTrue(text.contains("DEX12"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "chain_lightning")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "chain_lightning")); drain(fixture, 2);
var first = mock(LivingEntity.class); var second = mock(LivingEntity.class);
locate(first, caster.world, UUID.randomUUID(), 10); locate(second, caster.world, UUID.randomUUID(), 14);
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
doReturn(new Location(caster.world, 0, 65, 0)).when(caster.player).getEyeLocation();
doReturn(true).when(caster.player).hasLineOfSight(first);
doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(10, 64, 0), first))
.when(caster.world).rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0),
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
doReturn(List.of(second)).when(first).getNearbyEntities(anyDouble(), anyDouble(), anyDouble()); doReturn(true).when(first).hasLineOfSight(second);
var damage = new LinkedHashMap<UUID, Double>(); nativeDamage(fixture, first, damage); nativeDamage(fixture, second, damage);
use(fixture, caster); drain(fixture, 2);
assertFalse(damage.isEmpty(), caster.messages.toString());
assertEquals(13, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(9.75, damage.get(second.getUniqueId()), 1.0e-10);
var stored = ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
.find(caster.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning"));
assertEquals("chain_lightning", stored.loadout().getFirst()); assertNotNull(stored.cooldowns().get("chain_lightning"));
}
}
@Test void exactChainUsesNearestUuidTiesFourTargetLimitDecayAndCancellationContinuation() {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
var world = mock(org.bukkit.World.class); UUID casterId = UUID.randomUUID(); when(caster.getUniqueId()).thenReturn(casterId);
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
var targets = new ArrayList<LivingEntity>();
for (int i = 0; i < 5; i++) { var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(new UUID(0, i + 1));
when(target.getLocation()).thenReturn(new Location(world, 4 + i * 2, 64, 0)); when(target.hasLineOfSight(any(org.bukkit.entity.Entity.class))).thenReturn(true); targets.add(target); }
when(caster.hasLineOfSight(targets.getFirst())).thenReturn(true);
when(targets.get(0).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(2), targets.get(1), targets.get(1)));
when(targets.get(1).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(3), targets.get(2)));
when(targets.get(2).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(4), targets.get(3)));
when(targets.get(3).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(4)));
var active = new EquipmentSessions.Active(casterId, UUID.randomUUID(), UUID.randomUUID());
var execution = execution(caster, active, targets.getFirst()); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
when(execution.definition()).thenReturn(chain.definition());
var attempted = new LinkedHashMap<UUID, Double>();
for (int i = 0; i < targets.size(); i++) { final int index = i; var target = targets.get(i);
doAnswer(call -> { double amount = call.getArgument(0); attempted.put(target.getUniqueId(), amount);
if (index != 1) chain.acceptedDamage(new EntityDamageEvent(target, EntityDamageEvent.DamageCause.LIGHTNING,
(DamageSource) call.getArgument(1), amount)); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class)); }
chain.activate(execution);
assertEquals(List.of(targets.get(0).getUniqueId(), targets.get(1).getUniqueId(), targets.get(2).getUniqueId(), targets.get(3).getUniqueId()),
List.copyOf(attempted.keySet()));
assertArrayEquals(new double[]{13, 9.75, 7.3125, 5.484375}, attempted.values().stream().mapToDouble(Double::doubleValue).toArray(), 1.0e-10);
verify(targets.get(4), never()).damage(anyDouble(), any(DamageSource.class));
verify(execution, times(3)).report(eq(AbilityReport.Type.DAMAGE), any(), any());
}
@Test void boundsVisibilityExclusionsAndEligibilityRaceStopInvalidStrikes() {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
var initial = mock(LivingEntity.class); locate(initial, world, UUID.randomUUID(), 16); when(caster.hasLineOfSight(initial)).thenReturn(true);
var blocked = mock(LivingEntity.class); locate(blocked, world, UUID.randomUUID(), 18); when(initial.hasLineOfSight(blocked)).thenReturn(false);
var outside = mock(LivingEntity.class); locate(outside, world, UUID.randomUUID(), 22.01); when(initial.hasLineOfSight(outside)).thenReturn(true);
var ordinary = mock(Player.class); locate(ordinary, world, UUID.randomUUID(), 18); when(initial.hasLineOfSight(ordinary)).thenReturn(true);
when(initial.getNearbyEntities(6, 6, 6)).thenReturn(List.of(blocked, outside, ordinary));
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
var execution = execution(caster, active, initial); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
when(execution.definition()).thenReturn(chain.definition()); chain.activate(execution);
verify(initial).damage(anyDouble(), any(DamageSource.class)); verify(blocked, never()).damage(anyDouble(), any(DamageSource.class));
verify(outside, never()).damage(anyDouble(), any(DamageSource.class)); verify(ordinary, never()).damage(anyDouble(), any(DamageSource.class));
when(execution.stillCurrent()).thenReturn(false); chain.activate(execution); verify(initial, times(1)).damage(anyDouble(), any(DamageSource.class));
}
@Test void jumpInspectionStopsAtSharedCandidateCeiling() {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
var initial = mock(LivingEntity.class); locate(initial, world, UUID.randomUUID(), 10); when(caster.hasLineOfSight(initial)).thenReturn(true);
when(initial.hasLineOfSight(any(org.bukkit.entity.Entity.class))).thenReturn(true);
var candidates = new ArrayList<org.bukkit.entity.Entity>(); LivingEntity beyond = null;
for (int index = 0; index < 257; index++) {
var target = mock(LivingEntity.class); locate(target, world, new UUID(1, index + 1), index == 256 ? 10.1 : 15);
candidates.add(target); if (index == 256) beyond = target;
}
when(initial.getNearbyEntities(6, 6, 6)).thenReturn(candidates);
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
var execution = execution(caster, active, initial); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
when(execution.definition()).thenReturn(chain.definition()); chain.activate(execution);
verify(initial).damage(anyDouble(), any(DamageSource.class)); verify(beyond, never()).damage(anyDouble(), any(DamageSource.class));
}
@Test void definitionFormulaAndArbitraryPrecisionAreExact() {
var chain = new ChainLightning(ChainLightning.Settings.defaults(), mock(EquipmentSessions.class), mock(PartyRoster.class));
var definition = chain.definition(); assertEquals("chain_lightning", definition.id()); assertEquals(org.bukkit.Material.LIGHTNING_ROD, definition.icon());
assertEquals(java.time.Duration.ofSeconds(18), definition.cooldown()); assertEquals(BigInteger.ONE, definition.pointCost());
assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.INT)); assertEquals(BigInteger.valueOf(12), definition.requirements().get(Stat.DEX));
assertEquals(BigInteger.valueOf(13), ChainLightning.initialDamage(BigInteger.valueOf(20)));
assertEquals(BigInteger.valueOf(7), ChainLightning.initialDamage(BigInteger.valueOf(9)));
var huge = BigInteger.TEN.pow(500); assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(8)), ChainLightning.initialDamage(huge));
}
@Test void pluginLoadsEverySettingAndAdminPublishesOnlyAfterDurableSave() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-base-damage: 9\nchain-lightning-cooldown-seconds: 4\n"
+ "chain-lightning-initial-range-blocks: 15\nchain-lightning-jump-range-blocks: 5\nchain-lightning-additional-targets: 2\nchain-lightning-jump-multiplier: 0.5\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var definition = registry.find("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("9"), definition.parameters().get("base-damage"));
assertEquals(java.time.Duration.ofSeconds(4), definition.cooldown());
assertEquals(0, new BigDecimal("15").compareTo(definition.parameters().get("initial-range")));
assertEquals(0, new BigDecimal("5").compareTo(definition.parameters().get("jump-range")));
assertEquals(new BigDecimal("2"), definition.parameters().get("additional-targets"));
assertEquals(new BigDecimal("0.5"), definition.parameters().get("jump-multiplier"));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}).isEmpty());
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("chain-lightning-additional-targets", "chain-lightning-base-damage", "chain-lightning-cooldown-seconds",
"chain-lightning-initial-range-blocks", "chain-lightning-jump-multiplier", "chain-lightning-jump-range-blocks"),
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}));
assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "chain-lightning-jump-range-blocks", ""}));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks"});
assertTrue(sender.messages.getLast().contains("=5"));
for (String[] update : List.of(new String[]{"chain-lightning-base-damage", "10"},
new String[]{"chain-lightning-cooldown-seconds", "3"}, new String[]{"chain-lightning-initial-range-blocks", "14"},
new String[]{"chain-lightning-jump-range-blocks", "7"}, new String[]{"chain-lightning-additional-targets", "1"},
new String[]{"chain-lightning-jump-multiplier", "0.25"})) {
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]});
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
}
var configured = registry.find("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("10"), configured.parameters().get("base-damage"));
assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
assertEquals(0, new BigDecimal("14").compareTo(configured.parameters().get("initial-range")));
assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range")));
assertEquals(new BigDecimal("1"), configured.parameters().get("additional-targets"));
assertEquals(new BigDecimal("0.25"), configured.parameters().get("jump-multiplier"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
var reloaded = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var saved = reloaded.find("chain_lightning").orElseThrow();
assertEquals(0, new BigDecimal("7").compareTo(saved.parameters().get("jump-range")));
assertEquals(new BigDecimal("0.25"), saved.parameters().get("jump-multiplier"));
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
}
}
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
doAnswer(call -> { double amount = call.getArgument(0); DamageSource source = call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getCausingEntity(), target,
EntityDamageEvent.DamageCause.LIGHTNING, source, amount);
fixture.dispatcher.accept(event); if (!event.isCancelled()) damage.put(target.getUniqueId(), event.getFinalDamage()); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class));
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
}
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
}
private static net.minecraft.network.chat.ClickEvent.Custom button(
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(book);
for (var page : book.pages()) { var found = button(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + ability);
}
private static net.minecraft.network.chat.ClickEvent.Custom button(
net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) { var found = button(child, label, ability); if (found != null) return found; }
return null;
}
private static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.INT, BigInteger.valueOf(20)); stats.put(Stat.DEX, BigInteger.valueOf(12));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
learned ? Set.of("chain_lightning") : old.learned(), learned ? List.of("chain_lightning", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static AbilityExecution execution(Player source, EquipmentSessions.Active active, LivingEntity target) {
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active); when(input.target()).thenReturn(target);
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
var record = new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(record.stats()); stats.put(Stat.INT, BigInteger.valueOf(20));
when(execution.character()).thenReturn(new CharacterRecord(active.character(), record.name(), record.revision(), stats,
record.level(), record.experience(), record.unspentPoints(), record.health(), Set.of("chain_lightning"),
List.of("chain_lightning", "", "", "", "", "", ""), record.cooldowns(), record.restrictedKills(), false, null, null));
return execution;
}
private static void locate(LivingEntity entity, org.bukkit.World world, UUID id, double x) {
when(entity.getUniqueId()).thenReturn(id); when(entity.getLocation()).thenReturn(new Location(world, x, 64, 0));
}
}
@@ -1285,7 +1285,7 @@ class EquipmentSessionEventsNativeTest {
@Test void selectedAbilityUseReachesItsHandlerWithoutVanillaThrowDrinkEquipBlockOrPlacement() throws Exception {
try (var f = new Fixture()) {
String[] abilities = {"fireball", "purify", "guardian_bond", "shield_bash", "rallying_cry", "war_banner", "revive"};
String[] abilities = {"chain_lightning", "purify", "guardian_bond", "shield_bash", "rallying_cry", "frost_nova", "revive"};
f.loadout(abilities);
var a = f.actor; f.core.activate(a);
var target = port(org.bukkit.entity.Player.class); a.targetEntity = target;
@@ -1721,6 +1721,8 @@ class EquipmentSessionsNativeTest {
}
case "getOpenInventory" -> view;
case "getLocation" -> location.clone();
case "getBoundingBox" -> new org.bukkit.util.BoundingBox(location.getX() - 0.3, location.getY(),
location.getZ() - 0.3, location.getX() + 0.3, location.getY() + 1.8, location.getZ() + 0.3);
case "getGameMode" -> gameMode;
case "setGameMode" -> { beforeMode.run(); gameMode = (org.bukkit.GameMode) args[0]; yield null; }
case "getHealth" -> health;
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
assertNotNull(fixture.packetListener);
assertEquals(6, fixture.timers.size(),
"Presentation, ability/bond cleanup, provenance, reward retry and session particles");
assertEquals(7, fixture.timers.size(),
"Presentation, ability/bond/rally cleanup, provenance, reward retry and session particles");
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
@@ -0,0 +1,324 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.ability.AbilityExecution;
import games.dmg.fantasystick.ability.FrostNova;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FrostNovaNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedBurstDamagesSlowsEligibleTargetsOnceAndRetainsCooldown() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
assertTrue(java.util.Arrays.stream(EntityDamageEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getListener() instanceof FrostNova));
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
var ally = new FantasyStickLifecycleNativeTest.Actor(data); var opponent = new FantasyStickLifecycleNativeTest.Actor(data);
prepareCaster(data, caster, false); fixture.players.put(caster.core.id, caster.player);
fixture.players.put(ally.core.id, ally.player); fixture.players.put(opponent.core.id, opponent.player);
fixture.activate(caster); fixture.activate(ally); fixture.activate(opponent); party(fixture, caster, ally);
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getLocation()).thenReturn(new Location(caster.world, 3, 64, 0)); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
var vetoed = mock(LivingEntity.class); when(vetoed.getUniqueId()).thenReturn(UUID.randomUUID());
when(vetoed.getLocation()).thenReturn(new Location(caster.world, 2.5, 64, 0));
doReturn(true).when(caster.player).hasLineOfSight(enemy); doReturn(true).when(caster.player).hasLineOfSight(vetoed);
locate(caster, ally.player, 2, true); locate(caster, opponent.player, 4, true);
var ordinary = mock(Player.class); when(ordinary.getUniqueId()).thenReturn(UUID.randomUUID()); locate(caster, ordinary, 2, true);
doReturn(List.of(enemy, enemy, vetoed, ally.player, ordinary, opponent.player)).when(caster.player)
.getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
var enemyEffects = effects(enemy); var vetoedEffects = effects(vetoed);
var opponentEffects = effects(opponent.player); effects(ally.player); effects(ordinary);
var damage = new HashMap<UUID, Double>(); damage(fixture, enemy, damage); damage(fixture, vetoed, damage); damage(fixture, opponent.player, damage);
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
public void cancel(EntityDamageEvent event) { if (event.getEntity() == vetoed) event.setCancelled(true); }
}
fixture.dispatcher.register(new Veto(), fixture.plugin);
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
assertTrue(text.contains("FrostNova")); assertTrue(text.contains("INT16")); assertTrue(text.contains("DEX10"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("5")); assertTrue(text.contains("SlownessII"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "frost_nova")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "frost_nova")); drain(fixture, 2);
use(fixture, caster); drain(fixture, 2);
assertEquals(8, damage.get(enemy.getUniqueId()), 1.0e-9); assertEquals(8, damage.get(opponent.core.id), 1.0e-9);
assertEquals(2, damage.size(), "Duplicate, cancelled, party and ordinary targets cannot add accepted hits");
assertFalse(vetoedEffects.containsKey(PotionEffectType.SLOWNESS));
assertEquals(1, enemyEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
assertEquals(1, opponentEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
var stored = storage(fixture).find(caster.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertNotNull(stored.cooldowns().get("frost_nova"));
assertEquals(60L, fixture.delayedTicks.getLast()); fixture.delayed.getLast().run();
assertFalse(enemyEffects.containsKey(PotionEffectType.SLOWNESS)); assertFalse(opponentEffects.containsKey(PotionEffectType.SLOWNESS));
}
}
@Test void spherePathOrderingCandidateAndTargetBoundsAreDeterministic() {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
var world = mock(org.bukkit.World.class); when(source.getUniqueId()).thenReturn(UUID.randomUUID());
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0)); when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0));
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
when(sessions.active(source.getUniqueId())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
var candidates = new ArrayList<Entity>(); var hitOrder = new ArrayList<UUID>();
for (int index = 0; index < 270; index++) {
var target = mock(LivingEntity.class); var id = new UUID(0, 300 - index); when(target.getUniqueId()).thenReturn(id);
double distance = index < 70 ? 1 + index / 100.0 : 4;
when(target.getLocation()).thenReturn(new Location(world, distance, 64, 0)); when(source.hasLineOfSight(target)).thenReturn(index != 1);
doAnswer(call -> { hitOrder.add(id); return null; }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
effects(target); candidates.add(target);
}
when(source.getNearbyEntities(5, 5, 5)).thenReturn(candidates);
var execution = execution(source, active); var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties);
when(execution.definition()).thenReturn(nova.definition()); nova.activate(execution);
assertEquals(64, hitOrder.size()); assertFalse(hitOrder.contains(new UUID(0, 299)), "Obstructed target excluded");
var sorted = new ArrayList<>(hitOrder); sorted.sort(UUID::compareTo);
assertNotEquals(sorted, hitOrder, "Distance precedes UUID while UUID breaks equal-distance ties");
verify((LivingEntity) candidates.getLast(), never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
verify(execution, atMost(64)).report(any(), any(), any());
}
@Test void exactSphereBoundaryIsIncludedWhileOutsideAndObstructedTargetsAreExcluded() {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
var world = mock(org.bukkit.World.class); when(source.getUniqueId()).thenReturn(UUID.randomUUID());
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0));
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
when(sessions.active(source.getUniqueId())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
var boundary = mock(LivingEntity.class); var outside = mock(LivingEntity.class); var blocked = mock(LivingEntity.class);
for (var target : List.of(boundary, outside, blocked)) when(target.getUniqueId()).thenReturn(UUID.randomUUID());
when(boundary.getLocation()).thenReturn(new Location(world, 5, 64, 0));
when(outside.getLocation()).thenReturn(new Location(world, 5.01, 64, 0));
when(blocked.getLocation()).thenReturn(new Location(world, 4, 64, 0));
when(source.hasLineOfSight(boundary)).thenReturn(true); when(source.hasLineOfSight(outside)).thenReturn(true);
when(source.hasLineOfSight(blocked)).thenReturn(true); when(source.getNearbyEntities(5, 5, 5)).thenReturn(List.of(boundary, outside, blocked));
when(world.rayTraceBlocks(any(Location.class), any(org.bukkit.util.Vector.class), eq(4.0),
eq(org.bukkit.FluidCollisionMode.NEVER), eq(true))).thenReturn(mock(org.bukkit.util.RayTraceResult.class));
effects(boundary); effects(outside); effects(blocked);
var execution = execution(source, active); var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties);
when(execution.definition()).thenReturn(nova.definition()); nova.activate(execution);
verify(boundary).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
verify(outside, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
verify(blocked, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
}
@Test void cancelledDamageAndPartyOrModeRaceApplyNoSlownessOrCredit() throws Exception {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
var target = mock(Player.class); var world = mock(org.bukkit.World.class); UUID sourceId = UUID.randomUUID(), targetId = UUID.randomUUID();
when(source.getUniqueId()).thenReturn(sourceId); when(target.getUniqueId()).thenReturn(targetId);
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0)); when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0));
when(target.getLocation()).thenReturn(new Location(world, 3, 64, 0)); when(source.hasLineOfSight(target)).thenReturn(true);
when(source.getNearbyEntities(5, 5, 5)).thenReturn(List.of(target));
var sourceActive = new EquipmentSessions.Active(sourceId, UUID.randomUUID(), UUID.randomUUID());
var targetActive = new EquipmentSessions.Active(targetId, UUID.randomUUID(), UUID.randomUUID());
when(sessions.active(sourceId)).thenReturn(java.util.Optional.of(sourceActive)); when(sessions.current(sourceActive)).thenReturn(true);
when(sessions.active(targetId)).thenReturn(java.util.Optional.of(targetActive)); when(sessions.current(targetActive)).thenReturn(true);
var targetEffects = effects(target); var execution = execution(source, sourceActive);
var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties); when(execution.definition()).thenReturn(nova.definition());
doAnswer(call -> { when(parties.partyOf(sourceId)).thenReturn(java.util.Optional.of(
new PartyRoster.Party(UUID.randomUUID(), sourceId, List.of(sourceId, targetId)))); return null; })
.when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
nova.activate(execution); assertTrue(targetEffects.isEmpty()); verify(execution, never()).report(any(), any(), any());
when(parties.partyOf(sourceId)).thenReturn(java.util.Optional.empty()); when(sessions.current(targetActive)).thenReturn(false);
nova.activate(execution); verify(target, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
}
@Test void ownedSlownessRestoresWeakerPriorStateButPreservesNewerStrongerState() throws Exception {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
var target = mock(LivingEntity.class); var world = mock(org.bukkit.World.class);
when(source.getUniqueId()).thenReturn(UUID.randomUUID()); when(target.getUniqueId()).thenReturn(UUID.randomUUID());
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0)); when(target.getLocation()).thenReturn(new Location(world, 2, 64, 0));
when(source.hasLineOfSight(target)).thenReturn(true); when(source.getNearbyEntities(5, 5, 5)).thenReturn(List.of(target));
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
when(sessions.active(source.getUniqueId())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
var targetEffects = effects(target); targetEffects.put(PotionEffectType.SLOWNESS,
new PotionEffect(PotionEffectType.SLOWNESS, 200, 0));
var execution = execution(source, active); var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties);
when(execution.definition()).thenReturn(nova.definition());
doAnswer(call -> { var damageSource = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new EntityDamageEvent(target, EntityDamageEvent.DamageCause.FREEZE, damageSource, (Double) call.getArgument(0));
nova.acceptedDamage(event); return null; }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
nova.activate(execution); assertEquals(1, targetEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
var resource = org.mockito.ArgumentCaptor.forClass(AutoCloseable.class); verify(execution).own(resource.capture());
resource.getValue().close(); assertEquals(0, targetEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
var execution2 = execution(source, active); when(execution2.definition()).thenReturn(nova.definition());
nova.activate(execution2); targetEffects.put(PotionEffectType.SLOWNESS, new PotionEffect(PotionEffectType.SLOWNESS, 400, 3));
var resource2 = org.mockito.ArgumentCaptor.forClass(AutoCloseable.class); verify(execution2).own(resource2.capture());
resource2.getValue().close(); assertEquals(3, targetEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
}
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"frost-nova-base-damage: 7\nfrost-nova-cooldown-seconds: 2\nfrost-nova-radius-blocks: 8\n"
+ "frost-nova-slowness-amplifier: 2\nfrost-nova-slowness-duration-seconds: 5\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true); var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var definition = registry.find("frost_nova").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(new BigDecimal("7"), definition.parameters().get("base-damage")); assertEquals(new BigDecimal("8"), definition.parameters().get("radius"));
assertEquals(new BigDecimal("2"), definition.parameters().get("slowness-amplifier"));
assertEquals(0, new BigDecimal("5").compareTo(definition.parameters().get("slowness-duration-seconds")));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-nova-"}).isEmpty());
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "10"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("frost-nova-base-damage", "frost-nova-cooldown-seconds", "frost-nova-radius-blocks",
"frost-nova-slowness-amplifier", "frost-nova-slowness-duration-seconds"),
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-nova-"}));
assertEquals(List.of("5", "8"), fixture.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "frost-nova-radius-blocks", ""}));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks"});
assertTrue(sender.messages.getLast().contains("=8"));
for (String[] update : List.of(new String[]{"frost-nova-base-damage", "9"},
new String[]{"frost-nova-cooldown-seconds", "3"}, new String[]{"frost-nova-radius-blocks", "10"},
new String[]{"frost-nova-slowness-amplifier", "3"}, new String[]{"frost-nova-slowness-duration-seconds", "6"})) {
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]});
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
}
var configured = registry.find("frost_nova").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
assertEquals(new BigDecimal("9"), configured.parameters().get("base-damage"));
assertEquals(0, new BigDecimal("10").compareTo(configured.parameters().get("radius")));
assertEquals(new BigDecimal("3"), configured.parameters().get("slowness-amplifier"));
assertEquals(0, new BigDecimal("6").compareTo(configured.parameters().get("slowness-duration-seconds")));
var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, true);
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getLocation()).thenReturn(new Location(caster.world, 9, 64, 0)); doReturn(true).when(caster.player).hasLineOfSight(enemy);
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getEyeLocation();
doReturn(List.of(enemy)).when(caster.player).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
var enemyEffects = effects(enemy); var applied = new HashMap<UUID, Double>(); damage(fixture, enemy, applied);
use(fixture, caster); drain(fixture, 2); assertEquals(12, applied.get(enemy.getUniqueId()), 1.0e-9);
assertEquals(3, enemyEffects.get(PotionEffectType.SLOWNESS).getAmplifier()); assertEquals(120L, fixture.delayedTicks.getLast());
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "10", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
var saved = ((games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry"))
.find("frost_nova").orElseThrow(); assertEquals(new BigDecimal("9"), saved.parameters().get("base-damage"));
assertEquals(0, new BigDecimal("10").compareTo(saved.parameters().get("radius")));
assertEquals(new BigDecimal("3"), saved.parameters().get("slowness-amplifier"));
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
assertEquals(0, new BigDecimal("6").compareTo(saved.parameters().get("slowness-duration-seconds")));
}
for (String invalid : List.of("frost-nova-base-damage: -1\n", "frost-nova-cooldown-seconds: -1\n",
"frost-nova-radius-blocks: 0\n", "frost-nova-slowness-amplifier: -1\n", "frost-nova-slowness-duration-seconds: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
}
}
}
@Test void arbitraryPrecisionFormulaUsesFloorModifier() {
assertEquals(BigInteger.valueOf(8), FrostNova.damage(BigInteger.valueOf(16)));
assertEquals(BigInteger.valueOf(4), FrostNova.damage(BigInteger.valueOf(9)));
var huge = BigInteger.TEN.pow(500); assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(5)), FrostNova.damage(huge));
}
private static AbilityExecution execution(Player source, EquipmentSessions.Active active) {
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active);
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
var record = new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(record.stats()); stats.put(Stat.INT, BigInteger.valueOf(16));
when(execution.character()).thenReturn(new CharacterRecord(active.character(), record.name(), record.revision(), stats,
record.level(), record.experience(), record.unspentPoints(), record.health(), Set.of("frost_nova"),
List.of("frost_nova", "", "", "", "", "", ""), record.cooldowns(), record.restrictedKills(), false, null, null));
return execution;
}
private static Map<org.bukkit.potion.PotionEffectType, PotionEffect> effects(LivingEntity target) {
var effects = new HashMap<org.bukkit.potion.PotionEffectType, PotionEffect>();
doAnswer(call -> effects.get(call.getArgument(0))).when(target).getPotionEffect(any());
doAnswer(call -> { PotionEffect effect = call.getArgument(0); effects.put(effect.getType(), effect); return true; })
.when(target).addPotionEffect(any());
doAnswer(call -> { effects.remove(call.getArgument(0)); return null; }).when(target).removePotionEffect(any()); return effects;
}
private static void damage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
doAnswer(call -> { double amount = call.getArgument(0); org.bukkit.damage.DamageSource source = call.getArgument(1);
var event = new EntityDamageByEntityEvent(source.getCausingEntity(), target, EntityDamageEvent.DamageCause.FREEZE, source, amount);
fixture.dispatcher.accept(event); if (!event.isCancelled()) damage.merge(target.getUniqueId(), event.getFinalDamage(), Double::sum); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
}
private static void locate(FantasyStickLifecycleNativeTest.Actor caster, Player target, double distance, boolean visible) {
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getEyeLocation();
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
doReturn(new Location(caster.world, distance, 64, 0)).when(target).getLocation(); doReturn(visible).when(caster.player).hasLineOfSight(target);
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
}
private static void party(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor source, FantasyStickLifecycleNativeTest.Actor target) {
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(source.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, parties.invite(source.core.id, group.id(), target.core.id).orElseThrow().id()));
}
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
}
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(book);
for (var page : book.pages()) { var found = button(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + ability);
}
private static ClickEvent.Custom button(net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) { var found = button(child, label, ability); if (found != null) return found; } return null;
}
private static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.INT, BigInteger.valueOf(16)); stats.put(Stat.DEX, BigInteger.TEN);
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
learned ? Set.of("frost_nova") : old.learned(), learned ? List.of("frost_nova", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,85 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.ability.AbilityExecution;
import games.dmg.fantasystick.ability.AbilityReport;
import games.dmg.fantasystick.ability.OutgoingDamageBonus;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class OutgoingDamageBonusNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void strongestDeterministicGrantAppliesOnceAndReportsOnlyAcceptedAllyUse() throws Exception {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
var recipient = mock(Player.class); var enemy = mock(LivingEntity.class);
var recipientId = UUID.randomUUID(); when(recipient.getUniqueId()).thenReturn(recipientId);
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
var aided = new EquipmentSessions.Active(recipientId, UUID.randomUUID(), UUID.randomUUID());
when(sessions.active(recipientId)).thenReturn(java.util.Optional.of(aided)); when(sessions.current(aided)).thenReturn(true);
var sourcePlayer = mock(Player.class); when(sourcePlayer.getUniqueId()).thenReturn(UUID.randomUUID());
var party = new PartyRoster.Party(UUID.randomUUID(), sourcePlayer.getUniqueId(), List.of(sourcePlayer.getUniqueId(), recipientId));
when(parties.partyOf(sourcePlayer.getUniqueId())).thenReturn(java.util.Optional.of(party));
when(parties.partyOf(recipientId)).thenReturn(java.util.Optional.of(party));
var supports = new ArrayList<OutgoingDamageBonus.Support>();
var bonuses = new OutgoingDamageBonus(sessions, parties, supports::add);
var first = execution(sourcePlayer, UUID.randomUUID()); var strongest = execution(sourcePlayer, UUID.randomUUID());
var tiedLater = execution(sourcePlayer, UUID.randomUUID());
var firstGrant = bonuses.grant(first, recipient, new BigDecimal("20"), () -> true);
var strongestGrant = bonuses.grant(strongest, recipient, new BigDecimal("25"), () -> true);
var tiedGrant = bonuses.grant(tiedLater, recipient, new BigDecimal("25"), () -> true);
var hit = hit(recipient, enemy, 10); bonuses.increase(hit); assertEquals(12.5, hit.getDamage(), 1.0e-9);
bonuses.accepted(hit);
assertEquals(1, supports.size()); assertEquals(0, new BigDecimal("2.50").compareTo(supports.getFirst().amount()));
assertEquals(strongest.input().session().character(), supports.getFirst().supportingCharacter());
verify(strongest).report(eq(AbilityReport.Type.BOOST_USED), eq(enemy),
argThat(amount -> amount.compareTo(new BigDecimal("2.50")) == 0));
verify(first, never()).report(any(), any(), any()); verify(tiedLater, never()).report(any(), any(), any());
var cancelled = hit(recipient, enemy, 10); bonuses.increase(cancelled); cancelled.setCancelled(true); bonuses.accepted(cancelled);
assertEquals(1, supports.size());
firstGrant.close(); strongestGrant.close(); tiedGrant.close(); bonuses.close();
}
@Test void invalidOrSelfUseAndMarkedGuardianRedirectionCreateNoSupportOrRepeatedBonus() throws Exception {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
var player = mock(Player.class); var enemy = mock(LivingEntity.class); var id = UUID.randomUUID();
when(player.getUniqueId()).thenReturn(id); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
when(sessions.active(id)).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
var supports = new ArrayList<OutgoingDamageBonus.Support>(); var bonuses = new OutgoingDamageBonus(sessions, parties, supports::add);
var valid = new java.util.concurrent.atomic.AtomicBoolean(true);
var self = execution(player, active.character()); bonuses.grant(self, player, new BigDecimal("20"), valid::get);
var hit = hit(player, enemy, 10); bonuses.increase(hit); bonuses.accepted(hit);
assertEquals(12, hit.getDamage(), 1.0e-9); assertTrue(supports.isEmpty());
when(sessions.current(active)).thenReturn(false); valid.set(false);
var stale = hit(player, enemy, 10); bonuses.increase(stale); assertEquals(10, stale.getDamage(), 1.0e-9);
bonuses.close();
}
private static AbilityExecution execution(Player source, UUID character) {
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), character);
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active);
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true); return execution;
}
private static EntityDamageByEntityEvent hit(Player attacker, LivingEntity target, double amount) {
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
.withCausingEntity(attacker).withDirectEntity(attacker).build();
return new EntityDamageByEntityEvent(attacker, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount);
}
}
@@ -0,0 +1,287 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.ability.AbilityExecution;
import games.dmg.fantasystick.ability.OutgoingDamageBonus;
import games.dmg.fantasystick.ability.RallyingCry;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class RallyingCryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseBuffsCasterAndNearbyPartyAllyAndRewardsActualUse() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data);
var ally = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, false);
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); nearby(caster, List.of(ally.player));
var casterEffects = effects(caster.player); var allyEffects = effects(ally.player);
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
assertTrue(text.contains("RallyingCry")); assertTrue(text.contains("CHR16")); assertTrue(text.contains("CON12"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("20%")); assertTrue(text.contains("6seconds"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "rallying_cry")); drain(fixture, 2);
use(fixture, caster); drain(fixture, 2);
assertEquals(0, casterEffects.get(PotionEffectType.SPEED).getAmplifier());
assertEquals(0, allyEffects.get(PotionEffectType.SPEED).getAmplifier());
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry"));
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(enemy.getLocation()).thenReturn(new Location(caster.world, 4, 64, 0)); when(caster.world.getUID()).thenReturn(UUID.randomUUID());
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
var hit = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.dispatcher.accept(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
fixture.delayed.getLast().run();
assertFalse(casterEffects.containsKey(PotionEffectType.SPEED)); assertFalse(allyEffects.containsKey(PotionEffectType.SPEED));
var expired = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.dispatcher.accept(expired); assertEquals(10, expired.getDamage(), 1.0e-9);
var deadline = storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry");
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
var unlearn = button(caster, "[Unlearn]", "rallying_cry");
PartyBookNativeTest.click(fixture, caster, unlearn); PartyBookNativeTest.click(fixture, caster, unlearn); drain(fixture, 2);
assertEquals(deadline, storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "rallying_cry")); drain(fixture, 2);
use(fixture, caster); assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void dynamicSphereMembershipSessionAndSourceValidityRecheckOwnedBenefits() throws Exception {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
var bonus = mock(OutgoingDamageBonus.class); var sourceGrant = mock(AutoCloseable.class); var allyGrant = mock(AutoCloseable.class);
when(bonus.grant(any(), any(), any(), any())).thenReturn(sourceGrant, allyGrant);
var source = mock(Player.class); var ally = mock(Player.class); var outsider = mock(Player.class);
var world = mock(org.bukkit.World.class); var sourceId = UUID.randomUUID(); var allyId = UUID.randomUUID(); var outsiderId = UUID.randomUUID();
when(source.getUniqueId()).thenReturn(sourceId); when(ally.getUniqueId()).thenReturn(allyId); when(outsider.getUniqueId()).thenReturn(outsiderId);
when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(ally.getLocation()).thenReturn(new Location(world, 7, 64, 0));
when(outsider.getLocation()).thenReturn(new Location(world, 2, 64, 0));
when(source.getNearbyEntities(8, 8, 8)).thenReturn(List.of(ally, outsider));
var sourceEffects = effects(source); var allyEffects = effects(ally); effects(outsider);
var sourceActive = new EquipmentSessions.Active(sourceId, UUID.randomUUID(), UUID.randomUUID());
var allyActive = new EquipmentSessions.Active(allyId, UUID.randomUUID(), UUID.randomUUID());
var outsiderActive = new EquipmentSessions.Active(outsiderId, UUID.randomUUID(), UUID.randomUUID());
when(sessions.active(sourceId)).thenReturn(java.util.Optional.of(sourceActive)); when(sessions.current(sourceActive)).thenReturn(true);
when(sessions.active(allyId)).thenReturn(java.util.Optional.of(allyActive)); when(sessions.current(allyActive)).thenReturn(true);
when(sessions.active(outsiderId)).thenReturn(java.util.Optional.of(outsiderActive)); when(sessions.current(outsiderActive)).thenReturn(true);
var party = new PartyRoster.Party(UUID.randomUUID(), sourceId, List.of(sourceId, allyId));
when(parties.partyOf(sourceId)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party));
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
when(input.player()).thenReturn(source); when(input.session()).thenReturn(sourceActive);
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
var rally = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties, bonus); when(execution.definition()).thenReturn(rally.definition());
rally.activate(execution);
assertTrue(sourceEffects.containsKey(PotionEffectType.SPEED)); assertTrue(allyEffects.containsKey(PotionEffectType.SPEED));
verify(bonus, times(2)).grant(eq(execution), any(), eq(new BigDecimal("20")), any());
when(ally.getLocation()).thenReturn(new Location(world, 9, 64, 0)); rally.sweep();
assertFalse(allyEffects.containsKey(PotionEffectType.SPEED)); verify(allyGrant).close();
when(ally.getLocation()).thenReturn(new Location(world, 7, 64, 0)); rally.sweep();
assertTrue(allyEffects.containsKey(PotionEffectType.SPEED), "A valid party ally re-entering the active cry is rechecked");
when(parties.partyOf(allyId)).thenReturn(java.util.Optional.empty()); rally.sweep();
assertFalse(allyEffects.containsKey(PotionEffectType.SPEED));
when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party)); rally.sweep();
assertTrue(allyEffects.containsKey(PotionEffectType.SPEED));
when(sessions.current(allyActive)).thenReturn(false); rally.sweep();
assertFalse(allyEffects.containsKey(PotionEffectType.SPEED));
when(sessions.current(sourceActive)).thenReturn(false); rally.sweep(); verify(execution).finish(); verify(sourceGrant).close();
}
@Test void duplicateRalliesDoNotStackSpeedOrDamageAndPreserveStrongerUnrelatedSpeed() throws Exception {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
var supports = new ArrayList<OutgoingDamageBonus.Support>(); var bonus = new OutgoingDamageBonus(sessions, parties, supports::add);
var sourceA = mock(Player.class); var sourceB = mock(Player.class); var ally = mock(Player.class); var enemy = mock(LivingEntity.class);
var world = mock(org.bukkit.World.class); UUID a = UUID.randomUUID(), b = UUID.randomUUID(), c = UUID.randomUUID();
when(sourceA.getUniqueId()).thenReturn(a); when(sourceB.getUniqueId()).thenReturn(b); when(ally.getUniqueId()).thenReturn(c);
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
for (var player : List.of(sourceA, sourceB, ally)) when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0));
when(sourceA.getNearbyEntities(8, 8, 8)).thenReturn(List.of(sourceB, ally));
when(sourceB.getNearbyEntities(8, 8, 8)).thenReturn(List.of(sourceA, ally));
effects(sourceA); effects(sourceB); var allyEffects = effects(ally);
allyEffects.put(PotionEffectType.SPEED, new PotionEffect(PotionEffectType.SPEED, 200, 1));
var aa = new EquipmentSessions.Active(a, UUID.randomUUID(), UUID.randomUUID());
var ba = new EquipmentSessions.Active(b, UUID.randomUUID(), UUID.randomUUID());
var ca = new EquipmentSessions.Active(c, UUID.randomUUID(), UUID.randomUUID());
when(sessions.active(a)).thenReturn(java.util.Optional.of(aa)); when(sessions.active(b)).thenReturn(java.util.Optional.of(ba));
when(sessions.active(c)).thenReturn(java.util.Optional.of(ca)); when(sessions.current(any())).thenReturn(true);
var party = new PartyRoster.Party(UUID.randomUUID(), a, List.of(a, b, c));
when(parties.partyOf(a)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(b)).thenReturn(java.util.Optional.of(party));
when(parties.partyOf(c)).thenReturn(java.util.Optional.of(party));
var rally = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties, bonus);
var first = execution(sourceA, aa, rally); var second = execution(sourceB, ba, rally); rally.activate(first); rally.activate(second);
var hit = hit(ally, enemy, 10); bonus.increase(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
assertEquals(1, allyEffects.get(PotionEffectType.SPEED).getAmplifier(), "Stronger unrelated Speed is untouched");
rally.close(); bonus.close(); assertEquals(1, allyEffects.get(PotionEffectType.SPEED).getAmplifier());
}
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"rallying-cry-cooldown-seconds: 2\nrallying-cry-radius-blocks: 10\nrallying-cry-duration-seconds: 8\n"
+ "rallying-cry-speed-amplifier: 1\nrallying-cry-damage-bonus-percent: 30\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true); var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var definition = registry.find("rallying_cry").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(0, new BigDecimal("10").compareTo(definition.parameters().get("radius")));
assertEquals(0, new BigDecimal("8").compareTo(definition.parameters().get("duration-seconds")));
assertEquals(new BigDecimal("1"), definition.parameters().get("speed-amplifier"));
assertEquals(new BigDecimal("30"), definition.parameters().get("damage-bonus-percent"));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "rallying-cry-"}).isEmpty());
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks", "12"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("rallying-cry-cooldown-seconds", "rallying-cry-damage-bonus-percent",
"rallying-cry-duration-seconds", "rallying-cry-radius-blocks", "rallying-cry-speed-amplifier"),
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "rallying-cry-"}));
assertEquals(List.of("10", "8"), fixture.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "rallying-cry-radius-blocks", ""}));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks"});
assertTrue(sender.messages.getLast().contains("=10"));
for (String[] update : List.of(new String[]{"rallying-cry-cooldown-seconds", "3"},
new String[]{"rallying-cry-radius-blocks", "12"}, new String[]{"rallying-cry-duration-seconds", "9"},
new String[]{"rallying-cry-speed-amplifier", "2"}, new String[]{"rallying-cry-damage-bonus-percent", "40"})) {
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]});
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
}
var configured = registry.find("rallying_cry").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
assertEquals(0, new BigDecimal("12").compareTo(configured.parameters().get("radius")));
assertEquals(0, new BigDecimal("9").compareTo(configured.parameters().get("duration-seconds")));
assertEquals(new BigDecimal("2"), configured.parameters().get("speed-amplifier"));
assertEquals(new BigDecimal("40"), configured.parameters().get("damage-bonus-percent"));
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepareCaster(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); nearby(caster, List.of(ally.player));
var casterEffects = effects(caster.player); var allyEffects = effects(ally.player); use(fixture, caster); drain(fixture, 2);
assertEquals(2, casterEffects.get(PotionEffectType.SPEED).getAmplifier());
assertEquals(2, allyEffects.get(PotionEffectType.SPEED).getAmplifier()); assertEquals(180L, fixture.delayedTicks.getLast());
var futureTarget = mock(LivingEntity.class); when(futureTarget.getUniqueId()).thenReturn(UUID.randomUUID());
var future = hit(ally.player, futureTarget, 10); fixture.dispatcher.accept(future);
assertEquals(14, future.getDamage(), 1.0e-9);
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-duration-seconds", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks", "12", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var saved = reloaded.find("rallying_cry").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
assertEquals(new BigDecimal("12"), saved.parameters().get("radius")); assertEquals(new BigDecimal("40"), saved.parameters().get("damage-bonus-percent"));
assertEquals(0, new BigDecimal("9").compareTo(saved.parameters().get("duration-seconds")));
assertEquals(new BigDecimal("2"), saved.parameters().get("speed-amplifier"));
}
for (String invalid : List.of("rallying-cry-cooldown-seconds: -1\n", "rallying-cry-radius-blocks: 0\n",
"rallying-cry-duration-seconds: 0\n", "rallying-cry-speed-amplifier: -1\n",
"rallying-cry-damage-bonus-percent: 101\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
}
}
}
private static AbilityExecution execution(Player player, EquipmentSessions.Active active, RallyingCry rally) {
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
when(input.player()).thenReturn(player); when(input.session()).thenReturn(active);
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true); when(execution.definition()).thenReturn(rally.definition());
return execution;
}
private static Map<org.bukkit.potion.PotionEffectType, PotionEffect> effects(Player player) {
var effects = new HashMap<org.bukkit.potion.PotionEffectType, PotionEffect>();
doAnswer(call -> effects.get(call.getArgument(0))).when(player).getPotionEffect(any());
doAnswer(call -> { PotionEffect effect = call.getArgument(0); effects.put(effect.getType(), effect); return true; })
.when(player).addPotionEffect(any(), eq(true));
doAnswer(call -> { effects.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return effects;
}
private static void nearby(FantasyStickLifecycleNativeTest.Actor source, List<? extends org.bukkit.entity.Entity> entities) {
doReturn(new Location(source.world, 0, 64, 0)).when(source.player).getLocation();
doReturn(new ArrayList<>(entities)).when(source.player).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
for (var entity : entities) if (entity instanceof Player player) doReturn(new Location(source.world, 4, 64, 0)).when(player).getLocation();
}
private static EntityDamageByEntityEvent hit(Player attacker, LivingEntity target, double amount) {
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
.withCausingEntity(attacker).withDirectEntity(attacker).build();
return new EntityDamageByEntityEvent(attacker, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount);
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
}
private static PartyRoster.Party party(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor source,
FantasyStickLifecycleNativeTest.Actor target) {
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(source.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, parties.invite(source.core.id, group.id(), target.core.id).orElseThrow().id())); return group;
}
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
}
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(book);
for (var page : book.pages()) { var found = button(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + ability);
}
private static ClickEvent.Custom button(net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) { var found = button(child, label, ability); if (found != null) return found; } return null;
}
private static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.CHR, BigInteger.valueOf(16)); stats.put(Stat.CON, BigInteger.valueOf(12));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
learned ? Set.of("rallying_cry") : old.learned(), learned ? List.of("rallying_cry", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,82 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChainLightningSettingsTest {
@TempDir Path data;
@Test void defaultsAndParsingRetainExactDecay() {
var settings = ChainLightningSettings.parse(defaults());
assertEquals(java.math.BigInteger.valueOf(8), settings.baseDamage());
assertEquals(java.time.Duration.ofSeconds(18), settings.cooldown());
assertEquals(16, settings.initialRange(), 0); assertEquals(6, settings.jumpRange(), 0);
assertEquals(3, settings.additionalTargets());
assertEquals(new java.math.BigDecimal("0.75"), settings.jumpMultiplier());
}
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new ChainLightningSettings(data, defaults())) {
var range = settings.set(ChainLightningSettings.JUMP_RANGE, "7").toCompletableFuture();
var cooldown = settings.set(ChainLightningSettings.COOLDOWN, "24").toCompletableFuture();
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
assertEquals(6, settings.settings().jumpRange(), 0);
settings.publish(savedCooldown); settings.publish(savedRange);
assertEquals(7, settings.settings().jumpRange(), 0);
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", yaml.get("unrelated"));
assertEquals("7", String.valueOf(yaml.get(ChainLightningSettings.JUMP_RANGE)));
}
}
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
try (var settings = new ChainLightningSettings(data, defaults())) {
for (var entry : Map.of(
ChainLightningSettings.COOLDOWN, "-1", ChainLightningSettings.INITIAL_RANGE, "0",
ChainLightningSettings.JUMP_RANGE, "0", ChainLightningSettings.ADDITIONAL, "-1",
ChainLightningSettings.BASE, "-1", ChainLightningSettings.MULTIPLIER, "0").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(ChainLightningSettings.COOLDOWN, "0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new ChainLightningSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(ChainLightningSettings.JUMP_RANGE, "7").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("6", settings.value(ChainLightningSettings.JUMP_RANGE));
}
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var unsafe = data.resolve("unsafe"); Files.createDirectories(unsafe);
var outside = data.resolve("outside.yml"); Files.writeString(outside, "safe\n");
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
try (var settings = new ChainLightningSettings(unsafe, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(ChainLightningSettings.MULTIPLIER, "0.5").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(outside));
}
}
var closed = new ChainLightningSettings(data.resolve("closed"), defaults()); closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,
() -> closed.set(ChainLightningSettings.BASE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var values = new LinkedHashMap<String, String>();
for (String key : ChainLightningSettings.keys()) values.put(key, ChainLightningSettings.defaultValue(key));
return values;
}
}
@@ -0,0 +1,75 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FrostNovaSettingsTest {
@TempDir Path data;
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new FrostNovaSettings(data, defaults())) {
var range = settings.set(FrostNovaSettings.RADIUS, "7").toCompletableFuture();
var cooldown = settings.set(FrostNovaSettings.COOLDOWN, "24").toCompletableFuture();
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
assertEquals(5, settings.settings().radius(), 0);
settings.publish(savedCooldown); settings.publish(savedRange);
assertEquals(7, settings.settings().radius(), 0);
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", yaml.get("unrelated"));
assertEquals("7", String.valueOf(yaml.get(FrostNovaSettings.RADIUS)));
}
}
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
try (var settings = new FrostNovaSettings(data, defaults())) {
for (var entry : Map.of(
FrostNovaSettings.COOLDOWN, "-1",
FrostNovaSettings.RADIUS, "0",
FrostNovaSettings.DURATION, "0",
FrostNovaSettings.BASE, "-1",
FrostNovaSettings.SLOWNESS, "-1").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(FrostNovaSettings.COOLDOWN, "0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new FrostNovaSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(FrostNovaSettings.RADIUS, "7").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("5", settings.value(FrostNovaSettings.RADIUS));
}
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var unsafe = data.resolve("unsafe"); Files.createDirectories(unsafe);
var outside = data.resolve("outside.yml"); Files.writeString(outside, "safe\n");
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
try (var settings = new FrostNovaSettings(unsafe, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(FrostNovaSettings.DURATION, "8").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(outside));
}
}
var closed = new FrostNovaSettings(data.resolve("closed"), defaults()); closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,
() -> closed.set(FrostNovaSettings.BASE, "8").toCompletableFuture().get(5, TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var values = new LinkedHashMap<String, String>();
for (String key : FrostNovaSettings.keys()) values.put(key, FrostNovaSettings.defaultValue(key));
return values;
}
}
@@ -0,0 +1,75 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class RallyingCrySettingsTest {
@TempDir Path data;
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new RallyingCrySettings(data, defaults())) {
var range = settings.set(RallyingCrySettings.RADIUS, "12").toCompletableFuture();
var cooldown = settings.set(RallyingCrySettings.COOLDOWN, "24").toCompletableFuture();
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
assertEquals(8, settings.settings().radius(), 0);
settings.publish(savedCooldown); settings.publish(savedRange);
assertEquals(12, settings.settings().radius(), 0);
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", yaml.get("unrelated"));
assertEquals("12", String.valueOf(yaml.get(RallyingCrySettings.RADIUS)));
}
}
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
try (var settings = new RallyingCrySettings(data, defaults())) {
for (var entry : Map.of(
RallyingCrySettings.COOLDOWN, "-1",
RallyingCrySettings.RADIUS, "0",
RallyingCrySettings.DURATION, "0",
RallyingCrySettings.SPEED, "-1",
RallyingCrySettings.BONUS, "101").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(RallyingCrySettings.COOLDOWN, "0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new RallyingCrySettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(RallyingCrySettings.RADIUS, "12").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("8", settings.value(RallyingCrySettings.RADIUS));
}
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var unsafe = data.resolve("unsafe"); Files.createDirectories(unsafe);
var outside = data.resolve("outside.yml"); Files.writeString(outside, "safe\n");
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
try (var settings = new RallyingCrySettings(unsafe, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(RallyingCrySettings.DURATION, "8").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(outside));
}
}
var closed = new RallyingCrySettings(data.resolve("closed"), defaults()); closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,
() -> closed.set(RallyingCrySettings.BONUS, "30").toCompletableFuture().get(5, TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var values = new LinkedHashMap<String, String>();
for (String key : RallyingCrySettings.keys()) values.put(key, RallyingCrySettings.defaultValue(key));
return values;
}
}