Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4abaeecf63 |
No files matched your search
@@ -70,6 +70,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
|
||||
private games.dmg.fantasystick.ability.MendingLight mendingLight;
|
||||
private games.dmg.fantasystick.persistence.MendingLightSettings mendingLightSettings;
|
||||
private games.dmg.fantasystick.ability.Purify purify;
|
||||
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
|
||||
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
|
||||
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
@@ -138,6 +140,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var inspireInitial = games.dmg.fantasystick.persistence.InspireSettings.parse(inspireConfiguration);
|
||||
var mendingLightConfiguration = mendingLightConfiguration();
|
||||
var mendingLightInitial = games.dmg.fantasystick.persistence.MendingLightSettings.parse(mendingLightConfiguration);
|
||||
var purifyConfiguration = purifyConfiguration();
|
||||
var purifyInitial = games.dmg.fantasystick.persistence.PurifySettings.parse(purifyConfiguration);
|
||||
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
|
||||
var factory = new CharacterFactory();
|
||||
@@ -233,6 +237,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
|
||||
purify = new games.dmg.fantasystick.ability.Purify(purifyInitial, sessions, parties,
|
||||
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,
|
||||
@@ -248,6 +255,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityRegistry.register(hex.definition());
|
||||
abilityRegistry.register(inspire.definition());
|
||||
abilityRegistry.register(mendingLight.definition());
|
||||
abilityRegistry.register(purify.definition());
|
||||
abilityRegistry.register(greatSword.definition());
|
||||
abilityRegistry.register(revive.definition());
|
||||
sessions.attachAbilityRegistry(abilityRegistry);
|
||||
@@ -291,6 +299,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
|
||||
inspireSettings, inspire, abilityRegistry);
|
||||
admin.attachMendingLight(mendingLightSettings, mendingLight);
|
||||
admin.attachPurify(purifySettings, purify);
|
||||
admin.attachAdminRevival(adminRevival, adminReviveSettings);
|
||||
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
|
||||
characters, rules, sessions, nextTick, getServer()::getPlayer,
|
||||
@@ -392,6 +401,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingInspireSettings = inspireSettings; inspireSettings = null;
|
||||
mendingLight = null;
|
||||
var closingMendingLightSettings = mendingLightSettings; mendingLightSettings = null;
|
||||
purify = null;
|
||||
var closingPurifySettings = purifySettings; purifySettings = null;
|
||||
var closingAdminRevival = adminRevival; adminRevival = null;
|
||||
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
|
||||
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
|
||||
@@ -425,6 +436,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingHexSettings != null) cleanup("Hex configuration", closingHexSettings::close);
|
||||
if (closingInspireSettings != null) cleanup("Inspire configuration", closingInspireSettings::close);
|
||||
if (closingMendingLightSettings != null) cleanup("Mending Light configuration", closingMendingLightSettings::close);
|
||||
if (closingPurifySettings != null) cleanup("Purify configuration", closingPurifySettings::close);
|
||||
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
|
||||
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
|
||||
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
|
||||
@@ -452,6 +464,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: closingInspireSettings.drained().toCompletableFuture(),
|
||||
closingMendingLightSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingMendingLightSettings.drained().toCompletableFuture(),
|
||||
closingPurifySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPurifySettings.drained().toCompletableFuture(),
|
||||
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdminReviveSettings.drained().toCompletableFuture(),
|
||||
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -663,6 +677,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> purifyConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.PurifySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.PurifySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.PurifySettings.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,128 @@
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Purify's self-or-aimed-party native harmful-effect removal. */
|
||||
public final class Purify implements AbilityEffect {
|
||||
public record Settings(Duration cooldown, double range) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64) {
|
||||
throw new IllegalArgumentException("Invalid Purify settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(Duration.ofSeconds(15), 12); }
|
||||
}
|
||||
|
||||
public record Support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
public Support {
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (amount.signum() <= 0) throw new IllegalArgumentException("Support amount must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private record Target(Player player, EquipmentSessions.Active active) { }
|
||||
private static final List<PotionEffectType> HARMFUL = List.of(
|
||||
PotionEffectType.POISON, PotionEffectType.WITHER, PotionEffectType.SLOWNESS, PotionEffectType.WEAKNESS);
|
||||
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Consumer<Support> supports;
|
||||
private volatile Settings settings;
|
||||
|
||||
public Purify(Settings settings, EquipmentSessions sessions, PartyRoster parties, Consumer<Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties); this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("purify", "Purify", Material.HONEY_BOTTLE,
|
||||
List.of("Remove Poison, Wither, Slowness and Weakness.",
|
||||
"Aimed party-ally range: " + decimal(current.range()) + " blocks; beneficial effects remain."),
|
||||
"Sneak+Use cleanses self; normal Use requires an aimed active party ally.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PARTY, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
var target = target(input, settings.range());
|
||||
return target != null && HARMFUL.stream().anyMatch(type -> target.player().getPotionEffect(type) != null);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (!execution.stillCurrent()) return;
|
||||
var configured = from(execution.definition()); var target = target(execution.input(), configured.range());
|
||||
if (target == null) return;
|
||||
int removed = 0;
|
||||
for (var type : HARMFUL) {
|
||||
if (!execution.stillCurrent() || !current(execution.input(), target, configured.range())) break;
|
||||
if (target.player().getPotionEffect(type) == null) continue;
|
||||
target.player().removePotionEffect(type);
|
||||
if (target.player().getPotionEffect(type) == null) removed++;
|
||||
}
|
||||
if (removed == 0) return;
|
||||
execution.input().player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
|
||||
if (target.player() != execution.input().player()
|
||||
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
|
||||
var amount = BigDecimal.valueOf(removed);
|
||||
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
|
||||
target.player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
|
||||
}
|
||||
}
|
||||
|
||||
private Target target(ControlInput input, double range) {
|
||||
if (!(input.target() instanceof Player player)) return null;
|
||||
if (input.player().isSneaking()) {
|
||||
if (player != input.player()) return null;
|
||||
} else if (player == input.player() || !inRange(input.player(), player, range) || !sameParty(input.player(), player)) {
|
||||
return null;
|
||||
}
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
return active == null ? null : new Target(player, active);
|
||||
}
|
||||
|
||||
private boolean current(ControlInput input, Target target, double range) {
|
||||
if (!sessions.current(target.active())) return false;
|
||||
if (target.player() == input.player()) return input.player().isSneaking();
|
||||
return !input.player().isSneaking() && inRange(input.player(), target.player(), range)
|
||||
&& sameParty(input.player(), target.player());
|
||||
}
|
||||
|
||||
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 inRange(Player source, Player target, double range) {
|
||||
try {
|
||||
var from = source.getLocation(); var to = target.getLocation();
|
||||
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
return new Settings(definition.cooldown(), definition.parameters().get("range").doubleValue());
|
||||
}
|
||||
|
||||
private static String decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); }
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Purify;
|
||||
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 Purify settings; publication remains a server-thread caller decision. */
|
||||
public final class PurifySettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "purify-cooldown-seconds";
|
||||
public static final String RANGE = "purify-range-blocks";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, RANGE);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "15", RANGE, "12");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Purify.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-purify-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 PurifySettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
PurifySettings(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 Purify.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("Purify 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 Purify.Settings parse(Map<String, String> values) {
|
||||
try { return new Purify.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RANGE))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Purify 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 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); }
|
||||
}
|
||||
@@ -41,6 +41,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.ability.Inspire inspire;
|
||||
private games.dmg.fantasystick.persistence.MendingLightSettings mendingLightSettings;
|
||||
private games.dmg.fantasystick.ability.MendingLight mendingLight;
|
||||
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
|
||||
private games.dmg.fantasystick.ability.Purify purify;
|
||||
private AdminCharacterIssuer characterIssuer;
|
||||
private AdminRevival adminRevival;
|
||||
private AdminExperienceGrant experienceGrant;
|
||||
@@ -225,6 +227,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
mendingLightSettings = Objects.requireNonNull(settings); mendingLight = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachPurify(games.dmg.fantasystick.persistence.PurifySettings settings,
|
||||
games.dmg.fantasystick.ability.Purify effect) {
|
||||
if (closed || purifySettings != null) throw new IllegalStateException("Purify already attached or command closed");
|
||||
purifySettings = Objects.requireNonNull(settings); purify = 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);}
|
||||
@@ -431,6 +439,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (mendingLightSettings != null && games.dmg.fantasystick.persistence.MendingLightSettings.keys().contains(args[1])) {
|
||||
mendingLightConfiguration(sender, args); return;
|
||||
}
|
||||
if (purifySettings != null && games.dmg.fantasystick.persistence.PurifySettings.keys().contains(args[1])) {
|
||||
purifyConfiguration(sender, args); return;
|
||||
}
|
||||
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
configurationUsage(sender); return;
|
||||
}
|
||||
@@ -558,6 +569,33 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private void purifyConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + purifySettings.value(key) + " (applies to future Purify casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PurifySettings.Update> operation;
|
||||
try { operation = purifySettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Purify 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) {
|
||||
purify.configure(saved.settings()); abilities.replace(purify.definition()); purifySettings.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 Purify casts; active work and deadlines are unchanged."
|
||||
: "Purify 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;
|
||||
@@ -713,6 +751,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
keys.addAll(games.dmg.fantasystick.persistence.HexSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.InspireSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.MendingLightSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.PurifySettings.keys());
|
||||
return matching(keys, args[1]);
|
||||
}
|
||||
if (args.length == 3 && args[0].equals("config")) {
|
||||
@@ -762,6 +801,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
String defaults = games.dmg.fantasystick.persistence.MendingLightSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (purifySettings != null && games.dmg.fantasystick.persistence.PurifySettings.keys().contains(args[1])) {
|
||||
String current = purifySettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.PurifySettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
org.bukkit.entity.Entity target = selected.ability().equals("hex")
|
||||
|| selected.ability().equals("mending_light")
|
||||
? event.getPlayer().getTargetEntity(64)
|
||||
: selected.ability().equals("inspire")
|
||||
: selected.ability().equals("inspire") || selected.ability().equals("purify")
|
||||
? event.getPlayer().isSneaking() ? event.getPlayer() : event.getPlayer().getTargetEntity(64)
|
||||
: null;
|
||||
emit(event.getPlayer(), event.getItem(), selected, ControlInput.Gesture.USE, target, 0, 0);
|
||||
|
||||
@@ -95,3 +95,8 @@ inspire-duration-seconds: 6
|
||||
mending-light-base-healing: 6
|
||||
mending-light-cooldown-seconds: 8
|
||||
mending-light-range-blocks: 12
|
||||
|
||||
# Purify defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
purify-cooldown-seconds: 15
|
||||
purify-range-blocks: 12
|
||||
@@ -1288,7 +1288,7 @@ class EquipmentSessionEventsNativeTest {
|
||||
String[] abilities = {"fireball", "purify", "guardian_bond", "shield_bash", "rallying_cry", "war_banner", "revive"};
|
||||
f.loadout(abilities);
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var target = port(org.bukkit.entity.Player.class);
|
||||
var target = port(org.bukkit.entity.Player.class); a.targetEntity = target;
|
||||
for (int index = 0; index < abilities.length; index++) {
|
||||
int slot = index + 2; a.inventory.getInventory().setSelectedSlot(slot);
|
||||
var item = a.inventory.getItem(slot);
|
||||
|
||||
@@ -1688,6 +1688,8 @@ class EquipmentSessionsNativeTest {
|
||||
Runnable beforeHealth = () -> {};
|
||||
Runnable healthMutation = () -> {};
|
||||
float attackStrength = 1;
|
||||
boolean sneaking;
|
||||
org.bukkit.entity.Entity targetEntity;
|
||||
ItemStack activeItem = new ItemStack(Material.AIR);
|
||||
int useStarts;
|
||||
int useClears;
|
||||
@@ -1723,6 +1725,8 @@ class EquipmentSessionsNativeTest {
|
||||
case "setGameMode" -> { beforeMode.run(); gameMode = (org.bukkit.GameMode) args[0]; yield null; }
|
||||
case "getHealth" -> health;
|
||||
case "getAttackCooldown" -> attackStrength;
|
||||
case "isSneaking" -> sneaking;
|
||||
case "getTargetEntity" -> targetEntity;
|
||||
case "getActiveItem" -> activeItem.clone();
|
||||
case "hasActiveItem" -> !activeItem.isEmpty();
|
||||
case "getActiveItemHand" -> org.bukkit.inventory.EquipmentSlot.HAND;
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PurifyNativeTest {
|
||||
private static List<PotionEffectType> harmful;
|
||||
@TempDir Path data;
|
||||
|
||||
@BeforeAll static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
harmful = List.of(PotionEffectType.POISON, PotionEffectType.WITHER,
|
||||
PotionEffectType.SLOWNESS, PotionEffectType.WEAKNESS);
|
||||
}
|
||||
|
||||
@Test void bookAcquisitionSneakUseRemovesEveryHarmfulLayerAndPreservesBeneficialEffectsAndCooldown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, false);
|
||||
fixture.players.put(actor.core.id, actor.player); fixture.activate(actor);
|
||||
var effects = effects(fixture, actor.player);
|
||||
harmful.forEach(type -> effects.put(type, layered(type)));
|
||||
effects.put(PotionEffectType.REGENERATION, instance(PotionEffectType.REGENERATION, 200, 1));
|
||||
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
String book = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("Purify")); assertTrue(book.contains("WIS14")); assertTrue(book.contains("CHR10"));
|
||||
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("15")); assertTrue(book.contains("12"));
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
|
||||
doReturn(true).when(actor.player).isSneaking(); use(fixture, actor); drain(fixture, 2);
|
||||
|
||||
harmful.forEach(type -> assertFalse(effects.containsKey(type), type + " and its hidden chain must be gone"));
|
||||
assertTrue(effects.containsKey(PotionEffectType.REGENERATION));
|
||||
var storage = storage(fixture); var saved = storage.find(actor.core.record.id()).get().orElseThrow();
|
||||
var deadline = saved.cooldowns().get("purify"); assertNotNull(deadline);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
var unlearn = button(actor, "[Unlearn]", "purify");
|
||||
PartyBookNativeTest.click(fixture, actor, unlearn); PartyBookNativeTest.click(fixture, actor, unlearn); drain(fixture, 2);
|
||||
assertEquals(deadline, storage.find(actor.core.record.id()).get().orElseThrow().cooldowns().get("purify"));
|
||||
learnAndAssign(fixture, actor); use(fixture, actor);
|
||||
assertTrue(actor.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void aimedAllyActualCleanseCreatesPersistedSupportShareThatFollowsSourceCharacter() 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);
|
||||
prepare(data, caster, true); 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); aim(caster, ally.player, 8, true);
|
||||
var effects = effects(fixture, ally.player); effects.put(PotionEffectType.POISON, layered(PotionEffectType.POISON));
|
||||
doReturn(false).when(caster.player).isSneaking(); use(fixture, caster); drain(fixture, 2);
|
||||
assertFalse(effects.containsKey(PotionEffectType.POISON));
|
||||
|
||||
var sheet = caster.core.inventory.getItemInOffHand().clone(); caster.core.inventory.setItemInOffHand(null);
|
||||
fixture.changed(caster); drain(fixture, 1); fixture.awaitCharacterAndReceipt(caster);
|
||||
var holder = new FantasyStickLifecycleNativeTest.Actor(data); holder.core.inventory.setItemInOffHand(sheet);
|
||||
fixture.players.put(holder.core.id, holder.player); fixture.activate(holder);
|
||||
|
||||
var enemy = mock(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 = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
fixture.dispatcher.accept(new EntityDamageByEntityEvent(ally.player, enemy,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 3));
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
assertTrue(holder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void normalSelfOrdinaryNonPartyOutOfRangeObstructedAndNoOpTargetsRefuseBeforeCooldown() throws Exception {
|
||||
for (int route = 0; route < 6; route++) {
|
||||
var directory = data.resolve("refusal-" + route);
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(directory); prepare(directory, caster, true);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
|
||||
Player target;
|
||||
if (route == 0) { target = caster.player; doReturn(false).when(caster.player).isSneaking(); }
|
||||
else if (route == 1) { target = mock(Player.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID()); }
|
||||
else {
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(directory); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(ally); target = ally.player; effects(fixture, ally.player);
|
||||
if (route != 2) party(fixture, caster, ally);
|
||||
}
|
||||
if (route != 5 && route > 1) effectMap(target).put(PotionEffectType.WEAKNESS, instance(PotionEffectType.WEAKNESS, 200, 0));
|
||||
aim(caster, target, route == 3 ? 13 : 8, route != 4); doReturn(false).when(caster.player).isSneaking();
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("purify"),
|
||||
"route " + route + " must refuse before cooldown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledRemovalAndSelfCleanseCreateNoSupportWhileAcknowledgedCooldownRemains() 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);
|
||||
prepare(data, caster, true); 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); aim(caster, ally.player, 8, true);
|
||||
var effects = effects(fixture, ally.player); effects.put(PotionEffectType.POISON, layered(PotionEffectType.POISON));
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void removal(EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() == ally.player && event.getAction() == EntityPotionEffectEvent.Action.REMOVED) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin); doReturn(false).when(caster.player).isSneaking();
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertTrue(effects.containsKey(PotionEffectType.POISON));
|
||||
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("purify"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void selfCleanseNeverEmitsSupportEvidence() {
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var parties = mock(games.dmg.fantasystick.party.PartyRoster.class); var player = mock(Player.class);
|
||||
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
|
||||
when(player.getUniqueId()).thenReturn(active.player()); when(player.isSneaking()).thenReturn(true);
|
||||
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
var poison = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
when(player.getPotionEffect(PotionEffectType.POISON)).thenAnswer(ignored -> poison.get()
|
||||
? new PotionEffect(PotionEffectType.POISON, 200, 0) : null);
|
||||
doAnswer(ignored -> { poison.set(false); return null; }).when(player).removePotionEffect(PotionEffectType.POISON);
|
||||
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
|
||||
when(input.player()).thenReturn(player); when(input.target()).thenReturn(player); when(input.session()).thenReturn(active);
|
||||
var supports = new ArrayList<games.dmg.fantasystick.ability.Purify.Support>();
|
||||
var purify = new games.dmg.fantasystick.ability.Purify(
|
||||
games.dmg.fantasystick.ability.Purify.Settings.defaults(), sessions, parties, supports::add);
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
when(execution.stillCurrent()).thenReturn(true); when(execution.input()).thenReturn(input);
|
||||
when(execution.definition()).thenReturn(purify.definition());
|
||||
purify.activate(execution);
|
||||
assertFalse(poison.get()); assertTrue(supports.isEmpty()); verify(player).removePotionEffect(PotionEffectType.POISON);
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "purify-cooldown-seconds: 0\npurify-range-blocks: 16\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var definition = registry.find("purify").orElseThrow(); assertEquals(java.time.Duration.ZERO, definition.cooldown());
|
||||
assertEquals(0, new BigDecimal("16").compareTo(definition.parameters().get("range")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "purify-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("purify-cooldown-seconds", "purify-range-blocks"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "purify-"}));
|
||||
assertEquals(List.of("12", "16"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "purify-range-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=16"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20"});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(0, new BigDecimal("20").compareTo(registry.find("purify").orElseThrow().parameters().get("range")));
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepare(data, caster, true); 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); aim(caster, ally.player, 19, true);
|
||||
effects(fixture, ally.player).put(PotionEffectType.SLOWNESS, instance(PotionEffectType.SLOWNESS, 200, 0));
|
||||
doReturn(false).when(caster.player).isSneaking(); use(fixture, caster); drain(fixture, 2);
|
||||
assertNull(ally.player.getPotionEffect(PotionEffectType.SLOWNESS));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
assertEquals(0, new BigDecimal("20").compareTo(reloaded.find("purify").orElseThrow().parameters().get("range")));
|
||||
}
|
||||
for (String invalid : List.of("purify-cooldown-seconds: -1\n", "purify-range-blocks: 0\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<Player, Map<PotionEffectType, MobEffectInstance>> EFFECTS = new java.util.WeakHashMap<>();
|
||||
|
||||
private static Map<PotionEffectType, MobEffectInstance> effects(FantasyStickLifecycleNativeTest.Fixture fixture, Player player) {
|
||||
var active = new HashMap<PotionEffectType, MobEffectInstance>(); EFFECTS.put(player, active);
|
||||
doAnswer(call -> {
|
||||
var effect = active.get(call.getArgument(0));
|
||||
return effect == null ? null : new PotionEffect(call.getArgument(0), effect.getDuration(), effect.getAmplifier());
|
||||
}).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> {
|
||||
PotionEffectType type = call.getArgument(0); var current = active.get(type);
|
||||
if (current == null) return null;
|
||||
var old = new PotionEffect(type, current.getDuration(), current.getAmplifier());
|
||||
var event = new EntityPotionEffectEvent(player, old, null, null, EntityPotionEffectEvent.Cause.MILK,
|
||||
EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
fixture.dispatcher.accept(event); if (!event.isCancelled()) active.remove(type); return null;
|
||||
}).when(player).removePotionEffect(any());
|
||||
return active;
|
||||
}
|
||||
|
||||
private static Map<PotionEffectType, MobEffectInstance> effectMap(Player player) {
|
||||
return EFFECTS.computeIfAbsent(player, ignored -> new HashMap<>());
|
||||
}
|
||||
|
||||
private static MobEffectInstance layered(PotionEffectType type) {
|
||||
var current = instance(type, 200, 0); current.update(instance(type, 40, 1));
|
||||
assertNotNull(current.hiddenEffect); return current;
|
||||
}
|
||||
|
||||
private static MobEffectInstance instance(PotionEffectType type, int duration, int amplifier) {
|
||||
return new MobEffectInstance(org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(type), duration, amplifier);
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
|
||||
}
|
||||
|
||||
private static void party(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor caster,
|
||||
FantasyStickLifecycleNativeTest.Actor ally) {
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(caster.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(ally.core.id, parties.invite(caster.core.id, group.id(), ally.core.id).orElseThrow().id()));
|
||||
}
|
||||
|
||||
private static void aim(FantasyStickLifecycleNativeTest.Actor caster, Player target, double distance, boolean visible) {
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
doReturn(new Location(caster.world, distance, 64, 0)).when(target).getLocation();
|
||||
doReturn(visible).when(caster.player).hasLineOfSight(target); doReturn(target).when(caster.player).getTargetEntity(64);
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
}
|
||||
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
|
||||
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.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 + " for " + ability);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom button(net.minecraft.network.chat.Component component, String label, String ability) {
|
||||
if (component.getString().equals(label) && component.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 : component.getSiblings()) { var found = button(child, label, ability); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void prepare(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.WIS, BigInteger.valueOf(14)); stats.put(Stat.CHR, BigInteger.TEN);
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
|
||||
learned ? Set.of("purify") : old.learned(), learned ? List.of("purify", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PurifySettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new PurifySettings(data, defaults())) {
|
||||
var range = settings.set(PurifySettings.RANGE, "16").toCompletableFuture();
|
||||
var cooldown = settings.set(PurifySettings.COOLDOWN, "20").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(12, settings.settings().range(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertEquals(16, settings.settings().range(), 0);
|
||||
assertEquals(java.time.Duration.ofSeconds(20), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated")); assertEquals("16", String.valueOf(yaml.get(PurifySettings.RANGE)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new PurifySettings(data, defaults())) {
|
||||
for (var entry : Map.of(PurifySettings.COOLDOWN, "-1", PurifySettings.RANGE, "0").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(PurifySettings.COOLDOWN, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new PurifySettings(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(PurifySettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("12", settings.value(PurifySettings.RANGE));
|
||||
}
|
||||
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 PurifySettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(PurifySettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new PurifySettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(PurifySettings.RANGE, "16").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 : PurifySettings.keys()) values.put(key, PurifySettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user