Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2340d02b |
No files matched your search
@@ -81,6 +81,8 @@ 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.persistence.HexSettings hexSettings;
|
||||
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
@@ -159,6 +161,8 @@ 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 adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
|
||||
var factory = new CharacterFactory();
|
||||
@@ -273,6 +277,12 @@ 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()));
|
||||
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 +303,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityRegistry.register(rallyingCry.definition());
|
||||
abilityRegistry.register(frostNova.definition());
|
||||
abilityRegistry.register(chainLightning.definition());
|
||||
abilityRegistry.register(sanctuary.definition());
|
||||
abilityRegistry.register(greatSword.definition());
|
||||
abilityRegistry.register(revive.definition());
|
||||
sessions.attachAbilityRegistry(abilityRegistry);
|
||||
@@ -341,6 +352,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachRallyingCry(rallyingCrySettings, rallyingCry);
|
||||
admin.attachFrostNova(frostNovaSettings, frostNova);
|
||||
admin.attachChainLightning(chainLightningSettings, chainLightning);
|
||||
admin.attachSanctuary(sanctuarySettings, sanctuary);
|
||||
admin.attachAdminRevival(adminRevival, adminReviveSettings);
|
||||
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
|
||||
characters, rules, sessions, nextTick, getServer()::getPlayer,
|
||||
@@ -458,6 +470,8 @@ 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 closingOutgoingDamageBonus = outgoingDamageBonus; outgoingDamageBonus = null;
|
||||
var closingAdminRevival = adminRevival; adminRevival = null;
|
||||
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
|
||||
@@ -501,6 +515,8 @@ 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 (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 +555,8 @@ 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(),
|
||||
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdminReviveSettings.drained().toCompletableFuture(),
|
||||
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -799,6 +817,16 @@ 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 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,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,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); }
|
||||
}
|
||||
@@ -51,6 +51,8 @@ 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 AdminCharacterIssuer characterIssuer;
|
||||
private AdminRevival adminRevival;
|
||||
private AdminExperienceGrant experienceGrant;
|
||||
@@ -265,6 +267,12 @@ 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 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 +494,9 @@ 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 (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
configurationUsage(sender); return;
|
||||
}
|
||||
@@ -748,6 +759,33 @@ 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 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 +946,7 @@ 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());
|
||||
return matching(keys, args[1]);
|
||||
}
|
||||
if (args.length == 3 && args[0].equals("config")) {
|
||||
@@ -986,6 +1025,12 @@ 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]);
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@@ -132,3 +132,13 @@ 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
|
||||
@@ -0,0 +1,197 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.ability.*;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.*;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SanctuaryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedAreaHealingCreatesPersistedSupportReward() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data,caster,false); setHealth(data,ally,BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id,caster.player);fixture.players.put(ally.core.id,ally.player);fixture.activate(caster);fixture.activate(ally);party(fixture,caster,ally);
|
||||
caster.core.location=new Location(caster.world,0,64,0);ally.core.location=new Location(caster.world,2,64,0);
|
||||
doReturn(List.of(ally.player)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(fixture,caster);
|
||||
String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Sanctuary"));assertTrue(text.contains("WIS24"));assertTrue(text.contains("CHR20"));assertTrue(text.contains("CON10"));
|
||||
PartyBookNativeTest.click(fixture,caster,button(caster,"[Learn]","sanctuary"));drain(fixture,2);
|
||||
PartyBookNativeTest.click(fixture,caster,button(caster,"[3]","sanctuary"));drain(fixture,2);
|
||||
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
|
||||
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(caster.world.getUID()).thenReturn(UUID.randomUUID());when(enemy.getLocation()).thenReturn(new Location(caster.world,8,64,0));
|
||||
var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(fixture,4);
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"characters");
|
||||
assertEquals(BigInteger.valueOf(5),storage.find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5),storage.find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fixedCylinderHasNoImmediatePulseThenSixOwnedHealingAndMitigationPulses() {
|
||||
var fixture = directFixture(); fixture.sanctuary.activate(fixture.execution);
|
||||
assertEquals(10, fixture.allyHealth.get(), 0); assertEquals(1, fixture.scheduled.size());
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var source = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9); fixture.mitigation.accepted(hit);
|
||||
verify(fixture.execution).report(eq(AbilityReport.Type.PREVENTION), eq(fixture.ally), eq(BigDecimal.valueOf(2.0)));
|
||||
run(fixture.scheduled, 9); assertEquals(10, fixture.allyHealth.get(), 0);
|
||||
run(fixture.scheduled, 1); assertEquals(12, fixture.allyHealth.get(), 0); assertEquals(1, fixture.supports.size());
|
||||
run(fixture.scheduled, 50); assertEquals(20, fixture.allyHealth.get(), 0, "Six two-point pulses cap at maximum health");
|
||||
assertEquals(5, fixture.supports.size(), "The sixth overhealing pulse creates no support evidence");
|
||||
verify(fixture.execution, atLeastOnce()).finish();
|
||||
}
|
||||
|
||||
@Test void exactHorizontalAndVerticalCylinderBoundaryIsIncludedButOutsideIsRemoved() {
|
||||
var fixture=directFixture();fixture.allyLocation.set(new Location(fixture.world,6,67,0));fixture.sanctuary.activate(fixture.execution);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);var source=DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var boundary=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,fixture.ally,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);
|
||||
fixture.mitigation.reduce(boundary);assertEquals(8,boundary.getDamage(),0);
|
||||
fixture.allyLocation.set(new Location(fixture.world,6.01,67,0));run(fixture.scheduled,1);
|
||||
var outside=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,fixture.ally,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);
|
||||
fixture.mitigation.reduce(outside);assertEquals(10,outside.getDamage(),0);
|
||||
}
|
||||
|
||||
@Test void leavingCylinderOrObstructionEndsBenefitsAndSourceInvalidationCleansUp() {
|
||||
var fixture = directFixture(); fixture.sanctuary.activate(fixture.execution); run(fixture.scheduled, 10);
|
||||
doReturn(mock(org.bukkit.util.RayTraceResult.class)).when(fixture.world).rayTraceBlocks(any(Location.class),
|
||||
any(org.bukkit.util.Vector.class), anyDouble(), eq(org.bukkit.FluidCollisionMode.NEVER), eq(true));
|
||||
run(fixture.scheduled, 1);
|
||||
fixture.allyLocation.set(new Location(fixture.world, 2, 68, 0));
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); var source = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.mitigation.reduce(hit); assertEquals(10, hit.getDamage(), 0);
|
||||
when(fixture.execution.stillCurrent()).thenReturn(false); fixture.scheduled.removeFirst().run(); verify(fixture.execution).finish();
|
||||
}
|
||||
|
||||
@Test void overlappingSanctuariesUseEarliestSourceForOnePulseAndStrongestReduction() {
|
||||
var fixture = directFixture(); var second = execution(fixture.source, fixture.sourceActive);
|
||||
when(second.definition()).thenReturn(fixture.sanctuary.definition()); var character = fixture.execution.character();
|
||||
when(second.character()).thenReturn(character);
|
||||
doAnswer(call->{fixture.scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);})
|
||||
.when(second).later(any(),any());
|
||||
fixture.sanctuary.activate(fixture.execution); fixture.sanctuary.activate(second);
|
||||
for (int i = 0; i < 20; i++) fixture.scheduled.removeFirst().run();
|
||||
assertEquals(12, fixture.allyHealth.get(), 0); assertEquals(1, fixture.supports.size(), "Overlap supplies one healing pulse");
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); var damageSource = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource, 10);
|
||||
fixture.mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 0, "Two sanctuaries do not multiply reduction");
|
||||
fixture.mitigation.accepted(hit); verify(fixture.execution).report(eq(AbilityReport.Type.PREVENTION), eq(fixture.ally), any());
|
||||
verify(second, never()).report(eq(AbilityReport.Type.PREVENTION), any(), any());
|
||||
var stone = mock(AbilityExecution.class); when(stone.stillCurrent()).thenReturn(true);
|
||||
fixture.mitigation.grant(stone, fixture.ally, BigDecimal.valueOf(50));
|
||||
var stronger = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource, 10);
|
||||
fixture.mitigation.reduce(stronger); assertEquals(5, stronger.getDamage(), 0, "Stronger Stone Skin-style mitigation supersedes Sanctuary");
|
||||
}
|
||||
|
||||
@Test void definitionAndPluginConfigurationExposeAllApprovedValues() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "sanctuary-cooldown-seconds: 30\nsanctuary-radius-blocks: 7\n"
|
||||
+ "sanctuary-vertical-range-blocks: 4\nsanctuary-duration-seconds: 4\nsanctuary-pulse-interval-seconds: 1\n"
|
||||
+ "sanctuary-healing-per-pulse: 3\nsanctuary-damage-reduction-percent: 25\n");
|
||||
try (var plugin = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
plugin.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin, "abilityRegistry");
|
||||
var definition = registry.find("sanctuary").orElseThrow(); assertEquals(org.bukkit.Material.SEA_LANTERN, definition.icon());
|
||||
assertEquals(BigInteger.valueOf(24), definition.requirements().get(Stat.WIS)); assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.CHR));
|
||||
assertEquals(BigInteger.TEN, definition.requirements().get(Stat.CON)); assertEquals(java.time.Duration.ofSeconds(30), definition.cooldown());
|
||||
assertEquals(new BigDecimal("3"), definition.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("25"), definition.parameters().get("damage-reduction-percent"));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-"}).isEmpty());
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("sanctuary-cooldown-seconds","sanctuary-damage-reduction-percent","sanctuary-duration-seconds",
|
||||
"sanctuary-healing-per-pulse","sanctuary-pulse-interval-seconds","sanctuary-radius-blocks","sanctuary-vertical-range-blocks"),
|
||||
plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-"}));
|
||||
assertEquals(List.of("2","3"),plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-healing-per-pulse",""}));
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-healing-per-pulse"});assertTrue(sender.messages.getLast().contains("=3"));
|
||||
for(String[] update:List.of(new String[]{"sanctuary-cooldown-seconds","20"},new String[]{"sanctuary-radius-blocks","8"},
|
||||
new String[]{"sanctuary-vertical-range-blocks","5"},new String[]{"sanctuary-duration-seconds","5"},
|
||||
new String[]{"sanctuary-pulse-interval-seconds","1"},new String[]{"sanctuary-healing-per-pulse","4"},
|
||||
new String[]{"sanctuary-damage-reduction-percent","30"})){
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
|
||||
var configured=registry.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),configured.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("30"),configured.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),configured.cooldown());
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8","extra"});assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
plugin.plugin.setEnabled(false);plugin.plugin.setEnabled(true);
|
||||
var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin,"abilityRegistry");
|
||||
var saved=reloaded.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),saved.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("30"),saved.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),saved.cooldown());
|
||||
}
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor){actor.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,actor.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,org.bukkit.inventory.EquipmentSlot.HAND));}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture,int count)throws Exception{for(int i=0;i<count;i++)PartyBookNativeTest.drain(fixture);}
|
||||
private static void party(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor source,FantasyStickLifecycleNativeTest.Actor target){var parties=PartyLifecycleNativeTest.parties(fixture);var group=parties.create(source.core.id).orElseThrow();assertTrue(parties.accept(target.core.id,parties.invite(source.core.id,group.id(),target.core.id).orElseThrow().id()));}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor actor,String label,String ability){var book=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(book);for(var page:book.pages()){var found=button(page.raw(),label,ability);if(found!=null)return found;}throw new AssertionError("Missing "+label);}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom button(net.minecraft.network.chat.Component component,String label,String ability){if(component.getString().equals(label)&&component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom&&custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload&&payload.value().contains(":"+ability))return custom;for(var child:component.getSiblings()){var found=button(child,label,ability);if(found!=null)return found;}return null;}
|
||||
private static void prepareCaster(Path directory,FantasyStickLifecycleNativeTest.Actor actor,boolean learned)throws Exception{java.nio.file.Files.createDirectories(directory);var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();var stats=new EnumMap<>(old.stats());stats.put(Stat.WIS,BigInteger.valueOf(24));stats.put(Stat.CHR,BigInteger.valueOf(20));stats.put(Stat.CON,BigInteger.TEN);repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),BigInteger.valueOf(learned?0:1),old.health(),learned?Set.of("sanctuary"):old.learned(),learned?List.of("sanctuary","","","","","",""):old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
|
||||
private static void setHealth(Path directory,FantasyStickLifecycleNativeTest.Actor actor,BigDecimal health)throws Exception{var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),health,old.learned(),old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());actor.core.health=health.doubleValue();}
|
||||
|
||||
private static void run(ArrayDeque<Runnable> scheduled,int count){for(int i=0;i<count;i++){assertFalse(scheduled.isEmpty());scheduled.removeFirst().run();}}
|
||||
|
||||
private DirectFixture directFixture() {
|
||||
var source=mock(Player.class);var ally=mock(Player.class);var world=mock(org.bukkit.World.class);
|
||||
UUID sourceId=UUID.randomUUID(),allyId=UUID.randomUUID();when(source.getUniqueId()).thenReturn(sourceId);when(ally.getUniqueId()).thenReturn(allyId);
|
||||
var sourceLocation=new AtomicReference<>(new Location(world,0,64,0));var allyLocation=new AtomicReference<>(new Location(world,2,64,0));
|
||||
when(source.getLocation()).thenAnswer(call->sourceLocation.get().clone());when(ally.getLocation()).thenAnswer(call->allyLocation.get().clone());
|
||||
when(source.getBoundingBox()).thenAnswer(call->box(sourceLocation.get()));when(ally.getBoundingBox()).thenAnswer(call->box(allyLocation.get()));
|
||||
when(source.getWorld()).thenReturn(world);when(ally.getWorld()).thenReturn(world);when(world.getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble())).thenReturn(List.of(ally));
|
||||
var sourceHealth=new AtomicReference<>(20.0);var allyHealth=new AtomicReference<>(10.0);health(source,sourceHealth);health(ally,allyHealth);
|
||||
var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var characters=mock(CharacterStorage.class);
|
||||
var sourceActive=new EquipmentSessions.Active(sourceId,UUID.randomUUID(),UUID.randomUUID());var allyActive=new EquipmentSessions.Active(allyId,UUID.randomUUID(),UUID.randomUUID());
|
||||
when(sessions.active(sourceId)).thenReturn(Optional.of(sourceActive));when(sessions.active(allyId)).thenReturn(Optional.of(allyActive));when(sessions.current(any())).thenReturn(true);
|
||||
var records=new HashMap<UUID,AtomicReference<CharacterRecord>>();records.put(sourceId,new AtomicReference<>(record(sourceActive.character(),BigDecimal.valueOf(20))));records.put(allyId,new AtomicReference<>(record(allyActive.character(),BigDecimal.TEN)));
|
||||
when(sessions.currentRecord(sourceActive)).thenAnswer(call->Optional.of(records.get(sourceId).get()));when(sessions.currentRecord(allyActive)).thenAnswer(call->Optional.of(records.get(allyId).get()));
|
||||
when(sessions.acknowledgeProgress(any(),any(),any())).thenAnswer(call->{EquipmentSessions.Active active=call.getArgument(0);CharacterRecord saved=call.getArgument(2);records.get(active.player()).set(saved);return true;});
|
||||
when(characters.saveHealth(any(),any(),any(),any())).thenAnswer(call->{UUID character=call.getArgument(0);BigInteger expected=call.getArgument(1);BigDecimal health=call.getArgument(2);
|
||||
var entry=records.values().stream().filter(ref->ref.get().id().equals(character)).findFirst().orElseThrow();var old=entry.get();
|
||||
if(!old.revision().equals(expected))return CompletableFuture.completedFuture(Optional.empty());
|
||||
var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),health,old.learned(),old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null);
|
||||
return CompletableFuture.completedFuture(Optional.of(saved));});
|
||||
var party=new PartyRoster.Party(UUID.randomUUID(),sourceId,List.of(sourceId,allyId));when(parties.partyOf(sourceId)).thenReturn(Optional.of(party));when(parties.partyOf(allyId)).thenReturn(Optional.of(party));
|
||||
var mitigation=new CombatMitigation();var scheduled=new ArrayDeque<Runnable>();var supports=new ArrayList<MendingLight.Support>();
|
||||
var sanctuary=new Sanctuary(Sanctuary.Settings.defaults(),characters,sessions,parties,mitigation,Runnable::run,event->{},()->BigDecimal.valueOf(Double.MAX_VALUE),supports::add);
|
||||
var execution=execution(source,sourceActive);when(execution.definition()).thenReturn(sanctuary.definition());
|
||||
var character=records.get(sourceId).get();when(execution.character()).thenReturn(character);
|
||||
doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());
|
||||
return new DirectFixture(source,ally,world,sourceActive,allyLocation,allyHealth,execution,sanctuary,mitigation,scheduled,supports);
|
||||
}
|
||||
private record DirectFixture(Player source,Player ally,org.bukkit.World world,EquipmentSessions.Active sourceActive,
|
||||
AtomicReference<Location> allyLocation,AtomicReference<Double> allyHealth,AbilityExecution execution,Sanctuary sanctuary,
|
||||
CombatMitigation mitigation,ArrayDeque<Runnable> scheduled,List<MendingLight.Support> supports){}
|
||||
private static AbilityExecution execution(Player source,EquipmentSessions.Active active){var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(source);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);return execution;}
|
||||
private static CharacterRecord record(UUID id,BigDecimal health){var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var stats=new EnumMap<>(base.stats());stats.put(Stat.CON,BigInteger.TEN);return new CharacterRecord(id,base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),health,Set.of("sanctuary"),List.of("sanctuary","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);}
|
||||
private static BoundingBox box(Location at){return new BoundingBox(at.getX()-.3,at.getY(),at.getZ()-.3,at.getX()+.3,at.getY()+1.8,at.getZ()+.3);}
|
||||
private static void health(Player player,AtomicReference<Double> health){var attribute=mock(AttributeInstance.class);when(attribute.getValue()).thenReturn(20.0);when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(attribute);when(player.getHealth()).thenAnswer(call->health.get());doAnswer(call->{health.set(call.getArgument(0));return null;}).when(player).setHealth(anyDouble());}
|
||||
}
|
||||
@@ -0,0 +1,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;}
|
||||
}
|
||||
Reference in new issue
Block a user