This commit is contained in:
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user