Compare commits

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

No files matched your search

@@ -81,6 +81,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.FrostNovaSettings frostNovaSettings;
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
private games.dmg.fantasystick.ability.Sanctuary sanctuary;
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
private games.dmg.fantasystick.ability.WarBanner warBanner;
private games.dmg.fantasystick.persistence.WarBannerSettings warBannerSettings;
private games.dmg.fantasystick.ability.Meteor meteor;
private games.dmg.fantasystick.persistence.MeteorSettings meteorSettings;
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
@@ -159,6 +165,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var frostNovaInitial = games.dmg.fantasystick.persistence.FrostNovaSettings.parse(frostNovaConfiguration);
var chainLightningConfiguration = chainLightningConfiguration();
var chainLightningInitial = games.dmg.fantasystick.persistence.ChainLightningSettings.parse(chainLightningConfiguration);
var sanctuaryConfiguration = sanctuaryConfiguration();
var sanctuaryInitial = games.dmg.fantasystick.persistence.SanctuarySettings.parse(sanctuaryConfiguration);
var warBannerConfiguration = warBannerConfiguration();
var warBannerInitial = games.dmg.fantasystick.persistence.WarBannerSettings.parse(warBannerConfiguration);
var meteorConfiguration = meteorConfiguration();
var meteorInitial = games.dmg.fantasystick.persistence.MeteorSettings.parse(meteorConfiguration);
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
var factory = new CharacterFactory();
@@ -273,6 +285,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
chainLightningSettings = new games.dmg.fantasystick.persistence.ChainLightningSettings(
getDataFolder().toPath(), chainLightningConfiguration);
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(chainLightningInitial, sessions, parties);
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
getDataFolder().toPath(), sanctuaryConfiguration);
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
warBannerSettings = new games.dmg.fantasystick.persistence.WarBannerSettings(
getDataFolder().toPath(), warBannerConfiguration);
warBanner = new games.dmg.fantasystick.ability.WarBanner(warBannerInitial, sessions, parties, outgoingDamageBonus);
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
@@ -293,6 +316,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityRegistry.register(rallyingCry.definition());
abilityRegistry.register(frostNova.definition());
abilityRegistry.register(chainLightning.definition());
abilityRegistry.register(sanctuary.definition());
abilityRegistry.register(warBanner.definition());
abilityRegistry.register(meteor.definition());
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
@@ -341,6 +367,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachRallyingCry(rallyingCrySettings, rallyingCry);
admin.attachFrostNova(frostNovaSettings, frostNova);
admin.attachChainLightning(chainLightningSettings, chainLightning);
admin.attachSanctuary(sanctuarySettings, sanctuary);
admin.attachWarBanner(warBannerSettings, warBanner);
admin.attachMeteor(meteorSettings, meteor);
admin.attachAdminRevival(adminRevival, adminReviveSettings);
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
characters, rules, sessions, nextTick, getServer()::getPlayer,
@@ -371,6 +400,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(frostShard, this);
getServer().getPluginManager().registerEvents(frostNova, this);
getServer().getPluginManager().registerEvents(chainLightning, this);
getServer().getPluginManager().registerEvents(meteor, this);
getServer().getPluginManager().registerEvents(longbow, this);
getServer().getPluginManager().registerEvents(quickstep, this);
getServer().getPluginManager().registerEvents(combatMitigation, this);
@@ -380,6 +410,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
getServer().getPluginManager().registerEvents(outgoingDamageBonus, this);
rallyingCry.install(this);
warBanner.install(this);
getServer().getPluginManager().registerEvents(temporaryHealth, this);
getServer().getPluginManager().registerEvents(greatSword, this);
getServer().getPluginManager().registerEvents(revive, this);
@@ -458,6 +489,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingFrostNovaSettings = frostNovaSettings; frostNovaSettings = null;
var closingChainLightning = chainLightning; chainLightning = null;
var closingChainLightningSettings = chainLightningSettings; chainLightningSettings = null;
var closingSanctuary = sanctuary; sanctuary = null;
var closingSanctuarySettings = sanctuarySettings; sanctuarySettings = null;
var closingWarBanner = warBanner; warBanner = null;
var closingWarBannerSettings = warBannerSettings; warBannerSettings = null;
var closingMeteor = meteor; meteor = null;
var closingMeteorSettings = meteorSettings; meteorSettings = null;
var closingOutgoingDamageBonus = outgoingDamageBonus; outgoingDamageBonus = null;
var closingAdminRevival = adminRevival; adminRevival = null;
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
@@ -501,6 +538,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingFrostNovaSettings != null) cleanup("Frost Nova configuration", closingFrostNovaSettings::close);
if (closingChainLightning != null) cleanup("Chain Lightning", closingChainLightning::close);
if (closingChainLightningSettings != null) cleanup("Chain Lightning configuration", closingChainLightningSettings::close);
if (closingSanctuary != null) cleanup("Sanctuary", closingSanctuary::close);
if (closingSanctuarySettings != null) cleanup("Sanctuary configuration", closingSanctuarySettings::close);
if (closingWarBanner != null) cleanup("War Banner", closingWarBanner::close);
if (closingWarBannerSettings != null) cleanup("War Banner configuration", closingWarBannerSettings::close);
if (closingMeteor != null) cleanup("Meteor", closingMeteor::close);
if (closingMeteorSettings != null) cleanup("Meteor configuration", closingMeteorSettings::close);
if (closingOutgoingDamageBonus != null) cleanup("outgoing damage bonuses", closingOutgoingDamageBonus::close);
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
@@ -539,6 +582,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
: closingFrostNovaSettings.drained().toCompletableFuture(),
closingChainLightningSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingChainLightningSettings.drained().toCompletableFuture(),
closingSanctuarySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSanctuarySettings.drained().toCompletableFuture(),
closingWarBannerSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingWarBannerSettings.drained().toCompletableFuture(),
closingMeteorSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingMeteorSettings.drained().toCompletableFuture(),
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdminReviveSettings.drained().toCompletableFuture(),
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -799,6 +848,36 @@ public final class FantasyStickPlugin extends JavaPlugin {
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> sanctuaryConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.SanctuarySettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.SanctuarySettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.SanctuarySettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> warBannerConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.WarBannerSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.WarBannerSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.WarBannerSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> meteorConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.MeteorSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.MeteorSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.MeteorSettings.parse(values);
return java.util.Map.copyOf(values);
}
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
@@ -7,6 +7,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
@@ -17,9 +18,13 @@ import org.bukkit.event.entity.EntityDamageEvent;
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
public final class CombatMitigation implements Listener, AutoCloseable {
private final class Grant implements AutoCloseable {
final AbilityExecution execution; final Player player; final BigDecimal percent; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent) {
this.execution = execution; this.player = player; this.percent = percent;
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
}
boolean valid() {
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
catch (RuntimeException invalid) { return false; }
}
@Override public void close() {
if (closed) return; closed = true;
@@ -33,10 +38,14 @@ public final class CombatMitigation implements Listener, AutoCloseable {
private boolean closed;
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent);
return grant(execution, player, percent, () -> true);
}
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid combat mitigation grant");
var grant = new Grant(execution, player, percent);
var grant = new Grant(execution, player, percent, current);
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
return grant;
}
@@ -48,7 +57,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
var owned = grants.get(player.getUniqueId());
if (owned == null) return;
for (var grant : List.copyOf(owned)) {
if (grant.closed || grant.player != player || !grant.execution.stillCurrent()) continue;
if (grant.player != player || !grant.valid()) continue;
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
}
double original = event.getDamage();
@@ -65,7 +74,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
public void accepted(EntityDamageEvent event) {
var prevention = pending.remove(event);
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|| !prevention.grant.execution.stillCurrent()) return;
|| !prevention.grant.valid()) return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
}
@@ -0,0 +1,79 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.*;
import org.bukkit.*;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.*;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
/** Bounded solid-top Meteor warning followed by one direct, terrain-safe cylinder impact. */
public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double targetRange, Duration warning,
double radius, double verticalRange) {
public Settings {
Objects.requireNonNull(baseDamage);Objects.requireNonNull(cooldown);Objects.requireNonNull(warning);
long warningTicks;
try { warningTicks=Math.max(1,(warning.toNanos()+49_999_999L)/50_000_000L); }
catch(ArithmeticException invalid){throw new IllegalArgumentException("Invalid Meteor warning",invalid);}
if(baseDamage.signum()<0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0
||!Double.isFinite(targetRange)||targetRange<=0||targetRange>64
||warning.isZero()||warning.isNegative()||warningTicks>63
||!Double.isFinite(radius)||radius<=0||radius>64
||!Double.isFinite(verticalRange)||verticalRange<0||verticalRange>64)
throw new IllegalArgumentException("Invalid Meteor settings");
}
public static Settings defaults(){return new Settings(BigInteger.valueOf(14),Duration.ofSeconds(45),24,
Duration.ofSeconds(2),5,4);}
}
private record Ground(Block block,Location origin){ }
private static final class Attempt {final AbilityExecution execution;final LivingEntity target;boolean accepted;Attempt(AbilityExecution e,LivingEntity t){execution=e;target=t;}}
private final class Warning implements AutoCloseable {
final AbilityExecution execution;final Player source;final Ground ground;final Settings captured;final BigInteger intelligence;
final int maximumTicks;int elapsed;boolean impacted;boolean closed;
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{target.damage(amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);ground.origin().getWorld().spawnParticle(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();world.spawnParticle(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);world.spawnParticle(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
@Override public void close(){if(closed)return;closed=true;warnings.remove(execution,this);}
}
private volatile Settings settings;private final EquipmentSessions sessions;private final PartyRoster parties;
private final Map<AbilityExecution,Warning> warnings=new IdentityHashMap<>();private Attempt damaging;private boolean closed;
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties){this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);}
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("meteor","Meteor",Material.FIRE_CHARGE,
List.of("Mark visible ground within "+decimal(current.targetRange())+" blocks for a "+seconds(current.warning())+"-second warning.","Impact exposed enemies in a "+decimal(current.radius())+"-block area for "+current.baseDamage()+" + INT modifier damage."),
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("base-damage",new BigDecimal(current.baseDamage()),"target-range",BigDecimal.valueOf(current.targetRange()).stripTrailingZeros(),"warning-seconds",nanos(current.warning()),"radius",BigDecimal.valueOf(current.radius()).stripTrailingZeros(),"vertical-range",BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros()),AbilityDefinition.TargetPolicy.AREA,this);}
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@Override public boolean accepts(ControlInput input){return !closed&&input.target()==null&&ground(input.player(),settings.targetRange())!=null;}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent())return;var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());if(target==null){execution.finish();return;}new Warning(execution,target,captured).start();}
private static Ground ground(Player player,double range){try{var eye=player.getEyeLocation();var hit=player.getWorld().rayTraceBlocks(eye,eye.getDirection(),range,FluidCollisionMode.NEVER,true);if(hit==null||hit.getHitBlock()==null||hit.getHitBlockFace()!=BlockFace.UP||!hit.getHitBlock().getType().isSolid())return null;var block=hit.getHitBlock();return new Ground(block,block.getLocation().add(.5,1,.5));}catch(RuntimeException invalid){return null;}}
private List<LivingEntity> candidates(Location origin,double radius,double vertical){List<Entity> nearby;try{nearby=new ArrayList<>(origin.getWorld().getNearbyEntities(origin,radius,vertical,radius));}catch(RuntimeException unavailable){return List.of();}var unique=new LinkedHashMap<UUID,LivingEntity>();int inspected=0;for(var entity:nearby){if(++inspected>256)break;if(entity instanceof LivingEntity living)unique.putIfAbsent(living.getUniqueId(),living);}var result=new ArrayList<>(unique.values());result.sort(Comparator.comparingDouble((LivingEntity target)->distance(origin,target)).thenComparing(LivingEntity::getUniqueId));return result;}
private boolean eligible(AbilityExecution execution,LivingEntity target,Location origin,double radius,double vertical){var source=execution.input().player();if(target==source||!inside(origin,target,radius,vertical)||!unobstructed(origin,target))return false;if(!(target instanceof Player player))return true;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return false;var sourceParty=parties.partyOf(source.getUniqueId()).orElse(null);var targetParty=parties.partyOf(player.getUniqueId()).orElse(null);return sourceParty==null||targetParty==null||!sourceParty.id().equals(targetParty.id());}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void acceptedDamage(EntityDamageEvent event){var attempt=damaging;if(attempt==null||attempt.accepted||event.isCancelled()||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;attempt.accepted=true;attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));}
private static boolean inside(Location origin,LivingEntity target,double radius,double vertical){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return false;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz<=radius*radius&&Math.abs(at.getY()-origin.getY())<=vertical;}catch(RuntimeException invalid){return false;}}
private static boolean unobstructed(Location origin,LivingEntity target){try{var world=origin.getWorld();var at=target.getLocation();if(world==null||at.getWorld()!=world)return false;var bounds=target.getBoundingBox();var center=bounds==null?at.toVector().add(new org.bukkit.util.Vector(0,target.getHeight()/2,0)):bounds.getCenter();var offset=center.subtract(origin.toVector());double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;}catch(RuntimeException invalid){return false;}}
private static double distance(Location origin,LivingEntity target){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;}catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}}
private static double finite(BigInteger value){return value.bitLength()>1023?Double.MAX_VALUE:value.doubleValue();}
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(p.get("base-damage").toBigIntegerExact(),definition.cooldown(),p.get("target-range").doubleValue(),Duration.ofNanos(p.get("warning-seconds").movePointRight(9).longValueExact()),p.get("radius").doubleValue(),p.get("vertical-range").doubleValue());}
private static BigDecimal nanos(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9);}private static String seconds(Duration value){return decimal(nanos(value));}private static String decimal(double value){return decimal(BigDecimal.valueOf(value));}private static String decimal(BigDecimal value){return value.stripTrailingZeros().toPlainString();}
@Override public void close(){if(closed)return;closed=true;damaging=null;for(var warning:List.copyOf(warnings.values())){warning.close();warning.execution.finish();}warnings.clear();}
}
@@ -0,0 +1,248 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Fixed bounded Sanctuary cylinder with delayed healing pulses and dynamic strongest mitigation. */
public final class Sanctuary implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, double radius, double verticalRange, Duration duration,
Duration pulseInterval, BigDecimal healingPerPulse, BigDecimal damageReductionPercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(pulseInterval);
Objects.requireNonNull(healingPerPulse); Objects.requireNonNull(damageReductionPercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|| pulseInterval.isZero() || pulseInterval.isNegative() || pulseInterval.compareTo(duration) > 0
|| duration.toNanos() / pulseInterval.toNanos() > 64
|| healingPerPulse.signum() < 0 || healingPerPulse.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|| damageReductionPercent.signum() < 0 || damageReductionPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new IllegalArgumentException("Invalid Sanctuary settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(45), 6, 3, Duration.ofSeconds(6), Duration.ofSeconds(1),
BigDecimal.valueOf(2), BigDecimal.valueOf(20));
}
}
private record Target(Player player, EquipmentSessions.Active active, CharacterRecord record) { }
private final class State implements AutoCloseable {
final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
int pending; boolean expired; boolean closed;
State(AbilityExecution execution, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
this.captured = captured; this.nextPulse = captured.pulseInterval();
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
this.scanPeriod = Duration.ofNanos(period);
}
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
void schedule() {
if (closed || expired) return;
Duration remaining = captured.duration().minus(elapsed);
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
}
void step() {
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
Duration remaining = captured.duration().minus(elapsed);
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
reconcile(); visual();
while (nextPulse.compareTo(elapsed) <= 0 && nextPulse.compareTo(captured.duration()) <= 0) {
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
}
if (elapsed.compareTo(captured.duration()) >= 0) {
expired = true; closeMitigation(); if (pending == 0) execution.finish();
} else schedule();
}
void reconcile() {
var eligible = recipients();
for (var entry : List.copyOf(mitigation.entrySet())) {
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
}
}
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
}
void pulse() {
for (var player : recipients()) if (owner(player) == this) heal(player);
}
void heal(Player player) {
var target = target(player); if (target == null) return;
var attribute = player.getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
double current = player.getHealth(), maximum = attribute.getValue(), missing = maximum - current;
double proposed = Math.min(missing, captured.healingPerPulse().doubleValue());
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(proposed) || proposed <= 0) return;
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event); double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
pending++; var expected = target.record().revision();
characters.saveHealth(target.record().id(), expected, BigDecimal.valueOf(current + accepted), nativeLimit.get())
.whenComplete((saved, failure) -> {
try { nextTick.accept(() -> completeHeal(this, target, expected, accepted, saved, failure)); }
catch (RuntimeException rejected) { completePending(this); }
});
}
List<Player> recipients() {
List<Entity> nearby;
try { nearby = new ArrayList<>(source.getWorld().getNearbyEntities(origin,
captured.radius(), captured.verticalRange(), captured.radius())); }
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256) break;
if (entity instanceof Player player && recipientCurrent(player)) unique.putIfAbsent(player.getUniqueId(), player);
}
var result = new ArrayList<>(unique.values());
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
return result.size() <= 64 ? result : new ArrayList<>(result.subList(0, 64));
}
boolean recipientCurrent(Player player) {
if (closed || !execution.stillCurrent() || !inside(origin, player, captured.radius(), captured.verticalRange())
|| !unobstructed(origin, player)) return false;
if (player == source) return sessions.active(player.getUniqueId()).filter(sessions::current)
.map(active -> active.equals(execution.input().session())).orElse(false);
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
return active != null && sameParty(source, player);
}
Target target(Player player) {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return record == null ? null : new Target(player, active, record);
}
void visual() {
try { source.getWorld().spawnParticle(Particle.END_ROD, origin, 12,
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
}
void closeMitigation() {
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
mitigation.clear();
}
@Override public void close() {
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
}
}
private volatile Settings settings;
private final CharacterStorage characters; private final EquipmentSessions sessions; private final PartyRoster parties;
private final CombatMitigation combatMitigation; private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents; private final Supplier<BigDecimal> nativeLimit;
private final Consumer<MendingLight.Support> supports; private final List<State> states = new ArrayList<>();
private boolean closed;
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
Consumer<MendingLight.Support> supports) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.combatMitigation = Objects.requireNonNull(combatMitigation); this.nextTick = Objects.requireNonNull(nextTick);
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
this.supports = Objects.requireNonNull(supports);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("sanctuary", "Sanctuary", Material.SEA_LANTERN,
List.of("Create a fixed " + decimal(current.radius()) + "-block sanctuary for " + seconds(current.duration()) + " seconds.",
"Party members inside heal " + decimal(current.healingPerPulse()) + " each second and take "
+ decimal(current.damageReductionPercent()) + "% less combat damage."),
"Use to hold exposed ground; healing starts after one second and the area does not follow you.",
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
"duration-seconds", nanos(current.duration()), "pulse-interval-seconds", nanos(current.pulseInterval()),
"healing-per-pulse", current.healingPerPulse(), "damage-reduction-percent", current.damageReductionPercent()),
AbilityDefinition.TargetPolicy.AREA, this);
}
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return; new State(execution, from(execution.definition())).start();
}
private void completeHeal(State state, Target target, BigInteger expected, double accepted,
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (failure != null || saved == null || saved.isEmpty() || !state.execution.stillCurrent()
|| !state.recipientCurrent(target.player())) return;
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
double current = target.player().getHealth(), maximum = attribute.getValue();
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
target.player().setHealth(Math.min(maximum, current + restored));
if (target.player() != state.source) supports.accept(new MendingLight.Support(
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
} finally { completePending(state); }
}
private static void completePending(State state) {
if (state.pending > 0) state.pending--; if (state.expired && state.pending == 0) state.execution.finish();
}
private State owner(Player player) {
for (var state : List.copyOf(states)) if (!state.expired
&& state.mitigation.containsKey(player.getUniqueId())) return state;
return null;
}
private boolean sameParty(Player source, Player target) {
var party = parties.partyOf(source.getUniqueId()).orElse(null);
return party != null && party.members().contains(target.getUniqueId());
}
private static boolean inside(Location origin, Player player, double radius, double vertical) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ();
return dx * dx + dz * dz <= radius * radius && Math.abs(at.getY() - origin.getY()) <= vertical;
} catch (RuntimeException invalid) { return false; }
}
private static boolean unobstructed(Location origin, Player player) {
try { var world = origin.getWorld(); if (world == null) return false; var box = player.getBoundingBox();
var center = box.getCenter(); var offset = center.clone().subtract(origin.toVector()); double distance = offset.length();
if (!Double.isFinite(distance) || distance == 0) return distance == 0;
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance, org.bukkit.FluidCollisionMode.NEVER, true) == null;
} catch (RuntimeException invalid) { return false; }
}
private static double distance(Location origin, Player player) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
} catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
}
private static Settings from(AbilityDefinition definition) { var p = definition.parameters();
return new Settings(definition.cooldown(), p.get("radius").doubleValue(), p.get("vertical-range").doubleValue(),
duration(p.get("duration-seconds")), duration(p.get("pulse-interval-seconds")),
p.get("healing-per-pulse"), p.get("damage-reduction-percent")); }
private static Duration duration(BigDecimal seconds) { return Duration.ofNanos(seconds.movePointRight(9).longValueExact()); }
private static BigDecimal nanos(Duration value) { return BigDecimal.valueOf(value.toNanos()).movePointLeft(9); }
private static String seconds(Duration value) { return decimal(nanos(value)); }
private static String decimal(double value) { return decimal(BigDecimal.valueOf(value)); }
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
@Override public void close() {
if (closed) return; closed = true; for (var state : List.copyOf(states)) { state.close(); state.execution.finish(); } states.clear();
}
}
@@ -0,0 +1,241 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import org.bukkit.Color;
import org.bukkit.FluidCollisionMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Particle;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.attribute.AttributeModifier;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
/** Fixed spectral War Banner cylinder with strongest damage and one owned knockback-resistance layer. */
public final class WarBanner implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, double placementRange, double radius, double verticalRange,
Duration duration, BigDecimal damageBonusPercent, BigDecimal knockbackResistancePercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration);
Objects.requireNonNull(damageBonusPercent); Objects.requireNonNull(knockbackResistancePercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(placementRange) || placementRange <= 0 || placementRange > 64
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|| damageBonusPercent.signum() < 0 || damageBonusPercent.compareTo(BigDecimal.valueOf(100)) > 0
|| knockbackResistancePercent.signum() < 0
|| knockbackResistancePercent.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new IllegalArgumentException("Invalid War Banner settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(45), 12, 8, 4, Duration.ofSeconds(10),
BigDecimal.valueOf(25), BigDecimal.valueOf(50));
}
}
private final class Area implements AutoCloseable {
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
boolean closed;
Area(AbilityExecution execution, Location origin, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
}
void refresh() {
if (closed) return;
if (!sourceCurrent()) { execution.finish(); return; }
var eligible = candidates();
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
visual();
}
boolean sourceCurrent() {
return execution.stillCurrent() && sessions.active(source.getUniqueId())
.filter(active -> active.equals(execution.input().session()) && sessions.current(active)).isPresent();
}
boolean eligible(Player player) {
if (!sourceCurrent() || !inside(origin, player, captured.radius(), captured.verticalRange())
|| !unobstructed(origin, player)) return false;
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
if (active == null) return false;
if (player == source) return active.equals(execution.input().session());
var sourceParty = parties.partyOf(source.getUniqueId()).orElse(null);
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
return sourceParty != null && targetParty != null && sourceParty.id().equals(targetParty.id());
}
Map<UUID, Player> candidates() {
List<Entity> nearby;
try { nearby = new ArrayList<>(origin.getWorld().getNearbyEntities(origin,
captured.radius(), captured.verticalRange(), captured.radius())); }
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256) break;
if (entity instanceof Player player && eligible(player)) unique.putIfAbsent(player.getUniqueId(), player);
}
var result = new ArrayList<>(unique.values());
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
if (result.size() > 64) result.subList(64, result.size()).clear();
var ordered = new LinkedHashMap<UUID, Player>(); for (var player : result) ordered.put(player.getUniqueId(), player); return ordered;
}
void visual() {
try { origin.getWorld().spawnParticle(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
0.25, 1.5, 0.25, 0, new Particle.DustOptions(Color.RED, 1.5f)); }
catch (RuntimeException ignored) { }
}
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
BooleanSupplier current = () -> !closed && area.eligible(player);
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(resistance);
}
}
private final class ResistanceGrant implements AutoCloseable {
final ResistanceState state; final long sequence = ++resistanceSequences; final BigDecimal percent;
final BooleanSupplier current; boolean closed;
ResistanceGrant(ResistanceState state, BigDecimal percent, BooleanSupplier current) {
this.state = state; this.percent = percent; this.current = current;
}
boolean valid() { try { return !closed && current.getAsBoolean(); } catch (RuntimeException invalid) { return false; } }
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
}
private final class ResistanceState {
final Player player; final List<ResistanceGrant> grants = new ArrayList<>(); AttributeModifier owned;
ResistanceState(Player player) { this.player = player; }
void refresh() {
AttributeInstance attribute;
try { attribute = player.getAttribute(Attribute.KNOCKBACK_RESISTANCE); }
catch (RuntimeException unavailable) { return; }
if (attribute == null) return;
ResistanceGrant strongest = null;
for (var grant : List.copyOf(grants)) if (grant.valid() && (strongest == null
|| grant.percent.compareTo(strongest.percent) > 0
|| grant.percent.compareTo(strongest.percent) == 0 && grant.sequence < strongest.sequence)) strongest = grant;
double desired = strongest == null ? 0 : strongest.percent.movePointLeft(2).doubleValue();
if (owned != null && (!attribute.getModifiers().contains(owned) || Double.compare(owned.getAmount(), desired) != 0)) {
attribute.removeModifier(owned); owned = null;
}
if (strongest != null && owned == null) {
var key = new NamespacedKey("fantasystick", "war_banner_" + player.getUniqueId());
for (var modifier : List.copyOf(attribute.getModifiers())) if (modifier.getKey().equals(key)) attribute.removeModifier(modifier);
owned = new AttributeModifier(key, desired, AttributeModifier.Operation.ADD_NUMBER); attribute.addModifier(owned);
}
if (strongest == null) { resistances.remove(player.getUniqueId(), this); owned = null; }
}
}
private volatile Settings settings;
private final EquipmentSessions sessions; private final PartyRoster parties; private final OutgoingDamageBonus bonuses;
private final Map<Long, Area> areas = new java.util.HashMap<>(); private final Map<UUID, ResistanceState> resistances = new java.util.HashMap<>();
private long sequences; private long resistanceSequences; private org.bukkit.scheduler.BukkitTask sweepTask; private boolean closed;
public WarBanner(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin); if (closed || sweepTask != null) throw new IllegalStateException("War Banner sweep already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
public void sweep() {
if (closed) return; int checked = 0;
for (var area : List.copyOf(areas.values())) { area.refresh(); if (++checked == 64) break; }
for (var state : List.copyOf(resistances.values())) state.refresh();
}
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("war_banner", "War Banner", Material.RED_BANNER,
List.of("Plant a spectral banner within " + decimal(current.placementRange()) + " blocks for " + seconds(current.duration()) + " seconds.",
"Party members in its " + decimal(current.radius()) + "-block area gain " + decimal(current.damageBonusPercent())
+ "% combat damage and " + decimal(current.knockbackResistancePercent()) + "% knockback resistance."),
"Use while looking at the solid top surface where the fixed banner should stand; copies do not stack.",
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("placement-range", BigDecimal.valueOf(current.placementRange()).stripTrailingZeros(),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
"duration-seconds", nanos(current.duration()), "damage-bonus-percent", current.damageBonusPercent(),
"knockback-resistance-percent", current.knockbackResistancePercent()), AbilityDefinition.TargetPolicy.AREA, this);
}
@Override public boolean accepts(ControlInput input) {
return !closed && input.target() == null && placement(input.player(), settings.placementRange()) != null;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
var origin = placement(execution.input().player(), captured.placementRange());
if (origin == null) { execution.finish(); return; }
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
area.refresh(); execution.later(captured.duration(), execution::finish);
}
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
var state = resistances.computeIfAbsent(player.getUniqueId(), ignored -> new ResistanceState(player));
var grant = new ResistanceGrant(state, percent, current); state.grants.add(grant); state.refresh(); return grant;
}
private static Location placement(Player player, double range) {
try { var eye = player.getEyeLocation(); var hit = player.getWorld().rayTraceBlocks(eye, eye.getDirection(), range,
FluidCollisionMode.NEVER, true);
if (hit == null || hit.getHitBlock() == null || hit.getHitBlockFace() != BlockFace.UP
|| !hit.getHitBlock().getType().isSolid()) return null;
return hit.getHitBlock().getLocation().add(0.5, 1, 0.5);
} catch (RuntimeException invalid) { return null; }
}
private static boolean inside(Location origin, Player player, double radius, double vertical) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
double dx=at.getX()-origin.getX(), dz=at.getZ()-origin.getZ();
return dx*dx+dz*dz<=radius*radius && Math.abs(at.getY()-origin.getY())<=vertical;
} catch (RuntimeException invalid) { return false; }
}
private static boolean unobstructed(Location origin, Player player) {
try { var world=origin.getWorld(); if(world==null)return false;var offset=player.getBoundingBox().getCenter().subtract(origin.toVector());
double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;
return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;
} catch(RuntimeException invalid){return false;}
}
private static double distance(Location origin, Player player) {
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
} catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}
}
private static Settings from(AbilityDefinition definition) { var p=definition.parameters();return new Settings(definition.cooldown(),
p.get("placement-range").doubleValue(),p.get("radius").doubleValue(),p.get("vertical-range").doubleValue(),
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-bonus-percent"),p.get("knockback-resistance-percent")); }
private static BigDecimal nanos(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9);}
private static String seconds(Duration value){return decimal(nanos(value));}
private static String decimal(double value){return decimal(BigDecimal.valueOf(value));}
private static String decimal(BigDecimal value){return value.stripTrailingZeros().toPlainString();}
private static void closeResource(AutoCloseable resource){try{resource.close();}catch(Exception impossible){throw new IllegalStateException(impossible);}}
@Override public void close(){if(closed)return;closed=true;if(sweepTask!=null)sweepTask.cancel();for(var area:List.copyOf(areas.values()))area.execution.finish();areas.clear();for(var state:List.copyOf(resistances.values())){for(var grant:List.copyOf(state.grants))grant.close();}resistances.clear();}
}
@@ -9,6 +9,15 @@ public final class BookPagination {
private BookPagination() { }
public static List<Component> pages(String text) {
var lines = lines(text);
var pages = new ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
}
return List.copyOf(pages);
}
static List<String> lines(String text) {
var lines = new ArrayList<String>();
var line = new StringBuilder();
int width = 0;
@@ -28,10 +37,6 @@ public final class BookPagination {
line.appendCodePoint(codePoint); width += advance;
}
lines.add(line.toString());
var pages = new ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
}
return List.copyOf(pages);
return List.copyOf(lines);
}
}
@@ -29,15 +29,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private volatile boolean closed;
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and party pages.
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
private static final net.kyori.adventure.key.Key ABILITY_ASSIGN = net.kyori.adventure.key.Key.key("fantasystick", "ability_assign");
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})(?::([0-6]))?\"");
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
@@ -107,47 +106,47 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
var first = new StringBuilder(record.name()).append("\n\n");
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
String xp = record.level() >= rules.maximumLevel() ? "MAX"
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
String learned = record.learned().isEmpty() ? "none" : String.join(", ", record.learned().stream().sorted().toList());
String second = "Level: " + record.level() + "\nXP: " + xp + "\nUnspent points: " + record.unspentPoints()
+ "\nHealth: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString()
+ "\n\nAbilities: " + learned;
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var all = BookPagination.pages(first + "\n" + second);
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, all.size());
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
statViews.put(input.session().player(), statView);
Component purchases = Component.text("Spend 1 point\n\n");
for (var stat : Stat.values()) {
purchases = purchases.append(Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + statView.nonce() + ":" + stat + "\""))))
.append(Component.newline());
}
pages.add(purchases);
var definitions = abilities.definitions();
if (!definitions.isEmpty() && end == all.size()) {
var allOverview = overviewPages(record, input, statView);
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::id))
.toList();
if (!definitions.isEmpty() && end == allOverview.size()) {
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
abilityViews.put(input.session().player(), abilityView);
for (var definition : definitions) pages.addAll(abilityPages(record, definition, abilityView));
var details = definitions.stream().map(definition -> abilityPages(record, definition, abilityView)).toList();
var placeholderTargets = definitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.ability.AbilityDefinition::id, ignored -> 1));
int activeCount = activePages(record, definitions, placeholderTargets).size();
int indexCount = indexPages(definitions, placeholderTargets).size();
var targets = new java.util.HashMap<String, Integer>();
int target = pages.size() + activeCount + indexCount + 1;
for (int index = 0; index < definitions.size(); index++) {
targets.put(definitions.get(index).id(), target);
target += details.get(index).size();
}
pages.addAll(activePages(record, definitions, targets));
pages.addAll(indexPages(definitions, targets));
details.forEach(pages::addAll);
}
if (all.size() > VOLUME_SIZE) {
if (allOverview.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < all.size() ? end : -1);
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + all.size());
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
pages.add(navigation);
@@ -158,6 +157,70 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return item;
}
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
var builder = new PageBuilder();
builder.text(record.name());
builder.blank();
for (var stat : Stat.values()) {
Component action = record.unspentPoints().signum() > 0
? statButton(stat, view) : null;
builder.inline(stat + ": " + record.stats().get(stat), action);
}
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
String xp = record.level() >= rules.maximumLevel() ? "MAX"
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
builder.text("Level: " + record.level());
builder.text("XP: " + xp);
builder.text("Unspent points: " + record.unspentPoints());
builder.text("Health: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString());
return builder.pages();
}
private static Component statButton(Stat stat, StatView view) {
return Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + stat + "\"")));
}
private static java.util.List<Component> activePages(CharacterRecord record,
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var byId = definitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.ability.AbilityDefinition::id, java.util.function.Function.identity()));
var builder = new PageBuilder();
builder.text("Active abilities"); builder.blank();
for (int slot = 0; slot < record.loadout().size(); slot++) {
String id = record.loadout().get(slot);
var definition = byId.get(id);
if (id.isEmpty()) builder.text((slot + 3) + ": No Ability");
else if (definition == null) builder.text((slot + 3) + ": " + id);
else builder.link((slot + 3) + ": ", definition.name(), targets.get(id));
}
var unassigned = record.learned().stream().filter(id -> !record.loadout().contains(id)).sorted().toList();
if (!unassigned.isEmpty()) {
builder.blank(); builder.text("Unassigned learned:");
for (var id : unassigned) {
var definition = byId.get(id);
if (definition == null) builder.text(id);
else builder.link("", definition.name(), targets.get(id));
}
}
return builder.pages();
}
private static java.util.List<Component> indexPages(
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
builder.link("", definition.name(), targets.get(definition.id()));
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
}
private static java.util.List<Component> abilityPages(CharacterRecord record,
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
@@ -166,29 +229,71 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
boolean learned = record.learned().contains(definition.id());
boolean stats = definition.requirements().entrySet().stream()
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
boolean points = record.unspentPoints().compareTo(definition.pointCost()) >= 0;
boolean slot = record.loadout().contains("");
String state = learned ? "Learned" : !stats ? "Unavailable: requirements"
: record.unspentPoints().compareTo(definition.pointCost()) < 0 ? "Unavailable: points" : "Available";
String parameters = definition.parameters().isEmpty() ? "" : "\nParameters: " + definition.parameters().entrySet().stream()
: !points ? "Unavailable: points" : !slot ? "Unavailable: slots" : "Available";
var builder = new PageBuilder();
builder.text(definition.name());
builder.text("Cost: " + definition.pointCost());
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank();
definition.lore().forEach(builder::text);
builder.blank(); builder.text(definition.instructions());
builder.text("Requires: " + requirements);
builder.text("Cooldown: " + durationSeconds(definition.cooldown()) + "s");
if (!definition.parameters().isEmpty()) builder.text("Parameters: " + definition.parameters().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(java.util.stream.Collectors.joining(", "));
String text = definition.name() + "\nIcon: " + definition.icon().name()
+ "\n\n" + String.join("\n", definition.lore()) + "\n\n" + definition.instructions()
+ "\nRequires: " + requirements + "\nCost: " + definition.pointCost()
+ "\nCooldown: " + durationSeconds(definition.cooldown()) + "s" + parameters + "\n\n" + state;
var pages = new java.util.ArrayList<>(BookPagination.pages(text));
if (!learned && stats && record.unspentPoints().compareTo(definition.pointCost()) >= 0) {
pages.add(Component.text("Ability action\n\n").append(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id(), null)));
} else if (learned) {
Component action = Component.text("Assign ability\n\nSlot:\n");
for (int slot = 0; slot < 7; slot++) {
if (slot > 0) action = action.append(slot == 4 ? Component.newline() : Component.space());
action = action.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
.collect(java.util.stream.Collectors.joining(", ")));
builder.text(state);
return builder.pages();
}
private static final class PageBuilder {
private final java.util.List<Component> lines = new java.util.ArrayList<>();
void blank() { lines.add(Component.empty()); }
void text(String text) { BookPagination.lines(text).forEach(line -> lines.add(Component.text(line))); }
void component(Component component) { lines.add(component); }
void inline(String text, Component action) {
var wrapped = BookPagination.lines(text);
for (int index = 0; index < wrapped.size() - 1; index++) lines.add(Component.text(wrapped.get(index)));
var last = Component.text(wrapped.getLast());
if (action != null && displayWidth(wrapped.getLast()) + 6 + 48 <= 108) {
lines.add(last.append(Component.space()).append(action));
} else {
lines.add(last);
if (action != null) lines.add(action);
}
action = action.append(Component.text("\n\nRefund: "))
.append(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id(), null));
pages.add(action);
}
return java.util.List.copyOf(pages);
void link(String prefix, String label, int page) {
if (displayWidth(prefix + label) <= 108) {
lines.add(Component.text(prefix).append(Component.text(label, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page))));
return;
}
for (var part : BookPagination.lines(prefix + label)) {
lines.add(Component.text(part, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page)));
}
}
private static int displayWidth(String text) {
return text.codePoints().map(codePoint -> codePoint < 128 ? 6 : 9).sum();
}
java.util.List<Component> pages() {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
Component page = Component.empty();
for (int index = start; index < Math.min(start + 13, lines.size()); index++) {
if (index > start) page = page.append(Component.newline());
page = page.append(lines.get(index));
}
pages.add(page);
}
if (pages.isEmpty()) pages.add(Component.empty());
return java.util.List.copyOf(pages);
}
}
private static String durationSeconds(java.time.Duration duration) {
@@ -197,8 +302,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private static Component abilityButton(String label, net.kyori.adventure.key.Key action, AbilityView view,
String ability, Integer slot) {
String payload = "\"" + view.nonce() + ":" + ability + (slot == null ? "" : ":" + slot) + "\"";
String ability) {
String payload = "\"" + view.nonce() + ":" + ability + "\"";
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(action,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
@@ -292,9 +397,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
@org.bukkit.event.EventHandler
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
boolean assign = ABILITY_ASSIGN.equals(event.getIdentifier());
boolean unlearn = ABILITY_UNLEARN.equals(event.getIdentifier());
if (closed || abilityEngine == null || !unlock && !assign && !unlearn
if (closed || abilityEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer();
@@ -304,12 +408,10 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (raw.length() > 120) return;
var match = ABILITY_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|| assign != (match.group(3) != null)
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String ability = match.group(2);
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
: unlearn ? abilityEngine.unlearn(view.input(), ability, view.revision())
: abilityEngine.assign(view.input(), ability, Integer.parseInt(match.group(3)), view.revision());
: abilityEngine.unlearn(view.input(), ability, view.revision());
operation.whenComplete((mutation, failure) -> {
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
@@ -317,22 +419,22 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
player.sendMessage("Ability change could not be confirmed; reopen the character after session recovery.");
return;
}
player.sendMessage(abilityMessage(unlock, unlearn, ability, mutation.status()));
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
load(view.input(), 0);
});
}
private static String abilityMessage(boolean unlock, boolean unlearn, String ability,
private static String abilityMessage(boolean unlock, String ability,
CharacterStorage.AbilityMutationStatus status) {
return switch (status) {
case APPLIED -> unlock ? "Learned " + ability + "."
: unlearn ? "Unlearned " + ability + " and refunded 1 point." : "Assigned " + ability + ".";
case UNCHANGED -> ability + " is already assigned there.";
case APPLIED -> unlock ? "Learned " + ability + " and assigned it to the first available slot."
: "Unlearned " + ability + " and refunded 1 point.";
case UNCHANGED -> ability + " is unchanged.";
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
case NO_POINTS -> "This character does not have enough unspent points.";
case LOADOUT_FULL -> "All seven ability slots are occupied; unlearn an ability before learning another.";
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
case NOT_LEARNED -> unlearn ? "This character no longer knows " + ability + "."
: "Learn " + ability + " before assigning it.";
case NOT_LEARNED -> "This character no longer knows " + ability + ".";
case STALE -> "That character page is stale; reopen it before changing abilities.";
case COOLDOWN -> "That ability is still on cooldown.";
case UNAVAILABLE -> "That character or ability is unavailable.";
@@ -295,7 +295,7 @@ public final class CharacterStorage implements AutoCloseable {
}
public enum AbilityMutationStatus {
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, LOADOUT_FULL, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
}
public record AbilityMutation(AbilityMutationStatus status, Optional<CharacterRecord> record) { }
@@ -317,9 +317,12 @@ public final class CharacterStorage implements AutoCloseable {
return new AbilityMutation(AbilityMutationStatus.REQUIREMENTS, Optional.of(old));
}
if (old.unspentPoints().compareTo(cost) < 0) return new AbilityMutation(AbilityMutationStatus.NO_POINTS, Optional.of(old));
int slot = old.loadout().indexOf("");
if (slot < 0) return new AbilityMutation(AbilityMutationStatus.LOADOUT_FULL, Optional.of(old));
var learned = new java.util.HashSet<>(old.learned()); learned.add(ability);
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, ability);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, old.loadout(), old.cooldowns(),
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
@@ -0,0 +1,176 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Meteor;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Meteor settings; publication remains a server-thread caller decision. */
public final class MeteorSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "meteor-base-damage";
public static final String COOLDOWN = "meteor-cooldown-seconds";
public static final String TARGET_RANGE = "meteor-target-range-blocks";
public static final String WARNING = "meteor-warning-seconds";
public static final String RADIUS = "meteor-radius-blocks";
public static final String VERTICAL = "meteor-vertical-range-blocks";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, TARGET_RANGE, WARNING, RADIUS, VERTICAL);
private static final Map<String, String> DEFAULTS = Map.of(
BASE_DAMAGE, "14", COOLDOWN, "45", TARGET_RANGE, "24", WARNING, "2", RADIUS, "5", VERTICAL, "4");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Meteor.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-meteor-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public MeteorSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
MeteorSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Meteor.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Meteor configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Meteor.Settings parse(Map<String, String> values) {
try { return new Meteor.Settings(new BigDecimal(values.get(BASE_DAMAGE)).toBigIntegerExact(),
seconds(values.get(COOLDOWN)), finite(values.get(TARGET_RANGE)), seconds(values.get(WARNING)),
finite(values.get(RADIUS)), finiteNonnegative(values.get(VERTICAL))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Meteor settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private static double finiteNonnegative(String value) {
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,177 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Sanctuary;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Sanctuary settings; publication remains a server-thread caller decision. */
public final class SanctuarySettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "sanctuary-cooldown-seconds";
public static final String RADIUS = "sanctuary-radius-blocks";
public static final String VERTICAL = "sanctuary-vertical-range-blocks";
public static final String DURATION = "sanctuary-duration-seconds";
public static final String INTERVAL = "sanctuary-pulse-interval-seconds";
public static final String HEALING = "sanctuary-healing-per-pulse";
public static final String REDUCTION = "sanctuary-damage-reduction-percent";
private static final List<String> KEYS = List.of(COOLDOWN, RADIUS, VERTICAL, DURATION, INTERVAL, HEALING, REDUCTION);
private static final Map<String, String> DEFAULTS = Map.of(
COOLDOWN, "45", RADIUS, "6", VERTICAL, "3", DURATION, "6", INTERVAL, "1", HEALING, "2", REDUCTION, "20");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Sanctuary.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-sanctuary-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public SanctuarySettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
SanctuarySettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Sanctuary.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Sanctuary configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Sanctuary.Settings parse(Map<String, String> values) {
try { return new Sanctuary.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
finiteNonnegative(values.get(VERTICAL)), seconds(values.get(DURATION)), seconds(values.get(INTERVAL)),
new BigDecimal(values.get(HEALING)), new BigDecimal(values.get(REDUCTION))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Sanctuary settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private static double finiteNonnegative(String value) {
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,179 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.WarBanner;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable WarBanner settings; publication remains a server-thread caller decision. */
public final class WarBannerSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "war-banner-cooldown-seconds";
public static final String PLACEMENT_RANGE = "war-banner-placement-range-blocks";
public static final String RADIUS = "war-banner-radius-blocks";
public static final String VERTICAL = "war-banner-vertical-range-blocks";
public static final String DURATION = "war-banner-duration-seconds";
public static final String DAMAGE_BONUS = "war-banner-damage-bonus-percent";
public static final String KNOCKBACK_RESISTANCE = "war-banner-knockback-resistance-percent";
private static final List<String> KEYS = List.of(COOLDOWN, PLACEMENT_RANGE, RADIUS, VERTICAL, DURATION,
DAMAGE_BONUS, KNOCKBACK_RESISTANCE);
private static final Map<String, String> DEFAULTS = Map.of(
COOLDOWN, "45", PLACEMENT_RANGE, "12", RADIUS, "8", VERTICAL, "4", DURATION, "10",
DAMAGE_BONUS, "25", KNOCKBACK_RESISTANCE, "50");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, WarBanner.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-war-banner-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public WarBannerSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
WarBannerSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public WarBanner.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("WarBanner configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static WarBanner.Settings parse(Map<String, String> values) {
try { return new WarBanner.Settings(seconds(values.get(COOLDOWN)), finite(values.get(PLACEMENT_RANGE)),
finite(values.get(RADIUS)), finiteNonnegative(values.get(VERTICAL)), seconds(values.get(DURATION)),
new BigDecimal(values.get(DAMAGE_BONUS)), new BigDecimal(values.get(KNOCKBACK_RESISTANCE))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("WarBanner settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private static double finiteNonnegative(String value) {
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -51,6 +51,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.ability.FrostNova frostNova;
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
private games.dmg.fantasystick.ability.Sanctuary sanctuary;
private games.dmg.fantasystick.persistence.WarBannerSettings warBannerSettings;
private games.dmg.fantasystick.ability.WarBanner warBanner;
private games.dmg.fantasystick.persistence.MeteorSettings meteorSettings;
private games.dmg.fantasystick.ability.Meteor meteor;
private AdminCharacterIssuer characterIssuer;
private AdminRevival adminRevival;
private AdminExperienceGrant experienceGrant;
@@ -265,6 +271,24 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
chainLightningSettings = Objects.requireNonNull(settings); chainLightning = Objects.requireNonNull(effect);
}
public void attachSanctuary(games.dmg.fantasystick.persistence.SanctuarySettings settings,
games.dmg.fantasystick.ability.Sanctuary effect) {
if (closed || sanctuarySettings != null) throw new IllegalStateException("Sanctuary already attached or command closed");
sanctuarySettings = Objects.requireNonNull(settings); sanctuary = Objects.requireNonNull(effect);
}
public void attachWarBanner(games.dmg.fantasystick.persistence.WarBannerSettings settings,
games.dmg.fantasystick.ability.WarBanner effect) {
if (closed || warBannerSettings != null) throw new IllegalStateException("War Banner already attached or command closed");
warBannerSettings = Objects.requireNonNull(settings); warBanner = Objects.requireNonNull(effect);
}
public void attachMeteor(games.dmg.fantasystick.persistence.MeteorSettings settings,
games.dmg.fantasystick.ability.Meteor effect) {
if (closed || meteorSettings != null) throw new IllegalStateException("Meteor already attached or command closed");
meteorSettings = Objects.requireNonNull(settings); meteor = Objects.requireNonNull(effect);
}
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
@@ -486,6 +510,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (chainLightningSettings != null && games.dmg.fantasystick.persistence.ChainLightningSettings.keys().contains(args[1])) {
chainLightningConfiguration(sender, args); return;
}
if (sanctuarySettings != null && games.dmg.fantasystick.persistence.SanctuarySettings.keys().contains(args[1])) {
sanctuaryConfiguration(sender, args); return;
}
if (warBannerSettings != null && games.dmg.fantasystick.persistence.WarBannerSettings.keys().contains(args[1])) {
warBannerConfiguration(sender, args); return;
}
if (meteorSettings != null && games.dmg.fantasystick.persistence.MeteorSettings.keys().contains(args[1])) {
meteorConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -748,6 +781,59 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void sanctuaryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + sanctuarySettings.value(key) + " (applies to future Sanctuary casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SanctuarySettings.Update> operation;
try { operation = sanctuarySettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Sanctuary setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition()); sanctuarySettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Sanctuary casts; active areas and deadlines are unchanged."
: "Sanctuary configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void warBannerConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) { sender.sendMessage(key + "=" + warBannerSettings.value(key) + " (applies to future War Banner casts only)"); return; }
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.WarBannerSettings.Update> operation;
try { operation = warBannerSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("War Banner setting is invalid; the effective value is unchanged."); return; }
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure); if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) { warBanner.configure(saved.settings()); abilities.replace(warBanner.definition()); warBannerSettings.publish(saved); }
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future War Banner casts; active areas and deadlines are unchanged."
: "War Banner configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void meteorConfiguration(CommandSender sender, String[] args) {
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+meteorSettings.value(key)+" (applies to future Meteor casts only)");return;}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MeteorSettings.Update> operation;
try{operation=meteorSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Meteor setting is invalid; the effective value is unchanged.");return;}
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null){meteor.configure(saved.settings());abilities.replace(meteor.definition());meteorSettings.publish(saved);}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Meteor casts; active warnings and deadlines are unchanged.":"Meteor configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
}
private void hexConfiguration(CommandSender sender, String[] args) {
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
@@ -908,6 +994,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
keys.addAll(games.dmg.fantasystick.persistence.RallyingCrySettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.FrostNovaSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.ChainLightningSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.SanctuarySettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.WarBannerSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.MeteorSettings.keys());
return matching(keys, args[1]);
}
if (args.length == 3 && args[0].equals("config")) {
@@ -986,6 +1075,22 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
String defaults = games.dmg.fantasystick.persistence.ChainLightningSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (sanctuarySettings != null
&& games.dmg.fantasystick.persistence.SanctuarySettings.keys().contains(args[1])) {
String current = sanctuarySettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.SanctuarySettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (warBannerSettings != null
&& games.dmg.fantasystick.persistence.WarBannerSettings.keys().contains(args[1])) {
String current = warBannerSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.WarBannerSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (meteorSettings != null && games.dmg.fantasystick.persistence.MeteorSettings.keys().contains(args[1])) {
String current=meteorSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.MeteorSettings.defaultValue(args[1]);
return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);
}
}
return List.of();
}
+29
View File
@@ -132,3 +132,32 @@ chain-lightning-initial-range-blocks: 16
chain-lightning-jump-range-blocks: 6
chain-lightning-additional-targets: 3
chain-lightning-jump-multiplier: 0.75
# Sanctuary defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
sanctuary-cooldown-seconds: 45
sanctuary-radius-blocks: 6
sanctuary-vertical-range-blocks: 3
sanctuary-duration-seconds: 6
sanctuary-pulse-interval-seconds: 1
sanctuary-healing-per-pulse: 2
sanctuary-damage-reduction-percent: 20
# War Banner defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
war-banner-cooldown-seconds: 45
war-banner-placement-range-blocks: 12
war-banner-radius-blocks: 8
war-banner-vertical-range-blocks: 4
war-banner-duration-seconds: 10
war-banner-damage-bonus-percent: 25
war-banner-knockback-resistance-percent: 50
# Meteor defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
meteor-base-damage: 14
meteor-cooldown-seconds: 45
meteor-target-range-blocks: 24
meteor-warning-seconds: 2
meteor-radius-blocks: 5
meteor-vertical-range-blocks: 4
@@ -146,7 +146,7 @@ class AbilityEngineNativeTest {
String available = PartyBookNativeTest.text(a);
String compactAvailable = available.replaceAll("\\s+", "");
assertTrue(compactAvailable.contains("TestFocus"), available);
assertTrue(compactAvailable.contains("AMETHYST_SHARD"), available);
assertFalse(compactAvailable.contains("Icon:"), available);
assertTrue(compactAvailable.contains("Cost:1"), available);
assertTrue(compactAvailable.contains("Cooldown:4s"), available);
var learn = PartyBookNativeTest.button(a, "[Learn]");
@@ -157,10 +157,7 @@ class AbilityEngineNativeTest {
assertEquals(java.util.Set.of("test_focus"), learned.learned());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints(), "Repeated stale book action cannot spend twice");
var assign = PartyBookNativeTest.button(a, "[3]");
PartyBookNativeTest.click(f, a, assign); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals("test_focus", assigned.loadout().getFirst());
assertEquals("test_focus", learned.loadout().getFirst());
var control = a.core.inventory.getItem(2);
assertEquals(Material.AMETHYST_SHARD, control.getType());
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(control).orElseThrow());
@@ -38,7 +38,6 @@ class ChainLightningNativeTest {
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
assertTrue(text.contains("ChainLightning")); assertTrue(text.contains("INT20")); assertTrue(text.contains("DEX12"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "chain_lightning")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "chain_lightning")); drain(fixture, 2);
var first = mock(LivingEntity.class); var second = mock(LivingEntity.class);
locate(first, caster.world, UUID.randomUUID(), 10); locate(second, caster.world, UUID.randomUUID(), 14);
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
@@ -72,7 +72,7 @@ class CharacterBookNativeTest {
assertEquals(1, a.books.size());
String rendered = text(a.books.getFirst()).replace("\n", "");
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "STR: " + strength,
"revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
"Revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
assertEquals(saved, characters(f).find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@@ -218,7 +218,7 @@ class CharacterBookNativeTest {
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
stats, old.level(), old.experience(), java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
byte[] bytes = Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml"));
click(f, a); drainReads(f); runQueued(f);
@@ -307,6 +307,120 @@ class CharacterBookNativeTest {
}
}
@Test void linkedBookOrdersOverviewActiveIndexAndConciseAbilityDetailsWithAutomaticAssignment() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(12));
var eligible = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(eligible, old.revision());
f.players.put(a.core.id, a.player); f.activate(a);
PartyBookNativeTest.open(f, a);
var pages = nativePages(a);
assertTrue(pages.size() <= 100, "Keep each native linked-book view bounded");
assertTrue(pages.get(0).getString().contains("STR: 0 [+1 STR]"));
assertTrue(pages.get(0).getString().contains("INT: 12 [+1 INT]"));
assertTrue(pages.get(1).getString().startsWith("Active abilities"));
assertTrue(pages.get(1).getString().contains("3: No Ability"));
int index = firstPageStarting(pages, "Ability index");
assertTrue(index > 1);
var indexedNames = new java.util.ArrayList<String>();
int partyPage = firstPageStarting(pages, "Party");
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Ability index")) {
indexedNames.add(line);
var jump = changePage(pages.get(page), line);
assertNotNull(jump, line);
assertTrue(jump.page() - 1 < partyPage, "Ability details must precede party controls");
}
}
var sortedNames = indexedNames.stream().sorted().toList();
assertEquals(sortedNames, indexedNames);
assertEquals(20, indexedNames.size());
var fireballJump = changePage(pages.get(index), "Fireball");
assertNotNull(fireballJump);
var detail = pages.get(fireballJump.page() - 1);
assertTrue(detail.getString().startsWith("Fireball\nCost: 1\n[Learn]"), detail.getString());
assertFalse(detail.getString().contains("Icon:"));
assertFalse(text(a.books.getLast()).contains("Assign ability"));
assertFalse(text(a.books.getLast()).contains("[3]"));
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals("fireball", saved.loadout().get(0));
var refreshed = nativePages(a);
assertTrue(refreshed.get(1).getString().contains("3: Fireball"));
var activeJump = changePage(refreshed.get(1), "Fireball");
assertNotNull(activeJump);
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nCost: 1\n[Unlearn]"));
}
}
@Test void unlearnAndRelearnReordersIntoLowestOpenSlotsWhileFullLoadoutsRefuse() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
for (var stat : games.dmg.fantasystick.progression.Stat.values()) stats.put(stat, java.math.BigInteger.valueOf(100));
var prepared = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), java.math.BigInteger.TEN, old.health(),
java.util.Set.of("unknown_saved", "orphan_unknown"), java.util.List.of("unknown_saved", "", "", "", "", "", ""),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(prepared, old.revision());
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).contains("orphan_unknown"));
mutate(f, a, "[Learn]", "fireball");
mutate(f, a, "[Learn]", "frost_shard");
mutate(f, a, "[Unlearn]", "fireball");
mutate(f, a, "[Unlearn]", "frost_shard");
mutate(f, a, "[Learn]", "frost_shard");
mutate(f, a, "[Learn]", "fireball");
var reordered = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.util.List.of("unknown_saved", "frost_shard", "fireball", "", "", "", ""), reordered.loadout());
assertTrue(reordered.learned().contains("orphan_unknown"));
var fullActor = new FantasyStickLifecycleNativeTest.Actor(data);
var fullOld = repository.load(fullActor.core.record.id()).orElseThrow();
var fullStats = new java.util.EnumMap<>(fullOld.stats());
for (var stat : games.dmg.fantasystick.progression.Stat.values()) fullStats.put(stat, java.math.BigInteger.valueOf(100));
var ids = java.util.List.of("unknown_0", "unknown_1", "unknown_2", "unknown_3", "unknown_4", "unknown_5", "unknown_6");
var full = new games.dmg.fantasystick.persistence.CharacterRecord(fullOld.id(), fullOld.name(), fullOld.revision().add(java.math.BigInteger.ONE),
fullStats, fullOld.level(), fullOld.experience(), java.math.BigInteger.TEN, fullOld.health(), java.util.Set.copyOf(ids), ids,
fullOld.cooldowns(), fullOld.restrictedKills(), false, null, null);
repository.replace(full, fullOld.revision());
f.players.put(fullActor.core.id, fullActor.player); f.activate(fullActor); PartyBookNativeTest.open(f, fullActor);
assertTrue(PartyBookNativeTest.text(fullActor).contains("Unavailable: slots"));
assertNull(abilityButtonOrNull(fullActor, "[Learn]", "fireball"));
assertEquals(full, characters(f).find(full.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void overviewHidesEveryStatActionWhenNoPointsRemain() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var noPoints = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
old.stats(), old.level(), old.experience(), java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(noPoints, old.revision());
f.players.put(a.core.id, a.player); f.activate(a);
PartyBookNativeTest.open(f, a);
assertFalse(nativePages(a).get(0).getString().contains("[+1"));
}
}
@Test void bookAdapterIgnoresOtherControlsAndNativeItemVetoes() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -321,6 +435,51 @@ class CharacterBookNativeTest {
}
}
private static void mutate(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) throws Exception {
PartyBookNativeTest.click(fixture, actor, abilityButtonOrNull(actor, label, ability));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButtonOrNull(
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
for (var page : nativePages(actor)) {
var found = abilityButtonOrNull(page, label, ability);
if (found != null) return found;
}
return null;
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButtonOrNull(
net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) {
var found = abilityButtonOrNull(child, label, ability);
if (found != null) return found;
}
return null;
}
private static java.util.List<net.minecraft.network.chat.Component> nativePages(FantasyStickLifecycleNativeTest.Actor actor) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
return content.pages().stream().map(page -> page.raw()).toList();
}
private static int firstPageStarting(java.util.List<net.minecraft.network.chat.Component> pages, String prefix) {
for (int index = 0; index < pages.size(); index++) if (pages.get(index).getString().startsWith(prefix)) return index;
throw new AssertionError("Missing page " + prefix);
}
private static net.minecraft.network.chat.ClickEvent.ChangePage changePage(net.minecraft.network.chat.Component component,
String label) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page) return page;
for (var child : component.getSiblings()) {
var found = changePage(child, label);
if (found != null) return found;
}
return null;
}
private static String text(org.bukkit.inventory.ItemStack book) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
@@ -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);
@@ -105,7 +105,7 @@ class FantasyStickLifecycleNativeTest {
assertNotNull(content);
String text = content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
for (String expected : List.of("Current saved name", "STR: 0", "DEX: 0", "CON: 0", "INT: 0", "WIS: 0", "CHR: 0",
"Level: 1", "XP: 0/100", "Unspent points: 40", "Health: 10/10", "Abilities: none")) {
"Level: 1", "XP: 0/100", "Unspent points: 40", "Health: 10/10", "Active abilities", "3: No Ability")) {
assertTrue(text.contains(expected), expected + " missing from " + text);
}
assertArrayEquals(original, a.core.snapshot(), "Opening a virtual book never replaces or grants an inventory item");
@@ -23,8 +23,6 @@ class FireballNativeTest {
assertTrue(PartyBookNativeTest.text(a).contains("INT 12"));
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
@@ -109,8 +107,6 @@ class FireballNativeTest {
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
@@ -152,8 +148,6 @@ class FireballNativeTest {
assertEquals(BigInteger.valueOf(39), spent.unspentPoints());
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
cast(f, a); assertEquals(1, launches.get());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
@@ -76,7 +76,6 @@ class FrostNovaNativeTest {
assertTrue(text.contains("FrostNova")); assertTrue(text.contains("INT16")); assertTrue(text.contains("DEX10"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("5")); assertTrue(text.contains("SlownessII"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "frost_nova")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "frost_nova")); drain(fixture, 2);
use(fixture, caster); drain(fixture, 2);
assertEquals(8, damage.get(enemy.getUniqueId()), 1.0e-9); assertEquals(8, damage.get(opponent.core.id), 1.0e-9);
assertEquals(2, damage.size(), "Duplicate, cancelled, party and ordinary targets cannot add accepted hits");
@@ -28,8 +28,6 @@ class FrostShardNativeTest {
var learn = abilityButton(a, "[Learn]", "frost_shard");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "frost_shard"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
@@ -447,8 +445,6 @@ class FrostShardNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "frost_shard"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "frost_shard"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void cast(FantasyStickLifecycleNativeTest.Fixture fixture,
@@ -30,8 +30,6 @@ class GreatSwordNativeTest {
var learn = abilityButton(a, "[Learn]", "great_sword");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "great_sword"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
@@ -317,8 +315,6 @@ class GreatSwordNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "great_sword"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "great_sword"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
@@ -57,7 +57,6 @@ class GuardianBondNativeTest {
assertTrue(text.contains("GuardianBond")); assertTrue(text.contains("CON16")); assertTrue(text.contains("WIS10"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("30%")); assertTrue(text.contains("6seconds"));
PartyBookNativeTest.click(fixture, guardian, button(guardian, "[Learn]", "guardian_bond")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, guardian, button(guardian, "[3]", "guardian_bond")); drain(fixture, 2);
use(fixture, guardian); drain(fixture, 2);
var enemy = mock(org.bukkit.entity.Zombie.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
@@ -39,7 +39,6 @@ class HexNativeTest {
assertTrue(book.contains("Hex")); assertTrue(book.contains("CHR14")); assertTrue(book.contains("WIS12"));
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var enemy = mock(org.bukkit.entity.LivingEntity.class); var enemyId = java.util.UUID.randomUUID();
when(enemy.getUniqueId()).thenReturn(enemyId); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
@@ -174,7 +173,7 @@ class HexNativeTest {
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,abilityButton(a,"[3]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
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,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
@@ -2,7 +2,7 @@ package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.*;import games.dmg.fantasystick.progression.Stat;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import net.minecraft.network.chat.ClickEvent;import org.bukkit.event.player.PlayerInteractEvent;import org.bukkit.inventory.EquipmentSlot;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);f.players.put(a.core.id,a.player);f.activate(a);a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);String book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertTrue(book.contains("Cost:1"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);f.players.put(a.core.id,a.player);f.activate(a);a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);String book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertTrue(book.contains("Cost:1"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
double maximum=a.maximumHealth.getBaseValue();var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();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,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);assertEquals(maximum,a.maximumHealth.getBaseValue(),1e-9);assertEquals(120L,f.delayedTicks.getLast());f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
@@ -20,7 +20,7 @@ class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()thr
var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,absorption.get(),1e-9);assertEquals(160L,f.delayedTicks.getLast());
f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","-1"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reload=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new java.math.BigDecimal("9"),reload.find("inspire").orElseThrow().parameters().get("base-temporary-health"));}
for(String invalid:List.of("inspire-base-temporary-health: -1\n","inspire-cooldown-seconds: -1\n","inspire-range-blocks: 0\n","inspire-duration-seconds: 0\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
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,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a,String label,String ability){var b=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(b);for(var p:b.pages()){var x=button(p.raw(),label,ability);if(x!=null)return x;}throw new AssertionError("missing action");}
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 x&&x.payload().orElse(null) instanceof net.minecraft.nbt.StringTag p&&p.value().contains(":"+ability))return x;for(var k:c.getSiblings()){var x=button(k,label,ability);if(x!=null)return x;}return null;}
@@ -49,8 +49,6 @@ class LongbowNativeTest {
assertTrue(text.contains("1s"), text);
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "longbow"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "longbow"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var learned = storage.find(a.core.record.id()).get().orElseThrow();
assertTrue(learned.learned().contains("longbow")); assertEquals("longbow", learned.loadout().getFirst());
@@ -411,8 +409,6 @@ class LongbowNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "longbow"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "longbow"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void drawAndRelease(FantasyStickLifecycleNativeTest.Fixture fixture,
@@ -62,8 +62,6 @@ class MendingLightNativeTest {
assertTrue(text.contains("12")); assertTrue(text.contains("8"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "mending_light"));
drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "mending_light"));
drain(fixture, 2);
use(fixture, caster); drain(fixture, 3);
var storage = storage(fixture);
@@ -272,7 +270,6 @@ class MendingLightNativeTest {
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "mending_light")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "mending_light")); drain(fixture, 2);
}
private static PartyRoster.Party party(FantasyStickLifecycleNativeTest.Fixture fixture,
@@ -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);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());}
}
@@ -58,7 +58,6 @@ class PurifyNativeTest {
assertTrue(book.contains("Purify")); assertTrue(book.contains("WIS14")); assertTrue(book.contains("CHR10"));
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("15")); assertTrue(book.contains("12"));
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
doReturn(true).when(actor.player).isSneaking(); use(fixture, actor); drain(fixture, 2);
harmful.forEach(type -> assertFalse(effects.containsKey(type), type + " and its hidden chain must be gone"));
@@ -249,7 +248,6 @@ class PurifyNativeTest {
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
}
private static void party(FantasyStickLifecycleNativeTest.Fixture fixture,
@@ -37,8 +37,6 @@ class QuickstepNativeTest {
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("4")); assertTrue(text.contains("5s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "quickstep"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "quickstep"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
@@ -247,8 +245,6 @@ class QuickstepNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "quickstep"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "quickstep"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static AtomicReference<Vector> prepareMovement(FantasyStickLifecycleNativeTest.Actor actor, Vector initial) {
@@ -57,7 +57,6 @@ class RallyingCryNativeTest {
assertTrue(text.contains("RallyingCry")); assertTrue(text.contains("CHR16")); assertTrue(text.contains("CON12"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("20%")); assertTrue(text.contains("6seconds"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "rallying_cry")); drain(fixture, 2);
use(fixture, caster); drain(fixture, 2);
assertEquals(0, casterEffects.get(PotionEffectType.SPEED).getAmplifier());
assertEquals(0, allyEffects.get(PotionEffectType.SPEED).getAmplifier());
@@ -83,7 +82,6 @@ class RallyingCryNativeTest {
PartyBookNativeTest.click(fixture, caster, unlearn); PartyBookNativeTest.click(fixture, caster, unlearn); drain(fixture, 2);
assertEquals(deadline, storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "rallying_cry")); drain(fixture, 2);
use(fixture, caster); assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@@ -38,8 +38,6 @@ class ReviveNativeTest {
assertTrue(text.contains("CON12")); assertTrue(text.contains("Cooldown:86400s"));
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "revive"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "revive"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
var saved = storage.find(actor.core.record.id()).get().orElseThrow();
@@ -298,8 +296,6 @@ class ReviveNativeTest {
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[Learn]"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[3]"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
caster.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
@@ -0,0 +1,196 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.ability.*;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.*;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Location;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.*;
import org.bukkit.util.BoundingBox;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class SanctuaryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedAreaHealingCreatesPersistedSupportReward() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data);
var ally = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data,caster,false); setHealth(data,ally,BigDecimal.valueOf(4));
fixture.players.put(caster.core.id,caster.player);fixture.players.put(ally.core.id,ally.player);fixture.activate(caster);fixture.activate(ally);party(fixture,caster,ally);
caster.core.location=new Location(caster.world,0,64,0);ally.core.location=new Location(caster.world,2,64,0);
doReturn(List.of(ally.player)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(fixture,caster);
String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Sanctuary"));assertTrue(text.contains("WIS24"));assertTrue(text.contains("CHR20"));assertTrue(text.contains("CON10"));
PartyBookNativeTest.click(fixture,caster,button(caster,"[Learn]","sanctuary"));drain(fixture,2);
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(caster.world.getUID()).thenReturn(UUID.randomUUID());when(enemy.getLocation()).thenReturn(new Location(caster.world,8,64,0));
var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));
fixture.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(fixture,4);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"characters");
assertEquals(BigInteger.valueOf(5),storage.find(caster.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5),storage.find(ally.core.record.id()).get().orElseThrow().experience());
}
}
@Test void fixedCylinderHasNoImmediatePulseThenSixOwnedHealingAndMitigationPulses() {
var fixture = directFixture(); fixture.sanctuary.activate(fixture.execution);
assertEquals(10, fixture.allyHealth.get(), 0); assertEquals(1, fixture.scheduled.size());
var enemy = mock(org.bukkit.entity.LivingEntity.class);
var source = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9); fixture.mitigation.accepted(hit);
verify(fixture.execution).report(eq(AbilityReport.Type.PREVENTION), eq(fixture.ally), eq(BigDecimal.valueOf(2.0)));
run(fixture.scheduled, 9); assertEquals(10, fixture.allyHealth.get(), 0);
run(fixture.scheduled, 1); assertEquals(12, fixture.allyHealth.get(), 0); assertEquals(1, fixture.supports.size());
run(fixture.scheduled, 50); assertEquals(20, fixture.allyHealth.get(), 0, "Six two-point pulses cap at maximum health");
assertEquals(5, fixture.supports.size(), "The sixth overhealing pulse creates no support evidence");
verify(fixture.execution, atLeastOnce()).finish();
}
@Test void exactHorizontalAndVerticalCylinderBoundaryIsIncludedButOutsideIsRemoved() {
var fixture=directFixture();fixture.allyLocation.set(new Location(fixture.world,6,67,0));fixture.sanctuary.activate(fixture.execution);
var enemy=mock(org.bukkit.entity.LivingEntity.class);var source=DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
var boundary=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,fixture.ally,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);
fixture.mitigation.reduce(boundary);assertEquals(8,boundary.getDamage(),0);
fixture.allyLocation.set(new Location(fixture.world,6.01,67,0));run(fixture.scheduled,1);
var outside=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,fixture.ally,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);
fixture.mitigation.reduce(outside);assertEquals(10,outside.getDamage(),0);
}
@Test void leavingCylinderOrObstructionEndsBenefitsAndSourceInvalidationCleansUp() {
var fixture = directFixture(); fixture.sanctuary.activate(fixture.execution); run(fixture.scheduled, 10);
doReturn(mock(org.bukkit.util.RayTraceResult.class)).when(fixture.world).rayTraceBlocks(any(Location.class),
any(org.bukkit.util.Vector.class), anyDouble(), eq(org.bukkit.FluidCollisionMode.NEVER), eq(true));
run(fixture.scheduled, 1);
fixture.allyLocation.set(new Location(fixture.world, 2, 68, 0));
var enemy = mock(org.bukkit.entity.LivingEntity.class); var source = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.mitigation.reduce(hit); assertEquals(10, hit.getDamage(), 0);
when(fixture.execution.stillCurrent()).thenReturn(false); fixture.scheduled.removeFirst().run(); verify(fixture.execution).finish();
}
@Test void overlappingSanctuariesUseEarliestSourceForOnePulseAndStrongestReduction() {
var fixture = directFixture(); var second = execution(fixture.source, fixture.sourceActive);
when(second.definition()).thenReturn(fixture.sanctuary.definition()); var character = fixture.execution.character();
when(second.character()).thenReturn(character);
doAnswer(call->{fixture.scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);})
.when(second).later(any(),any());
fixture.sanctuary.activate(fixture.execution); fixture.sanctuary.activate(second);
for (int i = 0; i < 20; i++) fixture.scheduled.removeFirst().run();
assertEquals(12, fixture.allyHealth.get(), 0); assertEquals(1, fixture.supports.size(), "Overlap supplies one healing pulse");
var enemy = mock(org.bukkit.entity.LivingEntity.class); var damageSource = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource, 10);
fixture.mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 0, "Two sanctuaries do not multiply reduction");
fixture.mitigation.accepted(hit); verify(fixture.execution).report(eq(AbilityReport.Type.PREVENTION), eq(fixture.ally), any());
verify(second, never()).report(eq(AbilityReport.Type.PREVENTION), any(), any());
var stone = mock(AbilityExecution.class); when(stone.stillCurrent()).thenReturn(true);
fixture.mitigation.grant(stone, fixture.ally, BigDecimal.valueOf(50));
var stronger = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource, 10);
fixture.mitigation.reduce(stronger); assertEquals(5, stronger.getDamage(), 0, "Stronger Stone Skin-style mitigation supersedes Sanctuary");
}
@Test void definitionAndPluginConfigurationExposeAllApprovedValues() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "sanctuary-cooldown-seconds: 30\nsanctuary-radius-blocks: 7\n"
+ "sanctuary-vertical-range-blocks: 4\nsanctuary-duration-seconds: 4\nsanctuary-pulse-interval-seconds: 1\n"
+ "sanctuary-healing-per-pulse: 3\nsanctuary-damage-reduction-percent: 25\n");
try (var plugin = new FantasyStickLifecycleNativeTest.Fixture(data)) {
plugin.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin, "abilityRegistry");
var definition = registry.find("sanctuary").orElseThrow(); assertEquals(org.bukkit.Material.SEA_LANTERN, definition.icon());
assertEquals(BigInteger.valueOf(24), definition.requirements().get(Stat.WIS)); assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.CHR));
assertEquals(BigInteger.TEN, definition.requirements().get(Stat.CON)); assertEquals(java.time.Duration.ofSeconds(30), definition.cooldown());
assertEquals(new BigDecimal("3"), definition.parameters().get("healing-per-pulse"));
assertEquals(new BigDecimal("25"), definition.parameters().get("damage-reduction-percent"));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-"}).isEmpty());
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("sanctuary-cooldown-seconds","sanctuary-damage-reduction-percent","sanctuary-duration-seconds",
"sanctuary-healing-per-pulse","sanctuary-pulse-interval-seconds","sanctuary-radius-blocks","sanctuary-vertical-range-blocks"),
plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-"}));
assertEquals(List.of("2","3"),plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-healing-per-pulse",""}));
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-healing-per-pulse"});assertTrue(sender.messages.getLast().contains("=3"));
for(String[] update:List.of(new String[]{"sanctuary-cooldown-seconds","20"},new String[]{"sanctuary-radius-blocks","8"},
new String[]{"sanctuary-vertical-range-blocks","5"},new String[]{"sanctuary-duration-seconds","5"},
new String[]{"sanctuary-pulse-interval-seconds","1"},new String[]{"sanctuary-healing-per-pulse","4"},
new String[]{"sanctuary-damage-reduction-percent","30"})){
plugin.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
var configured=registry.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),configured.parameters().get("healing-per-pulse"));
assertEquals(new BigDecimal("30"),configured.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),configured.cooldown());
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8","extra"});assertTrue(sender.messages.getLast().contains("Usage"));
plugin.plugin.setEnabled(false);plugin.plugin.setEnabled(true);
var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin,"abilityRegistry");
var saved=reloaded.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),saved.parameters().get("healing-per-pulse"));
assertEquals(new BigDecimal("30"),saved.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),saved.cooldown());
}
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor){actor.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,actor.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,org.bukkit.inventory.EquipmentSlot.HAND));}
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture,int count)throws Exception{for(int i=0;i<count;i++)PartyBookNativeTest.drain(fixture);}
private static void party(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor source,FantasyStickLifecycleNativeTest.Actor target){var parties=PartyLifecycleNativeTest.parties(fixture);var group=parties.create(source.core.id).orElseThrow();assertTrue(parties.accept(target.core.id,parties.invite(source.core.id,group.id(),target.core.id).orElseThrow().id()));}
private static net.minecraft.network.chat.ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor actor,String label,String ability){var book=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(book);for(var page:book.pages()){var found=button(page.raw(),label,ability);if(found!=null)return found;}throw new AssertionError("Missing "+label);}
private static net.minecraft.network.chat.ClickEvent.Custom button(net.minecraft.network.chat.Component component,String label,String ability){if(component.getString().equals(label)&&component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom&&custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload&&payload.value().contains(":"+ability))return custom;for(var child:component.getSiblings()){var found=button(child,label,ability);if(found!=null)return found;}return null;}
private static void prepareCaster(Path directory,FantasyStickLifecycleNativeTest.Actor actor,boolean learned)throws Exception{java.nio.file.Files.createDirectories(directory);var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();var stats=new EnumMap<>(old.stats());stats.put(Stat.WIS,BigInteger.valueOf(24));stats.put(Stat.CHR,BigInteger.valueOf(20));stats.put(Stat.CON,BigInteger.TEN);repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),BigInteger.valueOf(learned?0:1),old.health(),learned?Set.of("sanctuary"):old.learned(),learned?List.of("sanctuary","","","","","",""):old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
private static void setHealth(Path directory,FantasyStickLifecycleNativeTest.Actor actor,BigDecimal health)throws Exception{var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),health,old.learned(),old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());actor.core.health=health.doubleValue();}
private static void run(ArrayDeque<Runnable> scheduled,int count){for(int i=0;i<count;i++){assertFalse(scheduled.isEmpty());scheduled.removeFirst().run();}}
private DirectFixture directFixture() {
var source=mock(Player.class);var ally=mock(Player.class);var world=mock(org.bukkit.World.class);
UUID sourceId=UUID.randomUUID(),allyId=UUID.randomUUID();when(source.getUniqueId()).thenReturn(sourceId);when(ally.getUniqueId()).thenReturn(allyId);
var sourceLocation=new AtomicReference<>(new Location(world,0,64,0));var allyLocation=new AtomicReference<>(new Location(world,2,64,0));
when(source.getLocation()).thenAnswer(call->sourceLocation.get().clone());when(ally.getLocation()).thenAnswer(call->allyLocation.get().clone());
when(source.getBoundingBox()).thenAnswer(call->box(sourceLocation.get()));when(ally.getBoundingBox()).thenAnswer(call->box(allyLocation.get()));
when(source.getWorld()).thenReturn(world);when(ally.getWorld()).thenReturn(world);when(world.getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble())).thenReturn(List.of(ally));
var sourceHealth=new AtomicReference<>(20.0);var allyHealth=new AtomicReference<>(10.0);health(source,sourceHealth);health(ally,allyHealth);
var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var characters=mock(CharacterStorage.class);
var sourceActive=new EquipmentSessions.Active(sourceId,UUID.randomUUID(),UUID.randomUUID());var allyActive=new EquipmentSessions.Active(allyId,UUID.randomUUID(),UUID.randomUUID());
when(sessions.active(sourceId)).thenReturn(Optional.of(sourceActive));when(sessions.active(allyId)).thenReturn(Optional.of(allyActive));when(sessions.current(any())).thenReturn(true);
var records=new HashMap<UUID,AtomicReference<CharacterRecord>>();records.put(sourceId,new AtomicReference<>(record(sourceActive.character(),BigDecimal.valueOf(20))));records.put(allyId,new AtomicReference<>(record(allyActive.character(),BigDecimal.TEN)));
when(sessions.currentRecord(sourceActive)).thenAnswer(call->Optional.of(records.get(sourceId).get()));when(sessions.currentRecord(allyActive)).thenAnswer(call->Optional.of(records.get(allyId).get()));
when(sessions.acknowledgeProgress(any(),any(),any())).thenAnswer(call->{EquipmentSessions.Active active=call.getArgument(0);CharacterRecord saved=call.getArgument(2);records.get(active.player()).set(saved);return true;});
when(characters.saveHealth(any(),any(),any(),any())).thenAnswer(call->{UUID character=call.getArgument(0);BigInteger expected=call.getArgument(1);BigDecimal health=call.getArgument(2);
var entry=records.values().stream().filter(ref->ref.get().id().equals(character)).findFirst().orElseThrow();var old=entry.get();
if(!old.revision().equals(expected))return CompletableFuture.completedFuture(Optional.empty());
var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),health,old.learned(),old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null);
return CompletableFuture.completedFuture(Optional.of(saved));});
var party=new PartyRoster.Party(UUID.randomUUID(),sourceId,List.of(sourceId,allyId));when(parties.partyOf(sourceId)).thenReturn(Optional.of(party));when(parties.partyOf(allyId)).thenReturn(Optional.of(party));
var mitigation=new CombatMitigation();var scheduled=new ArrayDeque<Runnable>();var supports=new ArrayList<MendingLight.Support>();
var sanctuary=new Sanctuary(Sanctuary.Settings.defaults(),characters,sessions,parties,mitigation,Runnable::run,event->{},()->BigDecimal.valueOf(Double.MAX_VALUE),supports::add);
var execution=execution(source,sourceActive);when(execution.definition()).thenReturn(sanctuary.definition());
var character=records.get(sourceId).get();when(execution.character()).thenReturn(character);
doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());
return new DirectFixture(source,ally,world,sourceActive,allyLocation,allyHealth,execution,sanctuary,mitigation,scheduled,supports);
}
private record DirectFixture(Player source,Player ally,org.bukkit.World world,EquipmentSessions.Active sourceActive,
AtomicReference<Location> allyLocation,AtomicReference<Double> allyHealth,AbilityExecution execution,Sanctuary sanctuary,
CombatMitigation mitigation,ArrayDeque<Runnable> scheduled,List<MendingLight.Support> supports){}
private static AbilityExecution execution(Player source,EquipmentSessions.Active active){var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(source);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);return execution;}
private static CharacterRecord record(UUID id,BigDecimal health){var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var stats=new EnumMap<>(base.stats());stats.put(Stat.CON,BigInteger.TEN);return new CharacterRecord(id,base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),health,Set.of("sanctuary"),List.of("sanctuary","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);}
private static BoundingBox box(Location at){return new BoundingBox(at.getX()-.3,at.getY(),at.getZ()-.3,at.getX()+.3,at.getY()+1.8,at.getZ()+.3);}
private static void health(Player player,AtomicReference<Double> health){var attribute=mock(AttributeInstance.class);when(attribute.getValue()).thenReturn(20.0);when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(attribute);when(player.getHealth()).thenAnswer(call->health.get());doAnswer(call->{health.set(call.getArgument(0));return null;}).when(player).setHealth(anyDouble());}
}
@@ -34,8 +34,6 @@ class SecondWindNativeTest {
assertTrue(text.contains("SecondWind")); assertTrue(text.contains("CON12")); assertTrue(text.contains("WIS12"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "second_wind"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "second_wind"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(4, a.player.getHealth());
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
@@ -173,8 +171,6 @@ class SecondWindNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "second_wind"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "second_wind"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
@@ -35,8 +35,6 @@ class ShieldBashNativeTest {
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "shield_bash"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "shield_bash"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
assertEquals(org.bukkit.Material.SHIELD, a.core.inventory.getItem(2).getType());
@@ -298,8 +296,6 @@ class ShieldBashNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "shield_bash"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "shield_bash"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
@@ -34,8 +34,6 @@ class StoneSkinNativeTest {
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("25%")); assertTrue(text.contains("4s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -58,8 +56,6 @@ class StoneSkinNativeTest {
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("stone_skin"));
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); use(f, a);
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
@@ -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);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());}
}
@@ -169,6 +169,38 @@ class CharacterStorageTest {
}
}
@Test
void abilityUnlockFillsLowestEmptySlotAndRefusesAFullLoadoutAtomically() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var partlyOccupied = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), BigInteger.TEN, base.health(), java.util.Set.of("known", "unknown_saved"),
java.util.List.of("known", "", "unknown_saved", "", "", "", ""), base.cooldowns(),
base.restrictedKills(), false, null, null);
repository.create(partlyOccupied);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var learned = storage.unlockAbility(partlyOccupied.id(), partlyOccupied.revision(), "new_ability",
java.util.Map.of(), BigInteger.ONE).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, learned.status());
var saved = learned.record().orElseThrow();
assertEquals("new_ability", saved.loadout().get(1), "Learning must use the lowest empty slot");
assertEquals("unknown_saved", saved.loadout().get(2), "Unknown saved assignments must not be overwritten");
assertEquals(BigInteger.valueOf(9), saved.unspentPoints());
}
var full = new CharacterRecord(UUID.randomUUID(), base.name(), BigInteger.ZERO, base.stats(), base.level(),
base.experience(), BigInteger.TEN, base.health(), java.util.Set.of("a", "b", "c", "d", "e", "f", "g"),
java.util.List.of("a", "b", "c", "d", "e", "f", "g"), base.cooldowns(), base.restrictedKills(), false, null, null);
repository.create(full);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var refused = storage.unlockAbility(full.id(), full.revision(), "eighth", java.util.Map.of(), BigInteger.ONE).join();
assertEquals(CharacterStorage.AbilityMutationStatus.LOADOUT_FULL, refused.status());
assertEquals(full, refused.record().orElseThrow());
assertEquals(full, repository.load(full.id()).orElseThrow());
}
}
@Test
void abilityUnlearnRefundsExactlyOnePointClearsEverySlotAndPreservesCooldown() throws Exception {
var repository = new YamlCharacterRepository(data);
@@ -0,0 +1,19 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class MeteorSettingsTest {
@TempDir Path data;
@Test void defaultsMatchApprovedPacket(){var s=MeteorSettings.parse(defaults());assertEquals(java.math.BigInteger.valueOf(14),s.baseDamage());assertEquals(Duration.ofSeconds(45),s.cooldown());assertEquals(24,s.targetRange());assertEquals(Duration.ofSeconds(2),s.warning());assertEquals(5,s.radius());assertEquals(4,s.verticalRange());}
@Test void validatesEveryBound(){for(var values:List.of(changed(MeteorSettings.BASE_DAMAGE,"-1"),changed(MeteorSettings.COOLDOWN,"-1"),changed(MeteorSettings.TARGET_RANGE,"0"),changed(MeteorSettings.WARNING,"0"),changed(MeteorSettings.WARNING,"3.2"),changed(MeteorSettings.RADIUS,"0"),changed(MeteorSettings.VERTICAL,"-1"),changed(MeteorSettings.RADIUS,"NaN")))assertThrows(IllegalArgumentException.class,()->MeteorSettings.parse(values));}
@Test void persistsBeforePublicationAndPreservesUnrelatedYaml()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");try(var store=new MeteorSettings(data,defaults())){var update=store.set(MeteorSettings.RADIUS,"6.00").toCompletableFuture().get();assertEquals("5",store.value(MeteorSettings.RADIUS));assertEquals("6",update.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));store.publish(update);assertEquals("6",store.value(MeteorSettings.RADIUS));}}
@Test void failedUnsafeAndClosedWritesRetainEffectiveState()throws Exception{var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var store=new MeteorSettings(data,defaults(),(from,to)->{if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(from,to,StandardCopyOption.REPLACE_EXISTING);})) {assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("5",store.value(MeteorSettings.RADIUS));}if(!System.getProperty("os.name","").toLowerCase().contains("win")){var unsafe=data.resolve("unsafe");Files.createDirectories(unsafe);var outside=data.resolve("outside");Files.writeString(outside,"safe\n");Files.createSymbolicLink(unsafe.resolve("config.yml"),outside);try(var store=new MeteorSettings(unsafe,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("safe\n",Files.readString(outside));}}var closed=new MeteorSettings(data.resolve("closed"),defaults());closed.close();assertThrows(java.util.concurrent.ExecutionException.class,()->closed.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());closed.drained().toCompletableFuture().get();}
private static Map<String,String> defaults(){var result=new LinkedHashMap<String,String>();for(var key:MeteorSettings.keys())result.put(key,MeteorSettings.defaultValue(key));return result;}
private static Map<String,String> changed(String key,String value){var result=new LinkedHashMap<>(defaults());result.put(key,value);return result;}
}
@@ -0,0 +1,52 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SanctuarySettingsTest {
@TempDir Path data;
@Test void defaultsAndParsingMatchApprovedPolicy() {
var value = SanctuarySettings.parse(defaults());
assertEquals(java.time.Duration.ofSeconds(45), value.cooldown()); assertEquals(6, value.radius(), 0);
assertEquals(3, value.verticalRange(), 0); assertEquals(java.time.Duration.ofSeconds(6), value.duration());
assertEquals(java.time.Duration.ofSeconds(1), value.pulseInterval()); assertEquals(new java.math.BigDecimal("2"), value.healingPerPulse());
assertEquals(new java.math.BigDecimal("20"), value.damageReductionPercent());
}
@Test void serializesPreservesUnrelatedAndPublishesAcknowledgedOrder() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new SanctuarySettings(data, defaults())) {
var radius = settings.set(SanctuarySettings.RADIUS, "7").toCompletableFuture();
var cooldown = settings.set(SanctuarySettings.COOLDOWN, "30").toCompletableFuture();
var savedRadius = radius.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
assertEquals(6, settings.settings().radius(), 0); settings.publish(savedCooldown); settings.publish(savedRadius);
assertEquals(7, settings.settings().radius(), 0); assertEquals(java.time.Duration.ofSeconds(30), settings.settings().cooldown());
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", yaml.get("unrelated")); assertEquals("7", String.valueOf(yaml.get(SanctuarySettings.RADIUS)));
}
}
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
try (var settings = new SanctuarySettings(data, defaults())) {
for (var e : Map.of(SanctuarySettings.COOLDOWN,"-1",SanctuarySettings.RADIUS,"0",SanctuarySettings.VERTICAL,"-1",
SanctuarySettings.DURATION,"0",SanctuarySettings.INTERVAL,"0",SanctuarySettings.HEALING,"-1",SanctuarySettings.REDUCTION,"101").entrySet())
assertThrows(IllegalArgumentException.class, () -> settings.set(e.getKey(), e.getValue()));
assertThrows(IllegalArgumentException.class, () -> settings.set(SanctuarySettings.INTERVAL,"0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new SanctuarySettings(data, defaults(), (from,to)->{if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(from,to,StandardCopyOption.REPLACE_EXISTING);})) {
assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(SanctuarySettings.RADIUS,"7").toCompletableFuture().get(5,TimeUnit.SECONDS));
assertEquals("6",settings.value(SanctuarySettings.RADIUS));
}
if (!System.getProperty("os.name","").toLowerCase().contains("win")) {
var unsafe=data.resolve("unsafe");Files.createDirectories(unsafe);var outside=data.resolve("outside.yml");Files.writeString(outside,"safe\n");Files.createSymbolicLink(unsafe.resolve("config.yml"),outside);
try(var settings=new SanctuarySettings(unsafe,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(SanctuarySettings.HEALING,"3").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("safe\n",Files.readString(outside));}
}
var closed=new SanctuarySettings(data.resolve("closed"),defaults());closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,()->closed.set(SanctuarySettings.HEALING,"3").toCompletableFuture().get(5,TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
}
private static Map<String,String> defaults(){var values=new LinkedHashMap<String,String>();for(String key:SanctuarySettings.keys())values.put(key,SanctuarySettings.defaultValue(key));return values;}
}
@@ -0,0 +1,64 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.ability.WarBanner;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class WarBannerSettingsTest {
@TempDir Path data;
@Test void defaultsMatchApprovedPacket() {
var settings = WarBannerSettings.parse(defaults());
assertEquals(Duration.ofSeconds(45), settings.cooldown());
assertEquals(12, settings.placementRange()); assertEquals(8, settings.radius());
assertEquals(4, settings.verticalRange()); assertEquals(Duration.ofSeconds(10), settings.duration());
assertEquals(new BigDecimal("25"), settings.damageBonusPercent());
assertEquals(new BigDecimal("50"), settings.knockbackResistancePercent());
}
@Test void validatesEveryBoundAndPulseFreeAreaLimit() {
for (var invalid : java.util.List.of(
changed(WarBannerSettings.COOLDOWN,"-1"), changed(WarBannerSettings.PLACEMENT_RANGE,"0"),
changed(WarBannerSettings.RADIUS,"0"), changed(WarBannerSettings.VERTICAL,"-1"),
changed(WarBannerSettings.DURATION,"0"), changed(WarBannerSettings.DAMAGE_BONUS,"-1"), changed(WarBannerSettings.DAMAGE_BONUS,"101"),
changed(WarBannerSettings.KNOCKBACK_RESISTANCE,"101"), changed(WarBannerSettings.RADIUS,"NaN"))) {
assertThrows(IllegalArgumentException.class,()->WarBannerSettings.parse(invalid));
}
}
@Test void persistsBeforeExplicitPublicationAndPreservesUnrelatedYaml() throws Exception {
Files.writeString(data.resolve("config.yml"),"unrelated: keep\nwar-banner-radius-blocks: 8\n");
try(var store=new WarBannerSettings(data,defaults())) {
var update=store.set(WarBannerSettings.RADIUS,"9.00").toCompletableFuture().get();
assertEquals("8",store.value(WarBannerSettings.RADIUS));assertEquals("9",update.value());
assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));
store.publish(update);assertEquals("9",store.value(WarBannerSettings.RADIUS));
}
}
@Test void failedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var store = new WarBannerSettings(data, defaults(), (from,to)->{
if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(from,to,java.nio.file.StandardCopyOption.REPLACE_EXISTING);})) {
assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(WarBannerSettings.RADIUS,"9").toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));
assertEquals("8",store.value(WarBannerSettings.RADIUS));
}
if(!System.getProperty("os.name","").toLowerCase().contains("win")){
var unsafe=data.resolve("unsafe");Files.createDirectories(unsafe);var outside=data.resolve("outside.yml");Files.writeString(outside,"safe\n");Files.createSymbolicLink(unsafe.resolve("config.yml"),outside);
try(var store=new WarBannerSettings(unsafe,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(WarBannerSettings.DAMAGE_BONUS,"30").toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));assertEquals("safe\n",Files.readString(outside));}
}
var closed=new WarBannerSettings(data.resolve("closed"),defaults());closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,()->closed.set(WarBannerSettings.RADIUS,"9").toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
}
private static Map<String,String> defaults(){var values=new LinkedHashMap<String,String>();for(String key:WarBannerSettings.keys())values.put(key,WarBannerSettings.defaultValue(key));return values;}
private static Map<String,String> changed(String key,String value){var result=new LinkedHashMap<>(defaults());result.put(key,value);return result;}
}