Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18b174609d | ||
|
|
dfa02bc335 |
No files matched your search
@@ -83,6 +83,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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;
|
||||
@@ -163,6 +167,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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();
|
||||
@@ -283,6 +291,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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,
|
||||
@@ -304,6 +317,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
@@ -353,6 +368,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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,
|
||||
@@ -383,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);
|
||||
@@ -392,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);
|
||||
@@ -472,6 +491,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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;
|
||||
@@ -517,6 +540,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
@@ -557,6 +584,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: 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)
|
||||
@@ -827,6 +858,26 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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"));
|
||||
|
||||
@@ -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&÷d[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,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,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); }
|
||||
}
|
||||
@@ -53,6 +53,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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;
|
||||
@@ -273,6 +277,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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);}
|
||||
@@ -497,6 +513,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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;
|
||||
}
|
||||
@@ -786,6 +808,32 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -947,6 +995,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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")) {
|
||||
@@ -1031,6 +1081,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -142,3 +142,22 @@ 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,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,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;}
|
||||
}
|
||||
Reference in new issue
Block a user