Compare commits

...
3 Commits
Author SHA1 Message Date
dmg 18b174609d feat(ability): add meteor
Release / release (push) Successful in 8m32s
2026-09-20 19:52:20 -04:00
dmg dfa02bc335 feat(ability): add war banner
Release / release (push) Successful in 8m31s
2026-09-20 19:37:01 -04:00
dmg 4c2340d02b feat(ability): add sanctuary
Release / release (push) Successful in 7m53s
2026-09-20 19:17:06 -04:00
17 changed files with 1812 additions and 9 deletions

No files matched your search

@@ -81,6 +81,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
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.ability.Sanctuary sanctuary;
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
private games.dmg.fantasystick.ability.WarBanner warBanner;
private games.dmg.fantasystick.persistence.WarBannerSettings warBannerSettings;
private games.dmg.fantasystick.ability.Meteor meteor;
private games.dmg.fantasystick.persistence.MeteorSettings meteorSettings;
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
@@ -159,6 +165,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var frostNovaInitial = games.dmg.fantasystick.persistence.FrostNovaSettings.parse(frostNovaConfiguration);
var chainLightningConfiguration = chainLightningConfiguration();
var chainLightningInitial = games.dmg.fantasystick.persistence.ChainLightningSettings.parse(chainLightningConfiguration);
var sanctuaryConfiguration = sanctuaryConfiguration();
var sanctuaryInitial = games.dmg.fantasystick.persistence.SanctuarySettings.parse(sanctuaryConfiguration);
var warBannerConfiguration = warBannerConfiguration();
var warBannerInitial = games.dmg.fantasystick.persistence.WarBannerSettings.parse(warBannerConfiguration);
var meteorConfiguration = meteorConfiguration();
var meteorInitial = games.dmg.fantasystick.persistence.MeteorSettings.parse(meteorConfiguration);
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
var factory = new CharacterFactory();
@@ -273,6 +285,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
chainLightningSettings = new games.dmg.fantasystick.persistence.ChainLightningSettings(
getDataFolder().toPath(), chainLightningConfiguration);
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(chainLightningInitial, sessions, parties);
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
getDataFolder().toPath(), sanctuaryConfiguration);
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
warBannerSettings = new games.dmg.fantasystick.persistence.WarBannerSettings(
getDataFolder().toPath(), warBannerConfiguration);
warBanner = new games.dmg.fantasystick.ability.WarBanner(warBannerInitial, sessions, parties, outgoingDamageBonus);
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, 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,
@@ -293,6 +316,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityRegistry.register(rallyingCry.definition());
abilityRegistry.register(frostNova.definition());
abilityRegistry.register(chainLightning.definition());
abilityRegistry.register(sanctuary.definition());
abilityRegistry.register(warBanner.definition());
abilityRegistry.register(meteor.definition());
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
@@ -341,6 +367,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachRallyingCry(rallyingCrySettings, rallyingCry);
admin.attachFrostNova(frostNovaSettings, frostNova);
admin.attachChainLightning(chainLightningSettings, chainLightning);
admin.attachSanctuary(sanctuarySettings, sanctuary);
admin.attachWarBanner(warBannerSettings, warBanner);
admin.attachMeteor(meteorSettings, meteor);
admin.attachAdminRevival(adminRevival, adminReviveSettings);
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
characters, rules, sessions, nextTick, getServer()::getPlayer,
@@ -371,6 +400,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(frostShard, this);
getServer().getPluginManager().registerEvents(frostNova, this);
getServer().getPluginManager().registerEvents(chainLightning, this);
getServer().getPluginManager().registerEvents(meteor, this);
getServer().getPluginManager().registerEvents(longbow, this);
getServer().getPluginManager().registerEvents(quickstep, this);
getServer().getPluginManager().registerEvents(combatMitigation, this);
@@ -380,6 +410,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
getServer().getPluginManager().registerEvents(outgoingDamageBonus, this);
rallyingCry.install(this);
warBanner.install(this);
getServer().getPluginManager().registerEvents(temporaryHealth, this);
getServer().getPluginManager().registerEvents(greatSword, this);
getServer().getPluginManager().registerEvents(revive, this);
@@ -458,6 +489,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingFrostNovaSettings = frostNovaSettings; frostNovaSettings = null;
var closingChainLightning = chainLightning; chainLightning = null;
var closingChainLightningSettings = chainLightningSettings; chainLightningSettings = null;
var closingSanctuary = sanctuary; sanctuary = null;
var closingSanctuarySettings = sanctuarySettings; sanctuarySettings = null;
var closingWarBanner = warBanner; warBanner = null;
var closingWarBannerSettings = warBannerSettings; warBannerSettings = null;
var closingMeteor = meteor; meteor = null;
var closingMeteorSettings = meteorSettings; meteorSettings = null;
var closingOutgoingDamageBonus = outgoingDamageBonus; outgoingDamageBonus = null;
var closingAdminRevival = adminRevival; adminRevival = null;
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
@@ -501,6 +538,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
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 (closingSanctuary != null) cleanup("Sanctuary", closingSanctuary::close);
if (closingSanctuarySettings != null) cleanup("Sanctuary configuration", closingSanctuarySettings::close);
if (closingWarBanner != null) cleanup("War Banner", closingWarBanner::close);
if (closingWarBannerSettings != null) cleanup("War Banner configuration", closingWarBannerSettings::close);
if (closingMeteor != null) cleanup("Meteor", closingMeteor::close);
if (closingMeteorSettings != null) cleanup("Meteor configuration", closingMeteorSettings::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);
@@ -539,6 +582,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
: closingFrostNovaSettings.drained().toCompletableFuture(),
closingChainLightningSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingChainLightningSettings.drained().toCompletableFuture(),
closingSanctuarySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSanctuarySettings.drained().toCompletableFuture(),
closingWarBannerSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingWarBannerSettings.drained().toCompletableFuture(),
closingMeteorSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingMeteorSettings.drained().toCompletableFuture(),
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdminReviveSettings.drained().toCompletableFuture(),
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -799,6 +848,36 @@ public final class FantasyStickPlugin extends JavaPlugin {
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> sanctuaryConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.SanctuarySettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.SanctuarySettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.SanctuarySettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> warBannerConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.WarBannerSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.WarBannerSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.WarBannerSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> meteorConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.MeteorSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.MeteorSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.MeteorSettings.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"));
@@ -7,6 +7,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
@@ -17,9 +18,13 @@ import org.bukkit.event.entity.EntityDamageEvent;
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
public final class CombatMitigation implements Listener, AutoCloseable {
private final class Grant implements AutoCloseable {
final AbilityExecution execution; final Player player; final BigDecimal percent; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent) {
this.execution = execution; this.player = player; this.percent = percent;
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
this.execution = execution; this.player = player; 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;
@@ -33,10 +38,14 @@ public final class CombatMitigation implements Listener, AutoCloseable {
private boolean closed;
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent);
return grant(execution, player, percent, () -> true);
}
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid combat mitigation grant");
var grant = new Grant(execution, player, percent);
var grant = new Grant(execution, player, percent, current);
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
return grant;
}
@@ -48,7 +57,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
var owned = grants.get(player.getUniqueId());
if (owned == null) return;
for (var grant : List.copyOf(owned)) {
if (grant.closed || grant.player != player || !grant.execution.stillCurrent()) continue;
if (grant.player != player || !grant.valid()) continue;
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
}
double original = event.getDamage();
@@ -65,7 +74,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
public void accepted(EntityDamageEvent event) {
var prevention = pending.remove(event);
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|| !prevention.grant.execution.stillCurrent()) return;
|| !prevention.grant.valid()) return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
}
@@ -0,0 +1,79 @@
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.*;
import org.bukkit.*;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.*;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
/** Bounded solid-top Meteor warning followed by one direct, terrain-safe cylinder impact. */
public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double targetRange, Duration warning,
double radius, double verticalRange) {
public Settings {
Objects.requireNonNull(baseDamage);Objects.requireNonNull(cooldown);Objects.requireNonNull(warning);
long warningTicks;
try { warningTicks=Math.max(1,(warning.toNanos()+49_999_999L)/50_000_000L); }
catch(ArithmeticException invalid){throw new IllegalArgumentException("Invalid Meteor warning",invalid);}
if(baseDamage.signum()<0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0
||!Double.isFinite(targetRange)||targetRange<=0||targetRange>64
||warning.isZero()||warning.isNegative()||warningTicks>63
||!Double.isFinite(radius)||radius<=0||radius>64
||!Double.isFinite(verticalRange)||verticalRange<0||verticalRange>64)
throw new IllegalArgumentException("Invalid Meteor settings");
}
public static Settings defaults(){return new Settings(BigInteger.valueOf(14),Duration.ofSeconds(45),24,
Duration.ofSeconds(2),5,4);}
}
private record Ground(Block block,Location origin){ }
private static final class Attempt {final AbilityExecution execution;final LivingEntity target;boolean accepted;Attempt(AbilityExecution e,LivingEntity t){execution=e;target=t;}}
private final class Warning implements AutoCloseable {
final AbilityExecution execution;final Player source;final Ground ground;final Settings captured;final BigInteger intelligence;
final int maximumTicks;int elapsed;boolean impacted;boolean closed;
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{target.damage(amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);ground.origin().getWorld().spawnParticle(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();world.spawnParticle(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);world.spawnParticle(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
@Override public void close(){if(closed)return;closed=true;warnings.remove(execution,this);}
}
private volatile Settings settings;private final EquipmentSessions sessions;private final PartyRoster parties;
private final Map<AbilityExecution,Warning> warnings=new IdentityHashMap<>();private Attempt damaging;private boolean closed;
public Meteor(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("meteor","Meteor",Material.FIRE_CHARGE,
List.of("Mark visible ground within "+decimal(current.targetRange())+" blocks for a "+seconds(current.warning())+"-second warning.","Impact exposed enemies in a "+decimal(current.radius())+"-block area for "+current.baseDamage()+" + INT modifier damage."),
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("base-damage",new BigDecimal(current.baseDamage()),"target-range",BigDecimal.valueOf(current.targetRange()).stripTrailingZeros(),"warning-seconds",nanos(current.warning()),"radius",BigDecimal.valueOf(current.radius()).stripTrailingZeros(),"vertical-range",BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros()),AbilityDefinition.TargetPolicy.AREA,this);}
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),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 Meteor 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&&ground(input.player(),settings.targetRange())!=null;}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent())return;var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());if(target==null){execution.finish();return;}new Warning(execution,target,captured).start();}
private static Ground ground(Player player,double range){try{var eye=player.getEyeLocation();var hit=player.getWorld().rayTraceBlocks(eye,eye.getDirection(),range,FluidCollisionMode.NEVER,true);if(hit==null||hit.getHitBlock()==null||hit.getHitBlockFace()!=BlockFace.UP||!hit.getHitBlock().getType().isSolid())return null;var block=hit.getHitBlock();return new Ground(block,block.getLocation().add(.5,1,.5));}catch(RuntimeException invalid){return null;}}
private List<LivingEntity> candidates(Location origin,double radius,double vertical){List<Entity> nearby;try{nearby=new ArrayList<>(origin.getWorld().getNearbyEntities(origin,radius,vertical,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 living)unique.putIfAbsent(living.getUniqueId(),living);}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,double vertical){var source=execution.input().player();if(target==source||!inside(origin,target,radius,vertical)||!unobstructed(origin,target))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(source.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.isCancelled()||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC||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 inside(Location origin,LivingEntity target,double radius,double vertical){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return false;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz<=radius*radius&&Math.abs(at.getY()-origin.getY())<=vertical;}catch(RuntimeException invalid){return false;}}
private static boolean unobstructed(Location origin,LivingEntity target){try{var world=origin.getWorld();var at=target.getLocation();if(world==null||at.getWorld()!=world)return false;var bounds=target.getBoundingBox();var center=bounds==null?at.toVector().add(new org.bukkit.util.Vector(0,target.getHeight()/2,0)):bounds.getCenter();var offset=center.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,FluidCollisionMode.NEVER,true)==null;}catch(RuntimeException invalid){return false;}}
private static double distance(Location origin,LivingEntity target){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;}catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}}
private static double finite(BigInteger value){return value.bitLength()>1023?Double.MAX_VALUE:value.doubleValue();}
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(p.get("base-damage").toBigIntegerExact(),definition.cooldown(),p.get("target-range").doubleValue(),Duration.ofNanos(p.get("warning-seconds").movePointRight(9).longValueExact()),p.get("radius").doubleValue(),p.get("vertical-range").doubleValue());}
private static BigDecimal nanos(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9);}private static String seconds(Duration value){return decimal(nanos(value));}private static String decimal(double value){return decimal(BigDecimal.valueOf(value));}private static String decimal(BigDecimal value){return value.stripTrailingZeros().toPlainString();}
@Override public void close(){if(closed)return;closed=true;damaging=null;for(var warning:List.copyOf(warnings.values())){warning.close();warning.execution.finish();}warnings.clear();}
}
@@ -0,0 +1,248 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
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 java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Fixed bounded Sanctuary cylinder with delayed healing pulses and dynamic strongest mitigation. */
public final class Sanctuary implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, double radius, double verticalRange, Duration duration,
Duration pulseInterval, BigDecimal healingPerPulse, BigDecimal damageReductionPercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(pulseInterval);
Objects.requireNonNull(healingPerPulse); Objects.requireNonNull(damageReductionPercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|| pulseInterval.isZero() || pulseInterval.isNegative() || pulseInterval.compareTo(duration) > 0
|| duration.toNanos() / pulseInterval.toNanos() > 64
|| healingPerPulse.signum() < 0 || healingPerPulse.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|| damageReductionPercent.signum() < 0 || damageReductionPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new IllegalArgumentException("Invalid Sanctuary settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(45), 6, 3, Duration.ofSeconds(6), Duration.ofSeconds(1),
BigDecimal.valueOf(2), BigDecimal.valueOf(20));
}
}
private record Target(Player player, EquipmentSessions.Active active, CharacterRecord record) { }
private final class State implements AutoCloseable {
final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
int pending; boolean expired; boolean closed;
State(AbilityExecution execution, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
this.captured = captured; this.nextPulse = captured.pulseInterval();
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
this.scanPeriod = Duration.ofNanos(period);
}
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
void schedule() {
if (closed || expired) return;
Duration remaining = captured.duration().minus(elapsed);
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
}
void step() {
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
Duration remaining = captured.duration().minus(elapsed);
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
reconcile(); visual();
while (nextPulse.compareTo(elapsed) <= 0 && nextPulse.compareTo(captured.duration()) <= 0) {
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
}
if (elapsed.compareTo(captured.duration()) >= 0) {
expired = true; closeMitigation(); if (pending == 0) execution.finish();
} else schedule();
}
void reconcile() {
var eligible = recipients();
for (var entry : List.copyOf(mitigation.entrySet())) {
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
}
}
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
}
void pulse() {
for (var player : recipients()) if (owner(player) == this) heal(player);
}
void heal(Player player) {
var target = target(player); if (target == null) return;
var attribute = player.getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
double current = player.getHealth(), maximum = attribute.getValue(), missing = maximum - current;
double proposed = Math.min(missing, captured.healingPerPulse().doubleValue());
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(proposed) || proposed <= 0) return;
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event); double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
pending++; var expected = target.record().revision();
characters.saveHealth(target.record().id(), expected, BigDecimal.valueOf(current + accepted), nativeLimit.get())
.whenComplete((saved, failure) -> {
try { nextTick.accept(() -> completeHeal(this, target, expected, accepted, saved, failure)); }
catch (RuntimeException rejected) { completePending(this); }
});
}
List<Player> recipients() {
List<Entity> nearby;
try { nearby = new ArrayList<>(source.getWorld().getNearbyEntities(origin,
captured.radius(), captured.verticalRange(), captured.radius())); }
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256) break;
if (entity instanceof Player player && recipientCurrent(player)) unique.putIfAbsent(player.getUniqueId(), player);
}
var result = new ArrayList<>(unique.values());
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
return result.size() <= 64 ? result : new ArrayList<>(result.subList(0, 64));
}
boolean recipientCurrent(Player player) {
if (closed || !execution.stillCurrent() || !inside(origin, player, captured.radius(), captured.verticalRange())
|| !unobstructed(origin, player)) return false;
if (player == source) return sessions.active(player.getUniqueId()).filter(sessions::current)
.map(active -> active.equals(execution.input().session())).orElse(false);
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
return active != null && sameParty(source, player);
}
Target target(Player player) {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return record == null ? null : new Target(player, active, record);
}
void visual() {
try { source.getWorld().spawnParticle(Particle.END_ROD, origin, 12,
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
}
void closeMitigation() {
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
mitigation.clear();
}
@Override public void close() {
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
}
}
private volatile Settings settings;
private final CharacterStorage characters; private final EquipmentSessions sessions; private final PartyRoster parties;
private final CombatMitigation combatMitigation; private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents; private final Supplier<BigDecimal> nativeLimit;
private final Consumer<MendingLight.Support> supports; private final List<State> states = new ArrayList<>();
private boolean closed;
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
Consumer<MendingLight.Support> supports) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.combatMitigation = Objects.requireNonNull(combatMitigation); this.nextTick = Objects.requireNonNull(nextTick);
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
this.supports = Objects.requireNonNull(supports);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("sanctuary", "Sanctuary", Material.SEA_LANTERN,
List.of("Create a fixed " + decimal(current.radius()) + "-block sanctuary for " + seconds(current.duration()) + " seconds.",
"Party members inside heal " + decimal(current.healingPerPulse()) + " each second and take "
+ decimal(current.damageReductionPercent()) + "% less combat damage."),
"Use to hold exposed ground; healing starts after one second and the area does not follow you.",
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
"duration-seconds", nanos(current.duration()), "pulse-interval-seconds", nanos(current.pulseInterval()),
"healing-per-pulse", current.healingPerPulse(), "damage-reduction-percent", current.damageReductionPercent()),
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; new State(execution, from(execution.definition())).start();
}
private void completeHeal(State state, Target target, BigInteger expected, double accepted,
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (failure != null || saved == null || saved.isEmpty() || !state.execution.stillCurrent()
|| !state.recipientCurrent(target.player())) return;
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
double current = target.player().getHealth(), maximum = attribute.getValue();
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
target.player().setHealth(Math.min(maximum, current + restored));
if (target.player() != state.source) supports.accept(new MendingLight.Support(
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
} finally { completePending(state); }
}
private static void completePending(State state) {
if (state.pending > 0) state.pending--; if (state.expired && state.pending == 0) state.execution.finish();
}
private State owner(Player player) {
for (var state : List.copyOf(states)) if (!state.expired
&& state.mitigation.containsKey(player.getUniqueId())) return state;
return null;
}
private boolean sameParty(Player source, Player target) {
var party = parties.partyOf(source.getUniqueId()).orElse(null);
return party != null && party.members().contains(target.getUniqueId());
}
private static boolean inside(Location origin, Player player, double radius, double vertical) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ();
return dx * dx + dz * dz <= radius * radius && Math.abs(at.getY() - origin.getY()) <= vertical;
} catch (RuntimeException invalid) { return false; }
}
private static boolean unobstructed(Location origin, Player player) {
try { var world = origin.getWorld(); if (world == null) return false; var box = player.getBoundingBox();
var center = box.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;
} catch (RuntimeException invalid) { return false; }
}
private static double distance(Location origin, Player player) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
} catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
}
private static Settings from(AbilityDefinition definition) { var p = definition.parameters();
return new Settings(definition.cooldown(), p.get("radius").doubleValue(), p.get("vertical-range").doubleValue(),
duration(p.get("duration-seconds")), duration(p.get("pulse-interval-seconds")),
p.get("healing-per-pulse"), p.get("damage-reduction-percent")); }
private static Duration duration(BigDecimal seconds) { return Duration.ofNanos(seconds.movePointRight(9).longValueExact()); }
private static BigDecimal nanos(Duration value) { return BigDecimal.valueOf(value.toNanos()).movePointLeft(9); }
private static String seconds(Duration value) { return decimal(nanos(value)); }
private static String decimal(double value) { return decimal(BigDecimal.valueOf(value)); }
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
@Override public void close() {
if (closed) return; closed = true; for (var state : List.copyOf(states)) { state.close(); state.execution.finish(); } states.clear();
}
}
@@ -0,0 +1,241 @@
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 java.util.function.BooleanSupplier;
import org.bukkit.Color;
import org.bukkit.FluidCollisionMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Particle;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.attribute.AttributeModifier;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
/** Fixed spectral War Banner cylinder with strongest damage and one owned knockback-resistance layer. */
public final class WarBanner implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, double placementRange, double radius, double verticalRange,
Duration duration, BigDecimal damageBonusPercent, BigDecimal knockbackResistancePercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration);
Objects.requireNonNull(damageBonusPercent); Objects.requireNonNull(knockbackResistancePercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(placementRange) || placementRange <= 0 || placementRange > 64
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|| damageBonusPercent.signum() < 0 || damageBonusPercent.compareTo(BigDecimal.valueOf(100)) > 0
|| knockbackResistancePercent.signum() < 0
|| knockbackResistancePercent.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new IllegalArgumentException("Invalid War Banner settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(45), 12, 8, 4, Duration.ofSeconds(10),
BigDecimal.valueOf(25), BigDecimal.valueOf(50));
}
}
private final class Area implements AutoCloseable {
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
boolean closed;
Area(AbilityExecution execution, Location origin, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
}
void refresh() {
if (closed) return;
if (!sourceCurrent()) { execution.finish(); return; }
var eligible = candidates();
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
visual();
}
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() || !inside(origin, player, captured.radius(), captured.verticalRange())
|| !unobstructed(origin, player)) return false;
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
if (active == null) return false;
if (player == source) return active.equals(execution.input().session());
var sourceParty = parties.partyOf(source.getUniqueId()).orElse(null);
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
return sourceParty != null && targetParty != null && sourceParty.id().equals(targetParty.id());
}
Map<UUID, Player> candidates() {
List<Entity> nearby;
try { nearby = new ArrayList<>(origin.getWorld().getNearbyEntities(origin,
captured.radius(), captured.verticalRange(), captured.radius())); }
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256) break;
if (entity instanceof Player player && eligible(player)) unique.putIfAbsent(player.getUniqueId(), player);
}
var result = new ArrayList<>(unique.values());
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
if (result.size() > 64) result.subList(64, result.size()).clear();
var ordered = new LinkedHashMap<UUID, Player>(); for (var player : result) ordered.put(player.getUniqueId(), player); return ordered;
}
void visual() {
try { origin.getWorld().spawnParticle(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
0.25, 1.5, 0.25, 0, new Particle.DustOptions(Color.RED, 1.5f)); }
catch (RuntimeException ignored) { }
}
@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 bonus; final AutoCloseable resistance; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
BooleanSupplier current = () -> !closed && area.eligible(player);
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(resistance);
}
}
private final class ResistanceGrant implements AutoCloseable {
final ResistanceState state; final long sequence = ++resistanceSequences; final BigDecimal percent;
final BooleanSupplier current; boolean closed;
ResistanceGrant(ResistanceState state, BigDecimal percent, BooleanSupplier current) {
this.state = state; this.percent = percent; this.current = current;
}
boolean valid() { try { return !closed && current.getAsBoolean(); } catch (RuntimeException invalid) { return false; } }
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
}
private final class ResistanceState {
final Player player; final List<ResistanceGrant> grants = new ArrayList<>(); AttributeModifier owned;
ResistanceState(Player player) { this.player = player; }
void refresh() {
AttributeInstance attribute;
try { attribute = player.getAttribute(Attribute.KNOCKBACK_RESISTANCE); }
catch (RuntimeException unavailable) { return; }
if (attribute == null) return;
ResistanceGrant strongest = null;
for (var grant : List.copyOf(grants)) if (grant.valid() && (strongest == null
|| grant.percent.compareTo(strongest.percent) > 0
|| grant.percent.compareTo(strongest.percent) == 0 && grant.sequence < strongest.sequence)) strongest = grant;
double desired = strongest == null ? 0 : strongest.percent.movePointLeft(2).doubleValue();
if (owned != null && (!attribute.getModifiers().contains(owned) || Double.compare(owned.getAmount(), desired) != 0)) {
attribute.removeModifier(owned); owned = null;
}
if (strongest != null && owned == null) {
var key = new NamespacedKey("fantasystick", "war_banner_" + player.getUniqueId());
for (var modifier : List.copyOf(attribute.getModifiers())) if (modifier.getKey().equals(key)) attribute.removeModifier(modifier);
owned = new AttributeModifier(key, desired, AttributeModifier.Operation.ADD_NUMBER); attribute.addModifier(owned);
}
if (strongest == null) { resistances.remove(player.getUniqueId(), this); owned = null; }
}
}
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, ResistanceState> resistances = new java.util.HashMap<>();
private long sequences; private long resistanceSequences; private org.bukkit.scheduler.BukkitTask sweepTask; private boolean closed;
public WarBanner(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("War Banner 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(resistances.values())) state.refresh();
}
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("war_banner", "War Banner", Material.RED_BANNER,
List.of("Plant a spectral banner within " + decimal(current.placementRange()) + " blocks for " + seconds(current.duration()) + " seconds.",
"Party members in its " + decimal(current.radius()) + "-block area gain " + decimal(current.damageBonusPercent())
+ "% combat damage and " + decimal(current.knockbackResistancePercent()) + "% knockback resistance."),
"Use while looking at the solid top surface where the fixed banner should stand; copies do not stack.",
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("placement-range", BigDecimal.valueOf(current.placementRange()).stripTrailingZeros(),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
"duration-seconds", nanos(current.duration()), "damage-bonus-percent", current.damageBonusPercent(),
"knockback-resistance-percent", current.knockbackResistancePercent()), AbilityDefinition.TargetPolicy.AREA, this);
}
@Override public boolean accepts(ControlInput input) {
return !closed && input.target() == null && placement(input.player(), settings.placementRange()) != null;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
var origin = placement(execution.input().player(), captured.placementRange());
if (origin == null) { execution.finish(); return; }
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
area.refresh(); execution.later(captured.duration(), execution::finish);
}
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
var state = resistances.computeIfAbsent(player.getUniqueId(), ignored -> new ResistanceState(player));
var grant = new ResistanceGrant(state, percent, current); state.grants.add(grant); state.refresh(); return grant;
}
private static Location placement(Player player, double range) {
try { var eye = player.getEyeLocation(); var hit = player.getWorld().rayTraceBlocks(eye, eye.getDirection(), range,
FluidCollisionMode.NEVER, true);
if (hit == null || hit.getHitBlock() == null || hit.getHitBlockFace() != BlockFace.UP
|| !hit.getHitBlock().getType().isSolid()) return null;
return hit.getHitBlock().getLocation().add(0.5, 1, 0.5);
} catch (RuntimeException invalid) { return null; }
}
private static boolean inside(Location origin, Player player, double radius, double vertical) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
double dx=at.getX()-origin.getX(), dz=at.getZ()-origin.getZ();
return dx*dx+dz*dz<=radius*radius && Math.abs(at.getY()-origin.getY())<=vertical;
} catch (RuntimeException invalid) { return false; }
}
private static boolean unobstructed(Location origin, Player player) {
try { var world=origin.getWorld(); if(world==null)return false;var offset=player.getBoundingBox().getCenter().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,FluidCollisionMode.NEVER,true)==null;
} catch(RuntimeException invalid){return false;}
}
private static double distance(Location origin, Player player) {
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
} catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}
}
private static Settings from(AbilityDefinition definition) { var p=definition.parameters();return new Settings(definition.cooldown(),
p.get("placement-range").doubleValue(),p.get("radius").doubleValue(),p.get("vertical-range").doubleValue(),
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-bonus-percent"),p.get("knockback-resistance-percent")); }
private static BigDecimal nanos(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9);}
private static String seconds(Duration value){return decimal(nanos(value));}
private static String decimal(double value){return decimal(BigDecimal.valueOf(value));}
private static String decimal(BigDecimal value){return value.stripTrailingZeros().toPlainString();}
private static void closeResource(AutoCloseable resource){try{resource.close();}catch(Exception impossible){throw new IllegalStateException(impossible);}}
@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(resistances.values())){for(var grant:List.copyOf(state.grants))grant.close();}resistances.clear();}
}
@@ -0,0 +1,176 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Meteor;
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 Meteor settings; publication remains a server-thread caller decision. */
public final class MeteorSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "meteor-base-damage";
public static final String COOLDOWN = "meteor-cooldown-seconds";
public static final String TARGET_RANGE = "meteor-target-range-blocks";
public static final String WARNING = "meteor-warning-seconds";
public static final String RADIUS = "meteor-radius-blocks";
public static final String VERTICAL = "meteor-vertical-range-blocks";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, TARGET_RANGE, WARNING, RADIUS, VERTICAL);
private static final Map<String, String> DEFAULTS = Map.of(
BASE_DAMAGE, "14", COOLDOWN, "45", TARGET_RANGE, "24", WARNING, "2", RADIUS, "5", VERTICAL, "4");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Meteor.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-meteor-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 MeteorSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
MeteorSettings(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 Meteor.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("Meteor 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 Meteor.Settings parse(Map<String, String> values) {
try { return new Meteor.Settings(new BigDecimal(values.get(BASE_DAMAGE)).toBigIntegerExact(),
seconds(values.get(COOLDOWN)), finite(values.get(TARGET_RANGE)), seconds(values.get(WARNING)),
finite(values.get(RADIUS)), finiteNonnegative(values.get(VERTICAL))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Meteor 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 static double finiteNonnegative(String value) {
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); 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,177 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Sanctuary;
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 Sanctuary settings; publication remains a server-thread caller decision. */
public final class SanctuarySettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "sanctuary-cooldown-seconds";
public static final String RADIUS = "sanctuary-radius-blocks";
public static final String VERTICAL = "sanctuary-vertical-range-blocks";
public static final String DURATION = "sanctuary-duration-seconds";
public static final String INTERVAL = "sanctuary-pulse-interval-seconds";
public static final String HEALING = "sanctuary-healing-per-pulse";
public static final String REDUCTION = "sanctuary-damage-reduction-percent";
private static final List<String> KEYS = List.of(COOLDOWN, RADIUS, VERTICAL, DURATION, INTERVAL, HEALING, REDUCTION);
private static final Map<String, String> DEFAULTS = Map.of(
COOLDOWN, "45", RADIUS, "6", VERTICAL, "3", DURATION, "6", INTERVAL, "1", HEALING, "2", REDUCTION, "20");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Sanctuary.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-sanctuary-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 SanctuarySettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
SanctuarySettings(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 Sanctuary.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("Sanctuary 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 Sanctuary.Settings parse(Map<String, String> values) {
try { return new Sanctuary.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
finiteNonnegative(values.get(VERTICAL)), seconds(values.get(DURATION)), seconds(values.get(INTERVAL)),
new BigDecimal(values.get(HEALING)), new BigDecimal(values.get(REDUCTION))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Sanctuary 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 static double finiteNonnegative(String value) {
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); 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,179 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.WarBanner;
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 WarBanner settings; publication remains a server-thread caller decision. */
public final class WarBannerSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "war-banner-cooldown-seconds";
public static final String PLACEMENT_RANGE = "war-banner-placement-range-blocks";
public static final String RADIUS = "war-banner-radius-blocks";
public static final String VERTICAL = "war-banner-vertical-range-blocks";
public static final String DURATION = "war-banner-duration-seconds";
public static final String DAMAGE_BONUS = "war-banner-damage-bonus-percent";
public static final String KNOCKBACK_RESISTANCE = "war-banner-knockback-resistance-percent";
private static final List<String> KEYS = List.of(COOLDOWN, PLACEMENT_RANGE, RADIUS, VERTICAL, DURATION,
DAMAGE_BONUS, KNOCKBACK_RESISTANCE);
private static final Map<String, String> DEFAULTS = Map.of(
COOLDOWN, "45", PLACEMENT_RANGE, "12", RADIUS, "8", VERTICAL, "4", DURATION, "10",
DAMAGE_BONUS, "25", KNOCKBACK_RESISTANCE, "50");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, WarBanner.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-war-banner-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 WarBannerSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
WarBannerSettings(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 WarBanner.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("WarBanner 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 WarBanner.Settings parse(Map<String, String> values) {
try { return new WarBanner.Settings(seconds(values.get(COOLDOWN)), finite(values.get(PLACEMENT_RANGE)),
finite(values.get(RADIUS)), finiteNonnegative(values.get(VERTICAL)), seconds(values.get(DURATION)),
new BigDecimal(values.get(DAMAGE_BONUS)), new BigDecimal(values.get(KNOCKBACK_RESISTANCE))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("WarBanner 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 static double finiteNonnegative(String value) {
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); 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); }
}
@@ -51,6 +51,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.ability.FrostNova frostNova;
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
private games.dmg.fantasystick.ability.Sanctuary sanctuary;
private games.dmg.fantasystick.persistence.WarBannerSettings warBannerSettings;
private games.dmg.fantasystick.ability.WarBanner warBanner;
private games.dmg.fantasystick.persistence.MeteorSettings meteorSettings;
private games.dmg.fantasystick.ability.Meteor meteor;
private AdminCharacterIssuer characterIssuer;
private AdminRevival adminRevival;
private AdminExperienceGrant experienceGrant;
@@ -265,6 +271,24 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
chainLightningSettings = Objects.requireNonNull(settings); chainLightning = Objects.requireNonNull(effect);
}
public void attachSanctuary(games.dmg.fantasystick.persistence.SanctuarySettings settings,
games.dmg.fantasystick.ability.Sanctuary effect) {
if (closed || sanctuarySettings != null) throw new IllegalStateException("Sanctuary already attached or command closed");
sanctuarySettings = Objects.requireNonNull(settings); sanctuary = Objects.requireNonNull(effect);
}
public void attachWarBanner(games.dmg.fantasystick.persistence.WarBannerSettings settings,
games.dmg.fantasystick.ability.WarBanner effect) {
if (closed || warBannerSettings != null) throw new IllegalStateException("War Banner already attached or command closed");
warBannerSettings = Objects.requireNonNull(settings); warBanner = Objects.requireNonNull(effect);
}
public void attachMeteor(games.dmg.fantasystick.persistence.MeteorSettings settings,
games.dmg.fantasystick.ability.Meteor effect) {
if (closed || meteorSettings != null) throw new IllegalStateException("Meteor already attached or command closed");
meteorSettings = Objects.requireNonNull(settings); meteor = 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);}
@@ -486,6 +510,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (chainLightningSettings != null && games.dmg.fantasystick.persistence.ChainLightningSettings.keys().contains(args[1])) {
chainLightningConfiguration(sender, args); return;
}
if (sanctuarySettings != null && games.dmg.fantasystick.persistence.SanctuarySettings.keys().contains(args[1])) {
sanctuaryConfiguration(sender, args); return;
}
if (warBannerSettings != null && games.dmg.fantasystick.persistence.WarBannerSettings.keys().contains(args[1])) {
warBannerConfiguration(sender, args); return;
}
if (meteorSettings != null && games.dmg.fantasystick.persistence.MeteorSettings.keys().contains(args[1])) {
meteorConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -748,6 +781,59 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void sanctuaryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + sanctuarySettings.value(key) + " (applies to future Sanctuary casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SanctuarySettings.Update> operation;
try { operation = sanctuarySettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Sanctuary 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) {
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition()); sanctuarySettings.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 Sanctuary casts; active areas and deadlines are unchanged."
: "Sanctuary configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void warBannerConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) { sender.sendMessage(key + "=" + warBannerSettings.value(key) + " (applies to future War Banner casts only)"); return; }
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.WarBannerSettings.Update> operation;
try { operation = warBannerSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("War Banner 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) { warBanner.configure(saved.settings()); abilities.replace(warBanner.definition()); warBannerSettings.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 War Banner casts; active areas and deadlines are unchanged."
: "War Banner configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void meteorConfiguration(CommandSender sender, String[] args) {
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+meteorSettings.value(key)+" (applies to future Meteor casts only)");return;}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MeteorSettings.Update> operation;
try{operation=meteorSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Meteor 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){meteor.configure(saved.settings());abilities.replace(meteor.definition());meteorSettings.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 Meteor casts; active warnings and deadlines are unchanged.":"Meteor 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;
@@ -908,6 +994,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
keys.addAll(games.dmg.fantasystick.persistence.RallyingCrySettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.FrostNovaSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.ChainLightningSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.SanctuarySettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.WarBannerSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.MeteorSettings.keys());
return matching(keys, args[1]);
}
if (args.length == 3 && args[0].equals("config")) {
@@ -986,6 +1075,22 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
String defaults = games.dmg.fantasystick.persistence.ChainLightningSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (sanctuarySettings != null
&& games.dmg.fantasystick.persistence.SanctuarySettings.keys().contains(args[1])) {
String current = sanctuarySettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.SanctuarySettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (warBannerSettings != null
&& games.dmg.fantasystick.persistence.WarBannerSettings.keys().contains(args[1])) {
String current = warBannerSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.WarBannerSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (meteorSettings != null && games.dmg.fantasystick.persistence.MeteorSettings.keys().contains(args[1])) {
String current=meteorSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.MeteorSettings.defaultValue(args[1]);
return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);
}
}
return List.of();
}
+29
View File
@@ -132,3 +132,32 @@ chain-lightning-initial-range-blocks: 16
chain-lightning-jump-range-blocks: 6
chain-lightning-additional-targets: 3
chain-lightning-jump-multiplier: 0.75
# Sanctuary defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
sanctuary-cooldown-seconds: 45
sanctuary-radius-blocks: 6
sanctuary-vertical-range-blocks: 3
sanctuary-duration-seconds: 6
sanctuary-pulse-interval-seconds: 1
sanctuary-healing-per-pulse: 2
sanctuary-damage-reduction-percent: 20
# War Banner defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
war-banner-cooldown-seconds: 45
war-banner-placement-range-blocks: 12
war-banner-radius-blocks: 8
war-banner-vertical-range-blocks: 4
war-banner-duration-seconds: 10
war-banner-damage-bonus-percent: 25
war-banner-knockback-resistance-percent: 50
# Meteor defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
meteor-base-damage: 14
meteor-cooldown-seconds: 45
meteor-target-range-blocks: 24
meteor-warning-seconds: 2
meteor-radius-blocks: 5
meteor-vertical-range-blocks: 4
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
assertNotNull(fixture.packetListener);
assertEquals(7, fixture.timers.size(),
"Presentation, ability/bond/rally cleanup, provenance, reward retry and session particles");
assertEquals(8, fixture.timers.size(),
"Presentation, ability/bond/rally/banner 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,63 @@
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.*;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.*;
import java.math.*;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.*;
import org.bukkit.block.*;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.*;
import org.bukkit.event.entity.*;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.util.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class MeteorNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void characterBookPurchaseEquippedGroundWarningAndNativeImpact()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);PartyBookNativeTest.click(f,caster,button(caster,"[3]","meteor"));drain(f,2);use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);verify(caster.world,atLeast(40)).spawnParticle(eq(Particle.DUST),any(Location.class),eq(24),anyDouble(),anyDouble(),anyDouble(),eq(0d),any(Particle.DustOptions.class));verify(caster.world,atLeastOnce()).spawnParticle(eq(Particle.EXPLOSION_EMITTER),any(Location.class),eq(2),anyDouble(),anyDouble(),anyDouble(),anyDouble());verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
}
}
@Test void missingOrSideGroundRefusesBeforeCooldown()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.UP);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));}}
@Test void impactRechecksGroundPartyModeGeometryObstructionCancellationAndSourceLifecycle(){
var d=direct();var escaping=target(d.world,new UUID(0,1),2,65);var joined=mock(Player.class);locate(joined,d.world,new UUID(0,2),2,65,1);var ordinary=mock(Player.class);locate(ordinary,d.world,new UUID(0,3),2,65,2);var inactive=mock(Player.class);locate(inactive,d.world,new UUID(0,8),2,65,3);var inactiveSession=new EquipmentSessions.Active(inactive.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(inactive.getUniqueId())).thenReturn(Optional.of(inactiveSession));when(d.sessions.current(inactiveSession)).thenReturn(false);var blocked=target(d.world,new UUID(0,4),4.5,65);var cancelled=target(d.world,new UUID(0,5),2,65);var boundary=mock(LivingEntity.class);locate(boundary,d.world,new UUID(0,6),5.5,69,.5);var outside=mock(LivingEntity.class);locate(outside,d.world,new UUID(0,7),5.51,69,.5);doReturn(List.of(escaping,joined,ordinary,inactive,blocked,cancelled,boundary,outside,d.caster)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var joinedActive=new EquipmentSessions.Active(joined.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(joined.getUniqueId())).thenReturn(Optional.of(joinedActive));when(d.sessions.current(joinedActive)).thenReturn(true);var party=new PartyRoster.Party(UUID.randomUUID(),d.caster.getUniqueId(),List.of(d.caster.getUniqueId(),joined.getUniqueId()));when(d.parties.partyOf(d.caster.getUniqueId())).thenReturn(Optional.of(party));when(d.parties.partyOf(joined.getUniqueId())).thenReturn(Optional.of(party));doReturn(mock(RayTraceResult.class)).when(d.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),doubleThat(distance->distance>4&&distance<4.5),eq(FluidCollisionMode.NEVER),eq(true));when(escaping.getLocation()).thenReturn(new Location(d.world,6,65,.5));doAnswer(call->{var event=new EntityDamageEvent(cancelled,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0));event.setCancelled(true);d.meteor.acceptedDamage(event);return null;}).when(cancelled).damage(anyDouble(),any(DamageSource.class));doAnswer(call->{d.meteor.acceptedDamage(new EntityDamageEvent(boundary,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(boundary).damage(anyDouble(),any(DamageSource.class));d.meteor.activate(d.execution);run(d.scheduled,40);verify(escaping,never()).damage(anyDouble(),any(DamageSource.class));verify(joined,never()).damage(anyDouble(),any(DamageSource.class));verify(ordinary,never()).damage(anyDouble(),any(DamageSource.class));verify(inactive,never()).damage(anyDouble(),any(DamageSource.class));verify(blocked,never()).damage(anyDouble(),any(DamageSource.class));verify(cancelled).damage(anyDouble(),any(DamageSource.class));verify(boundary).damage(anyDouble(),any(DamageSource.class));verify(outside,never()).damage(anyDouble(),any(DamageSource.class));verify(d.execution,times(1)).report(eq(AbilityReport.Type.DAMAGE),eq(boundary),any());
var invalid=direct();invalid.meteor.activate(invalid.execution);invalid.blockType.set(Material.AIR);run(invalid.scheduled,40);verify(invalid.execution,never()).report(any(),any(),any());verify(invalid.world,never()).createExplosion(any(Location.class),anyFloat());
var ended=direct();ended.meteor.activate(ended.execution);when(ended.execution.stillCurrent()).thenReturn(false);run(ended.scheduled,1);verify(ended.execution).finish();
}
@Test void impactUsesExactCylinderDistanceUuidOrderAndSharedBoundsOnce(){var d=direct();var targets=new ArrayList<LivingEntity>();var order=new ArrayList<UUID>();for(int i=0;i<257;i++){var target=target(d.world,new UUID(0,i+1),i==256?0:Math.min(5,i/20d),65+(i%2==0?4:0));targets.add(target);doAnswer(call->{order.add(target.getUniqueId());d.meteor.acceptedDamage(new EntityDamageEvent(target,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(target).damage(anyDouble(),any(DamageSource.class));}doReturn(new ArrayList<Entity>(targets)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());d.meteor.activate(d.execution);run(d.scheduled,40);assertEquals(64,order.size());assertFalse(order.contains(targets.get(256).getUniqueId()),"Candidates after the 256 inspection ceiling are ignored");assertEquals(new UUID(0,11),order.getFirst());verify(d.execution,times(64)).report(eq(AbilityReport.Type.DAMAGE),any(),any());assertEquals(BigInteger.valueOf(22),Meteor.damage(BigInteger.valueOf(26)));}
@Test void configuredDefinitionAdminRestartAndFutureImpactCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"meteor-base-damage: 16\nmeteor-cooldown-seconds: 3\nmeteor-target-range-blocks: 20\nmeteor-warning-seconds: 1\nmeteor-radius-blocks: 6\nmeteor-vertical-range-blocks: 3\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("16"),definition.parameters().get("base-damage"));assertEquals(java.time.Duration.ofSeconds(3),definition.cooldown());var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(6,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).size());assertEquals(List.of("5","6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,""}));for(String[] update:List.of(new String[]{MeteorSettings.BASE_DAMAGE,"18"},new String[]{MeteorSettings.COOLDOWN,"4"},new String[]{MeteorSettings.TARGET_RANGE,"22"},new String[]{MeteorSettings.WARNING,"1.5"},new String[]{MeteorSettings.RADIUS,"7"},new String[]{MeteorSettings.VERTICAL,"5"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}var configured=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("18"),configured.parameters().get("base-damage"));assertEquals(0,new BigDecimal("1.5").compareTo(configured.parameters().get("warning-seconds")));
var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);placement(caster,22,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,UUID.randomUUID(),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{damaged.add(call.getArgument(0));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));use(f,caster);drain(f,2);for(int i=0;i<30;i++)f.delayed.removeFirst().run();assertEquals(List.of(26d),damaged);
f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("18"),reloaded.find("meteor").orElseThrow().parameters().get("base-damage"));}}
private record Direct(Meteor meteor,AbilityExecution execution,Player caster,World world,EquipmentSessions sessions,PartyRoster parties,ArrayDeque<Runnable> scheduled,java.util.concurrent.atomic.AtomicReference<Material> blockType){}
private static Direct direct(){var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var caster=mock(Player.class);var world=mock(World.class);UUID id=UUID.randomUUID();when(caster.getUniqueId()).thenReturn(id);when(caster.getWorld()).thenReturn(world);when(caster.getLocation()).thenReturn(new Location(world,0,65,0));when(caster.getBoundingBox()).thenReturn(new BoundingBox(0,65,0,1,67,1));var active=new EquipmentSessions.Active(id,UUID.randomUUID(),UUID.randomUUID());when(sessions.active(id)).thenReturn(Optional.of(active));when(sessions.current(active)).thenReturn(true);var type=new java.util.concurrent.atomic.AtomicReference<>(Material.STONE);var block=mock(Block.class);when(block.getType()).thenAnswer(call->type.get());when(block.getWorld()).thenReturn(world);when(block.getLocation()).thenReturn(new Location(world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(BlockFace.UP);when(caster.getEyeLocation()).thenReturn(new Location(world,0,65.62,0,0,45));doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(24d),eq(FluidCollisionMode.NEVER),eq(true));var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(caster);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);var character=mock(CharacterRecord.class);var stats=new EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,BigInteger.ZERO);stats.put(Stat.INT,BigInteger.valueOf(26));when(character.stats()).thenReturn(stats);when(execution.character()).thenReturn(character);var scheduled=new ArrayDeque<Runnable>();doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());var meteor=new Meteor(Meteor.Settings.defaults(),sessions,parties);when(execution.definition()).thenReturn(meteor.definition());return new Direct(meteor,execution,caster,world,sessions,parties,scheduled,type);}
private static Block placement(FantasyStickLifecycleNativeTest.Actor actor,double range,BlockFace face){var block=mock(Block.class);when(block.getType()).thenReturn(Material.STONE);when(block.getWorld()).thenReturn(actor.world);when(block.getLocation()).thenReturn(new Location(actor.world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(face);doReturn(new Location(actor.world,0,65.62,0,0,45)).when(actor.player).getEyeLocation();doReturn(ray).when(actor.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(range),eq(FluidCollisionMode.NEVER),eq(true));return block;}
private static LivingEntity target(World world,UUID id,double x,double y){var target=mock(LivingEntity.class);locate(target,world,id,x,y,0);return target;}
private static void locate(LivingEntity target,World world,UUID id,double x,double y,double z){when(target.getUniqueId()).thenReturn(id);when(target.getLocation()).thenReturn(new Location(world,x,y,z));when(target.getBoundingBox()).thenReturn(new BoundingBox(x-.3,y,z-.3,x+.3,y+1.8,z+.3));when(target.getHeight()).thenReturn(1.8);}
private static void run(ArrayDeque<Runnable> scheduled,int count){for(int i=0;i<count;i++){assertFalse(scheduled.isEmpty(),"missing warning tick "+i);scheduled.removeFirst().run();}}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,BlockFace.SELF,EquipmentSlot.HAND));}
private static void drain(FantasyStickLifecycleNativeTest.Fixture f,int count)throws Exception{for(int i=0;i<count;i++)PartyBookNativeTest.drain(f);}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f){return(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.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);}
private static ClickEvent.Custom button(net.minecraft.network.chat.Component c,String label,String ability){if(c.getString().equals(label)&&c.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:c.getSiblings()){var found=button(child,label,ability);if(found!=null)return found;}return null;}
private static void prepare(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(26));stats.put(Stat.WIS,BigInteger.valueOf(20));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?0:1),old.health(),learned?Set.of("meteor"):old.learned(),learned?List.of("meteor","","","","","",""):old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
}
@@ -0,0 +1,197 @@
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.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.*;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Location;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.*;
import org.bukkit.util.BoundingBox;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class SanctuaryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedAreaHealingCreatesPersistedSupportReward() 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); setHealth(data,ally,BigDecimal.valueOf(4));
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);
caster.core.location=new Location(caster.world,0,64,0);ally.core.location=new Location(caster.world,2,64,0);
doReturn(List.of(ally.player)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(fixture,caster);
String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Sanctuary"));assertTrue(text.contains("WIS24"));assertTrue(text.contains("CHR20"));assertTrue(text.contains("CON10"));
PartyBookNativeTest.click(fixture,caster,button(caster,"[Learn]","sanctuary"));drain(fixture,2);
PartyBookNativeTest.click(fixture,caster,button(caster,"[3]","sanctuary"));drain(fixture,2);
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(caster.world.getUID()).thenReturn(UUID.randomUUID());when(enemy.getLocation()).thenReturn(new Location(caster.world,8,64,0));
var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));
fixture.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(fixture,4);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"characters");
assertEquals(BigInteger.valueOf(5),storage.find(caster.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5),storage.find(ally.core.record.id()).get().orElseThrow().experience());
}
}
@Test void fixedCylinderHasNoImmediatePulseThenSixOwnedHealingAndMitigationPulses() {
var fixture = directFixture(); fixture.sanctuary.activate(fixture.execution);
assertEquals(10, fixture.allyHealth.get(), 0); assertEquals(1, fixture.scheduled.size());
var enemy = mock(org.bukkit.entity.LivingEntity.class);
var source = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9); fixture.mitigation.accepted(hit);
verify(fixture.execution).report(eq(AbilityReport.Type.PREVENTION), eq(fixture.ally), eq(BigDecimal.valueOf(2.0)));
run(fixture.scheduled, 9); assertEquals(10, fixture.allyHealth.get(), 0);
run(fixture.scheduled, 1); assertEquals(12, fixture.allyHealth.get(), 0); assertEquals(1, fixture.supports.size());
run(fixture.scheduled, 50); assertEquals(20, fixture.allyHealth.get(), 0, "Six two-point pulses cap at maximum health");
assertEquals(5, fixture.supports.size(), "The sixth overhealing pulse creates no support evidence");
verify(fixture.execution, atLeastOnce()).finish();
}
@Test void exactHorizontalAndVerticalCylinderBoundaryIsIncludedButOutsideIsRemoved() {
var fixture=directFixture();fixture.allyLocation.set(new Location(fixture.world,6,67,0));fixture.sanctuary.activate(fixture.execution);
var enemy=mock(org.bukkit.entity.LivingEntity.class);var source=DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
var boundary=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,fixture.ally,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);
fixture.mitigation.reduce(boundary);assertEquals(8,boundary.getDamage(),0);
fixture.allyLocation.set(new Location(fixture.world,6.01,67,0));run(fixture.scheduled,1);
var outside=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,fixture.ally,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);
fixture.mitigation.reduce(outside);assertEquals(10,outside.getDamage(),0);
}
@Test void leavingCylinderOrObstructionEndsBenefitsAndSourceInvalidationCleansUp() {
var fixture = directFixture(); fixture.sanctuary.activate(fixture.execution); run(fixture.scheduled, 10);
doReturn(mock(org.bukkit.util.RayTraceResult.class)).when(fixture.world).rayTraceBlocks(any(Location.class),
any(org.bukkit.util.Vector.class), anyDouble(), eq(org.bukkit.FluidCollisionMode.NEVER), eq(true));
run(fixture.scheduled, 1);
fixture.allyLocation.set(new Location(fixture.world, 2, 68, 0));
var enemy = mock(org.bukkit.entity.LivingEntity.class); var source = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.mitigation.reduce(hit); assertEquals(10, hit.getDamage(), 0);
when(fixture.execution.stillCurrent()).thenReturn(false); fixture.scheduled.removeFirst().run(); verify(fixture.execution).finish();
}
@Test void overlappingSanctuariesUseEarliestSourceForOnePulseAndStrongestReduction() {
var fixture = directFixture(); var second = execution(fixture.source, fixture.sourceActive);
when(second.definition()).thenReturn(fixture.sanctuary.definition()); var character = fixture.execution.character();
when(second.character()).thenReturn(character);
doAnswer(call->{fixture.scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);})
.when(second).later(any(),any());
fixture.sanctuary.activate(fixture.execution); fixture.sanctuary.activate(second);
for (int i = 0; i < 20; i++) fixture.scheduled.removeFirst().run();
assertEquals(12, fixture.allyHealth.get(), 0); assertEquals(1, fixture.supports.size(), "Overlap supplies one healing pulse");
var enemy = mock(org.bukkit.entity.LivingEntity.class); var damageSource = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource, 10);
fixture.mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 0, "Two sanctuaries do not multiply reduction");
fixture.mitigation.accepted(hit); verify(fixture.execution).report(eq(AbilityReport.Type.PREVENTION), eq(fixture.ally), any());
verify(second, never()).report(eq(AbilityReport.Type.PREVENTION), any(), any());
var stone = mock(AbilityExecution.class); when(stone.stillCurrent()).thenReturn(true);
fixture.mitigation.grant(stone, fixture.ally, BigDecimal.valueOf(50));
var stronger = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource, 10);
fixture.mitigation.reduce(stronger); assertEquals(5, stronger.getDamage(), 0, "Stronger Stone Skin-style mitigation supersedes Sanctuary");
}
@Test void definitionAndPluginConfigurationExposeAllApprovedValues() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "sanctuary-cooldown-seconds: 30\nsanctuary-radius-blocks: 7\n"
+ "sanctuary-vertical-range-blocks: 4\nsanctuary-duration-seconds: 4\nsanctuary-pulse-interval-seconds: 1\n"
+ "sanctuary-healing-per-pulse: 3\nsanctuary-damage-reduction-percent: 25\n");
try (var plugin = new FantasyStickLifecycleNativeTest.Fixture(data)) {
plugin.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin, "abilityRegistry");
var definition = registry.find("sanctuary").orElseThrow(); assertEquals(org.bukkit.Material.SEA_LANTERN, definition.icon());
assertEquals(BigInteger.valueOf(24), definition.requirements().get(Stat.WIS)); assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.CHR));
assertEquals(BigInteger.TEN, definition.requirements().get(Stat.CON)); assertEquals(java.time.Duration.ofSeconds(30), definition.cooldown());
assertEquals(new BigDecimal("3"), definition.parameters().get("healing-per-pulse"));
assertEquals(new BigDecimal("25"), definition.parameters().get("damage-reduction-percent"));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-"}).isEmpty());
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("sanctuary-cooldown-seconds","sanctuary-damage-reduction-percent","sanctuary-duration-seconds",
"sanctuary-healing-per-pulse","sanctuary-pulse-interval-seconds","sanctuary-radius-blocks","sanctuary-vertical-range-blocks"),
plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-"}));
assertEquals(List.of("2","3"),plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-healing-per-pulse",""}));
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-healing-per-pulse"});assertTrue(sender.messages.getLast().contains("=3"));
for(String[] update:List.of(new String[]{"sanctuary-cooldown-seconds","20"},new String[]{"sanctuary-radius-blocks","8"},
new String[]{"sanctuary-vertical-range-blocks","5"},new String[]{"sanctuary-duration-seconds","5"},
new String[]{"sanctuary-pulse-interval-seconds","1"},new String[]{"sanctuary-healing-per-pulse","4"},
new String[]{"sanctuary-damage-reduction-percent","30"})){
plugin.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
var configured=registry.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),configured.parameters().get("healing-per-pulse"));
assertEquals(new BigDecimal("30"),configured.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),configured.cooldown());
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8","extra"});assertTrue(sender.messages.getLast().contains("Usage"));
plugin.plugin.setEnabled(false);plugin.plugin.setEnabled(true);
var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin,"abilityRegistry");
var saved=reloaded.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),saved.parameters().get("healing-per-pulse"));
assertEquals(new BigDecimal("30"),saved.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),saved.cooldown());
}
}
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 i=0;i<count;i++)PartyBookNativeTest.drain(fixture);}
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 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);}
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 games.dmg.fantasystick.persistence.YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();var stats=new EnumMap<>(old.stats());stats.put(Stat.WIS,BigInteger.valueOf(24));stats.put(Stat.CHR,BigInteger.valueOf(20));stats.put(Stat.CON,BigInteger.TEN);repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),BigInteger.valueOf(learned?0:1),old.health(),learned?Set.of("sanctuary"):old.learned(),learned?List.of("sanctuary","","","","","",""):old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
private static void setHealth(Path directory,FantasyStickLifecycleNativeTest.Actor actor,BigDecimal health)throws Exception{var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),health,old.learned(),old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());actor.core.health=health.doubleValue();}
private static void run(ArrayDeque<Runnable> scheduled,int count){for(int i=0;i<count;i++){assertFalse(scheduled.isEmpty());scheduled.removeFirst().run();}}
private DirectFixture directFixture() {
var source=mock(Player.class);var ally=mock(Player.class);var world=mock(org.bukkit.World.class);
UUID sourceId=UUID.randomUUID(),allyId=UUID.randomUUID();when(source.getUniqueId()).thenReturn(sourceId);when(ally.getUniqueId()).thenReturn(allyId);
var sourceLocation=new AtomicReference<>(new Location(world,0,64,0));var allyLocation=new AtomicReference<>(new Location(world,2,64,0));
when(source.getLocation()).thenAnswer(call->sourceLocation.get().clone());when(ally.getLocation()).thenAnswer(call->allyLocation.get().clone());
when(source.getBoundingBox()).thenAnswer(call->box(sourceLocation.get()));when(ally.getBoundingBox()).thenAnswer(call->box(allyLocation.get()));
when(source.getWorld()).thenReturn(world);when(ally.getWorld()).thenReturn(world);when(world.getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble())).thenReturn(List.of(ally));
var sourceHealth=new AtomicReference<>(20.0);var allyHealth=new AtomicReference<>(10.0);health(source,sourceHealth);health(ally,allyHealth);
var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var characters=mock(CharacterStorage.class);
var sourceActive=new EquipmentSessions.Active(sourceId,UUID.randomUUID(),UUID.randomUUID());var allyActive=new EquipmentSessions.Active(allyId,UUID.randomUUID(),UUID.randomUUID());
when(sessions.active(sourceId)).thenReturn(Optional.of(sourceActive));when(sessions.active(allyId)).thenReturn(Optional.of(allyActive));when(sessions.current(any())).thenReturn(true);
var records=new HashMap<UUID,AtomicReference<CharacterRecord>>();records.put(sourceId,new AtomicReference<>(record(sourceActive.character(),BigDecimal.valueOf(20))));records.put(allyId,new AtomicReference<>(record(allyActive.character(),BigDecimal.TEN)));
when(sessions.currentRecord(sourceActive)).thenAnswer(call->Optional.of(records.get(sourceId).get()));when(sessions.currentRecord(allyActive)).thenAnswer(call->Optional.of(records.get(allyId).get()));
when(sessions.acknowledgeProgress(any(),any(),any())).thenAnswer(call->{EquipmentSessions.Active active=call.getArgument(0);CharacterRecord saved=call.getArgument(2);records.get(active.player()).set(saved);return true;});
when(characters.saveHealth(any(),any(),any(),any())).thenAnswer(call->{UUID character=call.getArgument(0);BigInteger expected=call.getArgument(1);BigDecimal health=call.getArgument(2);
var entry=records.values().stream().filter(ref->ref.get().id().equals(character)).findFirst().orElseThrow();var old=entry.get();
if(!old.revision().equals(expected))return CompletableFuture.completedFuture(Optional.empty());
var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),health,old.learned(),old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null);
return CompletableFuture.completedFuture(Optional.of(saved));});
var party=new PartyRoster.Party(UUID.randomUUID(),sourceId,List.of(sourceId,allyId));when(parties.partyOf(sourceId)).thenReturn(Optional.of(party));when(parties.partyOf(allyId)).thenReturn(Optional.of(party));
var mitigation=new CombatMitigation();var scheduled=new ArrayDeque<Runnable>();var supports=new ArrayList<MendingLight.Support>();
var sanctuary=new Sanctuary(Sanctuary.Settings.defaults(),characters,sessions,parties,mitigation,Runnable::run,event->{},()->BigDecimal.valueOf(Double.MAX_VALUE),supports::add);
var execution=execution(source,sourceActive);when(execution.definition()).thenReturn(sanctuary.definition());
var character=records.get(sourceId).get();when(execution.character()).thenReturn(character);
doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());
return new DirectFixture(source,ally,world,sourceActive,allyLocation,allyHealth,execution,sanctuary,mitigation,scheduled,supports);
}
private record DirectFixture(Player source,Player ally,org.bukkit.World world,EquipmentSessions.Active sourceActive,
AtomicReference<Location> allyLocation,AtomicReference<Double> allyHealth,AbilityExecution execution,Sanctuary sanctuary,
CombatMitigation mitigation,ArrayDeque<Runnable> scheduled,List<MendingLight.Support> supports){}
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);return execution;}
private static CharacterRecord record(UUID id,BigDecimal health){var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var stats=new EnumMap<>(base.stats());stats.put(Stat.CON,BigInteger.TEN);return new CharacterRecord(id,base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),health,Set.of("sanctuary"),List.of("sanctuary","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);}
private static BoundingBox box(Location at){return new BoundingBox(at.getX()-.3,at.getY(),at.getZ()-.3,at.getX()+.3,at.getY()+1.8,at.getZ()+.3);}
private static void health(Player player,AtomicReference<Double> health){var attribute=mock(AttributeInstance.class);when(attribute.getValue()).thenReturn(20.0);when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(attribute);when(player.getHealth()).thenAnswer(call->health.get());doAnswer(call->{health.set(call.getArgument(0));return null;}).when(player).setHealth(anyDouble());}
}
@@ -0,0 +1,86 @@
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.*;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.*;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.*;
import org.bukkit.attribute.*;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.*;
import org.bukkit.event.entity.*;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.util.RayTraceResult;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class WarBannerNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void bookAcquisitionSolidTopPlacementBenefitsPartyAndRewardsUsedDamage()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);PartyBookNativeTest.click(f,caster,button(caster,"[3]","war_banner"));drain(f,2);use(f,caster);drain(f,2);assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
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,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=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);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
}
}
@Test void invalidOrNonTopPlacementRefusesBeforeCooldown()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster,12,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));}
}
@Test void fixedCylinderObstructionLifecycleOverlapAndRallyUseStrongestOnly()throws Exception{
var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var supports=new ArrayList<OutgoingDamageBonus.Support>();var bonuses=new OutgoingDamageBonus(sessions,parties,supports::add);var source=mock(Player.class);var ally=mock(Player.class);var world=mock(World.class);UUID a=UUID.randomUUID(),b=UUID.randomUUID();when(source.getUniqueId()).thenReturn(a);when(ally.getUniqueId()).thenReturn(b);var sa=new EquipmentSessions.Active(a,UUID.randomUUID(),UUID.randomUUID());var ba=new EquipmentSessions.Active(b,UUID.randomUUID(),UUID.randomUUID());when(sessions.active(a)).thenReturn(Optional.of(sa));when(sessions.active(b)).thenReturn(Optional.of(ba));when(sessions.current(any())).thenReturn(true);var party=new PartyRoster.Party(UUID.randomUUID(),a,List.of(a,b));when(parties.partyOf(a)).thenReturn(Optional.of(party));when(parties.partyOf(b)).thenReturn(Optional.of(party));when(source.getWorld()).thenReturn(world);when(source.getLocation()).thenReturn(new Location(world,.5,65,.5));when(ally.getLocation()).thenReturn(new Location(world,8.5,69,.5));when(source.getBoundingBox()).thenReturn(new org.bukkit.util.BoundingBox(0,65,0,1,67,1));when(ally.getBoundingBox()).thenReturn(new org.bukkit.util.BoundingBox(8,69,0,9,71,1));doReturn(List.of(source,ally)).when(world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var sourceResistance=attribute(source);var allyResistance=attribute(ally);var block=mock(Block.class);when(block.getType()).thenReturn(Material.STONE);when(block.getLocation()).thenReturn(new Location(world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(BlockFace.UP);when(source.getEyeLocation()).thenReturn(new Location(world,0,65.62,0,0,45));doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(12d),eq(FluidCollisionMode.NEVER),eq(true));
var banner=new WarBanner(WarBanner.Settings.defaults(),sessions,parties,bonuses);var be=execution(source,sa,banner.definition());var owned=new ArrayList<AutoCloseable>();doAnswer(call->{owned.add(call.getArgument(0));return null;}).when(be).own(any());banner.activate(be);assertEquals(.5,allyResistance.value(),1e-9);assertEquals(.5,sourceResistance.value(),1e-9);verify(block,never()).setType(any(Material.class));verify(world,atLeastOnce()).spawnParticle(eq(Particle.DUST),any(Location.class),eq(16),anyDouble(),anyDouble(),anyDouble(),eq(0d),any(Particle.DustOptions.class));
when(source.getLocation()).thenReturn(new Location(world,100,65,.5));banner.sweep();assertEquals(0,sourceResistance.value(),1e-9);assertEquals(.5,allyResistance.value(),1e-9,"The fixed banner remains when its caster moves away");when(source.getLocation()).thenReturn(new Location(world,.5,65,.5));banner.sweep();
when(ally.getLocation()).thenReturn(new Location(world,8.51,69,.5));banner.sweep();assertEquals(0,allyResistance.value(),1e-9);
when(ally.getLocation()).thenReturn(new Location(world,4.5,65,.5));when(ally.getBoundingBox()).thenReturn(new org.bukkit.util.BoundingBox(4,65,0,5,67,1));banner.sweep();assertEquals(.5,allyResistance.value(),1e-9);when(parties.partyOf(b)).thenReturn(Optional.empty());banner.sweep();assertEquals(0,allyResistance.value(),1e-9);when(parties.partyOf(b)).thenReturn(Optional.of(party));banner.sweep();assertEquals(.5,allyResistance.value(),1e-9);
var be2=execution(source,sa,banner.definition());var owned2=new ArrayList<AutoCloseable>();doAnswer(call->{owned2.add(call.getArgument(0));return null;}).when(be2).own(any());banner.activate(be2);assertEquals(.5,allyResistance.value(),1e-9,"Overlapping banners retain one resistance layer");var rally=new RallyingCry(RallyingCry.Settings.defaults(),sessions,parties,bonuses);when(source.getNearbyEntities(8,8,8)).thenReturn(List.of(ally));var re=execution(source,sa,rally.definition());var rallyOwned=new ArrayList<AutoCloseable>();doAnswer(call->{rallyOwned.add(call.getArgument(0));return null;}).when(re).own(any());rally.activate(re);
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());var hit=hit(ally,enemy,10);bonuses.increase(hit);assertEquals(12.5,hit.getDamage(),1e-9,"Banner 25% supersedes rather than adds Rally 20%");bonuses.accepted(hit);verify(be).report(eq(AbilityReport.Type.BOOST_USED),eq(enemy),argThat(value->value.compareTo(new BigDecimal("2.5"))==0));verify(be2,never()).report(eq(AbilityReport.Type.BOOST_USED),any(),any());verify(re,never()).report(eq(AbilityReport.Type.BOOST_USED),any(),any());
owned.getFirst().close();var fallback=hit(ally,enemy,10);bonuses.increase(fallback);assertEquals(12.5,fallback.getDamage(),1e-9);bonuses.accepted(fallback);verify(be2).report(eq(AbilityReport.Type.BOOST_USED),eq(enemy),any());assertEquals(.5,allyResistance.value(),1e-9);
doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),anyDouble(),eq(FluidCollisionMode.NEVER),eq(true));banner.sweep();
var rallyOnly=hit(ally,enemy,10);bonuses.increase(rallyOnly);assertEquals(12,rallyOnly.getDamage(),1e-9);assertEquals(0,allyResistance.value(),1e-9);owned2.getFirst().close();
banner.close();rally.close();bonuses.close();
}
@Test void configuredDefinitionAdminRestartAndEverySetting()throws Exception{
java.nio.file.Files.writeString(data.resolve("config.yml"),"war-banner-cooldown-seconds: 2\nwar-banner-placement-range-blocks: 10\nwar-banner-radius-blocks: 7\nwar-banner-vertical-range-blocks: 3\nwar-banner-duration-seconds: 8\nwar-banner-damage-bonus-percent: 30\nwar-banner-knockback-resistance-percent: 40\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("war_banner").orElseThrow();assertEquals(java.time.Duration.ofSeconds(2),definition.cooldown());assertEquals(0,new BigDecimal("10").compareTo(definition.parameters().get("placement-range")));assertEquals(new BigDecimal("30"),definition.parameters().get("damage-bonus-percent"));var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","war-banner-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","war-banner-radius-blocks","9"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(7,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","war-banner-"}).size());assertEquals(List.of("7","8"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,""}));f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS});assertTrue(sender.messages.getLast().contains("=7"));
for(String[] update:List.of(new String[]{WarBannerSettings.COOLDOWN,"3"},new String[]{WarBannerSettings.PLACEMENT_RANGE,"11"},new String[]{WarBannerSettings.RADIUS,"9"},new String[]{WarBannerSettings.VERTICAL,"5"},new String[]{WarBannerSettings.DURATION,"9"},new String[]{WarBannerSettings.DAMAGE_BONUS,"35"},new String[]{WarBannerSettings.KNOCKBACK_RESISTANCE,"60"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
var saved=registry.find("war_banner").orElseThrow();assertEquals(java.time.Duration.ofSeconds(3),saved.cooldown());assertEquals(new BigDecimal("35"),saved.parameters().get("damage-bonus-percent"));assertEquals(new BigDecimal("60"),saved.parameters().get("knockback-resistance-percent"));
var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster,11);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);use(f,caster);drain(f,2);assertEquals(180L,f.delayedTicks.getLast());assertEquals(.6,resistance.value(),1e-9);var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());var hit=hit(ally.player,enemy,10);f.dispatcher.accept(hit);assertEquals(13.5,hit.getDamage(),1e-9);
f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("35"),reloaded.find("war_banner").orElseThrow().parameters().get("damage-bonus-percent"));}
}
private record AttributeFixture(Set<AttributeModifier> modifiers){double value(){return modifiers.stream().mapToDouble(AttributeModifier::getAmount).sum();}}
private static AttributeFixture attribute(Player player){var instance=mock(AttributeInstance.class);var modifiers=new LinkedHashSet<AttributeModifier>();when(instance.getModifiers()).thenAnswer(call->Set.copyOf(modifiers));doAnswer(call->{modifiers.add(call.getArgument(0));return null;}).when(instance).addModifier(any());doAnswer(call->{modifiers.remove(call.getArgument(0));return null;}).when(instance).removeModifier(any(AttributeModifier.class));doReturn(instance).when(player).getAttribute(Attribute.KNOCKBACK_RESISTANCE);return new AttributeFixture(modifiers);}
private static AbilityExecution execution(Player player,EquipmentSessions.Active active,AbilityDefinition definition){var e=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(player);when(input.session()).thenReturn(active);when(e.input()).thenReturn(input);when(e.stillCurrent()).thenReturn(true);when(e.definition()).thenReturn(definition);return e;}
private static EntityDamageByEntityEvent hit(Player attacker,LivingEntity target,double amount){var source=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 placement(FantasyStickLifecycleNativeTest.Actor actor){placement(actor,12,BlockFace.UP);}
private static void placement(FantasyStickLifecycleNativeTest.Actor actor,double range){placement(actor,range,BlockFace.UP);}
private static void placement(FantasyStickLifecycleNativeTest.Actor actor,double range,BlockFace face){var block=mock(Block.class);when(block.getType()).thenReturn(Material.STONE);when(block.getLocation()).thenReturn(new Location(actor.world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(face);doReturn(new Location(actor.world,0,65.62,0,0,45)).when(actor.player).getEyeLocation();doReturn(ray).when(actor.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(range),eq(FluidCollisionMode.NEVER),eq(true));}
private static void nearby(FantasyStickLifecycleNativeTest.Actor source,List<? extends Entity> entities){doReturn(new ArrayList<>(entities)).when(source.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());for(var entity:entities)if(entity instanceof Player p)doReturn(new Location(source.world,2,65,0)).when(p).getLocation();}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,BlockFace.SELF,EquipmentSlot.HAND));}
private static void drain(FantasyStickLifecycleNativeTest.Fixture f,int count)throws Exception{for(int i=0;i<count;i++)PartyBookNativeTest.drain(f);}
private static void party(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a,FantasyStickLifecycleNativeTest.Actor b){var parties=PartyLifecycleNativeTest.parties(f);var group=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,group.id(),b.core.id).orElseThrow().id()));}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f){return(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.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);}
private static ClickEvent.Custom button(net.minecraft.network.chat.Component c,String label,String ability){if(c.getString().equals(label)&&c.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:c.getSiblings()){var found=button(child,label,ability);if(found!=null)return found;}return null;}
private static void prepare(Path directory,FantasyStickLifecycleNativeTest.Actor actor,boolean learned,boolean rally)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.STR,BigInteger.valueOf(22));stats.put(Stat.CHR,BigInteger.valueOf(22));stats.put(Stat.CON,BigInteger.valueOf(12));var learnedSet=new LinkedHashSet<String>();if(learned)learnedSet.add("war_banner");if(rally)learnedSet.add("rallying_cry");var slots=learned?List.of("war_banner","","","","","",""):old.loadout();repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),BigInteger.valueOf(learned?0:1),old.health(),learnedSet,slots,old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
}
@@ -0,0 +1,19 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class MeteorSettingsTest {
@TempDir Path data;
@Test void defaultsMatchApprovedPacket(){var s=MeteorSettings.parse(defaults());assertEquals(java.math.BigInteger.valueOf(14),s.baseDamage());assertEquals(Duration.ofSeconds(45),s.cooldown());assertEquals(24,s.targetRange());assertEquals(Duration.ofSeconds(2),s.warning());assertEquals(5,s.radius());assertEquals(4,s.verticalRange());}
@Test void validatesEveryBound(){for(var values:List.of(changed(MeteorSettings.BASE_DAMAGE,"-1"),changed(MeteorSettings.COOLDOWN,"-1"),changed(MeteorSettings.TARGET_RANGE,"0"),changed(MeteorSettings.WARNING,"0"),changed(MeteorSettings.WARNING,"3.2"),changed(MeteorSettings.RADIUS,"0"),changed(MeteorSettings.VERTICAL,"-1"),changed(MeteorSettings.RADIUS,"NaN")))assertThrows(IllegalArgumentException.class,()->MeteorSettings.parse(values));}
@Test void persistsBeforePublicationAndPreservesUnrelatedYaml()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");try(var store=new MeteorSettings(data,defaults())){var update=store.set(MeteorSettings.RADIUS,"6.00").toCompletableFuture().get();assertEquals("5",store.value(MeteorSettings.RADIUS));assertEquals("6",update.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));store.publish(update);assertEquals("6",store.value(MeteorSettings.RADIUS));}}
@Test void failedUnsafeAndClosedWritesRetainEffectiveState()throws Exception{var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var store=new MeteorSettings(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,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("5",store.value(MeteorSettings.RADIUS));}if(!System.getProperty("os.name","").toLowerCase().contains("win")){var unsafe=data.resolve("unsafe");Files.createDirectories(unsafe);var outside=data.resolve("outside");Files.writeString(outside,"safe\n");Files.createSymbolicLink(unsafe.resolve("config.yml"),outside);try(var store=new MeteorSettings(unsafe,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("safe\n",Files.readString(outside));}}var closed=new MeteorSettings(data.resolve("closed"),defaults());closed.close();assertThrows(java.util.concurrent.ExecutionException.class,()->closed.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());closed.drained().toCompletableFuture().get();}
private static Map<String,String> defaults(){var result=new LinkedHashMap<String,String>();for(var key:MeteorSettings.keys())result.put(key,MeteorSettings.defaultValue(key));return result;}
private static Map<String,String> changed(String key,String value){var result=new LinkedHashMap<>(defaults());result.put(key,value);return result;}
}
@@ -0,0 +1,52 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SanctuarySettingsTest {
@TempDir Path data;
@Test void defaultsAndParsingMatchApprovedPolicy() {
var value = SanctuarySettings.parse(defaults());
assertEquals(java.time.Duration.ofSeconds(45), value.cooldown()); assertEquals(6, value.radius(), 0);
assertEquals(3, value.verticalRange(), 0); assertEquals(java.time.Duration.ofSeconds(6), value.duration());
assertEquals(java.time.Duration.ofSeconds(1), value.pulseInterval()); assertEquals(new java.math.BigDecimal("2"), value.healingPerPulse());
assertEquals(new java.math.BigDecimal("20"), value.damageReductionPercent());
}
@Test void serializesPreservesUnrelatedAndPublishesAcknowledgedOrder() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new SanctuarySettings(data, defaults())) {
var radius = settings.set(SanctuarySettings.RADIUS, "7").toCompletableFuture();
var cooldown = settings.set(SanctuarySettings.COOLDOWN, "30").toCompletableFuture();
var savedRadius = radius.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
assertEquals(6, settings.settings().radius(), 0); settings.publish(savedCooldown); settings.publish(savedRadius);
assertEquals(7, settings.settings().radius(), 0); assertEquals(java.time.Duration.ofSeconds(30), 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(SanctuarySettings.RADIUS)));
}
}
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
try (var settings = new SanctuarySettings(data, defaults())) {
for (var e : Map.of(SanctuarySettings.COOLDOWN,"-1",SanctuarySettings.RADIUS,"0",SanctuarySettings.VERTICAL,"-1",
SanctuarySettings.DURATION,"0",SanctuarySettings.INTERVAL,"0",SanctuarySettings.HEALING,"-1",SanctuarySettings.REDUCTION,"101").entrySet())
assertThrows(IllegalArgumentException.class, () -> settings.set(e.getKey(), e.getValue()));
assertThrows(IllegalArgumentException.class, () -> settings.set(SanctuarySettings.INTERVAL,"0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new SanctuarySettings(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(SanctuarySettings.RADIUS,"7").toCompletableFuture().get(5,TimeUnit.SECONDS));
assertEquals("6",settings.value(SanctuarySettings.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 SanctuarySettings(unsafe,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(SanctuarySettings.HEALING,"3").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("safe\n",Files.readString(outside));}
}
var closed=new SanctuarySettings(data.resolve("closed"),defaults());closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,()->closed.set(SanctuarySettings.HEALING,"3").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:SanctuarySettings.keys())values.put(key,SanctuarySettings.defaultValue(key));return values;}
}
@@ -0,0 +1,64 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.ability.WarBanner;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class WarBannerSettingsTest {
@TempDir Path data;
@Test void defaultsMatchApprovedPacket() {
var settings = WarBannerSettings.parse(defaults());
assertEquals(Duration.ofSeconds(45), settings.cooldown());
assertEquals(12, settings.placementRange()); assertEquals(8, settings.radius());
assertEquals(4, settings.verticalRange()); assertEquals(Duration.ofSeconds(10), settings.duration());
assertEquals(new BigDecimal("25"), settings.damageBonusPercent());
assertEquals(new BigDecimal("50"), settings.knockbackResistancePercent());
}
@Test void validatesEveryBoundAndPulseFreeAreaLimit() {
for (var invalid : java.util.List.of(
changed(WarBannerSettings.COOLDOWN,"-1"), changed(WarBannerSettings.PLACEMENT_RANGE,"0"),
changed(WarBannerSettings.RADIUS,"0"), changed(WarBannerSettings.VERTICAL,"-1"),
changed(WarBannerSettings.DURATION,"0"), changed(WarBannerSettings.DAMAGE_BONUS,"-1"), changed(WarBannerSettings.DAMAGE_BONUS,"101"),
changed(WarBannerSettings.KNOCKBACK_RESISTANCE,"101"), changed(WarBannerSettings.RADIUS,"NaN"))) {
assertThrows(IllegalArgumentException.class,()->WarBannerSettings.parse(invalid));
}
}
@Test void persistsBeforeExplicitPublicationAndPreservesUnrelatedYaml() throws Exception {
Files.writeString(data.resolve("config.yml"),"unrelated: keep\nwar-banner-radius-blocks: 8\n");
try(var store=new WarBannerSettings(data,defaults())) {
var update=store.set(WarBannerSettings.RADIUS,"9.00").toCompletableFuture().get();
assertEquals("8",store.value(WarBannerSettings.RADIUS));assertEquals("9",update.value());
assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));
store.publish(update);assertEquals("9",store.value(WarBannerSettings.RADIUS));
}
}
@Test void failedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var store = new WarBannerSettings(data, defaults(), (from,to)->{
if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(from,to,java.nio.file.StandardCopyOption.REPLACE_EXISTING);})) {
assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(WarBannerSettings.RADIUS,"9").toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));
assertEquals("8",store.value(WarBannerSettings.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 store=new WarBannerSettings(unsafe,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(WarBannerSettings.DAMAGE_BONUS,"30").toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));assertEquals("safe\n",Files.readString(outside));}
}
var closed=new WarBannerSettings(data.resolve("closed"),defaults());closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,()->closed.set(WarBannerSettings.RADIUS,"9").toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
}
private static Map<String,String> defaults(){var values=new LinkedHashMap<String,String>();for(String key:WarBannerSettings.keys())values.put(key,WarBannerSettings.defaultValue(key));return values;}
private static Map<String,String> changed(String key,String value){var result=new LinkedHashMap<>(defaults());result.put(key,value);return result;}
}