Compare commits

...
1 Commits
Author SHA1 Message Date
dmg c77246321c feat(ability): add Quickstep
Release / release (push) Successful in 6m52s
2026-09-20 11:27:51 -04:00
8 changed files with 829 additions and 2 deletions

No files matched your search

@@ -53,6 +53,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.FrostShardSettings frostShardSettings;
private games.dmg.fantasystick.ability.Longbow longbow;
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
private games.dmg.fantasystick.ability.Quickstep quickstep;
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
private games.dmg.fantasystick.ability.GreatSword greatSword;
private games.dmg.fantasystick.ability.Revive revive;
private games.dmg.fantasystick.persistence.RevivalStore revivals;
@@ -88,6 +90,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
var longbowConfiguration = longbowConfiguration();
var longbowInitial = games.dmg.fantasystick.persistence.LongbowSettings.parse(longbowConfiguration);
var quickstepConfiguration = quickstepConfiguration();
var quickstepInitial = games.dmg.fantasystick.persistence.QuickstepSettings.parse(quickstepConfiguration);
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
@@ -134,6 +138,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
longbowSettings = new games.dmg.fantasystick.persistence.LongbowSettings(
getDataFolder().toPath(), longbowConfiguration);
longbow = new games.dmg.fantasystick.ability.Longbow(longbowInitial);
quickstepSettings = new games.dmg.fantasystick.persistence.QuickstepSettings(
getDataFolder().toPath(), quickstepConfiguration);
quickstep = new games.dmg.fantasystick.ability.Quickstep(quickstepInitial,
event -> getServer().getPluginManager().callEvent(event));
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,
@@ -142,6 +150,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityRegistry.register(fireball.definition());
abilityRegistry.register(frostShard.definition());
abilityRegistry.register(longbow.definition());
abilityRegistry.register(quickstep.definition());
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
@@ -185,7 +194,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
+ " for player " + problem.player(), problem.cause()), adventureSettings,
frostShardSettings, frostShard, longbowSettings, longbow, abilityRegistry);
frostShardSettings, frostShard, longbowSettings, longbow,
quickstepSettings, quickstep, abilityRegistry);
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
@@ -202,6 +212,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(fireball, this);
getServer().getPluginManager().registerEvents(frostShard, this);
getServer().getPluginManager().registerEvents(longbow, this);
getServer().getPluginManager().registerEvents(quickstep, this);
getServer().getPluginManager().registerEvents(greatSword, this);
getServer().getPluginManager().registerEvents(revive, this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
@@ -251,6 +262,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
var closingLongbow = longbow; longbow = null;
var closingLongbowSettings = longbowSettings; longbowSettings = null;
var closingQuickstep = quickstep; quickstep = null;
var closingQuickstepSettings = quickstepSettings; quickstepSettings = null;
var closingGreatSword = greatSword; greatSword = null;
var closingRevive = revive; revive = null;
var closingRevivals = revivals; revivals = null;
@@ -263,19 +276,23 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFrostSettings.drained().toCompletableFuture(),
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingLongbowSettings.drained().toCompletableFuture());
: closingLongbowSettings.drained().toCompletableFuture(),
closingQuickstepSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingQuickstepSettings.drained().toCompletableFuture());
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
if (closingFrostShard != null) cleanup("Frost Shard", closingFrostShard::close);
if (closingLongbow != null) cleanup("Longbow", closingLongbow::close);
if (closingQuickstep != null) cleanup("Quickstep", closingQuickstep::close);
if (closingGreatSword != null) cleanup("Great Sword", closingGreatSword::close);
if (closingRevive != null) cleanup("Revive", closingRevive::close);
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
@@ -295,6 +312,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingFireball);
unregister(closingFrostShard);
unregister(closingLongbow);
unregister(closingQuickstep);
unregister(closingGreatSword);
unregister(closingRevive);
unregister(closingBasicAttack);
@@ -387,6 +405,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> quickstepConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.QuickstepSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.QuickstepSettings.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,153 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerVelocityEvent;
import org.bukkit.util.Vector;
/** Quickstep's grounded, collision-respecting, owned horizontal velocity adapter. */
public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
public record Settings(Duration cooldown, double distance, double speedPerTick) {
public Settings {
Objects.requireNonNull(cooldown);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(distance) || distance <= 0 || distance > 64
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 8) {
throw new IllegalArgumentException("Invalid Quickstep settings");
}
}
public static Settings defaults() { return new Settings(Duration.ofSeconds(5), 4, 1); }
}
private final class State implements AutoCloseable {
final AbilityExecution execution;
final Player player;
final Location origin;
final Vector horizontal;
final Settings settings;
boolean owned = true;
boolean closed;
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
this.execution = execution; this.player = player; this.origin = origin;
this.horizontal = horizontal; this.settings = settings;
}
boolean stillOwns(Vector current) {
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
double magnitude = horizontalCurrent.length();
if (magnitude < 1.0e-6) return true;
return magnitude <= horizontal.length() * 1.05 + 1.0e-6
&& horizontalCurrent.clone().normalize().dot(horizontal.clone().normalize()) >= 0.99;
}
@Override public void close() {
if (closed) return;
closed = true; dashes.remove(player.getUniqueId(), this);
if (!owned) return;
Vector current = player.getVelocity();
if (!stillOwns(current)) return;
player.setVelocity(new Vector(0, current.getY(), 0));
}
}
private volatile Settings settings;
private final Consumer<PlayerVelocityEvent> velocityEvents;
private final Map<UUID, State> dashes = new HashMap<>();
private boolean closed;
public Quickstep(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("quickstep", "Quickstep", Material.RABBIT_FOOT,
List.of("Dash forward with native horizontal movement.",
"Travel up to " + decimal(current.distance()) + " blocks while obstacles remain authoritative."),
"Use while grounded, on foot, and not flying or gliding.",
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public boolean accepts(ControlInput input) {
var player = input.player();
if (!player.isOnGround() || player.isFlying() || player.isGliding() || player.isInsideVehicle()) return false;
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0);
return finite(direction) && direction.lengthSquared() > 1.0e-12;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition());
var player = execution.input().player();
if (!accepts(execution.input())) { execution.finish(); return; }
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
Vector before = player.getVelocity();
Vector horizontal = direction.multiply(current.speedPerTick());
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
var event = new PlayerVelocityEvent(player, proposed.clone());
velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); return; }
Vector accepted = event.getVelocity().clone(); accepted.setY(before.getY());
Vector acceptedHorizontal = new Vector(accepted.getX(), 0, accepted.getZ());
if (acceptedHorizontal.lengthSquared() < 1.0e-12) { execution.finish(); return; }
var state = new State(execution, player, player.getLocation().clone(), acceptedHorizontal, current);
var previous = dashes.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
execution.own(state);
player.setVelocity(accepted);
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), execution::finish);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void moved(PlayerMoveEvent event) {
var state = dashes.get(event.getPlayer().getUniqueId());
if (state == null || state.player != event.getPlayer()) return;
var to = event.getTo();
if (to == null || !state.execution.stillCurrent()) { state.execution.finish(); return; }
Vector current = state.player.getVelocity();
if (!state.stillOwns(current)) {
state.owned = false; state.execution.finish(); return;
}
double dx = to.getX() - state.origin.getX(); double dz = to.getZ() - state.origin.getZ();
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()
|| new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
state.execution.finish();
}
}
private static boolean finite(Vector vector) {
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
}
private static Settings from(AbilityDefinition definition) {
return new Settings(definition.cooldown(), definition.parameters().get("distance").doubleValue(),
definition.parameters().get("speed").doubleValue());
}
private static BigDecimal decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(dashes.values())) state.execution.finish();
dashes.clear();
}
}
@@ -0,0 +1,188 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Quickstep;
import java.io.IOException;
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 Quickstep settings; publication remains a server-thread caller decision. */
public final class QuickstepSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "quickstep-cooldown-seconds";
public static final String DISTANCE = "quickstep-distance-blocks";
public static final String SPEED = "quickstep-speed-blocks-per-tick";
private static final List<String> KEYS = List.of(COOLDOWN, DISTANCE, SPEED);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "5", DISTANCE, "4", SPEED, "1");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Quickstep.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-quickstep-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 QuickstepSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
QuickstepSettings(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 Quickstep.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
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(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
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("Quickstep 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 key, String raw) {
try {
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Quickstep.Settings parse(Map<String, String> values) {
try {
var cooldown = seconds(values.get(COOLDOWN));
double distance = finite(values.get(DISTANCE));
double speed = finite(values.get(SPEED));
return new Quickstep.Settings(cooldown, distance, speed);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Quickstep settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.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);
}
}
@@ -26,6 +26,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.ability.AbilityRegistry abilities;
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
private games.dmg.fantasystick.ability.Longbow longbow;
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
private games.dmg.fantasystick.ability.Quickstep quickstep;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -68,6 +70,22 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
this.longbow = Objects.requireNonNull(longbow);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, abilities);
this.quickstepSettings = Objects.requireNonNull(quickstepSettings);
this.quickstep = Objects.requireNonNull(quickstep);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
@@ -144,6 +162,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
longbowConfiguration(sender, args); return;
}
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
quickstepConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -210,6 +231,37 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void quickstepConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + quickstepSettings.value(key) + " (applies to future Quickstep dashes only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickstepSettings.Update> operation;
try { operation = quickstepSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Quickstep 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) {
quickstep.configure(saved.settings()); abilities.replace(quickstep.definition());
quickstepSettings.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 Quickstep dashes; active movement and deadlines are unchanged."
: "Quickstep configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void adventureConfiguration(CommandSender sender, String[] args) {
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]"); return;
@@ -250,6 +302,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
return matching(keys, args[1]);
}
if (args.length == 3 && args[0].equals("config")) {
@@ -264,6 +317,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
String defaults = games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
String current = quickstepSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
}
return List.of();
}
+6
View File
@@ -45,3 +45,9 @@ longbow-full-draw-ticks: 20
longbow-range-blocks: 64
longbow-lifetime-seconds: 10
longbow-full-projectile-speed-blocks-per-tick: 3
# Quickstep defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
quickstep-cooldown-seconds: 5
quickstep-distance-blocks: 4
quickstep-speed-blocks-per-tick: 1
@@ -0,0 +1,308 @@
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.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.util.Vector;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuickstepNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseAppliesAndStopsOwnedHorizontalVelocityAtFourBlocks() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, false);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(text.contains("Quickstep")); assertTrue(text.contains("DEX12"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("4")); assertTrue(text.contains("5s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "quickstep"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "quickstep"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
var velocity = prepareMovement(a, new Vector(0, 0.25, 0));
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(new Vector(1, 0.25, 0), velocity.get());
verify(a.player, never()).setFallDistance(anyFloat());
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
var from = new Location(a.world, 0, 64, 0); var to = new Location(a.world, 4, 64, 0);
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, to));
assertEquals(new Vector(0, 0.25, 0), velocity.get(), "Only remaining owned horizontal motion is removed");
}
}
@Test void airborneFlyingGlidingOrMountedUseRefusesBeforeCooldownAndMovement() throws Exception {
for (int route = 0; route < 4; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve(Integer.toString(route)))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve(Integer.toString(route)));
withStats(data.resolve(Integer.toString(route)), a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a);
var velocity = prepareMovement(a, new Vector(0.3, 0.4, 0.5));
doReturn(route != 0).when(a.player).isOnGround();
doReturn(route == 1).when(a.player).isFlying();
doReturn(route == 2).when(a.player).isGliding();
doReturn(route == 3).when(a.player).isInsideVehicle();
use(f, a);
assertEquals(new Vector(0.3, 0.4, 0.5), velocity.get());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("quickstep"));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
}
}
@Test void velocityCancellationObstacleAndUnrelatedReplacementAreAuthoritative() throws Exception {
for (int route = 0; route < 3; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("motion-" + route))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("motion-" + route));
withStats(data.resolve("motion-" + route), a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a);
var velocity = prepareMovement(a, new Vector(0, 0.3, 0));
if (route == 0) {
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler public void velocity(org.bukkit.event.player.PlayerVelocityEvent event) {
if (event.getPlayer() == a.player) event.setCancelled(true);
}
}
f.dispatcher.register(new Veto(), f.plugin);
}
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
if (route == 0) {
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Cancelled velocity applies no movement");
} else if (route == 1) {
velocity.set(new Vector(0, 0.3, 0));
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
new Location(a.world, 0.2, 64, 0)));
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Native collision stops movement early");
} else {
velocity.set(new Vector(0, 0.6, 2));
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
new Location(a.world, 0.2, 64, 0)));
assertEquals(new Vector(0, 0.6, 2), velocity.get(), "Replacement movement relinquishes ownership");
}
verify(a.player, never()).setFallDistance(anyFloat());
}
}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedHorizontalMotion() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a); var velocity = prepareMovement(a, new Vector(0, 0.4, 0));
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"); assertNotNull(deadline);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "quickstep");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("quickstep"));
assertEquals(new Vector(0, 0.4, 0), velocity.get());
learnAndAssign(f, a); use(f, a);
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
assertEquals(new Vector(0, 0.4, 0), velocity.get());
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); prepareMovement(b, new Vector(0.2, 0.3, 0.4)); use(f, b);
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void failedCooldownPersistenceAppliesNoVelocity() {
var storage = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var quickstep = new games.dmg.fantasystick.ability.Quickstep(
games.dmg.fantasystick.ability.Quickstep.Settings.defaults(), ignored -> {});
registry.register(quickstep.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(base.stats()); stats.put(Stat.DEX, BigInteger.valueOf(12));
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
base.unspentPoints(), base.health(), java.util.Set.of("quickstep"),
List.of("quickstep", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class);
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
doReturn(true).when(player).isOnGround(); doReturn(false).when(player).isFlying();
doReturn(false).when(player).isGliding(); doReturn(false).when(player).isInsideVehicle();
var eye = new Location(mock(org.bukkit.World.class), 0, 0, 0); eye.setDirection(new Vector(1, 0, 0));
doReturn(eye).when(player).getEyeLocation();
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated Quickstep cooldown failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("quickstep"), any())).thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "quickstep",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
verify(player, never()).setVelocity(any(Vector.class)); assertEquals(1, failures.size());
engine.close(); quickstep.close(); parties.close();
}
@Test void acknowledgedUpdatesAffectFutureDashesWhileActiveDashKeepsCapturedBounds() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a); var oldVelocity = prepareMovement(a, new Vector(0, 0.2, 0));
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-speed-blocks-per-tick", "2"});
for (int i = 0; i < 2; i++) {
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
}
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
new Location(a.world, 4, 64, 0)));
assertEquals(new Vector(0, 0.2, 0), oldVelocity.get(), "Active dash retains its four-block snapshot");
var b = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, b, 12, true);
f.players.put(b.core.id, b.player); f.activate(b); var newVelocity = prepareMovement(b, new Vector(0, 0.3, 0));
use(f, b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(new Vector(2, 0.3, 0), newVelocity.get(), "Future dash uses acknowledged speed");
}
}
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEveryQuickstepSetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"quickstep-cooldown-seconds: 2\nquickstep-distance-blocks: 6\nquickstep-speed-blocks-per-tick: 1.5\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("quickstep").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "inspect", "recover"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quickstep"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("quickstep-cooldown-seconds", "quickstep-distance-blocks", "quickstep-speed-blocks-per-tick"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quickstep-"}));
assertEquals(List.of("4", "6"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "quickstep-distance-blocks", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks"});
assertTrue(sender.messages.getLast().contains("=6"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(0, new java.math.BigDecimal("7").compareTo(registry.find("quickstep").orElseThrow()
.parameters().get("distance")));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port, "fantasystick",
new String[]{"config", "quickstep-distance-blocks", "7", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(0, new java.math.BigDecimal("7").compareTo(reloaded.find("quickstep").orElseThrow()
.parameters().get("distance")));
}
for (String invalid : List.of("quickstep-cooldown-seconds: -1\n", "quickstep-distance-blocks: 0\n",
"quickstep-speed-blocks-per-tick: 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 f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
}
}
}
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, abilityButton(actor, "[Learn]", "quickstep"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "quickstep"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static AtomicReference<Vector> prepareMovement(FantasyStickLifecycleNativeTest.Actor actor, Vector initial) {
var velocity = new AtomicReference<>(initial.clone());
doReturn(true).when(actor.player).isOnGround(); doReturn(false).when(actor.player).isFlying();
doReturn(false).when(actor.player).isGliding(); doReturn(false).when(actor.player).isInsideVehicle();
doAnswer(ignored -> velocity.get().clone()).when(actor.player).getVelocity();
doAnswer(call -> { velocity.set(call.getArgument(0, Vector.class).clone()); return null; }).when(actor.player).setVelocity(any(Vector.class));
var eye = new Location(actor.world, 0, 65.6, 0); eye.setDirection(new Vector(1, 0, 0));
doReturn(eye).when(actor.player).getEyeLocation();
return velocity;
}
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 ClickEvent.Custom abilityButton(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 = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static ClickEvent.Custom abilityButton(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 = abilityButton(child, label, ability);
if (found != null) return found;
}
return null;
}
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
int dexterity, boolean learned) throws Exception {
java.nio.file.Files.createDirectories(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new EnumMap<>(old.stats()); stats.put(Stat.DEX, BigInteger.valueOf(dexterity));
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 ? java.util.Set.of("quickstep") : old.learned(),
learned ? List.of("quickstep", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -56,6 +56,10 @@ class DistributionTest {
"longbow-full-projectile-speed-blocks-per-tick: 3")) {
assertTrue(defaults.contains(setting), "Missing packaged Longbow default: " + setting);
}
for (String setting : java.util.List.of("quickstep-cooldown-seconds: 5",
"quickstep-distance-blocks: 4", "quickstep-speed-blocks-per-tick: 1")) {
assertTrue(defaults.contains(setting), "Missing packaged Quickstep default: " + setting);
}
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
assertNotNull(entrypoint);
@@ -0,0 +1,82 @@
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 QuickstepSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesOnlyAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
try (var settings = new QuickstepSettings(data, defaults())) {
var distance = settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture();
var speed = settings.set(QuickstepSettings.SPEED, "1.5").toCompletableFuture();
var savedDistance = distance.get(5, TimeUnit.SECONDS); var savedSpeed = speed.get(5, TimeUnit.SECONDS);
assertEquals(4, settings.settings().distance(), 0);
settings.publish(savedSpeed); settings.publish(savedDistance);
assertEquals(6, settings.settings().distance(), 0); assertEquals(1.5, settings.settings().speedPerTick(), 0);
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals(Boolean.TRUE, saved.get("adventure-mode")); assertEquals("retained", saved.get("unrelated"));
assertEquals("6", String.valueOf(saved.get(QuickstepSettings.DISTANCE)));
assertEquals("1.5", String.valueOf(saved.get(QuickstepSettings.SPEED)));
}
}
@Test void rejectsMalformedOrOutOfRangeValuesBeforeQueueing() {
try (var settings = new QuickstepSettings(data, defaults())) {
for (var entry : Map.of(QuickstepSettings.COOLDOWN, "-1", QuickstepSettings.DISTANCE, "0",
QuickstepSettings.SPEED, "Infinity").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(QuickstepSettings.COOLDOWN, "0.0000000001"));
assertEquals("4", settings.value(QuickstepSettings.DISTANCE));
assertFalse(Files.exists(data.resolve("config.yml")));
}
}
@Test void failedReplacementAndUnsafeFilesRetainEffectiveStateAndAllowRetry() throws Exception {
Files.writeString(data.resolve("config.yml"), "quickstep-distance-blocks: 4\n");
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new QuickstepSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated failure");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("4", settings.value(QuickstepSettings.DISTANCE));
var retry = settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals("4", settings.value(QuickstepSettings.DISTANCE)); settings.publish(retry);
assertEquals("6", settings.value(QuickstepSettings.DISTANCE));
}
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
Files.delete(data.resolve("config.yml")); Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new QuickstepSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(QuickstepSettings.DISTANCE, "7").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
}
}
@Test void closeRejectsNewWritesAndCompletesDrain() throws Exception {
var settings = new QuickstepSettings(data, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var result = new LinkedHashMap<String, String>();
for (String key : QuickstepSettings.keys()) result.put(key, QuickstepSettings.defaultValue(key));
return result;
}
}