Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc6c1a7f44 |
No files matched your search
@@ -72,6 +72,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.persistence.MendingLightSettings mendingLightSettings;
|
||||
private games.dmg.fantasystick.ability.Purify purify;
|
||||
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
|
||||
private games.dmg.fantasystick.ability.GuardianBond guardianBond;
|
||||
private games.dmg.fantasystick.persistence.GuardianBondSettings guardianBondSettings;
|
||||
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
|
||||
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
@@ -142,6 +144,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var mendingLightInitial = games.dmg.fantasystick.persistence.MendingLightSettings.parse(mendingLightConfiguration);
|
||||
var purifyConfiguration = purifyConfiguration();
|
||||
var purifyInitial = games.dmg.fantasystick.persistence.PurifySettings.parse(purifyConfiguration);
|
||||
var guardianBondConfiguration = guardianBondConfiguration();
|
||||
var guardianBondInitial = games.dmg.fantasystick.persistence.GuardianBondSettings.parse(guardianBondConfiguration);
|
||||
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
|
||||
var factory = new CharacterFactory();
|
||||
@@ -240,6 +244,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
|
||||
purify = new games.dmg.fantasystick.ability.Purify(purifyInitial, sessions, parties,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
guardianBondSettings = new games.dmg.fantasystick.persistence.GuardianBondSettings(
|
||||
getDataFolder().toPath(), guardianBondConfiguration);
|
||||
guardianBond = new games.dmg.fantasystick.ability.GuardianBond(guardianBondInitial, sessions, parties,
|
||||
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
|
||||
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
|
||||
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
|
||||
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
|
||||
@@ -256,6 +264,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityRegistry.register(inspire.definition());
|
||||
abilityRegistry.register(mendingLight.definition());
|
||||
abilityRegistry.register(purify.definition());
|
||||
abilityRegistry.register(guardianBond.definition());
|
||||
abilityRegistry.register(greatSword.definition());
|
||||
abilityRegistry.register(revive.definition());
|
||||
sessions.attachAbilityRegistry(abilityRegistry);
|
||||
@@ -300,6 +309,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
inspireSettings, inspire, abilityRegistry);
|
||||
admin.attachMendingLight(mendingLightSettings, mendingLight);
|
||||
admin.attachPurify(purifySettings, purify);
|
||||
admin.attachGuardianBond(guardianBondSettings, guardianBond);
|
||||
admin.attachAdminRevival(adminRevival, adminReviveSettings);
|
||||
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
|
||||
characters, rules, sessions, nextTick, getServer()::getPlayer,
|
||||
@@ -331,6 +341,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(longbow, this);
|
||||
getServer().getPluginManager().registerEvents(quickstep, this);
|
||||
getServer().getPluginManager().registerEvents(combatMitigation, this);
|
||||
getServer().getPluginManager().registerEvents(guardianBond, this);
|
||||
guardianBond.install(this);
|
||||
getServer().getPluginManager().registerEvents(shieldBash, this);
|
||||
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
|
||||
getServer().getPluginManager().registerEvents(temporaryHealth, this);
|
||||
@@ -403,6 +415,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingMendingLightSettings = mendingLightSettings; mendingLightSettings = null;
|
||||
purify = null;
|
||||
var closingPurifySettings = purifySettings; purifySettings = null;
|
||||
var closingGuardianBond = guardianBond; guardianBond = null;
|
||||
var closingGuardianBondSettings = guardianBondSettings; guardianBondSettings = null;
|
||||
var closingAdminRevival = adminRevival; adminRevival = null;
|
||||
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
|
||||
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
|
||||
@@ -437,6 +451,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingInspireSettings != null) cleanup("Inspire configuration", closingInspireSettings::close);
|
||||
if (closingMendingLightSettings != null) cleanup("Mending Light configuration", closingMendingLightSettings::close);
|
||||
if (closingPurifySettings != null) cleanup("Purify configuration", closingPurifySettings::close);
|
||||
if (closingGuardianBond != null) cleanup("Guardian Bond", closingGuardianBond::close);
|
||||
if (closingGuardianBondSettings != null) cleanup("Guardian Bond configuration", closingGuardianBondSettings::close);
|
||||
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
|
||||
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
|
||||
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
|
||||
@@ -466,6 +482,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: closingMendingLightSettings.drained().toCompletableFuture(),
|
||||
closingPurifySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPurifySettings.drained().toCompletableFuture(),
|
||||
closingGuardianBondSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingGuardianBondSettings.drained().toCompletableFuture(),
|
||||
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdminReviveSettings.drained().toCompletableFuture(),
|
||||
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -686,6 +704,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> guardianBondConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.GuardianBondSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.GuardianBondSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.GuardianBondSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
|
||||
try {
|
||||
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.projectiles.ProjectileSource;
|
||||
|
||||
/** Conserved, non-recursive native combat redirection for Guardian Bond. */
|
||||
public final class GuardianBond implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double range, Duration duration, BigDecimal redirectPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(redirectPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64
|
||||
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| redirectPercent.signum() < 0 || redirectPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Guardian Bond settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(20), 10, Duration.ofSeconds(6), BigDecimal.valueOf(30));
|
||||
}
|
||||
}
|
||||
|
||||
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
public Support {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(supportingCharacter);
|
||||
Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface public interface Damage {
|
||||
BigDecimal apply(Player guardian, Entity attacker, BigDecimal requested);
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution;
|
||||
final Player guardian;
|
||||
final Player ally;
|
||||
final EquipmentSessions.Active aided;
|
||||
final Settings settings;
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player guardian, Player ally,
|
||||
EquipmentSessions.Active aided, Settings settings) {
|
||||
this.execution = execution; this.guardian = guardian; this.ally = ally;
|
||||
this.aided = aided; this.settings = settings;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
byGuardian.remove(guardian.getUniqueId(), this);
|
||||
byAlly.remove(ally.getUniqueId(), this);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NativeTransfer {
|
||||
final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
|
||||
BigDecimal accepted = BigDecimal.ZERO;
|
||||
NativeTransfer(Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
|
||||
this.guardian = guardian; this.attacker = attacker; this.requested = requested; this.available = available;
|
||||
}
|
||||
}
|
||||
|
||||
private static final ThreadLocal<Integer> REDIRECT_DEPTH = ThreadLocal.withInitial(() -> 0);
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Damage damage;
|
||||
private final Consumer<Support> supports;
|
||||
private final Map<UUID, State> byGuardian = new java.util.HashMap<>();
|
||||
private final Map<UUID, State> byAlly = new java.util.HashMap<>();
|
||||
private int transferring;
|
||||
private NativeTransfer nativeTransfer;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
public GuardianBond(Settings settings, EquipmentSessions sessions, PartyRoster parties,
|
||||
Consumer<Support> supports) {
|
||||
this(settings, sessions, parties, null, supports);
|
||||
}
|
||||
|
||||
public GuardianBond(Settings settings, EquipmentSessions sessions, PartyRoster parties,
|
||||
Damage damage, Consumer<Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.damage = damage == null ? this::nativeDamage : damage;
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Guardian Bond sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
|
||||
/** Bound active-link validation prevents an invalid link from becoming valid again later. */
|
||||
public void sweep() {
|
||||
if (closed) return;
|
||||
int checked = 0;
|
||||
for (var state : List.copyOf(byGuardian.values())) {
|
||||
if (!current(state)) breakBond(state);
|
||||
if (++checked == 256) break;
|
||||
}
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("guardian_bond", "Guardian Bond", Material.IRON_CHESTPLATE,
|
||||
List.of("Redirect " + decimal(current.redirectPercent()) + "% of an aimed party ally's combat damage.",
|
||||
"Range " + decimal(BigDecimal.valueOf(current.range())) + " blocks for " + seconds(current.duration()) + " seconds."),
|
||||
"Use on an aimed active party ally; one guardian and one bond per ally.",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
|
||||
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
|
||||
"redirect-percent", current.redirectPercent()),
|
||||
AbilityDefinition.TargetPolicy.PARTY, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
if (closed || !(input.target() instanceof Player ally) || ally == input.player()) return false;
|
||||
return byGuardian.get(input.player().getUniqueId()) == null && byAlly.get(ally.getUniqueId()) == null
|
||||
&& eligible(input.player(), ally, settings.range()) && aided(ally) != null;
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof Player ally)) return;
|
||||
var guardian = execution.input().player(); var captured = from(execution.definition()); var aided = aided(ally);
|
||||
if (ally == guardian || aided == null || !eligible(guardian, ally, captured.range())
|
||||
|| byGuardian.containsKey(guardian.getUniqueId()) || byAlly.containsKey(ally.getUniqueId())) {
|
||||
execution.finish(); return;
|
||||
}
|
||||
var state = new State(execution, guardian, ally, aided, captured);
|
||||
byGuardian.put(guardian.getUniqueId(), state); byAlly.put(ally.getUniqueId(), state);
|
||||
execution.own(state); execution.later(captured.duration(), execution::finish);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void redirect(EntityDamageByEntityEvent event) {
|
||||
if (closed || redirectedDamage() || transferring != 0 || !(event.getEntity() instanceof Player ally)) return;
|
||||
var state = byAlly.get(ally.getUniqueId());
|
||||
if (state == null) return;
|
||||
if (!current(state)) { breakBond(state); return; }
|
||||
double incoming = event.getDamage();
|
||||
if (!Double.isFinite(incoming) || incoming <= 0) return;
|
||||
BigDecimal requested = BigDecimal.valueOf(incoming)
|
||||
.multiply(state.settings.redirectPercent().movePointLeft(2));
|
||||
if (requested.signum() <= 0) return;
|
||||
Entity attacker = attacker(event);
|
||||
if (attacker == null) return;
|
||||
BigDecimal accepted;
|
||||
transferring++; REDIRECT_DEPTH.set(REDIRECT_DEPTH.get() + 1);
|
||||
try { accepted = damage.apply(state.guardian, attacker, requested); }
|
||||
catch (RuntimeException failure) { return; }
|
||||
finally {
|
||||
transferring--;
|
||||
int depth = REDIRECT_DEPTH.get() - 1;
|
||||
if (depth == 0) REDIRECT_DEPTH.remove(); else REDIRECT_DEPTH.set(depth);
|
||||
}
|
||||
if (accepted == null || accepted.signum() <= 0) return;
|
||||
BigDecimal maximum = requested.min(BigDecimal.valueOf(incoming));
|
||||
if (accepted.compareTo(maximum) > 0) accepted = maximum;
|
||||
double remaining = BigDecimal.valueOf(incoming).subtract(accepted).max(BigDecimal.ZERO).doubleValue();
|
||||
if (!Double.isFinite(remaining)) return;
|
||||
event.setDamage(remaining);
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
|
||||
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
|
||||
}
|
||||
|
||||
/** Capture accepted marked damage before synchronous fantasy-defeat restoration can replace native health. */
|
||||
private BigDecimal nativeDamage(Player guardian, Entity attacker, BigDecimal requested) {
|
||||
Objects.requireNonNull(guardian); Objects.requireNonNull(attacker); Objects.requireNonNull(requested);
|
||||
double amount = requested.doubleValue(); double health = effectiveHealth(guardian);
|
||||
if (!Double.isFinite(amount) || amount <= 0 || !Double.isFinite(health) || health <= 0) return BigDecimal.ZERO;
|
||||
var transfer = new NativeTransfer(guardian, attacker, requested, BigDecimal.valueOf(health));
|
||||
nativeTransfer = transfer;
|
||||
try { guardian.damage(amount, attacker); }
|
||||
finally { nativeTransfer = null; }
|
||||
return transfer.accepted;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedRedirect(EntityDamageByEntityEvent event) {
|
||||
var transfer = nativeTransfer;
|
||||
if (transfer == null || event.getEntity() != transfer.guardian || attacker(event) != transfer.attacker
|
||||
|| event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
|
||||
transfer.accepted = BigDecimal.valueOf(event.getFinalDamage()).min(transfer.requested).min(transfer.available);
|
||||
}
|
||||
|
||||
/** True only during synchronous dispatch of Guardian Bond's marked redirected damage. */
|
||||
public static boolean redirectedDamage() {
|
||||
int depth = REDIRECT_DEPTH.get();
|
||||
if (depth == 0) REDIRECT_DEPTH.remove();
|
||||
return depth > 0;
|
||||
}
|
||||
|
||||
private EquipmentSessions.Active aided(Player ally) {
|
||||
return sessions.active(ally.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
}
|
||||
|
||||
private boolean current(State state) {
|
||||
if (state.closed || !state.execution.stillCurrent() || state.ally != state.execution.input().target()
|
||||
|| !sessions.current(state.aided)
|
||||
|| !sessions.active(state.ally.getUniqueId()).filter(state.aided::equals).isPresent()
|
||||
|| !eligible(state.guardian, state.ally, state.settings.range())) return false;
|
||||
var guardianParty = parties.partyOf(state.guardian.getUniqueId()).orElse(null);
|
||||
var allyParty = parties.partyOf(state.ally.getUniqueId()).orElse(null);
|
||||
return guardianParty != null && allyParty != null && guardianParty.id().equals(allyParty.id());
|
||||
}
|
||||
|
||||
private void breakBond(State state) {
|
||||
state.close(); state.execution.finish();
|
||||
}
|
||||
|
||||
private static boolean eligible(Player guardian, Player ally, double range) {
|
||||
try {
|
||||
var source = guardian.getLocation(); var target = ally.getLocation();
|
||||
return source.getWorld() == target.getWorld() && source.distanceSquared(target) <= range * range
|
||||
&& guardian.hasLineOfSight(ally);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
|
||||
private static Entity attacker(EntityDamageByEntityEvent event) {
|
||||
Entity causing = event.getDamageSource().getCausingEntity();
|
||||
if (causing != null) return causing;
|
||||
if (event.getDamager() instanceof Projectile projectile) {
|
||||
ProjectileSource shooter = projectile.getShooter();
|
||||
if (shooter instanceof Entity entity) return entity;
|
||||
}
|
||||
return event.getDamager();
|
||||
}
|
||||
|
||||
private static double effectiveHealth(Player player) {
|
||||
return Math.max(0, player.getHealth()) + Math.max(0, player.getAbsorptionAmount());
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(definition.cooldown(), parameters.get("range").doubleValue(),
|
||||
Duration.ofNanos(parameters.get("duration-seconds").movePointRight(9).longValueExact()),
|
||||
parameters.get("redirect-percent"));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
private static String seconds(Duration duration) {
|
||||
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(byGuardian.values())) state.execution.finish();
|
||||
byGuardian.clear(); byAlly.clear();
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority=EventPriority.NORMAL,ignoreCancelled=true)
|
||||
public void reduce(EntityDamageByEntityEvent event){
|
||||
if(closed)return;Entity attacker=attacker(event);if(attacker==null)return;var owned=grants.get(attacker.getUniqueId());if(owned==null)return;
|
||||
if(closed||GuardianBond.redirectedDamage())return;Entity attacker=attacker(event);if(attacker==null)return;var owned=grants.get(attacker.getUniqueId());if(owned==null)return;
|
||||
Grant strongest=null;for(var grant:List.copyOf(owned)){if(grant.closed||grant.target!=attacker||!grant.execution.stillCurrent())continue;
|
||||
if(strongest==null||grant.percent.compareTo(strongest.percent)>0
|
||||
||grant.percent.compareTo(strongest.percent)==0&&grant.sequence>strongest.sequence)strongest=grant;}
|
||||
|
||||
@@ -109,7 +109,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || event.getFinalDamage() <= 0) return;
|
||||
if (closed || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
|
||||
|| event.getFinalDamage() <= 0) return;
|
||||
var origin = source(event);
|
||||
if (origin == null || origin.session() == null || !sources.current(origin)
|
||||
|| event.getEntity().getUniqueId().equals(origin.player())) return;
|
||||
|
||||
@@ -66,6 +66,7 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()) return;
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.GuardianBond;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized durable Guardian Bond settings; publication remains a server-thread caller decision. */
|
||||
public final class GuardianBondSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "guardian-bond-cooldown-seconds";
|
||||
public static final String RANGE = "guardian-bond-range-blocks";
|
||||
public static final String DURATION = "guardian-bond-duration-seconds";
|
||||
public static final String REDIRECT = "guardian-bond-redirect-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, DURATION, RANGE, REDIRECT);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
COOLDOWN, "20", RANGE, "10", DURATION, "6", REDIRECT, "30");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, GuardianBond.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-guardian-bond-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 GuardianBondSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
GuardianBondSettings(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 GuardianBond.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
|
||||
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Guardian Bond configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static GuardianBond.Settings parse(Map<String, String> values) {
|
||||
try { return new GuardianBond.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RANGE)),
|
||||
seconds(values.get(DURATION)), new BigDecimal(values.get(REDIRECT))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Guardian Bond settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe lock");
|
||||
}
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
|
||||
}
|
||||
@@ -43,6 +43,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.ability.MendingLight mendingLight;
|
||||
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
|
||||
private games.dmg.fantasystick.ability.Purify purify;
|
||||
private games.dmg.fantasystick.persistence.GuardianBondSettings guardianBondSettings;
|
||||
private games.dmg.fantasystick.ability.GuardianBond guardianBond;
|
||||
private AdminCharacterIssuer characterIssuer;
|
||||
private AdminRevival adminRevival;
|
||||
private AdminExperienceGrant experienceGrant;
|
||||
@@ -233,6 +235,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
purifySettings = Objects.requireNonNull(settings); purify = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachGuardianBond(games.dmg.fantasystick.persistence.GuardianBondSettings settings,
|
||||
games.dmg.fantasystick.ability.GuardianBond effect) {
|
||||
if (closed || guardianBondSettings != null) throw new IllegalStateException("Guardian Bond already attached or command closed");
|
||||
guardianBondSettings = Objects.requireNonNull(settings); guardianBond = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
|
||||
@@ -442,6 +450,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (purifySettings != null && games.dmg.fantasystick.persistence.PurifySettings.keys().contains(args[1])) {
|
||||
purifyConfiguration(sender, args); return;
|
||||
}
|
||||
if (guardianBondSettings != null && games.dmg.fantasystick.persistence.GuardianBondSettings.keys().contains(args[1])) {
|
||||
guardianBondConfiguration(sender, args); return;
|
||||
}
|
||||
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
configurationUsage(sender); return;
|
||||
}
|
||||
@@ -596,6 +607,33 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private void guardianBondConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + guardianBondSettings.value(key) + " (applies to future Guardian Bond casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GuardianBondSettings.Update> operation;
|
||||
try { operation = guardianBondSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Guardian Bond setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition()); guardianBondSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Guardian Bond casts; active bonds and deadlines are unchanged."
|
||||
: "Guardian Bond configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void hexConfiguration(CommandSender sender, String[] args) {
|
||||
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
|
||||
@@ -752,6 +790,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
keys.addAll(games.dmg.fantasystick.persistence.InspireSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.MendingLightSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.PurifySettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.GuardianBondSettings.keys());
|
||||
return matching(keys, args[1]);
|
||||
}
|
||||
if (args.length == 3 && args[0].equals("config")) {
|
||||
@@ -806,6 +845,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
String defaults = games.dmg.fantasystick.persistence.PurifySettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (guardianBondSettings != null
|
||||
&& games.dmg.fantasystick.persistence.GuardianBondSettings.keys().contains(args[1])) {
|
||||
String current = guardianBondSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.GuardianBondSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@@ -346,6 +346,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
if (selected != null && useGesture(selected)) {
|
||||
org.bukkit.entity.Entity target = selected.ability().equals("hex")
|
||||
|| selected.ability().equals("mending_light")
|
||||
|| selected.ability().equals("guardian_bond")
|
||||
? event.getPlayer().getTargetEntity(64)
|
||||
: selected.ability().equals("inspire") || selected.ability().equals("purify")
|
||||
? event.getPlayer().isSneaking() ? event.getPlayer() : event.getPlayer().getTargetEntity(64)
|
||||
|
||||
@@ -100,3 +100,10 @@ mending-light-range-blocks: 12
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
purify-cooldown-seconds: 15
|
||||
purify-range-blocks: 12
|
||||
|
||||
# Guardian Bond defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
guardian-bond-cooldown-seconds: 20
|
||||
guardian-bond-range-blocks: 10
|
||||
guardian-bond-duration-seconds: 6
|
||||
guardian-bond-redirect-percent: 30
|
||||
@@ -60,7 +60,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(5, fixture.timers.size(), "Presentation, ability cleanup, provenance, reward retry and session particles");
|
||||
assertEquals(6, fixture.timers.size(),
|
||||
"Presentation, ability/bond cleanup, provenance, reward retry and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
|
||||
@@ -1320,8 +1321,12 @@ class FantasyStickLifecycleNativeTest {
|
||||
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
|
||||
boolean healthScaled;
|
||||
double healthScale = 20;
|
||||
double absorption;
|
||||
Runnable scaleMutation = () -> {};
|
||||
Runnable foodMutation = () -> {};
|
||||
java.util.function.BiConsumer<Double, org.bukkit.entity.Entity> damage = (amount, source) -> {
|
||||
throw new AssertionError("Native damage port not supplied");
|
||||
};
|
||||
java.util.function.Consumer<org.bukkit.event.player.PlayerGameModeChangeEvent> modeEvents = ignored -> { };
|
||||
final org.bukkit.craftbukkit.entity.CraftPlayer player;
|
||||
final net.minecraft.server.level.ServerPlayer nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
@@ -1354,6 +1359,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
yield new org.bukkit.craftbukkit.attribute.CraftAttributeInstance(maximumHealth, org.bukkit.attribute.Attribute.MAX_HEALTH);
|
||||
}
|
||||
case "getMaxHealth" -> maximumHealth.getValue();
|
||||
case "getAbsorptionAmount" -> absorption;
|
||||
case "setAbsorptionAmount" -> { absorption = (Double) args[0]; yield null; }
|
||||
case "getFoodLevel" -> food.getFoodLevel();
|
||||
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); foodMutation.run(); yield null; }
|
||||
case "getSaturation" -> food.getSaturationLevel();
|
||||
@@ -1365,6 +1372,10 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "setHealthScale" -> { healthScale = (Double) args[0]; healthScaled = true; scaleMutation.run(); yield null; }
|
||||
case "setHealthScaled" -> { healthScaled = (Boolean) args[0]; yield null; }
|
||||
case "getGameMode" -> core.gameMode;
|
||||
case "damage" -> {
|
||||
damage.accept((Double) args[0], args.length > 1 ? (org.bukkit.entity.Entity) args[1] : null);
|
||||
yield null;
|
||||
}
|
||||
case "setGameMode" -> {
|
||||
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent((org.bukkit.entity.Player) call.getMock(), (org.bukkit.GameMode) args[0]);
|
||||
modeEvents.accept(event);
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityExecution;
|
||||
import games.dmg.fantasystick.ability.AbilityReport;
|
||||
import games.dmg.fantasystick.ability.GuardianBond;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.GuardianBondSettings;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class GuardianBondNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseRedirectsNativeDamageAndPersistsCooldown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var guardian = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareGuardian(data, guardian, false);
|
||||
fixture.players.put(guardian.core.id, guardian.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(guardian); fixture.activate(ally); party(fixture, guardian, ally); aim(guardian, ally, 8, true);
|
||||
nativeDamage(fixture, guardian);
|
||||
|
||||
guardian.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, guardian);
|
||||
String text = PartyBookNativeTest.text(guardian).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("GuardianBond")); assertTrue(text.contains("CON16")); assertTrue(text.contains("WIS10"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("30%")); assertTrue(text.contains("6seconds"));
|
||||
PartyBookNativeTest.click(fixture, guardian, button(guardian, "[Learn]", "guardian_bond")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, guardian, button(guardian, "[3]", "guardian_bond")); drain(fixture, 2);
|
||||
use(fixture, guardian); drain(fixture, 2);
|
||||
|
||||
var enemy = mock(org.bukkit.entity.Zombie.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var hit = hit(enemy, ally.player, 10); fixture.dispatcher.accept(hit);
|
||||
assertEquals(7, hit.getDamage(), 1.0e-9); assertEquals(7, guardian.player.getHealth(), 1.0e-9);
|
||||
var stored = storage(fixture).find(guardian.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertNotNull(stored.cooldowns().get("guardian_bond"));
|
||||
assertEquals(120L, fixture.delayedTicks.getLast()); fixture.delayed.getLast().run();
|
||||
var expired = hit(enemy, ally.player, 10); fixture.dispatcher.accept(expired);
|
||||
assertEquals(10, expired.getDamage(), 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void lethalRedirectUsesNormalDefeatAndOnlyRemovesGuardianAvailableHealth() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var guardian = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareGuardian(data, guardian, true);
|
||||
fixture.players.put(guardian.core.id, guardian.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(guardian); fixture.activate(ally); party(fixture, guardian, ally); aim(guardian, ally, 8, true);
|
||||
var defeatOrder = new ArrayList<String>();
|
||||
guardian.damage = (amount, source) -> {
|
||||
var redirected = hit(source, guardian.player, amount); fixture.dispatcher.accept(redirected);
|
||||
if (redirected.isCancelled()) return;
|
||||
guardian.core.health = Math.max(0, guardian.core.health - redirected.getFinalDamage());
|
||||
if (guardian.core.health == 0) {
|
||||
var deathSource = new org.bukkit.craftbukkit.damage.CraftDamageSource(
|
||||
new net.minecraft.world.damagesource.DamageSource(
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(guardian.player, deathSource,
|
||||
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("redirected defeat"), true);
|
||||
fixture.dispatcher.accept(death); assertTrue(death.isCancelled()); defeatOrder.add("guardian");
|
||||
}
|
||||
};
|
||||
use(fixture, guardian); drain(fixture, 2); guardian.core.health = 1; ally.core.health = 9;
|
||||
var hit = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(hit);
|
||||
assertEquals(List.of("guardian"), defeatOrder,
|
||||
"Redirected damage reaches normal guardian defeat before original damage resumes");
|
||||
assertEquals(9, hit.getDamage(), 1.0e-9, "Overkill cannot delete damage the guardian could not accept");
|
||||
ally.core.health = Math.max(0, ally.core.health - hit.getFinalDamage());
|
||||
if (ally.core.health == 0) {
|
||||
var deathSource = new org.bukkit.craftbukkit.damage.CraftDamageSource(
|
||||
new net.minecraft.world.damagesource.DamageSource(
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(ally.player, deathSource,
|
||||
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("remaining original defeat"), true);
|
||||
fixture.dispatcher.accept(death); assertTrue(death.isCancelled()); defeatOrder.add("ally");
|
||||
}
|
||||
assertEquals(List.of("guardian", "ally"), defeatOrder, "Both characters may be defeated in the approved order");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualProtectionCreatesOnePersistedSupportShareForTheAidedDirectContributor() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var guardian = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareGuardian(data, guardian, true);
|
||||
fixture.players.put(guardian.core.id, guardian.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(guardian); fixture.activate(ally); party(fixture, guardian, ally); aim(guardian, ally, 8, true);
|
||||
nativeDamage(fixture, guardian); use(fixture, guardian); drain(fixture, 2);
|
||||
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(enemy.getLocation()).thenReturn(new Location(guardian.world, 8, 64, 0));
|
||||
when(guardian.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
fixture.dispatcher.accept(hit(enemy, ally.player, 10));
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
fixture.dispatcher.accept(new EntityDamageByEntityEvent(ally.player, enemy,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 3));
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(guardian.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void conservedRoutingUsesPostRecipientAndPostGuardianMitigationCancellationAndLethalCaps() {
|
||||
var context = new Context(); var requested = new ArrayList<BigDecimal>();
|
||||
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
var guardianProtection = mock(AbilityExecution.class); when(guardianProtection.stillCurrent()).thenReturn(true);
|
||||
mitigation.grant(guardianProtection, context.guardian, new BigDecimal("25"));
|
||||
var outgoing = new games.dmg.fantasystick.ability.OutgoingDamageMitigation(
|
||||
context.sessions, context.parties, ignored -> {});
|
||||
var enemyPenalty = mock(AbilityExecution.class); when(enemyPenalty.stillCurrent()).thenReturn(true);
|
||||
outgoing.grant(enemyPenalty, context.enemy, new BigDecimal("20"));
|
||||
context.damage = (guardian, attacker, amount) -> {
|
||||
requested.add(amount); var redirected = hit(attacker, guardian, amount.doubleValue());
|
||||
outgoing.reduce(redirected); mitigation.reduce(redirected); return BigDecimal.valueOf(redirected.getDamage());
|
||||
};
|
||||
context.activate();
|
||||
var accepted = hit(context.enemy, context.ally, 10); outgoing.reduce(accepted); context.bond.redirect(accepted);
|
||||
assertEquals(0, new BigDecimal("2.4").compareTo(requested.getFirst()));
|
||||
assertEquals(6.2, accepted.getDamage(), 1.0e-9);
|
||||
assertEquals(new BigDecimal("1.8"), context.supports.getFirst().amount());
|
||||
verify(context.execution).report(AbilityReport.Type.PREVENTION, context.ally, new BigDecimal("1.8"));
|
||||
|
||||
context.damage = (guardian, attacker, amount) -> {
|
||||
var redirected = hit(attacker, guardian, amount.doubleValue()); redirected.setCancelled(true);
|
||||
mitigation.reduce(redirected); return redirected.isCancelled() ? BigDecimal.ZERO : BigDecimal.valueOf(redirected.getDamage());
|
||||
};
|
||||
var cancelled = hit(context.enemy, context.ally, 8); context.bond.redirect(cancelled);
|
||||
assertEquals(8, cancelled.getDamage(), 1.0e-9); assertEquals(1, context.supports.size());
|
||||
|
||||
context.damage = (guardian, attacker, amount) -> BigDecimal.ONE;
|
||||
var lethal = hit(context.enemy, context.ally, 10); context.bond.redirect(lethal);
|
||||
assertEquals(9, lethal.getDamage(), 1.0e-9, "Only the one point accepted by a lethal guardian is removed");
|
||||
}
|
||||
|
||||
@Test void conflictsCyclesAndInvalidatedRangePartyOrSessionCannotStackOrRedirect() {
|
||||
var context = new Context(); var calls = new java.util.concurrent.atomic.AtomicInteger();
|
||||
context.damage = (guardian, attacker, amount) -> {
|
||||
calls.incrementAndGet();
|
||||
var nested = hit(attacker, guardian, amount.doubleValue()); context.bond.redirect(nested);
|
||||
return BigDecimal.valueOf(nested.getDamage());
|
||||
};
|
||||
context.activate();
|
||||
assertFalse(context.bond.accepts(context.input), "The same caster/target cannot stack another bond");
|
||||
|
||||
var reverse = context.reverse(); reverse.activate();
|
||||
var cycle = hit(context.enemy, context.guardian, 10); context.bond.redirect(cycle);
|
||||
assertEquals(1, calls.get(), "Marked redirected damage must not recurse through the opposite bond");
|
||||
assertEquals(7, cycle.getDamage(), 1.0e-9);
|
||||
|
||||
when(context.ally.getLocation()).thenReturn(new Location(context.world, 11, 64, 0)); context.bond.sweep();
|
||||
when(context.ally.getLocation()).thenReturn(new Location(context.world, 8, 64, 0));
|
||||
var separated = hit(context.enemy, context.ally, 10); context.bond.redirect(separated);
|
||||
assertEquals(10, separated.getDamage(), 1.0e-9); verify(context.execution).finish();
|
||||
|
||||
var disbanded = new Context(); disbanded.activate();
|
||||
when(disbanded.parties.partyOf(disbanded.ally.getUniqueId())).thenReturn(java.util.Optional.empty()); disbanded.bond.sweep();
|
||||
when(disbanded.parties.partyOf(disbanded.ally.getUniqueId())).thenReturn(java.util.Optional.of(disbanded.party));
|
||||
var noParty = hit(disbanded.enemy, disbanded.ally, 10); disbanded.bond.redirect(noParty);
|
||||
assertEquals(10, noParty.getDamage(), 1.0e-9); verify(disbanded.execution).finish();
|
||||
var exited = new Context(); exited.activate(); when(exited.sessions.current(exited.allyActive)).thenReturn(false); exited.bond.sweep();
|
||||
when(exited.sessions.current(exited.allyActive)).thenReturn(true);
|
||||
var noSession = hit(exited.enemy, exited.ally, 10); exited.bond.redirect(noSession);
|
||||
assertEquals(10, noSession.getDamage(), 1.0e-9); verify(exited.execution).finish();
|
||||
}
|
||||
|
||||
@Test void selfOrdinaryNonPartyRangeLineOfSightAndConflictRefuseBeforeCooldown() throws Exception {
|
||||
for (int route = 0; route < 6; route++) {
|
||||
var directory = data.resolve("refusal-" + route);
|
||||
if (route == 5) {
|
||||
java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"),
|
||||
"guardian-bond-cooldown-seconds: 0\nguardian-bond-range-blocks: 10\n"
|
||||
+ "guardian-bond-duration-seconds: 6\nguardian-bond-redirect-percent: 30\n");
|
||||
}
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
fixture.plugin.setEnabled(true); var guardian = new FantasyStickLifecycleNativeTest.Actor(directory);
|
||||
prepareGuardian(directory, guardian, true); fixture.players.put(guardian.core.id, guardian.player); fixture.activate(guardian);
|
||||
Player target;
|
||||
if (route == 0) target = guardian.player;
|
||||
else if (route == 1) { target = mock(Player.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID()); }
|
||||
else {
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(directory);
|
||||
fixture.players.put(ally.core.id, ally.player); fixture.activate(ally); target = ally.player;
|
||||
if (route != 2) party(fixture, guardian, ally);
|
||||
}
|
||||
aim(guardian, target, route == 3 ? 11 : 8, route != 4);
|
||||
if (route == 5) { use(fixture, guardian); drain(fixture, 2); }
|
||||
use(fixture, guardian); drain(fixture, 2);
|
||||
var cooldowns = storage(fixture).find(guardian.core.record.id()).get().orElseThrow().cooldowns();
|
||||
if (route < 5) assertFalse(cooldowns.containsKey("guardian_bond"), "route " + route);
|
||||
else assertEquals(1, fixture.delayed.size(), "A conflicting zero-cooldown cast must not stack a second bond");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configurationAdminRestartAndFutureBondCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"guardian-bond-cooldown-seconds: 2\nguardian-bond-range-blocks: 14\n"
|
||||
+ "guardian-bond-duration-seconds: 8\nguardian-bond-redirect-percent: 40\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var definition = registry.find("guardian_bond").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(new BigDecimal("14"), definition.parameters().get("range"));
|
||||
assertEquals(0, new BigDecimal("8").compareTo(definition.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("40"), definition.parameters().get("redirect-percent"));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "guardian-bond-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-range-blocks", "16"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("guardian-bond-cooldown-seconds", "guardian-bond-duration-seconds",
|
||||
"guardian-bond-range-blocks", "guardian-bond-redirect-percent"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "guardian-bond-"}));
|
||||
assertEquals(List.of("10", "14"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "guardian-bond-range-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-range-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=14"));
|
||||
for (String[] update : List.of(
|
||||
new String[]{"guardian-bond-range-blocks", "16"},
|
||||
new String[]{"guardian-bond-cooldown-seconds", "3"},
|
||||
new String[]{"guardian-bond-duration-seconds", "9"},
|
||||
new String[]{"guardian-bond-redirect-percent", "50"})) {
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
}
|
||||
var configured = registry.find("guardian_bond").orElseThrow();
|
||||
assertEquals(new BigDecimal("16"), configured.parameters().get("range"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
|
||||
assertEquals(0, new BigDecimal("9").compareTo(configured.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("50"), configured.parameters().get("redirect-percent"));
|
||||
|
||||
var guardian = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareGuardian(data, guardian, true); fixture.players.put(guardian.core.id, guardian.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(guardian); fixture.activate(ally); party(fixture, guardian, ally); aim(guardian, ally, 15, true);
|
||||
nativeDamage(fixture, guardian); use(fixture, guardian); drain(fixture, 2);
|
||||
var future = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(future);
|
||||
assertEquals(5, future.getDamage(), 1.0e-9); assertEquals(180L, fixture.delayedTicks.getLast());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-duration-seconds", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-range-blocks", "16", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var afterRestart = reloaded.find("guardian_bond").orElseThrow();
|
||||
assertEquals(new BigDecimal("16"), afterRestart.parameters().get("range"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), afterRestart.cooldown());
|
||||
assertEquals(0, new BigDecimal("9").compareTo(afterRestart.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("50"), afterRestart.parameters().get("redirect-percent"));
|
||||
}
|
||||
for (String invalid : List.of("guardian-bond-cooldown-seconds: -1\n", "guardian-bond-range-blocks: 0\n",
|
||||
"guardian-bond-duration-seconds: 0\n", "guardian-bond-redirect-percent: 101\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class Context {
|
||||
final org.bukkit.World world = mock(org.bukkit.World.class); final Player guardian = mock(Player.class); final Player ally = mock(Player.class);
|
||||
final LivingEntity enemy = mock(LivingEntity.class); final EquipmentSessions sessions = mock(EquipmentSessions.class);
|
||||
final PartyRoster parties = mock(PartyRoster.class); final AbilityExecution execution = mock(AbilityExecution.class);
|
||||
final ControlInput input = mock(ControlInput.class); final List<GuardianBond.Support> supports = new ArrayList<>();
|
||||
GuardianBond.Damage damage = (target, attacker, amount) -> amount; final GuardianBond bond;
|
||||
final EquipmentSessions.Active guardianActive; final EquipmentSessions.Active allyActive;
|
||||
final PartyRoster.Party party;
|
||||
Context() {
|
||||
var guardianId = UUID.randomUUID(); var allyId = UUID.randomUUID();
|
||||
when(guardian.getUniqueId()).thenReturn(guardianId); when(ally.getUniqueId()).thenReturn(allyId);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(guardian.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(ally.getLocation()).thenReturn(new Location(world, 8, 64, 0));
|
||||
when(guardian.hasLineOfSight(ally)).thenReturn(true); when(ally.hasLineOfSight(guardian)).thenReturn(true);
|
||||
guardianActive = new EquipmentSessions.Active(guardianId, UUID.randomUUID(), UUID.randomUUID());
|
||||
allyActive = new EquipmentSessions.Active(allyId, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(allyId)).thenReturn(java.util.Optional.of(allyActive)); when(sessions.active(guardianId)).thenReturn(java.util.Optional.of(guardianActive));
|
||||
when(sessions.current(allyActive)).thenReturn(true); when(sessions.current(guardianActive)).thenReturn(true);
|
||||
party = new PartyRoster.Party(UUID.randomUUID(), guardianId, List.of(guardianId, allyId));
|
||||
when(parties.partyOf(guardianId)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party));
|
||||
when(input.player()).thenReturn(guardian); when(input.target()).thenReturn(ally); when(input.session()).thenReturn(guardianActive);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
|
||||
bond = new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties,
|
||||
(target, attacker, amount) -> damage.apply(target, attacker, amount), supports::add);
|
||||
when(execution.definition()).thenReturn(bond.definition());
|
||||
}
|
||||
void activate() { bond.activate(execution); }
|
||||
Context reverse() {
|
||||
var reverse = new Context();
|
||||
when(reverse.input.player()).thenReturn(ally); when(reverse.input.target()).thenReturn(guardian); when(reverse.input.session()).thenReturn(allyActive);
|
||||
when(reverse.execution.input()).thenReturn(reverse.input); when(reverse.execution.definition()).thenReturn(bond.definition());
|
||||
bond.activate(reverse.execution); return reverse;
|
||||
}
|
||||
}
|
||||
|
||||
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.damage = (amount, source) -> {
|
||||
var event = hit(source, actor.player, amount); fixture.dispatcher.accept(event);
|
||||
if (!event.isCancelled()) actor.core.health = Math.max(0, actor.core.health - event.getFinalDamage());
|
||||
};
|
||||
}
|
||||
private static EntityDamageByEntityEvent hit(Entity attacker, Entity target, double amount) {
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(attacker).withDirectEntity(attacker).build();
|
||||
return new EntityDamageByEntityEvent(attacker, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount);
|
||||
}
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
}
|
||||
private static PartyRoster.Party party(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor source,
|
||||
FantasyStickLifecycleNativeTest.Actor target) {
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(source.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(target.core.id, parties.invite(source.core.id, group.id(), target.core.id).orElseThrow().id()));
|
||||
return parties.partyOf(source.core.id).orElseThrow();
|
||||
}
|
||||
private static void aim(FantasyStickLifecycleNativeTest.Actor source,
|
||||
FantasyStickLifecycleNativeTest.Actor target, double distance, boolean visible) {
|
||||
aim(source, target.player, distance, visible);
|
||||
}
|
||||
private static void aim(FantasyStickLifecycleNativeTest.Actor source, Player target, double distance, boolean visible) {
|
||||
doReturn(new Location(source.world, 0, 64, 0)).when(source.player).getLocation();
|
||||
doReturn(new Location(source.world, distance, 64, 0)).when(target).getLocation();
|
||||
doReturn(visible).when(source.player).hasLineOfSight(target); doReturn(target).when(source.player).getTargetEntity(64);
|
||||
}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
|
||||
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
}
|
||||
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(book);
|
||||
for (var page : book.pages()) { var found = button(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + ability);
|
||||
}
|
||||
private static ClickEvent.Custom button(net.minecraft.network.chat.Component component, String label, String ability) {
|
||||
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
|
||||
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
|
||||
&& payload.value().contains(":" + ability)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = button(child, label, ability); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
private static void prepareGuardian(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.CON, BigInteger.valueOf(16)); stats.put(Stat.WIS, BigInteger.TEN);
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
|
||||
learned ? Set.of("guardian_bond") : old.learned(),
|
||||
learned ? List.of("guardian_bond", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class GuardianBondSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new GuardianBondSettings(data, defaults())) {
|
||||
var range = settings.set(GuardianBondSettings.RANGE, "16").toCompletableFuture();
|
||||
var cooldown = settings.set(GuardianBondSettings.COOLDOWN, "24").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(10, settings.settings().range(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertEquals(16, settings.settings().range(), 0);
|
||||
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated"));
|
||||
assertEquals("16", String.valueOf(yaml.get(GuardianBondSettings.RANGE)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new GuardianBondSettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
GuardianBondSettings.COOLDOWN, "-1",
|
||||
GuardianBondSettings.RANGE, "0",
|
||||
GuardianBondSettings.DURATION, "0",
|
||||
GuardianBondSettings.REDIRECT, "101").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(GuardianBondSettings.COOLDOWN, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new GuardianBondSettings(data, defaults(), (from, to) -> {
|
||||
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
|
||||
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(GuardianBondSettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("10", settings.value(GuardianBondSettings.RANGE));
|
||||
}
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var unsafe = data.resolve("unsafe"); Files.createDirectories(unsafe);
|
||||
var outside = data.resolve("outside.yml"); Files.writeString(outside, "safe\n");
|
||||
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
|
||||
try (var settings = new GuardianBondSettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(GuardianBondSettings.DURATION, "8").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new GuardianBondSettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(GuardianBondSettings.REDIRECT, "40").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var values = new LinkedHashMap<String, String>();
|
||||
for (String key : GuardianBondSettings.keys()) values.put(key, GuardianBondSettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user