Compare commits

...
1 Commits
Author SHA1 Message Date
dmg dfa02bc335 feat(ability): add war banner
Release / release (push) Successful in 8m31s
2026-09-20 19:37:01 -04:00
8 changed files with 645 additions and 2 deletions

No files matched your search

@@ -83,6 +83,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
private games.dmg.fantasystick.ability.Sanctuary sanctuary;
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
private games.dmg.fantasystick.ability.WarBanner warBanner;
private games.dmg.fantasystick.persistence.WarBannerSettings warBannerSettings;
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
@@ -163,6 +165,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var chainLightningInitial = games.dmg.fantasystick.persistence.ChainLightningSettings.parse(chainLightningConfiguration);
var sanctuaryConfiguration = sanctuaryConfiguration();
var sanctuaryInitial = games.dmg.fantasystick.persistence.SanctuarySettings.parse(sanctuaryConfiguration);
var warBannerConfiguration = warBannerConfiguration();
var warBannerInitial = games.dmg.fantasystick.persistence.WarBannerSettings.parse(warBannerConfiguration);
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
var factory = new CharacterFactory();
@@ -283,6 +287,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
warBannerSettings = new games.dmg.fantasystick.persistence.WarBannerSettings(
getDataFolder().toPath(), warBannerConfiguration);
warBanner = new games.dmg.fantasystick.ability.WarBanner(warBannerInitial, sessions, parties, outgoingDamageBonus);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
@@ -304,6 +311,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityRegistry.register(frostNova.definition());
abilityRegistry.register(chainLightning.definition());
abilityRegistry.register(sanctuary.definition());
abilityRegistry.register(warBanner.definition());
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
@@ -353,6 +361,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachFrostNova(frostNovaSettings, frostNova);
admin.attachChainLightning(chainLightningSettings, chainLightning);
admin.attachSanctuary(sanctuarySettings, sanctuary);
admin.attachWarBanner(warBannerSettings, warBanner);
admin.attachAdminRevival(adminRevival, adminReviveSettings);
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
characters, rules, sessions, nextTick, getServer()::getPlayer,
@@ -392,6 +401,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
getServer().getPluginManager().registerEvents(outgoingDamageBonus, this);
rallyingCry.install(this);
warBanner.install(this);
getServer().getPluginManager().registerEvents(temporaryHealth, this);
getServer().getPluginManager().registerEvents(greatSword, this);
getServer().getPluginManager().registerEvents(revive, this);
@@ -472,6 +482,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingChainLightningSettings = chainLightningSettings; chainLightningSettings = null;
var closingSanctuary = sanctuary; sanctuary = null;
var closingSanctuarySettings = sanctuarySettings; sanctuarySettings = null;
var closingWarBanner = warBanner; warBanner = null;
var closingWarBannerSettings = warBannerSettings; warBannerSettings = null;
var closingOutgoingDamageBonus = outgoingDamageBonus; outgoingDamageBonus = null;
var closingAdminRevival = adminRevival; adminRevival = null;
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
@@ -517,6 +529,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingChainLightningSettings != null) cleanup("Chain Lightning configuration", closingChainLightningSettings::close);
if (closingSanctuary != null) cleanup("Sanctuary", closingSanctuary::close);
if (closingSanctuarySettings != null) cleanup("Sanctuary configuration", closingSanctuarySettings::close);
if (closingWarBanner != null) cleanup("War Banner", closingWarBanner::close);
if (closingWarBannerSettings != null) cleanup("War Banner configuration", closingWarBannerSettings::close);
if (closingOutgoingDamageBonus != null) cleanup("outgoing damage bonuses", closingOutgoingDamageBonus::close);
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
@@ -557,6 +571,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
: closingChainLightningSettings.drained().toCompletableFuture(),
closingSanctuarySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSanctuarySettings.drained().toCompletableFuture(),
closingWarBannerSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingWarBannerSettings.drained().toCompletableFuture(),
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdminReviveSettings.drained().toCompletableFuture(),
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -827,6 +843,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> warBannerConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.WarBannerSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.WarBannerSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.WarBannerSettings.parse(values);
return java.util.Map.copyOf(values);
}
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
@@ -0,0 +1,241 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import org.bukkit.Color;
import org.bukkit.FluidCollisionMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Particle;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.attribute.AttributeModifier;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
/** Fixed spectral War Banner cylinder with strongest damage and one owned knockback-resistance layer. */
public final class WarBanner implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, double placementRange, double radius, double verticalRange,
Duration duration, BigDecimal damageBonusPercent, BigDecimal knockbackResistancePercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration);
Objects.requireNonNull(damageBonusPercent); Objects.requireNonNull(knockbackResistancePercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(placementRange) || placementRange <= 0 || placementRange > 64
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|| damageBonusPercent.signum() < 0 || damageBonusPercent.compareTo(BigDecimal.valueOf(100)) > 0
|| knockbackResistancePercent.signum() < 0
|| knockbackResistancePercent.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new IllegalArgumentException("Invalid War Banner settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(45), 12, 8, 4, Duration.ofSeconds(10),
BigDecimal.valueOf(25), BigDecimal.valueOf(50));
}
}
private final class Area implements AutoCloseable {
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
boolean closed;
Area(AbilityExecution execution, Location origin, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
}
void refresh() {
if (closed) return;
if (!sourceCurrent()) { execution.finish(); return; }
var eligible = candidates();
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
visual();
}
boolean sourceCurrent() {
return execution.stillCurrent() && sessions.active(source.getUniqueId())
.filter(active -> active.equals(execution.input().session()) && sessions.current(active)).isPresent();
}
boolean eligible(Player player) {
if (!sourceCurrent() || !inside(origin, player, captured.radius(), captured.verticalRange())
|| !unobstructed(origin, player)) return false;
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
if (active == null) return false;
if (player == source) return active.equals(execution.input().session());
var sourceParty = parties.partyOf(source.getUniqueId()).orElse(null);
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
return sourceParty != null && targetParty != null && sourceParty.id().equals(targetParty.id());
}
Map<UUID, Player> candidates() {
List<Entity> nearby;
try { nearby = new ArrayList<>(origin.getWorld().getNearbyEntities(origin,
captured.radius(), captured.verticalRange(), captured.radius())); }
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256) break;
if (entity instanceof Player player && eligible(player)) unique.putIfAbsent(player.getUniqueId(), player);
}
var result = new ArrayList<>(unique.values());
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
if (result.size() > 64) result.subList(64, result.size()).clear();
var ordered = new LinkedHashMap<UUID, Player>(); for (var player : result) ordered.put(player.getUniqueId(), player); return ordered;
}
void visual() {
try { origin.getWorld().spawnParticle(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
0.25, 1.5, 0.25, 0, new Particle.DustOptions(Color.RED, 1.5f)); }
catch (RuntimeException ignored) { }
}
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
BooleanSupplier current = () -> !closed && area.eligible(player);
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(resistance);
}
}
private final class ResistanceGrant implements AutoCloseable {
final ResistanceState state; final long sequence = ++resistanceSequences; final BigDecimal percent;
final BooleanSupplier current; boolean closed;
ResistanceGrant(ResistanceState state, BigDecimal percent, BooleanSupplier current) {
this.state = state; this.percent = percent; this.current = current;
}
boolean valid() { try { return !closed && current.getAsBoolean(); } catch (RuntimeException invalid) { return false; } }
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
}
private final class ResistanceState {
final Player player; final List<ResistanceGrant> grants = new ArrayList<>(); AttributeModifier owned;
ResistanceState(Player player) { this.player = player; }
void refresh() {
AttributeInstance attribute;
try { attribute = player.getAttribute(Attribute.KNOCKBACK_RESISTANCE); }
catch (RuntimeException unavailable) { return; }
if (attribute == null) return;
ResistanceGrant strongest = null;
for (var grant : List.copyOf(grants)) if (grant.valid() && (strongest == null
|| grant.percent.compareTo(strongest.percent) > 0
|| grant.percent.compareTo(strongest.percent) == 0 && grant.sequence < strongest.sequence)) strongest = grant;
double desired = strongest == null ? 0 : strongest.percent.movePointLeft(2).doubleValue();
if (owned != null && (!attribute.getModifiers().contains(owned) || Double.compare(owned.getAmount(), desired) != 0)) {
attribute.removeModifier(owned); owned = null;
}
if (strongest != null && owned == null) {
var key = new NamespacedKey("fantasystick", "war_banner_" + player.getUniqueId());
for (var modifier : List.copyOf(attribute.getModifiers())) if (modifier.getKey().equals(key)) attribute.removeModifier(modifier);
owned = new AttributeModifier(key, desired, AttributeModifier.Operation.ADD_NUMBER); attribute.addModifier(owned);
}
if (strongest == null) { resistances.remove(player.getUniqueId(), this); owned = null; }
}
}
private volatile Settings settings;
private final EquipmentSessions sessions; private final PartyRoster parties; private final OutgoingDamageBonus bonuses;
private final Map<Long, Area> areas = new java.util.HashMap<>(); private final Map<UUID, ResistanceState> resistances = new java.util.HashMap<>();
private long sequences; private long resistanceSequences; private org.bukkit.scheduler.BukkitTask sweepTask; private boolean closed;
public WarBanner(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin); if (closed || sweepTask != null) throw new IllegalStateException("War Banner sweep already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
public void sweep() {
if (closed) return; int checked = 0;
for (var area : List.copyOf(areas.values())) { area.refresh(); if (++checked == 64) break; }
for (var state : List.copyOf(resistances.values())) state.refresh();
}
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("war_banner", "War Banner", Material.RED_BANNER,
List.of("Plant a spectral banner within " + decimal(current.placementRange()) + " blocks for " + seconds(current.duration()) + " seconds.",
"Party members in its " + decimal(current.radius()) + "-block area gain " + decimal(current.damageBonusPercent())
+ "% combat damage and " + decimal(current.knockbackResistancePercent()) + "% knockback resistance."),
"Use while looking at the solid top surface where the fixed banner should stand; copies do not stack.",
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("placement-range", BigDecimal.valueOf(current.placementRange()).stripTrailingZeros(),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
"duration-seconds", nanos(current.duration()), "damage-bonus-percent", current.damageBonusPercent(),
"knockback-resistance-percent", current.knockbackResistancePercent()), AbilityDefinition.TargetPolicy.AREA, this);
}
@Override public boolean accepts(ControlInput input) {
return !closed && input.target() == null && placement(input.player(), settings.placementRange()) != null;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
var origin = placement(execution.input().player(), captured.placementRange());
if (origin == null) { execution.finish(); return; }
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
area.refresh(); execution.later(captured.duration(), execution::finish);
}
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
var state = resistances.computeIfAbsent(player.getUniqueId(), ignored -> new ResistanceState(player));
var grant = new ResistanceGrant(state, percent, current); state.grants.add(grant); state.refresh(); return grant;
}
private static Location placement(Player player, double range) {
try { var eye = player.getEyeLocation(); var hit = player.getWorld().rayTraceBlocks(eye, eye.getDirection(), range,
FluidCollisionMode.NEVER, true);
if (hit == null || hit.getHitBlock() == null || hit.getHitBlockFace() != BlockFace.UP
|| !hit.getHitBlock().getType().isSolid()) return null;
return hit.getHitBlock().getLocation().add(0.5, 1, 0.5);
} catch (RuntimeException invalid) { return null; }
}
private static boolean inside(Location origin, Player player, double radius, double vertical) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
double dx=at.getX()-origin.getX(), dz=at.getZ()-origin.getZ();
return dx*dx+dz*dz<=radius*radius && Math.abs(at.getY()-origin.getY())<=vertical;
} catch (RuntimeException invalid) { return false; }
}
private static boolean unobstructed(Location origin, Player player) {
try { var world=origin.getWorld(); if(world==null)return false;var offset=player.getBoundingBox().getCenter().subtract(origin.toVector());
double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;
return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;
} catch(RuntimeException invalid){return false;}
}
private static double distance(Location origin, Player player) {
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
} catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}
}
private static Settings from(AbilityDefinition definition) { var p=definition.parameters();return new Settings(definition.cooldown(),
p.get("placement-range").doubleValue(),p.get("radius").doubleValue(),p.get("vertical-range").doubleValue(),
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-bonus-percent"),p.get("knockback-resistance-percent")); }
private static BigDecimal nanos(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9);}
private static String seconds(Duration value){return decimal(nanos(value));}
private static String decimal(double value){return decimal(BigDecimal.valueOf(value));}
private static String decimal(BigDecimal value){return value.stripTrailingZeros().toPlainString();}
private static void closeResource(AutoCloseable resource){try{resource.close();}catch(Exception impossible){throw new IllegalStateException(impossible);}}
@Override public void close(){if(closed)return;closed=true;if(sweepTask!=null)sweepTask.cancel();for(var area:List.copyOf(areas.values()))area.execution.finish();areas.clear();for(var state:List.copyOf(resistances.values())){for(var grant:List.copyOf(state.grants))grant.close();}resistances.clear();}
}
@@ -0,0 +1,179 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.WarBanner;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable WarBanner settings; publication remains a server-thread caller decision. */
public final class WarBannerSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "war-banner-cooldown-seconds";
public static final String PLACEMENT_RANGE = "war-banner-placement-range-blocks";
public static final String RADIUS = "war-banner-radius-blocks";
public static final String VERTICAL = "war-banner-vertical-range-blocks";
public static final String DURATION = "war-banner-duration-seconds";
public static final String DAMAGE_BONUS = "war-banner-damage-bonus-percent";
public static final String KNOCKBACK_RESISTANCE = "war-banner-knockback-resistance-percent";
private static final List<String> KEYS = List.of(COOLDOWN, PLACEMENT_RANGE, RADIUS, VERTICAL, DURATION,
DAMAGE_BONUS, KNOCKBACK_RESISTANCE);
private static final Map<String, String> DEFAULTS = Map.of(
COOLDOWN, "45", PLACEMENT_RANGE, "12", RADIUS, "8", VERTICAL, "4", DURATION, "10",
DAMAGE_BONUS, "25", KNOCKBACK_RESISTANCE, "50");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, WarBanner.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-war-banner-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public WarBannerSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
WarBannerSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public WarBanner.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("WarBanner configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static WarBanner.Settings parse(Map<String, String> values) {
try { return new WarBanner.Settings(seconds(values.get(COOLDOWN)), finite(values.get(PLACEMENT_RANGE)),
finite(values.get(RADIUS)), finiteNonnegative(values.get(VERTICAL)), seconds(values.get(DURATION)),
new BigDecimal(values.get(DAMAGE_BONUS)), new BigDecimal(values.get(KNOCKBACK_RESISTANCE))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("WarBanner settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private static double finiteNonnegative(String value) {
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -53,6 +53,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
private games.dmg.fantasystick.ability.Sanctuary sanctuary;
private games.dmg.fantasystick.persistence.WarBannerSettings warBannerSettings;
private games.dmg.fantasystick.ability.WarBanner warBanner;
private AdminCharacterIssuer characterIssuer;
private AdminRevival adminRevival;
private AdminExperienceGrant experienceGrant;
@@ -273,6 +275,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
sanctuarySettings = Objects.requireNonNull(settings); sanctuary = Objects.requireNonNull(effect);
}
public void attachWarBanner(games.dmg.fantasystick.persistence.WarBannerSettings settings,
games.dmg.fantasystick.ability.WarBanner effect) {
if (closed || warBannerSettings != null) throw new IllegalStateException("War Banner already attached or command closed");
warBannerSettings = Objects.requireNonNull(settings); warBanner = Objects.requireNonNull(effect);
}
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
@@ -497,6 +505,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (sanctuarySettings != null && games.dmg.fantasystick.persistence.SanctuarySettings.keys().contains(args[1])) {
sanctuaryConfiguration(sender, args); return;
}
if (warBannerSettings != null && games.dmg.fantasystick.persistence.WarBannerSettings.keys().contains(args[1])) {
warBannerConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -786,6 +797,25 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void warBannerConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) { sender.sendMessage(key + "=" + warBannerSettings.value(key) + " (applies to future War Banner casts only)"); return; }
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.WarBannerSettings.Update> operation;
try { operation = warBannerSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("War Banner setting is invalid; the effective value is unchanged."); return; }
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure); if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) { warBanner.configure(saved.settings()); abilities.replace(warBanner.definition()); warBannerSettings.publish(saved); }
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future War Banner casts; active areas and deadlines are unchanged."
: "War Banner configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void hexConfiguration(CommandSender sender, String[] args) {
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
@@ -947,6 +977,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
keys.addAll(games.dmg.fantasystick.persistence.FrostNovaSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.ChainLightningSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.SanctuarySettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.WarBannerSettings.keys());
return matching(keys, args[1]);
}
if (args.length == 3 && args[0].equals("config")) {
@@ -1031,6 +1062,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
String defaults = games.dmg.fantasystick.persistence.SanctuarySettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (warBannerSettings != null
&& games.dmg.fantasystick.persistence.WarBannerSettings.keys().contains(args[1])) {
String current = warBannerSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.WarBannerSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
}
return List.of();
}
+10
View File
@@ -142,3 +142,13 @@ 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
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
assertNotNull(fixture.packetListener);
assertEquals(7, fixture.timers.size(),
"Presentation, ability/bond/rally cleanup, provenance, reward retry and session particles");
assertEquals(8, fixture.timers.size(),
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry and session particles");
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
@@ -0,0 +1,86 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.ability.*;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.*;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.*;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.*;
import org.bukkit.attribute.*;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.*;
import org.bukkit.event.entity.*;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.util.RayTraceResult;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class WarBannerNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void bookAcquisitionSolidTopPlacementBenefitsPartyAndRewardsUsedDamage()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);PartyBookNativeTest.click(f,caster,button(caster,"[3]","war_banner"));drain(f,2);use(f,caster);drain(f,2);assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
}
}
@Test void invalidOrNonTopPlacementRefusesBeforeCooldown()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster,12,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));}
}
@Test void fixedCylinderObstructionLifecycleOverlapAndRallyUseStrongestOnly()throws Exception{
var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var supports=new ArrayList<OutgoingDamageBonus.Support>();var bonuses=new OutgoingDamageBonus(sessions,parties,supports::add);var source=mock(Player.class);var ally=mock(Player.class);var world=mock(World.class);UUID a=UUID.randomUUID(),b=UUID.randomUUID();when(source.getUniqueId()).thenReturn(a);when(ally.getUniqueId()).thenReturn(b);var sa=new EquipmentSessions.Active(a,UUID.randomUUID(),UUID.randomUUID());var ba=new EquipmentSessions.Active(b,UUID.randomUUID(),UUID.randomUUID());when(sessions.active(a)).thenReturn(Optional.of(sa));when(sessions.active(b)).thenReturn(Optional.of(ba));when(sessions.current(any())).thenReturn(true);var party=new PartyRoster.Party(UUID.randomUUID(),a,List.of(a,b));when(parties.partyOf(a)).thenReturn(Optional.of(party));when(parties.partyOf(b)).thenReturn(Optional.of(party));when(source.getWorld()).thenReturn(world);when(source.getLocation()).thenReturn(new Location(world,.5,65,.5));when(ally.getLocation()).thenReturn(new Location(world,8.5,69,.5));when(source.getBoundingBox()).thenReturn(new org.bukkit.util.BoundingBox(0,65,0,1,67,1));when(ally.getBoundingBox()).thenReturn(new org.bukkit.util.BoundingBox(8,69,0,9,71,1));doReturn(List.of(source,ally)).when(world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var sourceResistance=attribute(source);var allyResistance=attribute(ally);var block=mock(Block.class);when(block.getType()).thenReturn(Material.STONE);when(block.getLocation()).thenReturn(new Location(world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(BlockFace.UP);when(source.getEyeLocation()).thenReturn(new Location(world,0,65.62,0,0,45));doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(12d),eq(FluidCollisionMode.NEVER),eq(true));
var banner=new WarBanner(WarBanner.Settings.defaults(),sessions,parties,bonuses);var be=execution(source,sa,banner.definition());var owned=new ArrayList<AutoCloseable>();doAnswer(call->{owned.add(call.getArgument(0));return null;}).when(be).own(any());banner.activate(be);assertEquals(.5,allyResistance.value(),1e-9);assertEquals(.5,sourceResistance.value(),1e-9);verify(block,never()).setType(any(Material.class));verify(world,atLeastOnce()).spawnParticle(eq(Particle.DUST),any(Location.class),eq(16),anyDouble(),anyDouble(),anyDouble(),eq(0d),any(Particle.DustOptions.class));
when(source.getLocation()).thenReturn(new Location(world,100,65,.5));banner.sweep();assertEquals(0,sourceResistance.value(),1e-9);assertEquals(.5,allyResistance.value(),1e-9,"The fixed banner remains when its caster moves away");when(source.getLocation()).thenReturn(new Location(world,.5,65,.5));banner.sweep();
when(ally.getLocation()).thenReturn(new Location(world,8.51,69,.5));banner.sweep();assertEquals(0,allyResistance.value(),1e-9);
when(ally.getLocation()).thenReturn(new Location(world,4.5,65,.5));when(ally.getBoundingBox()).thenReturn(new org.bukkit.util.BoundingBox(4,65,0,5,67,1));banner.sweep();assertEquals(.5,allyResistance.value(),1e-9);when(parties.partyOf(b)).thenReturn(Optional.empty());banner.sweep();assertEquals(0,allyResistance.value(),1e-9);when(parties.partyOf(b)).thenReturn(Optional.of(party));banner.sweep();assertEquals(.5,allyResistance.value(),1e-9);
var be2=execution(source,sa,banner.definition());var owned2=new ArrayList<AutoCloseable>();doAnswer(call->{owned2.add(call.getArgument(0));return null;}).when(be2).own(any());banner.activate(be2);assertEquals(.5,allyResistance.value(),1e-9,"Overlapping banners retain one resistance layer");var rally=new RallyingCry(RallyingCry.Settings.defaults(),sessions,parties,bonuses);when(source.getNearbyEntities(8,8,8)).thenReturn(List.of(ally));var re=execution(source,sa,rally.definition());var rallyOwned=new ArrayList<AutoCloseable>();doAnswer(call->{rallyOwned.add(call.getArgument(0));return null;}).when(re).own(any());rally.activate(re);
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());var hit=hit(ally,enemy,10);bonuses.increase(hit);assertEquals(12.5,hit.getDamage(),1e-9,"Banner 25% supersedes rather than adds Rally 20%");bonuses.accepted(hit);verify(be).report(eq(AbilityReport.Type.BOOST_USED),eq(enemy),argThat(value->value.compareTo(new BigDecimal("2.5"))==0));verify(be2,never()).report(eq(AbilityReport.Type.BOOST_USED),any(),any());verify(re,never()).report(eq(AbilityReport.Type.BOOST_USED),any(),any());
owned.getFirst().close();var fallback=hit(ally,enemy,10);bonuses.increase(fallback);assertEquals(12.5,fallback.getDamage(),1e-9);bonuses.accepted(fallback);verify(be2).report(eq(AbilityReport.Type.BOOST_USED),eq(enemy),any());assertEquals(.5,allyResistance.value(),1e-9);
doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),anyDouble(),eq(FluidCollisionMode.NEVER),eq(true));banner.sweep();
var rallyOnly=hit(ally,enemy,10);bonuses.increase(rallyOnly);assertEquals(12,rallyOnly.getDamage(),1e-9);assertEquals(0,allyResistance.value(),1e-9);owned2.getFirst().close();
banner.close();rally.close();bonuses.close();
}
@Test void configuredDefinitionAdminRestartAndEverySetting()throws Exception{
java.nio.file.Files.writeString(data.resolve("config.yml"),"war-banner-cooldown-seconds: 2\nwar-banner-placement-range-blocks: 10\nwar-banner-radius-blocks: 7\nwar-banner-vertical-range-blocks: 3\nwar-banner-duration-seconds: 8\nwar-banner-damage-bonus-percent: 30\nwar-banner-knockback-resistance-percent: 40\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("war_banner").orElseThrow();assertEquals(java.time.Duration.ofSeconds(2),definition.cooldown());assertEquals(0,new BigDecimal("10").compareTo(definition.parameters().get("placement-range")));assertEquals(new BigDecimal("30"),definition.parameters().get("damage-bonus-percent"));var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","war-banner-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","war-banner-radius-blocks","9"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(7,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","war-banner-"}).size());assertEquals(List.of("7","8"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,""}));f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS});assertTrue(sender.messages.getLast().contains("=7"));
for(String[] update:List.of(new String[]{WarBannerSettings.COOLDOWN,"3"},new String[]{WarBannerSettings.PLACEMENT_RANGE,"11"},new String[]{WarBannerSettings.RADIUS,"9"},new String[]{WarBannerSettings.VERTICAL,"5"},new String[]{WarBannerSettings.DURATION,"9"},new String[]{WarBannerSettings.DAMAGE_BONUS,"35"},new String[]{WarBannerSettings.KNOCKBACK_RESISTANCE,"60"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
var saved=registry.find("war_banner").orElseThrow();assertEquals(java.time.Duration.ofSeconds(3),saved.cooldown());assertEquals(new BigDecimal("35"),saved.parameters().get("damage-bonus-percent"));assertEquals(new BigDecimal("60"),saved.parameters().get("knockback-resistance-percent"));
var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster,11);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);use(f,caster);drain(f,2);assertEquals(180L,f.delayedTicks.getLast());assertEquals(.6,resistance.value(),1e-9);var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());var hit=hit(ally.player,enemy,10);f.dispatcher.accept(hit);assertEquals(13.5,hit.getDamage(),1e-9);
f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("35"),reloaded.find("war_banner").orElseThrow().parameters().get("damage-bonus-percent"));}
}
private record AttributeFixture(Set<AttributeModifier> modifiers){double value(){return modifiers.stream().mapToDouble(AttributeModifier::getAmount).sum();}}
private static AttributeFixture attribute(Player player){var instance=mock(AttributeInstance.class);var modifiers=new LinkedHashSet<AttributeModifier>();when(instance.getModifiers()).thenAnswer(call->Set.copyOf(modifiers));doAnswer(call->{modifiers.add(call.getArgument(0));return null;}).when(instance).addModifier(any());doAnswer(call->{modifiers.remove(call.getArgument(0));return null;}).when(instance).removeModifier(any(AttributeModifier.class));doReturn(instance).when(player).getAttribute(Attribute.KNOCKBACK_RESISTANCE);return new AttributeFixture(modifiers);}
private static AbilityExecution execution(Player player,EquipmentSessions.Active active,AbilityDefinition definition){var e=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(player);when(input.session()).thenReturn(active);when(e.input()).thenReturn(input);when(e.stillCurrent()).thenReturn(true);when(e.definition()).thenReturn(definition);return e;}
private static EntityDamageByEntityEvent hit(Player attacker,LivingEntity target,double amount){var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(attacker).withDirectEntity(attacker).build();return new EntityDamageByEntityEvent(attacker,target,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,amount);}
private static void placement(FantasyStickLifecycleNativeTest.Actor actor){placement(actor,12,BlockFace.UP);}
private static void placement(FantasyStickLifecycleNativeTest.Actor actor,double range){placement(actor,range,BlockFace.UP);}
private static void placement(FantasyStickLifecycleNativeTest.Actor actor,double range,BlockFace face){var block=mock(Block.class);when(block.getType()).thenReturn(Material.STONE);when(block.getLocation()).thenReturn(new Location(actor.world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(face);doReturn(new Location(actor.world,0,65.62,0,0,45)).when(actor.player).getEyeLocation();doReturn(ray).when(actor.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(range),eq(FluidCollisionMode.NEVER),eq(true));}
private static void nearby(FantasyStickLifecycleNativeTest.Actor source,List<? extends Entity> entities){doReturn(new ArrayList<>(entities)).when(source.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());for(var entity:entities)if(entity instanceof Player p)doReturn(new Location(source.world,2,65,0)).when(p).getLocation();}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,BlockFace.SELF,EquipmentSlot.HAND));}
private static void drain(FantasyStickLifecycleNativeTest.Fixture f,int count)throws Exception{for(int i=0;i<count;i++)PartyBookNativeTest.drain(f);}
private static void party(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a,FantasyStickLifecycleNativeTest.Actor b){var parties=PartyLifecycleNativeTest.parties(f);var group=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,group.id(),b.core.id).orElseThrow().id()));}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f){return(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");}
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor actor,String label,String ability){var book=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(book);for(var page:book.pages()){var found=button(page.raw(),label,ability);if(found!=null)return found;}throw new AssertionError("Missing "+label);}
private static ClickEvent.Custom button(net.minecraft.network.chat.Component c,String label,String ability){if(c.getString().equals(label)&&c.getStyle().getClickEvent() instanceof ClickEvent.Custom custom&&custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload&&payload.value().contains(":"+ability))return custom;for(var child:c.getSiblings()){var found=button(child,label,ability);if(found!=null)return found;}return null;}
private static void prepare(Path directory,FantasyStickLifecycleNativeTest.Actor actor,boolean learned,boolean rally)throws Exception{java.nio.file.Files.createDirectories(directory);var repository=new YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();var stats=new EnumMap<>(old.stats());stats.put(Stat.STR,BigInteger.valueOf(22));stats.put(Stat.CHR,BigInteger.valueOf(22));stats.put(Stat.CON,BigInteger.valueOf(12));var learnedSet=new LinkedHashSet<String>();if(learned)learnedSet.add("war_banner");if(rally)learnedSet.add("rallying_cry");var slots=learned?List.of("war_banner","","","","","",""):old.loadout();repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),BigInteger.valueOf(learned?0:1),old.health(),learnedSet,slots,old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
}
@@ -0,0 +1,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;}
}