Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d890fb8915 |
No files matched your search
@@ -79,6 +79,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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;
|
||||
@@ -155,6 +157,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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();
|
||||
@@ -266,6 +270,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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,
|
||||
@@ -285,6 +292,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
@@ -332,6 +340,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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,
|
||||
@@ -361,6 +370,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
@@ -446,6 +456,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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;
|
||||
@@ -487,6 +499,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
@@ -523,6 +537,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: 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)
|
||||
@@ -773,6 +789,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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,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); }
|
||||
}
|
||||
@@ -49,6 +49,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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;
|
||||
@@ -257,6 +259,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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);}
|
||||
@@ -475,6 +483,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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;
|
||||
}
|
||||
@@ -710,6 +721,33 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -869,6 +907,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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")) {
|
||||
@@ -941,6 +980,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -123,3 +123,12 @@ 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", "frost_nova", "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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user