feat(great-sword): audit charged native strikes
Release / release (push) Successful in 16m41s

This commit is contained in:
dmg committed 2026-09-25 21:22:45 -04:00
1 parent 830049b339
commit db4c3b2842
8 files changed
+627 -33

No files matched your search

@@ -124,6 +124,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
private games.dmg.fantasystick.ability.GreatSword greatSword;
private games.dmg.fantasystick.persistence.GreatSwordSettings greatSwordSettings;
private games.dmg.fantasystick.ability.Revive revive;
private games.dmg.fantasystick.persistence.RevivalStore revivals;
private games.dmg.fantasystick.session.AdminRevival adminRevival;
@@ -261,7 +262,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var rewardRules = combatRewardRules(hardMobXp);
var fireballConfiguration = fireballConfiguration();
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
var greatSwordSettings = greatSwordSettings();
var greatSwordConfiguration = greatSwordConfiguration();
var greatSwordInitial = games.dmg.fantasystick.persistence.GreatSwordSettings.parse(greatSwordConfiguration);
var frostShardConfiguration = frostShardConfiguration();
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
var longbowConfiguration = longbowConfiguration();
@@ -506,7 +508,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
warBannerInitial, sessions, parties, outgoingDamageBonus, particleDelivery);
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties, particleDelivery);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
greatSwordSettings = new games.dmg.fantasystick.persistence.GreatSwordSettings(
getDataFolder().toPath(), greatSwordConfiguration);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordInitial);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
(size, title) -> getServer().createInventory(null, size, title),
@@ -669,6 +673,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
inspireSettings, inspire, abilityRegistry);
admin.attachFireball(fireballSettings, fireball);
admin.attachGreatSword(greatSwordSettings, greatSword);
admin.attachMendingLight(mendingLightSettings, mendingLight);
admin.attachPurify(purifySettings, purify);
admin.attachGuardianBond(guardianBondSettings, guardianBond);
@@ -736,6 +741,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
particlesCommand.attachEvents(preferenceEvents);
feedbackCommand.attachEvents(preferenceEvents);
eventCapture.excludeOwnedDamage(fireball::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(greatSword::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
@@ -750,6 +756,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents);
abilityEngine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(eventLog::emit, Clock.systemUTC()));
abilityEngine.enableCastEventsFor("fireball");
abilityEngine.enableCastEventsFor("great_sword");
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
@@ -994,6 +1001,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingAbilityEngine = abilityEngine; abilityEngine = null;
var closingFireball = fireball; fireball = null;
var closingFireballSettings = fireballSettings; fireballSettings = null;
var closingGreatSwordSettings = greatSwordSettings; greatSwordSettings = null;
var closingFrostShard = frostShard; frostShard = null;
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
var closingLongbow = longbow; longbow = null;
@@ -1068,6 +1076,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
if (closingGreatSwordSettings != null) cleanup("Great Sword configuration", closingGreatSwordSettings::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);
@@ -1154,6 +1163,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
: closingSettings.drained().toCompletableFuture(),
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFireballSettings.drained().toCompletableFuture(),
closingGreatSwordSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingGreatSwordSettings.drained().toCompletableFuture(),
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFrostSettings.drained().toCompletableFuture(),
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -1580,15 +1591,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
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"));
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "0.75"));
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
} catch (ArithmeticException | NumberFormatException invalid) {
throw new IllegalArgumentException("Great Sword settings must use supported nonnegative numeric values", invalid);
}
private java.util.Map<String, String> greatSwordConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (var key : games.dmg.fantasystick.persistence.GreatSwordSettings.keys())
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.GreatSwordSettings.defaultValue(key)));
games.dmg.fantasystick.persistence.GreatSwordSettings.parse(values);
return java.util.Map.copyOf(values);
}
private void notifyPlayer(Object generation, EquipmentSessions.Notice notice) {
@@ -69,6 +69,12 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
if (castEvents != null) castEvents.resolved(audit, outcome, reason);
}
private void refuseCast(ControlInput input, CharacterRecord record, AbilityDefinition definition, String reason) {
if (castEvents == null || record == null || !definition.id().equals("great_sword")
|| !eventOwnedAbilities.contains(definition.id())) return;
resolve(castEvents.begin(input, record, definition), "refused", reason);
}
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
@@ -170,11 +176,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null || !record.learned().contains(definition.id())
|| input.slot() < 2 || !record.loadout().get(input.slot() - 2).equals(definition.id())) {
refuseCast(input, record, definition, "not_learned_or_assigned");
input.player().sendMessage("That ability is not learned and assigned to this control."); return;
}
Instant now = clock.instant();
Instant existing = record.cooldowns().get(definition.id());
if (existing != null && now.isBefore(existing)) {
refuseCast(input, record, definition, "cooldown_active");
input.player().sendMessage("" + definition.name() + " is ready in "
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
}
@@ -185,11 +193,15 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
input.player().sendMessage("That target could not be validated for " + definition.name() + "."); return;
}
if (!accepted) {
refuseCast(input, record, definition, "target_ineligible");
input.player().sendMessage("That target is not eligible for " + definition.name() + "."); return;
}
var key = new Key(input.session().character(), definition.id());
Object token = new Object();
if (pending.putIfAbsent(key, token) != null) { input.player().sendMessage("That cast is still being saved."); return; }
if (pending.putIfAbsent(key, token) != null) {
refuseCast(input, record, definition, "save_in_progress");
input.player().sendMessage("That cast is still being saved."); return;
}
long generation = generations.getOrDefault(key, 0L);
Instant readyAt;
try { readyAt = now.plus(definition.cooldown()); }
@@ -33,18 +33,25 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
private static final class Attempt {
final AbilityExecution execution;
final LivingEntity target;
final double requested;
final BigDecimal full;
final float charge;
boolean reported;
Attempt(AbilityExecution execution, LivingEntity target) {
boolean cancelled;
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal full, float charge) {
this.execution = execution; this.target = target;
this.requested = requested; this.full = full; this.charge = charge;
}
}
private final Settings settings;
private Settings settings;
private Attempt damaging;
private boolean closed;
public GreatSword(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
return new AbilityDefinition("great_sword", "Great Sword", Material.WOODEN_SWORD,
List.of("Charge a contained melee strike.",
@@ -81,8 +88,11 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
BigDecimal amount = damage(settings.baseDamage(), execution.character().stats().get(Stat.STR),
execution.input().attackStrength());
BigInteger strength = execution.character().stats().get(Stat.STR);
BigInteger baseDamage = execution.definition().parameters().get("base-damage").toBigIntegerExact();
float charge = execution.input().attackStrength();
BigDecimal full = damage(baseDamage, strength, 1.0f);
BigDecimal amount = damage(baseDamage, strength, charge);
double nativeDamage = amount.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
? Double.MAX_VALUE : amount.doubleValue();
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
@@ -91,23 +101,40 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
var player = execution.input().player();
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player)
.withDirectEntity(player).build();
var attempt = new Attempt(execution, target);
var attempt = new Attempt(execution, target, nativeDamage, full, charge);
damaging = attempt;
try { execution.damage(target, nativeDamage, source); }
finally { if (damaging == attempt) damaging = null; }
try {
execution.damage(target, nativeDamage, source);
execution.resolve(attempt.reported ? "hit" : attempt.cancelled ? "cancelled" : "miss",
Map.of("requested_amount", BigDecimal.valueOf(nativeDamage).toPlainString(),
"full_amount", full.toPlainString(), "charge", Float.toString(charge)));
} finally { if (damaging == attempt) damaging = null; }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
/** The generic incoming-player adapter must yield to this contained strike's effect owner. */
public boolean ownsAcceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
return event instanceof EntityDamageByEntityEvent by && matches(damaging, by);
}
private boolean matches(Attempt attempt, EntityDamageByEntityEvent event) {
if (closed || attempt == null || event.getDamager() != attempt.execution.input().player()
|| event.getEntity() != attempt.target) return false;
var source = event.getDamageSource();
return (source.getCausingEntity() == null || source.getCausingEntity() == attempt.execution.input().player())
&& (source.getDirectEntity() == null || source.getDirectEntity() == attempt.execution.input().player());
}
@EventHandler(priority = EventPriority.MONITOR)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.reported || event.isCancelled()
|| event.getDamager() != attempt.execution.input().player() || event.getEntity() != attempt.target
|| (event.getDamageSource().getCausingEntity() != null
&& event.getDamageSource().getCausingEntity() != attempt.execution.input().player())
|| (event.getDamageSource().getDirectEntity() != null
&& event.getDamageSource().getDirectEntity() != attempt.execution.input().player())
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
if (attempt == null || attempt.reported || !matches(attempt, event)) return;
if (event.isCancelled()) { attempt.cancelled = true; return; }
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.reported = true;
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
attempt.execution.input().player(), Map.of("charge", Float.toString(attempt.charge),
"full_amount", attempt.full.toPlainString(),
"scaled_requested_amount", BigDecimal.valueOf(attempt.requested).toPlainString()));
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
}
@@ -18,7 +18,12 @@ import org.bukkit.entity.Entity;
/** Shared cast and explicit effect-observation boundary; never infers effects from feedback. */
public final class AbilityCastEventBridge {
public record Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt) { }
String targetType, Map<String, Object> targetLocation, Instant requestedAt, float attackCharge) {
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt) {
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt, 1.0f);
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
@@ -39,7 +44,7 @@ public final class AbilityCastEventBridge {
definition.id(), target == null ? null : target.getUniqueId(),
target == null ? null : target instanceof org.bukkit.entity.Player ? "PLAYER" : target.getType().name(),
targetAtRequest == null ? Map.of() : locationData(targetAtRequest),
clock.instant());
clock.instant(), input.attackStrength());
emit(cast, "action.observed", "observed", Map.of("action_kind", "ability_cast"));
return cast;
} catch (RuntimeException ignored) {
@@ -59,6 +64,7 @@ public final class AbilityCastEventBridge {
data.put("activation", definition.activation().name().toLowerCase(java.util.Locale.ROOT));
data.put("captured_settings", settings);
data.put("settings_scope", "definition_at_request");
if (cast.abilityId().equals("great_sword")) data.put("captured_charge", Float.toString(cast.attackCharge()));
for (Stat stat : definition.requirements().keySet())
data.put("captured_" + stat.name().toLowerCase(java.util.Locale.ROOT), saved.stats().get(stat).toString());
emit(cast, "ability.cast", "accepted", data);
@@ -0,0 +1,159 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.GreatSword;
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 Great Sword configuration; the caller publishes acknowledged changes on the server thread. */
public final class GreatSwordSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "great-sword-base-damage";
public static final String RECOVERY = "great-sword-recovery-seconds";
private static final List<String> KEYS = List.of(BASE_DAMAGE, RECOVERY);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "7", RECOVERY, "0.75");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, GreatSword.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-great-sword-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 GreatSwordSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
GreatSwordSettings(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 GreatSword.Settings settings() { return parse(effectiveValues); }
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence(); return true;
}
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);
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("Great Sword 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 {
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static GreatSword.Settings parse(Map<String, String> values) {
try {
var seconds = new java.math.BigDecimal(values.get(RECOVERY));
Duration recovery = Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
return new GreatSword.Settings(new java.math.BigInteger(values.get(BASE_DAMAGE)), recovery);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Great Sword settings must use supported numeric values", invalid);
}
}
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); }
}
@@ -27,6 +27,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private volatile boolean closed;
private games.dmg.fantasystick.persistence.AdventureSettings settings;
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
private games.dmg.fantasystick.persistence.GreatSwordSettings greatSwordSettings;
private games.dmg.fantasystick.ability.GreatSword greatSword;
private games.dmg.fantasystick.events.AbilityConfigEventBridge abilityConfigEvents;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
@@ -426,6 +428,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
partyRegistrars=Objects.requireNonNull(directory); partyRegistrarController=Objects.requireNonNull(controller);
}
public void attachGreatSword(games.dmg.fantasystick.persistence.GreatSwordSettings settings,
games.dmg.fantasystick.ability.GreatSword effect) {
if (closed || greatSwordSettings != null) throw new IllegalStateException("Great Sword already attached or command closed");
greatSwordSettings = Objects.requireNonNull(settings); greatSword = Objects.requireNonNull(effect);
}
public void attachFireball(games.dmg.fantasystick.persistence.FireballSettings settings,
games.dmg.fantasystick.ability.Fireball effect) {
if (closed || fireballSettings != null) throw new IllegalStateException("Fireball already attached or command closed");
@@ -1050,6 +1058,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (fireballSettings != null && games.dmg.fantasystick.persistence.FireballSettings.keys().contains(args[1])) {
fireballConfiguration(sender, args); return;
}
if (greatSwordSettings != null && games.dmg.fantasystick.persistence.GreatSwordSettings.keys().contains(args[1])) {
greatSwordConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -1084,6 +1095,39 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void greatSwordConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = greatSwordSettings.value(key);
if (args.length == 2) {
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "great_sword", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Great Sword strikes only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GreatSwordSettings.Update> operation;
try { operation = greatSwordSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Great Sword 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;
boolean applied = failure == null && greatSwordSettings.publish(saved);
if (applied) {
greatSword.configure(greatSwordSettings.settings()); abilities.replace(greatSword.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "great_sword", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure != null
? "Great Sword configuration save failed; the effective setting is unchanged."
: applied ? "Saved " + key + "=" + saved.value() + " for future Great Sword strikes; existing deadlines are unchanged."
: "Saved request was superseded; effective " + key + "=" + greatSwordSettings.value(key) + ".");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void fireballConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
@@ -1845,6 +1889,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
keys.add(games.dmg.fantasystick.persistence.HealthStealSettings.KEY);
keys.addAll(games.dmg.fantasystick.persistence.FireballSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.GreatSwordSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
@@ -1909,6 +1954,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
String defaults = games.dmg.fantasystick.persistence.FireballSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (greatSwordSettings != null && games.dmg.fantasystick.persistence.GreatSwordSettings.keys().contains(args[1])) {
String current = greatSwordSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.GreatSwordSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
String current = frostSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
@@ -14,6 +14,160 @@ class GreatSwordNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredBookAndContainedStrikeExportOneCorrelatedAcceptedDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
withStrength(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
var learn = abilityButton(a, "[Learn]", "great_sword");
assertNotNull(learn);
PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
(Double) call.getArgument(0));
event.setDamage(6.5); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var contained = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(contained); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(contained.isCancelled());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f);
verify(target, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.plugin.setEnabled(false);
var owner = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(1, owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
assertEquals(1, owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
var casts = owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).toList();
assertEquals(1, casts.size());
var cast = casts.getFirst().getAsJsonObject("data");
assertEquals("12", cast.get("captured_str").getAsString());
assertEquals("1.0", cast.get("captured_charge").getAsString());
assertEquals("7", cast.getAsJsonObject("captured_settings").get("base-damage").getAsString());
assertTrue(cast.getAsJsonObject("target_location").has("world_id"));
assertTrue(cast.get("cooldown_until").getAsString().endsWith("Z"));
var resolutions = owner.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")).toList();
assertEquals(2, resolutions.size());
assertEquals(1, resolutions.stream().filter(event -> event.getAsJsonObject("data")
.get("outcome").getAsString().equals("hit")).count());
assertEquals(1, resolutions.stream().filter(event -> event.getAsJsonObject("data")
.get("outcome").getAsString().equals("refused")
&& event.getAsJsonObject("data").get("reason").getAsString().equals("cooldown_active")).count());
var damage = owner.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).toList();
assertEquals(1, damage.size(), "Only the accepted custom hit is exported, never vanilla continuation");
assertEquals(8, damage.getFirst().getAsJsonObject("data").get("requested_amount").getAsDouble());
assertEquals(6.5, damage.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble());
assertEquals(cast.get("action_id").getAsString(), damage.getFirst().getAsJsonObject("data").get("action_id").getAsString());
assertEquals("8", damage.getFirst().getAsJsonObject("data").get("full_amount").getAsString());
assertEquals("8.0", damage.getFirst().getAsJsonObject("data").get("scaled_requested_amount").getAsString());
assertEquals(target.getUniqueId().toString(), damage.getFirst().getAsJsonObject("data").get("target_entity_id").getAsString());
assertTrue(damage.getFirst().getAsJsonObject("data").getAsJsonObject("target_location").has("world_id"));
}
}
@Test void registeredAdminGreatSwordConfigReadAndAcknowledgedUpdateExportOnlyCommittedChanges() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage"});
assertTrue(admin.messages.getLast().contains("great-sword-base-damage=7"));
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(response, "A save must acknowledge before runtime publication"); response.run();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), registry.find("great_sword").orElseThrow().parameters().get("base-damage"));
assertTrue(java.nio.file.Files.readString(data.resolve("config.yml")).contains("great-sword-base-damage"));
try (var lockFile = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = lockFile.lock()) {
assertTrue(lock.isValid());
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "11"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals(new java.math.BigDecimal("9"), registry.find("great_sword").orElseThrow().parameters().get("base-damage"));
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "-1"});
assertTrue(admin.messages.getLast().contains("invalid"));
admin.allowed = false;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage"});
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-base-damage", "13"});
assertEquals(java.util.List.of(), f.command.tabComplete(admin.port, "fstickadmin",
new String[]{"config", "great-sword-base"}));
admin.allowed = true;
assertEquals(java.util.List.of("great-sword-base-damage"), f.command.tabComplete(admin.port, "fstickadmin",
new String[]{"config", "great-sword-base"}));
f.plugin.setEnabled(false);
}
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString().equals("great-sword-base-damage")
&& (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
assertEquals(2, config.size(), "Read and committed update only; no failed-write success");
assertEquals("7", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
}
@Test void acknowledgedRecoverySettingAffectsOnlyFutureStrikesAndSurvivesRestart() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
(Double) call.getArgument(0))); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var originalDeadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword");
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-recovery-seconds"});
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "great-sword-recovery-seconds", "2"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(java.time.Duration.ofSeconds(2), registry.find("great_sword").orElseThrow().cooldown());
assertEquals(originalDeadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
f.plugin.setEnabled(false);
}
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(java.time.Duration.ofSeconds(2), registry.find("great_sword").orElseThrow().cooldown());
f.plugin.setEnabled(false);
}
var changes = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")
&& event.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changes.size());
assertEquals("great-sword-recovery-seconds", changes.getFirst().getAsJsonObject("data").get("key").getAsString());
assertEquals("0.75", changes.getFirst().getAsJsonObject("data").get("before").getAsString());
assertEquals("2", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
}
@Test void bookAcquisitionAndFullyChargedEquippedStrikeReplaceVanillaDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -128,6 +282,8 @@ class GreatSwordNativeTest {
when(a.player.getAttackCooldown()).thenReturn(0.5f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var damages = new java.util.ArrayList<Double>();
doAnswer(call -> {
double amount = call.getArgument(0); damages.add(amount);
@@ -142,6 +298,48 @@ class GreatSwordNativeTest {
assertTrue(attack.isCancelled());
assertEquals(java.util.List.of(3.2), damages);
}
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(1, damage.size());
assertEquals(3.2, damage.getFirst().getAsJsonObject("data").get("requested_amount").getAsDouble());
assertEquals("8", damage.getFirst().getAsJsonObject("data").get("full_amount").getAsString());
assertEquals("0.5", damage.getFirst().getAsJsonObject("data").get("charge").getAsString());
}
@Test void acceptedFantasyPlayerStrikeHasOneOwnerNotAnIncomingNativeDuplicate() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
b.core.location.setWorld(a.world);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
doReturn(org.bukkit.entity.EntityType.PLAYER).when(a.player).getType();
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageEvent(b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FALL, 1));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
(Double) call.getArgument(0));
f.dispatcher.accept(event);
assertFalse(event.isCancelled()); return null;
}).when(b.player).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
verify(b.player, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.plugin.setEnabled(false);
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("target_player_id")
&& event.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.core.id.toString())).toList();
assertEquals(2, damage.size(), "One ordinary fall observation plus one strike, no second incoming strike");
assertEquals(1, damage.stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).count());
}
}
@Test void refusedPlayersAndCancelledNativeDamagePreserveProtectionWithoutFreeVanillaHits() throws Exception {
@@ -157,6 +355,7 @@ class GreatSwordNativeTest {
assertTrue(parties.accept(b.core.id,
parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
when(a.player.getAttackCooldown()).thenReturn(1.0f);
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
a.core.inventory.getInventory().setSelectedSlot(2);
var partyAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true);
@@ -166,6 +365,8 @@ class GreatSwordNativeTest {
var ordinary = mock(org.bukkit.entity.Player.class);
when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
doReturn(org.bukkit.entity.EntityType.PLAYER).when(ordinary).getType();
when(ordinary.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var ordinaryAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, ordinary, true);
f.dispatcher.accept(ordinaryAttack); PartyBookNativeTest.drain(f);
assertTrue(ordinaryAttack.isCancelled());
@@ -196,6 +397,17 @@ class GreatSwordNativeTest {
PartyBookNativeTest.drain(f);
assertEquals(BigInteger.ZERO, storage.find(a.core.record.id()).get().orElseThrow().experience());
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count(),
"Refused friendly and ordinary targets spend no recovery");
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
"A cancelled hit never claims accepted custom damage");
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("refused")
&& event.getAsJsonObject("data").get("reason").getAsString().equals("target_ineligible")).count());
}
@Test void unlearnRelearnAndWholeCharacterTransferRetainTheOriginalRecovery() throws Exception {
@@ -208,6 +420,8 @@ class GreatSwordNativeTest {
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var hits = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> {
hits.incrementAndGet();
@@ -257,6 +471,61 @@ class GreatSwordNativeTest {
assertEquals(1, hits.get(), "Transfer cannot reset the character-bound recovery");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
"Retry, relearn and transferred holder cannot claim another strike before the retained deadline");
}
@Test void restartAndDifferentHolderCannotReapplyACompletedChargedStrike() throws Exception {
org.bukkit.inventory.ItemStack sheet;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
(Double) call.getArgument(0))); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
verify(target, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
f.plugin.setEnabled(false);
}
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b);
when(b.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(b.world, 4, 64, 0));
b.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(b.player, target, true));
PartyBookNativeTest.drain(f);
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.plugin.setEnabled(false);
}
var records = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
}
@Test void fractionalRecoveryRefusesImmediatelyAndOneNanosecondBeforeButAcceptsExactBoundary() throws Exception {
@@ -307,6 +576,37 @@ class GreatSwordNativeTest {
}
}
@Test void refusedCooldownSaveExportsFailedResolutionWithoutInventingDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.close();
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
for (int tries = 0; tries < 4 && a.messages.stream().noneMatch(message -> message.contains("could not be saved")); tries++) {
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(response); response.run();
}
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("great_sword")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|| event.get("type").getAsString().endsWith("combat.damage")).count());
}
@Test void failedCooldownPersistenceDealsNoDamageOrFreeEffect() {
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
@@ -449,6 +749,16 @@ class GreatSwordNativeTest {
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
var events = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return events;
}
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
assertEquals(expected, value.get());
@@ -18,6 +18,15 @@ class SwitchDelayEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void exportedRecordsIgnoreTransientPendingFiles() throws Exception {
var day = data.resolve("events/2026-09-26");
var pending = data.resolve("events/pending");
Files.createDirectories(day); Files.createDirectories(pending);
Files.writeString(day.resolve("0.jsonl"), "{\"type\":\"exported\"}\n");
Files.writeString(pending.resolve("transient.jsonl"), "{\"type\":\"not_exported\"}\n");
assertEquals(List.of("exported"), records().stream().map(event -> event.get("type").getAsString()).toList());
}
@Test void persistedExitAndPrepackSameOrDifferentCharacterDecisionsReachJsonl() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
@@ -147,10 +156,22 @@ class SwitchDelayEventsNativeTest {
private List<com.google.gson.JsonObject> records() throws Exception {
var events = new ArrayList<com.google.gson.JsonObject>();
try (var files = Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : Files.readAllLines(path))
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
var directory = data.resolve("events");
if (!Files.exists(directory)) return events;
// Pending intents are transient and may be moved while a live writer is draining them.
try (var days = Files.list(directory)) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList())
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
}
}
}
return events;
}