Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2340d02b | ||
|
|
d890fb8915 | ||
|
|
683c4ed312 | ||
|
|
c80c088945 | ||
|
|
cc6c1a7f44 | ||
|
|
4abaeecf63 | ||
|
|
5a22dc343a | ||
|
|
db1c7b5906 | ||
|
|
9ab5bffd2c |
No files matched your search
@@ -68,6 +68,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.ability.TemporaryHealth temporaryHealth;
|
||||
private games.dmg.fantasystick.ability.Inspire inspire;
|
||||
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
|
||||
private games.dmg.fantasystick.ability.MendingLight mendingLight;
|
||||
private games.dmg.fantasystick.persistence.MendingLightSettings mendingLightSettings;
|
||||
private games.dmg.fantasystick.ability.Purify purify;
|
||||
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
|
||||
private games.dmg.fantasystick.ability.GuardianBond guardianBond;
|
||||
private games.dmg.fantasystick.persistence.GuardianBondSettings guardianBondSettings;
|
||||
private games.dmg.fantasystick.ability.OutgoingDamageBonus outgoingDamageBonus;
|
||||
private games.dmg.fantasystick.ability.RallyingCry rallyingCry;
|
||||
private games.dmg.fantasystick.persistence.RallyingCrySettings rallyingCrySettings;
|
||||
private games.dmg.fantasystick.ability.FrostNova frostNova;
|
||||
private games.dmg.fantasystick.persistence.FrostNovaSettings frostNovaSettings;
|
||||
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
|
||||
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
|
||||
private games.dmg.fantasystick.ability.Sanctuary sanctuary;
|
||||
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
|
||||
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
|
||||
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
@@ -134,6 +149,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var hexInitial = games.dmg.fantasystick.persistence.HexSettings.parse(hexConfiguration);
|
||||
var inspireConfiguration = inspireConfiguration();
|
||||
var inspireInitial = games.dmg.fantasystick.persistence.InspireSettings.parse(inspireConfiguration);
|
||||
var mendingLightConfiguration = mendingLightConfiguration();
|
||||
var mendingLightInitial = games.dmg.fantasystick.persistence.MendingLightSettings.parse(mendingLightConfiguration);
|
||||
var purifyConfiguration = purifyConfiguration();
|
||||
var purifyInitial = games.dmg.fantasystick.persistence.PurifySettings.parse(purifyConfiguration);
|
||||
var guardianBondConfiguration = guardianBondConfiguration();
|
||||
var guardianBondInitial = games.dmg.fantasystick.persistence.GuardianBondSettings.parse(guardianBondConfiguration);
|
||||
var rallyingCryConfiguration = rallyingCryConfiguration();
|
||||
var rallyingCryInitial = games.dmg.fantasystick.persistence.RallyingCrySettings.parse(rallyingCryConfiguration);
|
||||
var frostNovaConfiguration = frostNovaConfiguration();
|
||||
var frostNovaInitial = games.dmg.fantasystick.persistence.FrostNovaSettings.parse(frostNovaConfiguration);
|
||||
var chainLightningConfiguration = chainLightningConfiguration();
|
||||
var chainLightningInitial = games.dmg.fantasystick.persistence.ChainLightningSettings.parse(chainLightningConfiguration);
|
||||
var sanctuaryConfiguration = sanctuaryConfiguration();
|
||||
var sanctuaryInitial = games.dmg.fantasystick.persistence.SanctuarySettings.parse(sanctuaryConfiguration);
|
||||
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
|
||||
var factory = new CharacterFactory();
|
||||
@@ -223,6 +252,37 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
|
||||
inspireSettings = new games.dmg.fantasystick.persistence.InspireSettings(getDataFolder().toPath(), inspireConfiguration);
|
||||
inspire = new games.dmg.fantasystick.ability.Inspire(inspireInitial, temporaryHealth);
|
||||
mendingLightSettings = new games.dmg.fantasystick.persistence.MendingLightSettings(
|
||||
getDataFolder().toPath(), mendingLightConfiguration);
|
||||
mendingLight = new games.dmg.fantasystick.ability.MendingLight(mendingLightInitial, characters, sessions, parties,
|
||||
nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
|
||||
purify = new games.dmg.fantasystick.ability.Purify(purifyInitial, sessions, parties,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
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()));
|
||||
outgoingDamageBonus = new games.dmg.fantasystick.ability.OutgoingDamageBonus(sessions, parties,
|
||||
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
|
||||
rallyingCrySettings = new games.dmg.fantasystick.persistence.RallyingCrySettings(
|
||||
getDataFolder().toPath(), rallyingCryConfiguration);
|
||||
rallyingCry = new games.dmg.fantasystick.ability.RallyingCry(
|
||||
rallyingCryInitial, sessions, parties, outgoingDamageBonus);
|
||||
frostNovaSettings = new games.dmg.fantasystick.persistence.FrostNovaSettings(
|
||||
getDataFolder().toPath(), frostNovaConfiguration);
|
||||
frostNova = new games.dmg.fantasystick.ability.FrostNova(frostNovaInitial, sessions, parties);
|
||||
chainLightningSettings = new games.dmg.fantasystick.persistence.ChainLightningSettings(
|
||||
getDataFolder().toPath(), chainLightningConfiguration);
|
||||
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(chainLightningInitial, sessions, parties);
|
||||
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
|
||||
getDataFolder().toPath(), sanctuaryConfiguration);
|
||||
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
|
||||
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
|
||||
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
|
||||
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
|
||||
@@ -237,6 +297,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityRegistry.register(shieldBash.definition());
|
||||
abilityRegistry.register(hex.definition());
|
||||
abilityRegistry.register(inspire.definition());
|
||||
abilityRegistry.register(mendingLight.definition());
|
||||
abilityRegistry.register(purify.definition());
|
||||
abilityRegistry.register(guardianBond.definition());
|
||||
abilityRegistry.register(rallyingCry.definition());
|
||||
abilityRegistry.register(frostNova.definition());
|
||||
abilityRegistry.register(chainLightning.definition());
|
||||
abilityRegistry.register(sanctuary.definition());
|
||||
abilityRegistry.register(greatSword.definition());
|
||||
abilityRegistry.register(revive.definition());
|
||||
sessions.attachAbilityRegistry(abilityRegistry);
|
||||
@@ -279,7 +346,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
|
||||
inspireSettings, inspire, abilityRegistry);
|
||||
admin.attachMendingLight(mendingLightSettings, mendingLight);
|
||||
admin.attachPurify(purifySettings, purify);
|
||||
admin.attachGuardianBond(guardianBondSettings, guardianBond);
|
||||
admin.attachRallyingCry(rallyingCrySettings, rallyingCry);
|
||||
admin.attachFrostNova(frostNovaSettings, frostNova);
|
||||
admin.attachChainLightning(chainLightningSettings, chainLightning);
|
||||
admin.attachSanctuary(sanctuarySettings, sanctuary);
|
||||
admin.attachAdminRevival(adminRevival, adminReviveSettings);
|
||||
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
|
||||
characters, rules, sessions, nextTick, getServer()::getPlayer,
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Admin experience grant for character " + problem.character(), problem.cause())));
|
||||
admin.attachSwitchDelay(switchDelaySettings);
|
||||
admin.attachRoleplaySettings(roleplaySettings);
|
||||
admin.attachPartySettings(partySettings);
|
||||
@@ -303,11 +381,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
combatExperience.install(this);
|
||||
getServer().getPluginManager().registerEvents(fireball, this);
|
||||
getServer().getPluginManager().registerEvents(frostShard, this);
|
||||
getServer().getPluginManager().registerEvents(frostNova, this);
|
||||
getServer().getPluginManager().registerEvents(chainLightning, this);
|
||||
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(outgoingDamageBonus, this);
|
||||
rallyingCry.install(this);
|
||||
getServer().getPluginManager().registerEvents(temporaryHealth, this);
|
||||
getServer().getPluginManager().registerEvents(greatSword, this);
|
||||
getServer().getPluginManager().registerEvents(revive, this);
|
||||
@@ -374,6 +458,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingHex = hex; hex = null;
|
||||
var closingInspire = inspire; inspire = null;
|
||||
var closingInspireSettings = inspireSettings; inspireSettings = null;
|
||||
mendingLight = null;
|
||||
var closingMendingLightSettings = mendingLightSettings; mendingLightSettings = null;
|
||||
purify = null;
|
||||
var closingPurifySettings = purifySettings; purifySettings = null;
|
||||
var closingGuardianBond = guardianBond; guardianBond = null;
|
||||
var closingGuardianBondSettings = guardianBondSettings; guardianBondSettings = null;
|
||||
var closingRallyingCry = rallyingCry; rallyingCry = null;
|
||||
var closingRallyingCrySettings = rallyingCrySettings; rallyingCrySettings = null;
|
||||
var closingFrostNova = frostNova; frostNova = null;
|
||||
var closingFrostNovaSettings = frostNovaSettings; frostNovaSettings = null;
|
||||
var closingChainLightning = chainLightning; chainLightning = null;
|
||||
var closingChainLightningSettings = chainLightningSettings; chainLightningSettings = null;
|
||||
var closingSanctuary = sanctuary; sanctuary = null;
|
||||
var closingSanctuarySettings = sanctuarySettings; sanctuarySettings = null;
|
||||
var closingOutgoingDamageBonus = outgoingDamageBonus; outgoingDamageBonus = null;
|
||||
var closingAdminRevival = adminRevival; adminRevival = null;
|
||||
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
|
||||
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
|
||||
@@ -406,6 +505,19 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingShieldBashSettings != null) cleanup("Shield Bash configuration", closingShieldBashSettings::close);
|
||||
if (closingHexSettings != null) cleanup("Hex configuration", closingHexSettings::close);
|
||||
if (closingInspireSettings != null) cleanup("Inspire configuration", closingInspireSettings::close);
|
||||
if (closingMendingLightSettings != null) cleanup("Mending Light configuration", closingMendingLightSettings::close);
|
||||
if (closingPurifySettings != null) cleanup("Purify configuration", closingPurifySettings::close);
|
||||
if (closingGuardianBond != null) cleanup("Guardian Bond", closingGuardianBond::close);
|
||||
if (closingGuardianBondSettings != null) cleanup("Guardian Bond configuration", closingGuardianBondSettings::close);
|
||||
if (closingRallyingCry != null) cleanup("Rallying Cry", closingRallyingCry::close);
|
||||
if (closingRallyingCrySettings != null) cleanup("Rallying Cry configuration", closingRallyingCrySettings::close);
|
||||
if (closingFrostNova != null) cleanup("Frost Nova", closingFrostNova::close);
|
||||
if (closingFrostNovaSettings != null) cleanup("Frost Nova configuration", closingFrostNovaSettings::close);
|
||||
if (closingChainLightning != null) cleanup("Chain Lightning", closingChainLightning::close);
|
||||
if (closingChainLightningSettings != null) cleanup("Chain Lightning configuration", closingChainLightningSettings::close);
|
||||
if (closingSanctuary != null) cleanup("Sanctuary", closingSanctuary::close);
|
||||
if (closingSanctuarySettings != null) cleanup("Sanctuary configuration", closingSanctuarySettings::close);
|
||||
if (closingOutgoingDamageBonus != null) cleanup("outgoing damage bonuses", closingOutgoingDamageBonus::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);
|
||||
@@ -431,6 +543,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: closingHexSettings.drained().toCompletableFuture(),
|
||||
closingInspireSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingInspireSettings.drained().toCompletableFuture(),
|
||||
closingMendingLightSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingMendingLightSettings.drained().toCompletableFuture(),
|
||||
closingPurifySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPurifySettings.drained().toCompletableFuture(),
|
||||
closingGuardianBondSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingGuardianBondSettings.drained().toCompletableFuture(),
|
||||
closingRallyingCrySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingRallyingCrySettings.drained().toCompletableFuture(),
|
||||
closingFrostNovaSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFrostNovaSettings.drained().toCompletableFuture(),
|
||||
closingChainLightningSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingChainLightningSettings.drained().toCompletableFuture(),
|
||||
closingSanctuarySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSanctuarySettings.drained().toCompletableFuture(),
|
||||
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdminReviveSettings.drained().toCompletableFuture(),
|
||||
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -632,6 +758,75 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> mendingLightConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.MendingLightSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.MendingLightSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.MendingLightSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> purifyConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.PurifySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.PurifySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.PurifySettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private 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 java.util.Map<String, String> rallyingCryConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.RallyingCrySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.RallyingCrySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.RallyingCrySettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> frostNovaConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.FrostNovaSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.FrostNovaSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.FrostNovaSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> chainLightningConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.ChainLightningSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.ChainLightningSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.ChainLightningSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> sanctuaryConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.SanctuarySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.SanctuarySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.SanctuarySettings.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,203 @@
|
||||
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.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Deterministic bounded four-target lightning chain with exact decimal decay. */
|
||||
public final class ChainLightning implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double initialRange, double jumpRange,
|
||||
int additionalTargets, BigDecimal jumpMultiplier) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(jumpMultiplier);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(initialRange) || initialRange <= 0 || initialRange > 64
|
||||
|| !Double.isFinite(jumpRange) || jumpRange <= 0 || jumpRange > 64
|
||||
|| additionalTargets < 0 || additionalTargets > 63
|
||||
|| jumpMultiplier.signum() <= 0 || jumpMultiplier.compareTo(BigDecimal.ONE) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Chain Lightning settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(8), Duration.ofSeconds(18), 16, 6, 3, new BigDecimal("0.75"));
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public ChainLightning(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("chain_lightning", "Chain Lightning", Material.LIGHTNING_ROD,
|
||||
List.of("Strike for " + current.baseDamage() + " + INT modifier damage, then jump up to "
|
||||
+ current.additionalTargets() + " times.",
|
||||
"Each jump deals " + decimal(current.jumpMultiplier().movePointRight(2))
|
||||
+ "% of the previous defined damage within " + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
|
||||
"Use while aiming at the first visible hostile within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
|
||||
"jump-range", BigDecimal.valueOf(current.jumpRange()).stripTrailingZeros(),
|
||||
"additional-targets", BigDecimal.valueOf(current.additionalTargets()),
|
||||
"jump-multiplier", current.jumpMultiplier()), AbilityDefinition.TargetPolicy.SELECTION, this);
|
||||
}
|
||||
|
||||
public static BigInteger initialDamage(BigInteger intelligence) { return initialDamage(BigInteger.valueOf(8), intelligence); }
|
||||
static BigInteger initialDamage(BigInteger base, BigInteger intelligence) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
|
||||
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning damage input");
|
||||
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return !closed && selectInitial(input, settings.initialRange()) != null;
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var captured = from(execution.definition()); var caster = execution.input().player();
|
||||
var initial = selectInitial(execution.input(), captured.initialRange());
|
||||
if (initial == null) return;
|
||||
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial;
|
||||
int maximum = Math.min(64, 1 + captured.additionalTargets());
|
||||
for (int strike = 0; strike < maximum && current != null && execution.stillCurrent(); strike++) {
|
||||
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
|
||||
strike(execution, caster, current, defined); visual(caster, current);
|
||||
if (strike + 1 == maximum) break;
|
||||
defined = defined.multiply(captured.jumpMultiplier());
|
||||
current = next(caster, current, visited, captured.jumpRange());
|
||||
}
|
||||
}
|
||||
|
||||
private void strike(AbilityExecution execution, Player caster, LivingEntity target, BigDecimal defined) {
|
||||
double amount = finite(definiteNonnegative(defined));
|
||||
var origin = caster.getEyeLocation().clone();
|
||||
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
|
||||
.withDirectEntity(caster).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
try { target.damage(amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
}
|
||||
|
||||
private LivingEntity selectInitial(ControlInput input, double range) {
|
||||
var caster = input.player();
|
||||
if (input.target() instanceof LivingEntity supplied && eligible(caster, supplied)
|
||||
&& inRangeAndVisible(caster, supplied, range)) return supplied;
|
||||
try {
|
||||
var eye = caster.getEyeLocation();
|
||||
var hit = caster.getWorld().rayTraceEntities(eye, eye.getDirection(), range, entity ->
|
||||
entity instanceof LivingEntity target && eligible(caster, target) && inRangeAndVisible(caster, target, range));
|
||||
if (hit == null || !(hit.getHitEntity() instanceof LivingEntity target)) return null;
|
||||
return eligible(caster, target) && inRangeAndVisible(caster, target, range) ? target : null;
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private LivingEntity next(Player caster, LivingEntity previous, Set<UUID> visited, double range) {
|
||||
List<Entity> nearby;
|
||||
try { nearby = previous.getNearbyEntities(range, range, range); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
var candidates = new ArrayList<LivingEntity>(); var seen = new HashSet<UUID>(); int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256) break;
|
||||
if (entity instanceof LivingEntity target && seen.add(target.getUniqueId()) && !visited.contains(target.getUniqueId())
|
||||
&& eligible(caster, target) && inRangeAndVisible(previous, target, range)) candidates.add(target);
|
||||
}
|
||||
var origin = previous.getLocation();
|
||||
candidates.sort(Comparator.comparingDouble((LivingEntity target) -> distance(origin, target))
|
||||
.thenComparing(LivingEntity::getUniqueId));
|
||||
return candidates.isEmpty() ? null : candidates.getFirst();
|
||||
}
|
||||
|
||||
private boolean eligible(Player caster, LivingEntity target) {
|
||||
if (target == caster) return false;
|
||||
if (!(target instanceof Player player)) return true;
|
||||
if (sessions.active(player.getUniqueId()).filter(sessions::current).isEmpty()) return false;
|
||||
var sourceParty = parties.partyOf(caster.getUniqueId()).orElse(null);
|
||||
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
return sourceParty == null || targetParty == null || !sourceParty.id().equals(targetParty.id());
|
||||
}
|
||||
|
||||
private static boolean inRangeAndVisible(LivingEntity source, LivingEntity target, double range) {
|
||||
try {
|
||||
var from = source.getLocation(); var to = target.getLocation();
|
||||
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static double distance(Location origin, LivingEntity target) {
|
||||
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
|
||||
? origin.distanceSquared(location) : Double.POSITIVE_INFINITY; }
|
||||
catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
private static void visual(Player caster, LivingEntity target) {
|
||||
try {
|
||||
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
|
||||
caster.getWorld().spawnParticle(Particle.ELECTRIC_SPARK, location, 18, 0.3, 0.5, 0.3, 0.02);
|
||||
caster.getWorld().playSound(location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.35f, 1.7f);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
private static BigDecimal definiteNonnegative(BigDecimal value) { return value.signum() < 0 ? BigDecimal.ZERO : value; }
|
||||
private static double finite(BigDecimal value) {
|
||||
if (value.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0) return Double.MAX_VALUE;
|
||||
double result = value.doubleValue(); return Double.isFinite(result) ? result : Double.MAX_VALUE;
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("initial-range").doubleValue(),
|
||||
p.get("jump-range").doubleValue(), p.get("additional-targets").intValueExact(), p.get("jump-multiplier"));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
@Override public void close() { closed = true; damaging = null; }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
@@ -17,9 +18,13 @@ import org.bukkit.event.entity.EntityDamageEvent;
|
||||
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
|
||||
public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
private final class Grant implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player player; final BigDecimal percent; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent) {
|
||||
this.execution = execution; this.player = player; this.percent = percent;
|
||||
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
|
||||
}
|
||||
boolean valid() {
|
||||
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
|
||||
catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
@@ -33,10 +38,14 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
private boolean closed;
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent);
|
||||
return grant(execution, player, percent, () -> true);
|
||||
}
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid combat mitigation grant");
|
||||
var grant = new Grant(execution, player, percent);
|
||||
var grant = new Grant(execution, player, percent, current);
|
||||
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
@@ -48,7 +57,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
var owned = grants.get(player.getUniqueId());
|
||||
if (owned == null) return;
|
||||
for (var grant : List.copyOf(owned)) {
|
||||
if (grant.closed || grant.player != player || !grant.execution.stillCurrent()) continue;
|
||||
if (grant.player != player || !grant.valid()) continue;
|
||||
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
|
||||
}
|
||||
double original = event.getDamage();
|
||||
@@ -65,7 +74,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
public void accepted(EntityDamageEvent event) {
|
||||
var prevention = pending.remove(event);
|
||||
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !prevention.grant.execution.stillCurrent()) return;
|
||||
|| !prevention.grant.valid()) return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
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.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Bounded instantaneous Frost Nova damage and ownership-aware Slowness. */
|
||||
public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double radius,
|
||||
int slownessAmplifier, Duration slownessDuration) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(slownessDuration);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|
||||
|| slownessAmplifier < 0 || slownessAmplifier > 255
|
||||
|| slownessDuration.isNegative() || slownessDuration.isZero()
|
||||
|| slownessDuration.compareTo(Duration.ofDays(1)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Frost Nova settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(5), Duration.ofSeconds(12), 5, 1, Duration.ofSeconds(3));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SlowLease implements AutoCloseable {
|
||||
final LivingEntity target; final PotionEffect previous; final PotionEffect applied;
|
||||
final long started = System.nanoTime(); boolean owned; boolean closed;
|
||||
SlowLease(LivingEntity target, PotionEffect applied) {
|
||||
this.target = target; previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
|
||||
}
|
||||
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; if (!owned) return;
|
||||
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
|
||||
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
|
||||
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
|
||||
if (current == null || current.getAmplifier() != applied.getAmplifier()
|
||||
|| current.getDuration() > expected + 2) return;
|
||||
target.removePotionEffect(PotionEffectType.SLOWNESS);
|
||||
if (previous != null) {
|
||||
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
|
||||
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
|
||||
}
|
||||
}
|
||||
}
|
||||
private final class LeaseGroup implements AutoCloseable {
|
||||
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>(); boolean closed;
|
||||
LeaseGroup(AbilityExecution execution) { this.execution = execution; }
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; active.remove(execution, this);
|
||||
for (var lease : List.copyOf(leases)) lease.close(); leases.clear();
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Map<AbilityExecution, LeaseGroup> active = new java.util.IdentityHashMap<>();
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public FrostNova(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("frost_nova", "Frost Nova", Material.SNOWBALL,
|
||||
List.of("Burst in a " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere for "
|
||||
+ current.baseDamage() + " + INT modifier damage.",
|
||||
"Applies Slowness " + level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + " seconds."),
|
||||
"Use to affect exposed enemies; caster, party and ordinary players are excluded.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
|
||||
"slowness-amplifier", BigDecimal.valueOf(current.slownessAmplifier()),
|
||||
"slowness-duration-seconds", BigDecimal.valueOf(current.slownessDuration().toNanos()).movePointLeft(9)),
|
||||
AbilityDefinition.TargetPolicy.AREA, this);
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger intelligence) { return damage(BigInteger.valueOf(5), intelligence); }
|
||||
static BigInteger damage(BigInteger base, BigInteger intelligence) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
|
||||
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Frost Nova damage input");
|
||||
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var captured = from(execution.definition()); var source = execution.input().player();
|
||||
Location origin = source.getEyeLocation().clone();
|
||||
var group = new LeaseGroup(execution); active.put(execution, group); execution.own(group);
|
||||
BigInteger exact = damage(captured.baseDamage(), execution.character().stats().get(Stat.INT));
|
||||
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
|
||||
int affected = 0;
|
||||
for (var target : candidates(source, origin, captured.radius())) {
|
||||
if (affected == 64 || !execution.stillCurrent()) break;
|
||||
if (!eligible(execution, target, origin, captured.radius())) continue;
|
||||
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
|
||||
.withDirectEntity(source).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
try { target.damage(amount, sourceDamage); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
affected++;
|
||||
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
|
||||
int ticks = Math.toIntExact(Math.max(1,
|
||||
(captured.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
|
||||
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
|
||||
captured.slownessAmplifier(), false, true, true));
|
||||
if (lease.apply()) group.leases.add(lease);
|
||||
}
|
||||
try { source.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 32,
|
||||
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
|
||||
catch (RuntimeException ignored) { }
|
||||
if (group.leases.isEmpty()) execution.finish();
|
||||
else execution.later(captured.slownessDuration(), execution::finish);
|
||||
}
|
||||
|
||||
private List<LivingEntity> candidates(Player source, Location origin, double radius) {
|
||||
List<Entity> nearby;
|
||||
try { nearby = source.getNearbyEntities(radius, radius, radius); }
|
||||
catch (RuntimeException unavailable) { return List.of(); }
|
||||
var unique = new LinkedHashMap<UUID, LivingEntity>(); int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256) break;
|
||||
if (entity instanceof LivingEntity target && target != source) unique.putIfAbsent(target.getUniqueId(), target);
|
||||
}
|
||||
var result = new ArrayList<>(unique.values());
|
||||
result.sort(Comparator.comparingDouble((LivingEntity target) -> distance(origin, target))
|
||||
.thenComparing(LivingEntity::getUniqueId));
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean eligible(AbilityExecution execution, LivingEntity target, Location origin, double radius) {
|
||||
if (target == execution.input().player() || !inside(origin, target, radius)) return false;
|
||||
try {
|
||||
if (!execution.input().player().hasLineOfSight(target) || !unobstructed(origin, target)) return false;
|
||||
} catch (RuntimeException unavailable) { return false; }
|
||||
if (!(target instanceof Player player)) return true;
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
if (active == null) return false;
|
||||
var sourceParty = parties.partyOf(execution.input().player().getUniqueId()).orElse(null);
|
||||
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
return sourceParty == null || targetParty == null || !sourceParty.id().equals(targetParty.id());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
private static boolean unobstructed(Location origin, LivingEntity target) {
|
||||
var world = origin.getWorld(); if (world == null) return false;
|
||||
var location = target.getLocation(); if (location.getWorld() != world) return false;
|
||||
var bounds = target.getBoundingBox();
|
||||
var center = bounds == null ? location.toVector().add(new org.bukkit.util.Vector(0, target.getHeight() / 2, 0))
|
||||
: bounds.getCenter();
|
||||
var offset = center.clone().subtract(origin.toVector()); double distance = offset.length();
|
||||
if (!Double.isFinite(distance) || distance == 0) return distance == 0;
|
||||
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance,
|
||||
org.bukkit.FluidCollisionMode.NEVER, true) == null;
|
||||
}
|
||||
|
||||
private static boolean inside(Location origin, LivingEntity target, double radius) {
|
||||
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
|
||||
&& origin.distanceSquared(location) <= radius * radius; }
|
||||
catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static double distance(Location origin, LivingEntity target) {
|
||||
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
|
||||
? origin.distanceSquared(location) : Double.POSITIVE_INFINITY; }
|
||||
catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(parameters.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
parameters.get("radius").doubleValue(), parameters.get("slowness-amplifier").intValueExact(),
|
||||
Duration.ofNanos(parameters.get("slowness-duration-seconds").movePointRight(9).longValueExact()));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
private static String seconds(Duration duration) {
|
||||
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
|
||||
}
|
||||
private static String level(int amplifier) {
|
||||
return switch (amplifier + 1) { case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; default -> Integer.toString(amplifier + 1); };
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; damaging = null;
|
||||
for (var group : List.copyOf(active.values())) { group.close(); group.execution.finish(); }
|
||||
active.clear();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
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 java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Mending Light's aimed party healing with persist-before-publication support evidence. */
|
||||
public final class MendingLight implements AbilityEffect {
|
||||
public record Settings(BigInteger baseHealing, Duration cooldown, double range) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseHealing); Objects.requireNonNull(cooldown);
|
||||
if (baseHealing.signum() < 0
|
||||
|| baseHealing.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger()) > 0
|
||||
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64) {
|
||||
throw new IllegalArgumentException("Invalid Mending Light settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(8), 12); }
|
||||
}
|
||||
|
||||
public record Support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
public Support {
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (amount.signum() <= 0) throw new IllegalArgumentException("Support amount must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private record Target(Player player, EquipmentSessions.Active active, CharacterRecord record) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents;
|
||||
private final Supplier<BigDecimal> nativeLimit;
|
||||
private final Consumer<Support> supports;
|
||||
private volatile Settings settings;
|
||||
|
||||
public MendingLight(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
|
||||
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
|
||||
Supplier<BigDecimal> nativeLimit, Consumer<Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.nextTick = Objects.requireNonNull(nextTick); this.healingEvents = Objects.requireNonNull(healingEvents);
|
||||
this.nativeLimit = Objects.requireNonNull(nativeLimit); this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("mending_light", "Mending Light", Material.GHAST_TEAR,
|
||||
List.of("Restore a party ally's missing fantasy health.",
|
||||
"Heals " + current.baseHealing() + " + WIS modifier within " + decimal(current.range()) + " blocks."),
|
||||
"Use while aiming at an injured active party ally; self and full-health targets are refused.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-healing", new BigDecimal(current.baseHealing()),
|
||||
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PARTY, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
var target = target(input, settings.range());
|
||||
if (target == null) return false;
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
double health = target.player().getHealth();
|
||||
return attribute != null && Double.isFinite(health) && health >= 0
|
||||
&& Double.isFinite(attribute.getValue()) && health < attribute.getValue();
|
||||
}
|
||||
|
||||
public static BigInteger healing(BigInteger wisdom) { return healing(BigInteger.valueOf(6), wisdom); }
|
||||
|
||||
static BigInteger healing(BigInteger base, BigInteger wisdom) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(wisdom);
|
||||
if (base.signum() < 0 || wisdom.signum() < 0) throw new IllegalArgumentException("Invalid Mending Light healing input");
|
||||
BigInteger delta = wisdom.subtract(BigInteger.TEN); BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
BigInteger modifier = divided[0];
|
||||
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (!execution.stillCurrent()) return;
|
||||
var configured = from(execution.definition());
|
||||
var target = target(execution.input(), configured.range());
|
||||
if (target == null) { execution.finish(); return; }
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) { execution.finish(); return; }
|
||||
double current = target.player().getHealth(); double maximum = attribute.getValue(); double missing = maximum - current;
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
|
||||
BigInteger exact = healing(configured.baseHealing(), execution.character().stats().get(Stat.WIS));
|
||||
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
|
||||
var event = new EntityRegainHealthEvent(target.player(), proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
|
||||
var expected = target.record().revision();
|
||||
BigDecimal persistedHealth = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
characters.saveHealth(target.record().id(), expected, persistedHealth, limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> complete(execution, target, expected, accepted, saved, failure));
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
});
|
||||
}
|
||||
|
||||
private void complete(AbilityExecution execution, Target target, BigInteger expected, double accepted,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty()) {
|
||||
execution.input().player().sendMessage("Mending Light health could not be saved; no healing was applied.");
|
||||
return;
|
||||
}
|
||||
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) return;
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) return;
|
||||
double current = target.player().getHealth(); double maximum = attribute.getValue();
|
||||
double desired = Math.min(maximum, persisted.health().doubleValue());
|
||||
double restored = Math.min(accepted, desired - current);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
|
||||
target.player().setHealth(Math.min(maximum, current + restored));
|
||||
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
|
||||
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
|
||||
execution.input().player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
|
||||
target.player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
|
||||
} finally { execution.finish(); }
|
||||
}
|
||||
|
||||
private Target target(ControlInput input, double range) {
|
||||
if (!(input.target() instanceof Player player) || player == input.player() || !inRange(input.player(), player, range)
|
||||
|| !sameParty(input.player(), player)) return null;
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return record == null ? null : new Target(player, active, record);
|
||||
}
|
||||
|
||||
private boolean current(Player source, Target target, double range) {
|
||||
return sessions.current(target.active())
|
||||
&& sessions.currentRecord(target.active()).filter(record -> record.id().equals(target.record().id())).isPresent()
|
||||
&& inRange(source, target.player(), range) && sameParty(source, target.player());
|
||||
}
|
||||
|
||||
private boolean sameParty(Player source, Player target) {
|
||||
var party = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
return party != null && party.members().contains(target.getUniqueId());
|
||||
}
|
||||
|
||||
private static boolean inRange(Player source, Player target, double range) {
|
||||
try {
|
||||
var from = source.getLocation(); var to = target.getLocation();
|
||||
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(parameters.get("base-healing").toBigIntegerExact(), definition.cooldown(),
|
||||
parameters.get("range").doubleValue());
|
||||
}
|
||||
|
||||
private static String decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
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.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.projectiles.ProjectileSource;
|
||||
|
||||
/** Strongest deterministic Fantasy Stick outgoing combat bonus stage. */
|
||||
public final class OutgoingDamageBonus implements Listener, AutoCloseable {
|
||||
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
|
||||
private final class Grant implements AutoCloseable {
|
||||
final long sequence; final AbilityExecution execution; final Player recipient;
|
||||
final BigDecimal percent; final BooleanSupplier current; boolean closed;
|
||||
Grant(AbilityExecution execution, Player recipient, BigDecimal percent, BooleanSupplier current) {
|
||||
sequence = ++sequences; this.execution = execution; this.recipient = recipient;
|
||||
this.percent = percent; this.current = current;
|
||||
}
|
||||
boolean valid() {
|
||||
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
|
||||
catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
var owned = grants.get(recipient.getUniqueId());
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Used(Grant grant, Entity target, BigDecimal amount) { }
|
||||
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Consumer<Support> supports;
|
||||
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent, Used> pending = new IdentityHashMap<>();
|
||||
private long sequences;
|
||||
private boolean closed;
|
||||
|
||||
public OutgoingDamageBonus(EquipmentSessions sessions, PartyRoster parties, Consumer<Support> supports) {
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player recipient, BigDecimal percent, BooleanSupplier current) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(recipient);
|
||||
Objects.requireNonNull(percent); Objects.requireNonNull(current);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(1000)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid outgoing damage bonus");
|
||||
}
|
||||
var grant = new Grant(execution, recipient, percent, current);
|
||||
grants.computeIfAbsent(recipient.getUniqueId(), ignored -> new ArrayList<>()).add(grant); return grant;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
|
||||
public void increase(EntityDamageByEntityEvent event) {
|
||||
if (closed || GuardianBond.redirectedDamage()) return;
|
||||
Entity attacker = attacker(event); if (!(attacker instanceof Player player)) return;
|
||||
var owned = grants.get(player.getUniqueId()); if (owned == null) return;
|
||||
Grant strongest = null;
|
||||
for (var grant : List.copyOf(owned)) {
|
||||
if (grant.recipient != player || !grant.valid()) continue;
|
||||
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0
|
||||
|| grant.percent.compareTo(strongest.percent) == 0 && grant.sequence < strongest.sequence) strongest = grant;
|
||||
}
|
||||
double original = event.getDamage();
|
||||
if (strongest == null || !Double.isFinite(original) || original <= 0) return;
|
||||
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
|
||||
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
|
||||
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
|
||||
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event) {
|
||||
var used = pending.remove(event);
|
||||
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
|
||||
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
|
||||
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
|
||||
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
|
||||
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
|
||||
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
|
||||
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var owned : List.copyOf(grants.values())) for (var grant : List.copyOf(owned)) grant.close();
|
||||
grants.clear(); pending.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;}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Purify's self-or-aimed-party native harmful-effect removal. */
|
||||
public final class Purify implements AbilityEffect {
|
||||
public record Settings(Duration cooldown, double range) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64) {
|
||||
throw new IllegalArgumentException("Invalid Purify settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(Duration.ofSeconds(15), 12); }
|
||||
}
|
||||
|
||||
public record Support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
public Support {
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (amount.signum() <= 0) throw new IllegalArgumentException("Support amount must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private record Target(Player player, EquipmentSessions.Active active) { }
|
||||
private static final List<PotionEffectType> HARMFUL = List.of(
|
||||
PotionEffectType.POISON, PotionEffectType.WITHER, PotionEffectType.SLOWNESS, PotionEffectType.WEAKNESS);
|
||||
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Consumer<Support> supports;
|
||||
private volatile Settings settings;
|
||||
|
||||
public Purify(Settings settings, EquipmentSessions sessions, PartyRoster parties, Consumer<Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties); this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("purify", "Purify", Material.HONEY_BOTTLE,
|
||||
List.of("Remove Poison, Wither, Slowness and Weakness.",
|
||||
"Aimed party-ally range: " + decimal(current.range()) + " blocks; beneficial effects remain."),
|
||||
"Sneak+Use cleanses self; normal Use requires an aimed active party ally.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PARTY, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
var target = target(input, settings.range());
|
||||
return target != null && HARMFUL.stream().anyMatch(type -> target.player().getPotionEffect(type) != null);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (!execution.stillCurrent()) return;
|
||||
var configured = from(execution.definition()); var target = target(execution.input(), configured.range());
|
||||
if (target == null) return;
|
||||
int removed = 0;
|
||||
for (var type : HARMFUL) {
|
||||
if (!execution.stillCurrent() || !current(execution.input(), target, configured.range())) break;
|
||||
if (target.player().getPotionEffect(type) == null) continue;
|
||||
target.player().removePotionEffect(type);
|
||||
if (target.player().getPotionEffect(type) == null) removed++;
|
||||
}
|
||||
if (removed == 0) return;
|
||||
execution.input().player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
|
||||
if (target.player() != execution.input().player()
|
||||
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
|
||||
var amount = BigDecimal.valueOf(removed);
|
||||
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
|
||||
target.player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
|
||||
}
|
||||
}
|
||||
|
||||
private Target target(ControlInput input, double range) {
|
||||
if (!(input.target() instanceof Player player)) return null;
|
||||
if (input.player().isSneaking()) {
|
||||
if (player != input.player()) return null;
|
||||
} else if (player == input.player() || !inRange(input.player(), player, range) || !sameParty(input.player(), player)) {
|
||||
return null;
|
||||
}
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
return active == null ? null : new Target(player, active);
|
||||
}
|
||||
|
||||
private boolean current(ControlInput input, Target target, double range) {
|
||||
if (!sessions.current(target.active())) return false;
|
||||
if (target.player() == input.player()) return input.player().isSneaking();
|
||||
return !input.player().isSneaking() && inRange(input.player(), target.player(), range)
|
||||
&& sameParty(input.player(), target.player());
|
||||
}
|
||||
|
||||
private boolean sameParty(Player source, Player target) {
|
||||
var party = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
return party != null && party.members().contains(target.getUniqueId());
|
||||
}
|
||||
|
||||
private static boolean inRange(Player source, Player target, double range) {
|
||||
try {
|
||||
var from = source.getLocation(); var to = target.getLocation();
|
||||
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
return new Settings(definition.cooldown(), definition.parameters().get("range").doubleValue());
|
||||
}
|
||||
|
||||
private static String decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); }
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Dynamic owned party sphere for Rallying Cry movement and outgoing damage benefits. */
|
||||
public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double radius, Duration duration,
|
||||
int speedAmplifier, BigDecimal damageBonusPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(damageBonusPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|
||||
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| speedAmplifier < 0 || speedAmplifier > 255
|
||||
|| damageBonusPercent.signum() < 0 || damageBonusPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Rallying Cry settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(25), 8, Duration.ofSeconds(6), 0, BigDecimal.valueOf(20));
|
||||
}
|
||||
}
|
||||
|
||||
private final class Area implements AutoCloseable {
|
||||
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
|
||||
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
|
||||
Area(AbilityExecution execution, Settings settings) {
|
||||
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
|
||||
}
|
||||
void refresh() {
|
||||
if (closed) return;
|
||||
if (!sourceCurrent()) { breakArea(this); return; }
|
||||
var eligible = candidates();
|
||||
for (var entry : List.copyOf(recipients.entrySet())) {
|
||||
if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
|
||||
}
|
||||
for (var entry : eligible.entrySet()) {
|
||||
if (!recipients.containsKey(entry.getKey())) {
|
||||
var benefit = new Benefit(this, entry.getValue()); recipients.put(entry.getKey(), benefit);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean sourceCurrent() {
|
||||
return execution.stillCurrent() && sessions.active(source.getUniqueId())
|
||||
.filter(active -> active.equals(execution.input().session()) && sessions.current(active)).isPresent();
|
||||
}
|
||||
boolean eligible(Player player) {
|
||||
if (!sourceCurrent()) return false;
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
if (active == null || !inside(source, player, settings.radius())) return false;
|
||||
if (player == source) return active.equals(execution.input().session());
|
||||
var sourceParty = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
var recipientParty = parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
return sourceParty != null && recipientParty != null && sourceParty.id().equals(recipientParty.id());
|
||||
}
|
||||
Map<UUID, Player> candidates() {
|
||||
var result = new LinkedHashMap<UUID, Player>();
|
||||
if (eligible(source)) result.put(source.getUniqueId(), source);
|
||||
List<Entity> nearby;
|
||||
try { nearby = source.getNearbyEntities(settings.radius(), settings.radius(), settings.radius()); }
|
||||
catch (RuntimeException unavailable) { nearby = List.of(); }
|
||||
int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256 || result.size() == 64) break;
|
||||
if (entity instanceof Player player && eligible(player)) result.putIfAbsent(player.getUniqueId(), player);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; areas.remove(sequence, this);
|
||||
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
speed = speed(player, area.settings.speedAmplifier());
|
||||
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
|
||||
closeResource(bonus); closeResource(speed);
|
||||
}
|
||||
}
|
||||
|
||||
private final class SpeedGrant implements AutoCloseable {
|
||||
final SpeedState state; final long sequence; final int amplifier; boolean closed;
|
||||
SpeedGrant(SpeedState state, int amplifier) { this.state = state; sequence = ++speedSequences; this.amplifier = amplifier; }
|
||||
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
|
||||
}
|
||||
private final class SpeedState {
|
||||
final Player player; final List<SpeedGrant> grants = new ArrayList<>(); PotionEffect owned;
|
||||
SpeedState(Player player) { this.player = player; }
|
||||
void refresh() {
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(PotionEffectType.SPEED); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (owned != null && !same(current, owned)) owned = null;
|
||||
SpeedGrant strongest = null;
|
||||
for (var grant : List.copyOf(grants)) if (!grant.closed && (strongest == null
|
||||
|| grant.amplifier > strongest.amplifier
|
||||
|| grant.amplifier == strongest.amplifier && grant.sequence < strongest.sequence)) strongest = grant;
|
||||
if (strongest == null) {
|
||||
if (owned != null && same(current, owned) && !removeOwned()) return;
|
||||
owned = null; speeds.remove(player.getUniqueId(), this); return;
|
||||
}
|
||||
if (owned != null && same(current, owned) && owned.getAmplifier() != strongest.amplifier) {
|
||||
if (!removeOwned()) return;
|
||||
current = null;
|
||||
}
|
||||
if (current != null && current.getAmplifier() >= strongest.amplifier) return;
|
||||
var effect = new PotionEffect(PotionEffectType.SPEED, PotionEffect.INFINITE_DURATION,
|
||||
strongest.amplifier, false, false, true);
|
||||
try { if (player.addPotionEffect(effect, true)) owned = effect; }
|
||||
catch (RuntimeException ignored) { owned = null; }
|
||||
}
|
||||
boolean removeOwned() {
|
||||
try {
|
||||
player.removePotionEffect(PotionEffectType.SPEED);
|
||||
if (same(player.getPotionEffect(PotionEffectType.SPEED), owned)) return false;
|
||||
} catch (RuntimeException failure) { return false; }
|
||||
owned = null; return true;
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final OutgoingDamageBonus bonuses;
|
||||
private final Map<Long, Area> areas = new java.util.HashMap<>();
|
||||
private final Map<UUID, SpeedState> speeds = new java.util.HashMap<>();
|
||||
private long sequences; private long speedSequences;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
public RallyingCry(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
|
||||
}
|
||||
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("Rallying Cry sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
public void sweep() {
|
||||
if (closed) return; int checked = 0;
|
||||
for (var area : List.copyOf(areas.values())) { area.refresh(); if (++checked == 64) break; }
|
||||
for (var state : List.copyOf(speeds.values())) state.refresh();
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("rallying_cry", "Rallying Cry", Material.GOAT_HORN,
|
||||
List.of("Grant nearby active party members Speed " + (current.speedAmplifier() + 1)
|
||||
+ " and " + decimal(current.damageBonusPercent()) + "% combat damage.",
|
||||
"A " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere lasting " + seconds(current.duration()) + " seconds."),
|
||||
"Use to rally yourself and nearby active party members; copies do not stack.",
|
||||
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
|
||||
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
|
||||
"speed-amplifier", BigDecimal.valueOf(current.speedAmplifier()),
|
||||
"damage-bonus-percent", current.damageBonusPercent()), AbilityDefinition.TargetPolicy.AREA, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
|
||||
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
|
||||
}
|
||||
|
||||
private AutoCloseable speed(Player player, int amplifier) {
|
||||
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
|
||||
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
|
||||
}
|
||||
private void breakArea(Area area) { area.close(); area.execution.finish(); }
|
||||
private static boolean inside(Player source, Player target, double radius) {
|
||||
try { var a = source.getLocation(); var b = target.getLocation();
|
||||
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static boolean same(PotionEffect first, PotionEffect second) {
|
||||
return first != null && second != null && first.getType().equals(second.getType())
|
||||
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite();
|
||||
}
|
||||
private static void closeResource(AutoCloseable resource) {
|
||||
try { resource.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(definition.cooldown(), parameters.get("radius").doubleValue(),
|
||||
Duration.ofNanos(parameters.get("duration-seconds").movePointRight(9).longValueExact()),
|
||||
parameters.get("speed-amplifier").intValueExact(), parameters.get("damage-bonus-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 area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
|
||||
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
|
||||
speeds.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
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.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Fixed bounded Sanctuary cylinder with delayed healing pulses and dynamic strongest mitigation. */
|
||||
public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double radius, double verticalRange, Duration duration,
|
||||
Duration pulseInterval, BigDecimal healingPerPulse, BigDecimal damageReductionPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(pulseInterval);
|
||||
Objects.requireNonNull(healingPerPulse); Objects.requireNonNull(damageReductionPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|
||||
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|
||||
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| pulseInterval.isZero() || pulseInterval.isNegative() || pulseInterval.compareTo(duration) > 0
|
||||
|| duration.toNanos() / pulseInterval.toNanos() > 64
|
||||
|| healingPerPulse.signum() < 0 || healingPerPulse.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
|| damageReductionPercent.signum() < 0 || damageReductionPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Sanctuary settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(45), 6, 3, Duration.ofSeconds(6), Duration.ofSeconds(1),
|
||||
BigDecimal.valueOf(2), BigDecimal.valueOf(20));
|
||||
}
|
||||
}
|
||||
private record Target(Player player, EquipmentSessions.Active active, CharacterRecord record) { }
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player source;
|
||||
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
|
||||
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
|
||||
int pending; boolean expired; boolean closed;
|
||||
State(AbilityExecution execution, Settings captured) {
|
||||
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
|
||||
this.captured = captured; this.nextPulse = captured.pulseInterval();
|
||||
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
|
||||
this.scanPeriod = Duration.ofNanos(period);
|
||||
}
|
||||
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
|
||||
void schedule() {
|
||||
if (closed || expired) return;
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
|
||||
}
|
||||
void step() {
|
||||
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
|
||||
reconcile(); visual();
|
||||
while (nextPulse.compareTo(elapsed) <= 0 && nextPulse.compareTo(captured.duration()) <= 0) {
|
||||
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
|
||||
}
|
||||
if (elapsed.compareTo(captured.duration()) >= 0) {
|
||||
expired = true; closeMitigation(); if (pending == 0) execution.finish();
|
||||
} else schedule();
|
||||
}
|
||||
void reconcile() {
|
||||
var eligible = recipients();
|
||||
for (var entry : List.copyOf(mitigation.entrySet())) {
|
||||
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
|
||||
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
|
||||
}
|
||||
}
|
||||
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
|
||||
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
|
||||
}
|
||||
void pulse() {
|
||||
for (var player : recipients()) if (owner(player) == this) heal(player);
|
||||
}
|
||||
void heal(Player player) {
|
||||
var target = target(player); if (target == null) return;
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
|
||||
double current = player.getHealth(), maximum = attribute.getValue(), missing = maximum - current;
|
||||
double proposed = Math.min(missing, captured.healingPerPulse().doubleValue());
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(proposed) || proposed <= 0) return;
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event); double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
|
||||
pending++; var expected = target.record().revision();
|
||||
characters.saveHealth(target.record().id(), expected, BigDecimal.valueOf(current + accepted), nativeLimit.get())
|
||||
.whenComplete((saved, failure) -> {
|
||||
try { nextTick.accept(() -> completeHeal(this, target, expected, accepted, saved, failure)); }
|
||||
catch (RuntimeException rejected) { completePending(this); }
|
||||
});
|
||||
}
|
||||
List<Player> recipients() {
|
||||
List<Entity> nearby;
|
||||
try { nearby = new ArrayList<>(source.getWorld().getNearbyEntities(origin,
|
||||
captured.radius(), captured.verticalRange(), captured.radius())); }
|
||||
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
|
||||
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256) break;
|
||||
if (entity instanceof Player player && recipientCurrent(player)) unique.putIfAbsent(player.getUniqueId(), player);
|
||||
}
|
||||
var result = new ArrayList<>(unique.values());
|
||||
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
|
||||
return result.size() <= 64 ? result : new ArrayList<>(result.subList(0, 64));
|
||||
}
|
||||
boolean recipientCurrent(Player player) {
|
||||
if (closed || !execution.stillCurrent() || !inside(origin, player, captured.radius(), captured.verticalRange())
|
||||
|| !unobstructed(origin, player)) return false;
|
||||
if (player == source) return sessions.active(player.getUniqueId()).filter(sessions::current)
|
||||
.map(active -> active.equals(execution.input().session())).orElse(false);
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
return active != null && sameParty(source, player);
|
||||
}
|
||||
Target target(Player player) {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return record == null ? null : new Target(player, active, record);
|
||||
}
|
||||
void visual() {
|
||||
try { source.getWorld().spawnParticle(Particle.END_ROD, origin, 12,
|
||||
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
void closeMitigation() {
|
||||
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
|
||||
mitigation.clear();
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final CharacterStorage characters; private final EquipmentSessions sessions; private final PartyRoster parties;
|
||||
private final CombatMitigation combatMitigation; private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents; private final Supplier<BigDecimal> nativeLimit;
|
||||
private final Consumer<MendingLight.Support> supports; private final List<State> states = new ArrayList<>();
|
||||
private boolean closed;
|
||||
|
||||
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
|
||||
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
|
||||
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
|
||||
Consumer<MendingLight.Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.combatMitigation = Objects.requireNonNull(combatMitigation); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("sanctuary", "Sanctuary", Material.SEA_LANTERN,
|
||||
List.of("Create a fixed " + decimal(current.radius()) + "-block sanctuary for " + seconds(current.duration()) + " seconds.",
|
||||
"Party members inside heal " + decimal(current.healingPerPulse()) + " each second and take "
|
||||
+ decimal(current.damageReductionPercent()) + "% less combat damage."),
|
||||
"Use to hold exposed ground; healing starts after one second and the area does not follow you.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
|
||||
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
|
||||
"duration-seconds", nanos(current.duration()), "pulse-interval-seconds", nanos(current.pulseInterval()),
|
||||
"healing-per-pulse", current.healingPerPulse(), "damage-reduction-percent", current.damageReductionPercent()),
|
||||
AbilityDefinition.TargetPolicy.AREA, this);
|
||||
}
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return; new State(execution, from(execution.definition())).start();
|
||||
}
|
||||
|
||||
private void completeHeal(State state, Target target, BigInteger expected, double accepted,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !state.execution.stillCurrent()
|
||||
|| !state.recipientCurrent(target.player())) return;
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
|
||||
double current = target.player().getHealth(), maximum = attribute.getValue();
|
||||
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
|
||||
target.player().setHealth(Math.min(maximum, current + restored));
|
||||
if (target.player() != state.source) supports.accept(new MendingLight.Support(
|
||||
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
|
||||
} finally { completePending(state); }
|
||||
}
|
||||
private static void completePending(State state) {
|
||||
if (state.pending > 0) state.pending--; if (state.expired && state.pending == 0) state.execution.finish();
|
||||
}
|
||||
private State owner(Player player) {
|
||||
for (var state : List.copyOf(states)) if (!state.expired
|
||||
&& state.mitigation.containsKey(player.getUniqueId())) return state;
|
||||
return null;
|
||||
}
|
||||
private boolean sameParty(Player source, Player target) {
|
||||
var party = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
return party != null && party.members().contains(target.getUniqueId());
|
||||
}
|
||||
private static boolean inside(Location origin, Player player, double radius, double vertical) {
|
||||
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
|
||||
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ();
|
||||
return dx * dx + dz * dz <= radius * radius && Math.abs(at.getY() - origin.getY()) <= vertical;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static boolean unobstructed(Location origin, Player player) {
|
||||
try { var world = origin.getWorld(); if (world == null) return false; var box = player.getBoundingBox();
|
||||
var center = box.getCenter(); var offset = center.clone().subtract(origin.toVector()); double distance = offset.length();
|
||||
if (!Double.isFinite(distance) || distance == 0) return distance == 0;
|
||||
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance, org.bukkit.FluidCollisionMode.NEVER, true) == null;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static double distance(Location origin, Player player) {
|
||||
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
|
||||
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
|
||||
} catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) { var p = definition.parameters();
|
||||
return new Settings(definition.cooldown(), p.get("radius").doubleValue(), p.get("vertical-range").doubleValue(),
|
||||
duration(p.get("duration-seconds")), duration(p.get("pulse-interval-seconds")),
|
||||
p.get("healing-per-pulse"), p.get("damage-reduction-percent")); }
|
||||
private static Duration duration(BigDecimal seconds) { return Duration.ofNanos(seconds.movePointRight(9).longValueExact()); }
|
||||
private static BigDecimal nanos(Duration value) { return BigDecimal.valueOf(value.toNanos()).movePointLeft(9); }
|
||||
private static String seconds(Duration value) { return decimal(nanos(value)); }
|
||||
private static String decimal(double value) { return decimal(BigDecimal.valueOf(value)); }
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; for (var state : List.copyOf(states)) { state.close(); state.execution.finish(); } states.clear();
|
||||
}
|
||||
}
|
||||
@@ -100,9 +100,17 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
contributions.support(target, aided.character(), supportingCharacter, clock.instant());
|
||||
}
|
||||
|
||||
/** Records effective healing; any directly contributed target can qualify it within ten seconds. */
|
||||
public void support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (closed || amount.signum() <= 0 || !sessions.current(aided) || supportingCharacter.equals(aided.character())) return;
|
||||
contributions.support(aided.character(), supportingCharacter, clock.instant());
|
||||
}
|
||||
|
||||
@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;
|
||||
@@ -205,6 +213,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
|
||||
public void retryPending() {
|
||||
if (closed) return;
|
||||
contributions.sweep(clock.instant());
|
||||
for (int attempted = 0; attempted < 64; attempted++) {
|
||||
UUID id = retries.poll();
|
||||
if (id == null) break;
|
||||
|
||||
@@ -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,173 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.ChainLightning;
|
||||
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 Chain Lightning settings; publication remains a server-thread caller decision. */
|
||||
public final class ChainLightningSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE = "chain-lightning-base-damage";
|
||||
public static final String COOLDOWN = "chain-lightning-cooldown-seconds";
|
||||
public static final String INITIAL_RANGE = "chain-lightning-initial-range-blocks";
|
||||
public static final String JUMP_RANGE = "chain-lightning-jump-range-blocks";
|
||||
public static final String ADDITIONAL = "chain-lightning-additional-targets";
|
||||
public static final String MULTIPLIER = "chain-lightning-jump-multiplier";
|
||||
private static final List<String> KEYS = List.of(BASE, COOLDOWN, INITIAL_RANGE, JUMP_RANGE, ADDITIONAL, MULTIPLIER);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
BASE, "8", COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", ADDITIONAL, "3", MULTIPLIER, "0.75");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, ChainLightning.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-chain-lightning-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 ChainLightningSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
ChainLightningSettings(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 ChainLightning.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("Chain Lightning 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 ChainLightning.Settings parse(Map<String, String> values) {
|
||||
try { return new ChainLightning.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
|
||||
seconds(values.get(COOLDOWN)), finite(values.get(INITIAL_RANGE)), finite(values.get(JUMP_RANGE)),
|
||||
new BigDecimal(values.get(ADDITIONAL)).intValueExact(), new BigDecimal(values.get(MULTIPLIER))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Chain Lightning 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); }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.FrostNova;
|
||||
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 Frost Nova settings; publication remains a server-thread caller decision. */
|
||||
public final class FrostNovaSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE = "frost-nova-base-damage";
|
||||
public static final String COOLDOWN = "frost-nova-cooldown-seconds";
|
||||
public static final String RADIUS = "frost-nova-radius-blocks";
|
||||
public static final String SLOWNESS = "frost-nova-slowness-amplifier";
|
||||
public static final String DURATION = "frost-nova-slowness-duration-seconds";
|
||||
private static final List<String> KEYS = List.of(BASE, COOLDOWN, RADIUS, SLOWNESS, DURATION);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
BASE, "5", COOLDOWN, "12", RADIUS, "5", SLOWNESS, "1", DURATION, "3");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, FrostNova.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-frost-nova-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 FrostNovaSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
FrostNovaSettings(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 FrostNova.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("Frost Nova 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 FrostNova.Settings parse(Map<String, String> values) {
|
||||
try { return new FrostNova.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
|
||||
seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
|
||||
new BigDecimal(values.get(SLOWNESS)).intValueExact(), seconds(values.get(DURATION))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Frost Nova 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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.MendingLight;
|
||||
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.math.BigDecimal;
|
||||
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 Mending Light settings; publication remains a server-thread caller decision. */
|
||||
public final class MendingLightSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_HEALING = "mending-light-base-healing";
|
||||
public static final String COOLDOWN = "mending-light-cooldown-seconds";
|
||||
public static final String RANGE = "mending-light-range-blocks";
|
||||
private static final List<String> KEYS = List.of(BASE_HEALING, COOLDOWN, RANGE);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_HEALING, "6", COOLDOWN, "8", RANGE, "12");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, MendingLight.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-mending-light-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 MendingLightSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
MendingLightSettings(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 MendingLight.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(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("Mending Light 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_HEALING)) return new java.math.BigInteger(raw).toString();
|
||||
return new BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static MendingLight.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
return new MendingLight.Settings(new java.math.BigInteger(values.get(BASE_HEALING)),
|
||||
seconds(values.get(COOLDOWN)), finite(values.get(RANGE)));
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Mending Light 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); }
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Purify;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized durable Purify settings; publication remains a server-thread caller decision. */
|
||||
public final class PurifySettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "purify-cooldown-seconds";
|
||||
public static final String RANGE = "purify-range-blocks";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, RANGE);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "15", RANGE, "12");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Purify.Settings settings, Map<String, String> values, long sequence) {
|
||||
public Update { values = Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-purify-configuration"));
|
||||
private volatile Map<String, String> effectiveValues;
|
||||
private Map<String, String> persistedValues;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public PurifySettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
PurifySettings(Path directory, Map<String, String> initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
var copy = new LinkedHashMap<String, String>();
|
||||
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
|
||||
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
public static List<String> keys() { return KEYS; }
|
||||
public static String defaultValue(String key) { return DEFAULTS.get(key); }
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public Purify.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
|
||||
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Purify configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static Purify.Settings parse(Map<String, String> values) {
|
||||
try { return new Purify.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RANGE))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Purify settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe lock");
|
||||
}
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.RallyingCry;
|
||||
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 Rallying Cry settings; publication remains a server-thread caller decision. */
|
||||
public final class RallyingCrySettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "rallying-cry-cooldown-seconds";
|
||||
public static final String RADIUS = "rallying-cry-radius-blocks";
|
||||
public static final String DURATION = "rallying-cry-duration-seconds";
|
||||
public static final String SPEED = "rallying-cry-speed-amplifier";
|
||||
public static final String BONUS = "rallying-cry-damage-bonus-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, BONUS, DURATION, RADIUS, SPEED);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
COOLDOWN, "25", RADIUS, "8", DURATION, "6", SPEED, "0", BONUS, "20");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, RallyingCry.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-rallying-cry-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 RallyingCrySettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
RallyingCrySettings(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 RallyingCry.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("Rallying Cry 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 RallyingCry.Settings parse(Map<String, String> values) {
|
||||
try { return new RallyingCry.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
|
||||
seconds(values.get(DURATION)), new BigDecimal(values.get(SPEED)).intValueExact(),
|
||||
new BigDecimal(values.get(BONUS))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Rallying Cry 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); }
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Sanctuary;
|
||||
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 Sanctuary settings; publication remains a server-thread caller decision. */
|
||||
public final class SanctuarySettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "sanctuary-cooldown-seconds";
|
||||
public static final String RADIUS = "sanctuary-radius-blocks";
|
||||
public static final String VERTICAL = "sanctuary-vertical-range-blocks";
|
||||
public static final String DURATION = "sanctuary-duration-seconds";
|
||||
public static final String INTERVAL = "sanctuary-pulse-interval-seconds";
|
||||
public static final String HEALING = "sanctuary-healing-per-pulse";
|
||||
public static final String REDUCTION = "sanctuary-damage-reduction-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, RADIUS, VERTICAL, DURATION, INTERVAL, HEALING, REDUCTION);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
COOLDOWN, "45", RADIUS, "6", VERTICAL, "3", DURATION, "6", INTERVAL, "1", HEALING, "2", REDUCTION, "20");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Sanctuary.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-sanctuary-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 SanctuarySettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
SanctuarySettings(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 Sanctuary.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("Sanctuary 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 Sanctuary.Settings parse(Map<String, String> values) {
|
||||
try { return new Sanctuary.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
|
||||
finiteNonnegative(values.get(VERTICAL)), seconds(values.get(DURATION)), seconds(values.get(INTERVAL)),
|
||||
new BigDecimal(values.get(HEALING)), new BigDecimal(values.get(REDUCTION))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Sanctuary 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 static double finiteNonnegative(String value) {
|
||||
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); 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); }
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public final class CombatContributions implements AutoCloseable {
|
||||
private final CombatRewardRules rules;
|
||||
private final Map<UUID, Map<UUID, Instant>> targets = new HashMap<>();
|
||||
private final Map<UUID, Map<UUID, Map<UUID, Instant>>> supports = new HashMap<>();
|
||||
private final Map<UUID, Map<UUID, Instant>> effectiveHealing = new HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
|
||||
@@ -34,6 +35,14 @@ public final class CombatContributions implements AutoCloseable {
|
||||
.merge(supportingCharacter, acceptedAt, (previous, next) -> previous.isAfter(next) ? previous : next);
|
||||
}
|
||||
|
||||
/** Records effective healing that may qualify through any target the aided character directly contributes to. */
|
||||
public void support(UUID aidedCharacter, UUID supportingCharacter, Instant acceptedAt) {
|
||||
Objects.requireNonNull(aidedCharacter); Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(acceptedAt);
|
||||
if (closed) return;
|
||||
effectiveHealing.computeIfAbsent(aidedCharacter, ignored -> new HashMap<>())
|
||||
.merge(supportingCharacter, acceptedAt, (previous, next) -> previous.isAfter(next) ? previous : next);
|
||||
}
|
||||
|
||||
public Set<UUID> consume(UUID target, Instant deathAt) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
|
||||
if (closed) return Set.of();
|
||||
@@ -45,8 +54,12 @@ public final class CombatContributions implements AutoCloseable {
|
||||
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
|
||||
});
|
||||
for (UUID aided : Set.copyOf(eligible)) {
|
||||
var supporters = assistance == null ? null : assistance.get(aided);
|
||||
if (supporters != null) supporters.forEach((supporter, acceptedAt) -> {
|
||||
var targeted = assistance == null ? null : assistance.get(aided);
|
||||
if (targeted != null) targeted.forEach((supporter, acceptedAt) -> {
|
||||
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
|
||||
});
|
||||
var healing = effectiveHealing.get(aided);
|
||||
if (healing != null) healing.forEach((supporter, acceptedAt) -> {
|
||||
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
|
||||
});
|
||||
}
|
||||
@@ -67,11 +80,14 @@ public final class CombatContributions implements AutoCloseable {
|
||||
byAlly.values().removeIf(Map::isEmpty);
|
||||
});
|
||||
supports.values().removeIf(Map::isEmpty);
|
||||
effectiveHealing.values().forEach(bySupporter ->
|
||||
bySupporter.entrySet().removeIf(entry -> !supports(entry.getValue(), now)));
|
||||
effectiveHealing.values().removeIf(Map::isEmpty);
|
||||
}
|
||||
|
||||
private static boolean supports(Instant acceptedAt, Instant defeatAt) {
|
||||
return !acceptedAt.isAfter(defeatAt) && !defeatAt.isAfter(acceptedAt.plus(SUPPORT_LIFETIME));
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; targets.clear(); supports.clear(); }
|
||||
@Override public void close() { closed = true; targets.clear(); supports.clear(); effectiveHealing.clear(); }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Acknowledged administrator XP mutation captured against one exact active character/session. */
|
||||
public final class AdminExperienceGrant implements AutoCloseable {
|
||||
public enum Status { APPLIED, AT_CAP, STALE, UNAVAILABLE, FAILED }
|
||||
public record Result(Status status, String name, BigInteger requested, BigInteger applied,
|
||||
int level, int levels) { }
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules progression;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Function<UUID, Player> players;
|
||||
private final Consumer<Failure> failures;
|
||||
private volatile boolean closed;
|
||||
|
||||
public AdminExperienceGrant(CharacterStorage characters, ProgressionRules progression, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Function<UUID, Player> players, Consumer<Failure> failures) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.progression = Objects.requireNonNull(progression);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.players = Objects.requireNonNull(players);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
/** Returns false before persistence when the captured active identity is no longer authoritative. */
|
||||
public boolean grant(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
|
||||
Consumer<Result> completion) {
|
||||
Objects.requireNonNull(active); Objects.requireNonNull(captured);
|
||||
Objects.requireNonNull(amount); Objects.requireNonNull(completion);
|
||||
if (amount.signum() <= 0) throw new IllegalArgumentException("Experience grant must be positive");
|
||||
if (closed || !sessions.current(active)
|
||||
|| sessions.currentRecord(active).filter(captured::equals).isEmpty()) return false;
|
||||
characters.awardExperience(captured.id(), captured.revision(), amount, progression)
|
||||
.whenComplete((award, failure) -> schedule(active, captured, amount, completion, award, failure));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void schedule(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
|
||||
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
|
||||
if (failure != null) report(captured.id(), failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> complete(active, captured, amount, completion, award, failure));
|
||||
} catch (RuntimeException rejected) { report(captured.id(), rejected); }
|
||||
}
|
||||
|
||||
private void complete(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
|
||||
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
|
||||
if (closed) return;
|
||||
if (failure != null) {
|
||||
completion.accept(new Result(Status.FAILED, captured.name(), amount, BigInteger.ZERO,
|
||||
captured.level(), 0));
|
||||
return;
|
||||
}
|
||||
var saved = award.record().orElse(captured);
|
||||
Status status = switch (award.status()) {
|
||||
case APPLIED -> Status.APPLIED;
|
||||
case AT_CAP, UNCHANGED -> Status.AT_CAP;
|
||||
case STALE -> Status.STALE;
|
||||
case UNAVAILABLE -> Status.UNAVAILABLE;
|
||||
};
|
||||
BigInteger applied = status == Status.APPLIED
|
||||
? saved.experience().subtract(captured.experience()) : BigInteger.ZERO;
|
||||
if (status == Status.APPLIED && sessions.current(active)) {
|
||||
boolean synchronizedSession = sessions.acknowledgeProgress(active, captured.revision(), saved);
|
||||
if (!synchronizedSession && sessions.current(active)
|
||||
&& sessions.currentRecord(active).filter(saved::equals).isEmpty()) {
|
||||
report(captured.id(), new IllegalStateException(
|
||||
"Saved administrator XP could not synchronize with its captured active session"));
|
||||
}
|
||||
if (synchronizedSession && sessions.current(active)) feedback(active, saved, applied, award.levels());
|
||||
}
|
||||
completion.accept(new Result(status, captured.name(), amount, applied, saved.level(), award.levels()));
|
||||
}
|
||||
|
||||
private void feedback(EquipmentSessions.Active active, CharacterRecord saved, BigInteger applied, int levels) {
|
||||
Player player;
|
||||
try { player = players.apply(active.player()); }
|
||||
catch (RuntimeException failure) { report(saved.id(), failure); return; }
|
||||
if (player == null || !player.isOnline() || !sessions.current(active)
|
||||
|| sessions.currentRecord(active).filter(saved::equals).isEmpty()) return;
|
||||
try {
|
||||
player.sendMessage("Received " + applied + " fantasy XP from an administrator.");
|
||||
for (int gained = 1; gained <= levels; gained++) {
|
||||
int level = saved.level() - levels + gained;
|
||||
player.sendMessage("Level " + level + "! Gained 1 character point.");
|
||||
SessionVisuals.burst(player, Color.BLUE, 12);
|
||||
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
|
||||
}
|
||||
} catch (RuntimeException failure) { report(saved.id(), failure); }
|
||||
}
|
||||
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics do not replay a grant. */ }
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
@@ -38,8 +39,23 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.ability.Hex hex;
|
||||
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
|
||||
private games.dmg.fantasystick.ability.Inspire inspire;
|
||||
private games.dmg.fantasystick.persistence.MendingLightSettings mendingLightSettings;
|
||||
private games.dmg.fantasystick.ability.MendingLight mendingLight;
|
||||
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
|
||||
private games.dmg.fantasystick.ability.Purify purify;
|
||||
private games.dmg.fantasystick.persistence.GuardianBondSettings guardianBondSettings;
|
||||
private games.dmg.fantasystick.ability.GuardianBond guardianBond;
|
||||
private games.dmg.fantasystick.persistence.RallyingCrySettings rallyingCrySettings;
|
||||
private games.dmg.fantasystick.ability.RallyingCry rallyingCry;
|
||||
private games.dmg.fantasystick.persistence.FrostNovaSettings frostNovaSettings;
|
||||
private games.dmg.fantasystick.ability.FrostNova frostNova;
|
||||
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
|
||||
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
|
||||
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
|
||||
private games.dmg.fantasystick.ability.Sanctuary sanctuary;
|
||||
private AdminCharacterIssuer characterIssuer;
|
||||
private AdminRevival adminRevival;
|
||||
private AdminExperienceGrant experienceGrant;
|
||||
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
@@ -215,11 +231,58 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
this.inspireSettings=Objects.requireNonNull(inspireSettings);this.inspire=Objects.requireNonNull(inspire);
|
||||
}
|
||||
|
||||
public void attachMendingLight(games.dmg.fantasystick.persistence.MendingLightSettings settings,
|
||||
games.dmg.fantasystick.ability.MendingLight effect) {
|
||||
if (closed || mendingLightSettings != null) throw new IllegalStateException("Mending Light already attached or command closed");
|
||||
mendingLightSettings = Objects.requireNonNull(settings); mendingLight = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachPurify(games.dmg.fantasystick.persistence.PurifySettings settings,
|
||||
games.dmg.fantasystick.ability.Purify effect) {
|
||||
if (closed || purifySettings != null) throw new IllegalStateException("Purify already attached or command closed");
|
||||
purifySettings = Objects.requireNonNull(settings); purify = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void 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 attachRallyingCry(games.dmg.fantasystick.persistence.RallyingCrySettings settings,
|
||||
games.dmg.fantasystick.ability.RallyingCry effect) {
|
||||
if (closed || rallyingCrySettings != null) throw new IllegalStateException("Rallying Cry already attached or command closed");
|
||||
rallyingCrySettings = Objects.requireNonNull(settings); rallyingCry = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachFrostNova(games.dmg.fantasystick.persistence.FrostNovaSettings settings,
|
||||
games.dmg.fantasystick.ability.FrostNova effect) {
|
||||
if (closed || frostNovaSettings != null) throw new IllegalStateException("Frost Nova already attached or command closed");
|
||||
frostNovaSettings = Objects.requireNonNull(settings); frostNova = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachChainLightning(games.dmg.fantasystick.persistence.ChainLightningSettings settings,
|
||||
games.dmg.fantasystick.ability.ChainLightning effect) {
|
||||
if (closed || chainLightningSettings != null) throw new IllegalStateException("Chain Lightning already attached or command closed");
|
||||
chainLightningSettings = Objects.requireNonNull(settings); chainLightning = Objects.requireNonNull(effect);
|
||||
}
|
||||
|
||||
public void attachSanctuary(games.dmg.fantasystick.persistence.SanctuarySettings settings,
|
||||
games.dmg.fantasystick.ability.Sanctuary effect) {
|
||||
if (closed || sanctuarySettings != null) throw new IllegalStateException("Sanctuary already attached or command closed");
|
||||
sanctuarySettings = Objects.requireNonNull(settings); sanctuary = 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);}
|
||||
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
|
||||
|
||||
public void attachExperienceGrant(AdminExperienceGrant grant) {
|
||||
if (closed || experienceGrant != null) throw new IllegalStateException("Experience grant already attached or command closed");
|
||||
experienceGrant = Objects.requireNonNull(grant);
|
||||
}
|
||||
|
||||
public void attachAdminRevival(AdminRevival revival, games.dmg.fantasystick.persistence.AdminReviveSettings settings) {
|
||||
if (closed || adminRevival != null) throw new IllegalStateException("Admin revival already attached or command closed");
|
||||
adminRevival=Objects.requireNonNull(revival);adminReviveSettings=Objects.requireNonNull(settings);
|
||||
@@ -247,6 +310,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args.length > 0 && args[0].equals("revive")) {
|
||||
adminRevive(sender,args);return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equals("experience")) {
|
||||
experience(sender, args); return true;
|
||||
}
|
||||
UUID owner;
|
||||
try {
|
||||
if (args.length != 2 || !(args[0].equals("inspect") || args[0].equals("recover"))) {
|
||||
@@ -307,6 +373,54 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void experience(CommandSender sender, String[] args) {
|
||||
if (experienceGrant == null || args.length < 4 || !args[1].equals("give")) {
|
||||
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
|
||||
}
|
||||
BigInteger amount;
|
||||
try { amount = new BigInteger(args[args.length - 1]); }
|
||||
catch (NumberFormatException invalid) {
|
||||
sender.sendMessage("Experience amount is invalid; enter a positive whole number."); return;
|
||||
}
|
||||
if (amount.signum() <= 0) {
|
||||
sender.sendMessage("Experience amount is invalid; enter a positive whole number."); return;
|
||||
}
|
||||
String name = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length - 1));
|
||||
if (name.isBlank()) {
|
||||
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
|
||||
}
|
||||
var matches = sessions.activeCharacters().stream().filter(active -> active.record().name().equals(name)).toList();
|
||||
if (matches.isEmpty()) {
|
||||
sender.sendMessage("Experience grant refused: character '" + name + "' is not active. No fantasy XP changed."); return;
|
||||
}
|
||||
if (matches.size() != 1) {
|
||||
sender.sendMessage("Experience grant refused: active character name '" + name
|
||||
+ "' is ambiguous. No fantasy XP changed."); return;
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) {
|
||||
sender.sendMessage("Experience grant refused before persistence. No fantasy XP changed."); return;
|
||||
}
|
||||
var target = matches.getFirst();
|
||||
boolean accepted = experienceGrant.grant(target.session(), target.record(), amount, result -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(switch (result.status()) {
|
||||
case APPLIED -> "Granted " + result.requested() + " fantasy XP to " + result.name()
|
||||
+ "; applied " + result.applied() + " after cap clamping; resulting level " + result.level() + ".";
|
||||
case AT_CAP -> "Granted 0 of requested " + result.requested() + " fantasy XP to " + result.name()
|
||||
+ "; the character is already capped at level " + result.level() + ".";
|
||||
case STALE -> "Experience grant refused: " + result.name()
|
||||
+ " changed concurrently. No grant success was recorded.";
|
||||
case UNAVAILABLE -> "Experience grant refused: " + result.name()
|
||||
+ " is unavailable or dead. No fantasy XP changed.";
|
||||
case FAILED -> "Experience grant failed before acknowledgment for " + result.name()
|
||||
+ "; no success was reported. See server diagnostics.";
|
||||
});
|
||||
});
|
||||
if (!accepted) sender.sendMessage("Experience grant refused: the captured character session changed before persistence. No fantasy XP changed.");
|
||||
}
|
||||
|
||||
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
|
||||
|
||||
private void give(CommandSender sender, String[] args) {
|
||||
@@ -362,6 +476,27 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (inspireSettings != null && games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])) {
|
||||
inspireConfiguration(sender, args); return;
|
||||
}
|
||||
if (mendingLightSettings != null && games.dmg.fantasystick.persistence.MendingLightSettings.keys().contains(args[1])) {
|
||||
mendingLightConfiguration(sender, args); return;
|
||||
}
|
||||
if (purifySettings != null && games.dmg.fantasystick.persistence.PurifySettings.keys().contains(args[1])) {
|
||||
purifyConfiguration(sender, args); return;
|
||||
}
|
||||
if (guardianBondSettings != null && games.dmg.fantasystick.persistence.GuardianBondSettings.keys().contains(args[1])) {
|
||||
guardianBondConfiguration(sender, args); return;
|
||||
}
|
||||
if (rallyingCrySettings != null && games.dmg.fantasystick.persistence.RallyingCrySettings.keys().contains(args[1])) {
|
||||
rallyingCryConfiguration(sender, args); return;
|
||||
}
|
||||
if (frostNovaSettings != null && games.dmg.fantasystick.persistence.FrostNovaSettings.keys().contains(args[1])) {
|
||||
frostNovaConfiguration(sender, args); return;
|
||||
}
|
||||
if (chainLightningSettings != null && games.dmg.fantasystick.persistence.ChainLightningSettings.keys().contains(args[1])) {
|
||||
chainLightningConfiguration(sender, args); return;
|
||||
}
|
||||
if (sanctuarySettings != null && games.dmg.fantasystick.persistence.SanctuarySettings.keys().contains(args[1])) {
|
||||
sanctuaryConfiguration(sender, args); return;
|
||||
}
|
||||
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
configurationUsage(sender); return;
|
||||
}
|
||||
@@ -461,6 +596,196 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void inspireConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+inspireSettings.value(key)+" (applies to future Inspire casts only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;try{operation=inspireSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Inspire 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){inspire.configure(saved.settings());abilities.replace(inspire.definition());inspireSettings.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 Inspire casts; active effects and deadlines are unchanged.":"Inspire configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void mendingLightConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + mendingLightSettings.value(key) + " (applies to future Mending Light casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MendingLightSettings.Update> operation;
|
||||
try { operation = mendingLightSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Mending Light 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) {
|
||||
mendingLight.configure(saved.settings()); abilities.replace(mendingLight.definition()); mendingLightSettings.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 Mending Light casts; active work and deadlines are unchanged."
|
||||
: "Mending Light configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void purifyConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + purifySettings.value(key) + " (applies to future Purify casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PurifySettings.Update> operation;
|
||||
try { operation = purifySettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Purify setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
purify.configure(saved.settings()); abilities.replace(purify.definition()); purifySettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Purify casts; active work and deadlines are unchanged."
|
||||
: "Purify configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void 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 rallyingCryConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + rallyingCrySettings.value(key) + " (applies to future Rallying Cry casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RallyingCrySettings.Update> operation;
|
||||
try { operation = rallyingCrySettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Rallying Cry 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) {
|
||||
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition()); rallyingCrySettings.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 Rallying Cry casts; active cries and deadlines are unchanged."
|
||||
: "Rallying Cry configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void frostNovaConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + frostNovaSettings.value(key) + " (applies to future Frost Nova casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostNovaSettings.Update> operation;
|
||||
try { operation = frostNovaSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Frost Nova 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) {
|
||||
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition()); frostNovaSettings.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 Frost Nova casts; active slows and deadlines are unchanged."
|
||||
: "Frost Nova configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void chainLightningConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + chainLightningSettings.value(key) + " (applies to future Chain Lightning casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ChainLightningSettings.Update> operation;
|
||||
try { operation = chainLightningSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Chain Lightning 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) {
|
||||
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition()); chainLightningSettings.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 Chain Lightning casts; active chains and deadlines are unchanged."
|
||||
: "Chain Lightning configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void sanctuaryConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + sanctuarySettings.value(key) + " (applies to future Sanctuary casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SanctuarySettings.Update> operation;
|
||||
try { operation = sanctuarySettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Sanctuary 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) {
|
||||
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition()); sanctuarySettings.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 Sanctuary casts; active areas and deadlines are unchanged."
|
||||
: "Sanctuary 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;
|
||||
@@ -597,7 +922,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
|
||||
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
|
||||
? List.of("config", "give", "inspect", "recover", "revive") : List.of("config", "give", "inspect", "recover"), args[0]);
|
||||
? List.of("config", "experience", "give", "inspect", "recover", "revive") : List.of("config", "experience", "give", "inspect", "recover"), args[0]);
|
||||
if (args.length == 2 && args[0].equals("experience")) return matching(List.of("give"), args[1]);
|
||||
if (args.length >= 3 && args[0].equals("experience") && args[1].equals("give")) return experienceNames(args);
|
||||
if (args.length == 2 && (args[0].equals("inspect") || args[0].equals("recover"))) return matching(
|
||||
server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
|
||||
.map(player -> player.getUniqueId().toString()).toList(), args[1]);
|
||||
@@ -613,6 +940,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
keys.addAll(games.dmg.fantasystick.persistence.ShieldBashSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.HexSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.InspireSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.MendingLightSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.PurifySettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.GuardianBondSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.RallyingCrySettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FrostNovaSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.ChainLightningSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.SanctuarySettings.keys());
|
||||
return matching(keys, args[1]);
|
||||
}
|
||||
if (args.length == 3 && args[0].equals("config")) {
|
||||
@@ -657,12 +991,62 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if(inspireSettings!=null&&games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])){String current=inspireSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.InspireSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if (mendingLightSettings != null && games.dmg.fantasystick.persistence.MendingLightSettings.keys().contains(args[1])) {
|
||||
String current = mendingLightSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.MendingLightSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (purifySettings != null && games.dmg.fantasystick.persistence.PurifySettings.keys().contains(args[1])) {
|
||||
String current = purifySettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.PurifySettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
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]);
|
||||
}
|
||||
if (rallyingCrySettings != null
|
||||
&& games.dmg.fantasystick.persistence.RallyingCrySettings.keys().contains(args[1])) {
|
||||
String current = rallyingCrySettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.RallyingCrySettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (frostNovaSettings != null
|
||||
&& games.dmg.fantasystick.persistence.FrostNovaSettings.keys().contains(args[1])) {
|
||||
String current = frostNovaSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.FrostNovaSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (chainLightningSettings != null
|
||||
&& games.dmg.fantasystick.persistence.ChainLightningSettings.keys().contains(args[1])) {
|
||||
String current = chainLightningSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.ChainLightningSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (sanctuarySettings != null
|
||||
&& games.dmg.fantasystick.persistence.SanctuarySettings.keys().contains(args[1])) {
|
||||
String current = sanctuarySettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.SanctuarySettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<String> experienceNames(String[] args) {
|
||||
int priorTokens = args.length - 3;
|
||||
String prefix = args[args.length - 1];
|
||||
return matching(sessions.activeCharacters().stream().map(active -> active.record().name())
|
||||
.map(name -> name.split(" ")).filter(tokens -> tokens.length > priorTokens)
|
||||
.filter(tokens -> java.util.stream.IntStream.range(0, priorTokens)
|
||||
.allMatch(index -> tokens[index].equals(args[index + 2])))
|
||||
.map(tokens -> tokens[priorTokens]).toList(), prefix);
|
||||
}
|
||||
|
||||
private static List<String> matching(java.util.Collection<String> values, String prefix) {
|
||||
return values.stream().filter(value -> value.startsWith(prefix)).distinct().sorted().toList();
|
||||
}
|
||||
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); }
|
||||
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(experienceGrant!=null)experienceGrant.close(); }
|
||||
}
|
||||
@@ -345,8 +345,10 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
|
||||
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("inspire") || selected.ability().equals("purify")
|
||||
? event.getPlayer().isSneaking() ? event.getPlayer() : event.getPlayer().getTargetEntity(64)
|
||||
: null;
|
||||
emit(event.getPlayer(), event.getItem(), selected, ControlInput.Gesture.USE, target, 0, 0);
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -34,6 +35,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public record Notice(UUID player, String code, String message) { }
|
||||
public record Failure(UUID player, UUID session, Throwable cause) { }
|
||||
public record Active(UUID player, UUID session, UUID character) { }
|
||||
public record ActiveCharacter(Active session, CharacterRecord record) { }
|
||||
public record SelectedControl(Active session, int slot, String ability) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
@@ -224,6 +226,14 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
}
|
||||
|
||||
/** Side-effect-free server-thread snapshots for exact active-character command resolution/autocomplete. */
|
||||
public synchronized List<ActiveCharacter> activeCharacters() {
|
||||
if (closed) return List.of();
|
||||
return active.values().stream().filter(attempt -> attempt.record != null)
|
||||
.map(attempt -> new ActiveCharacter(new Active(attempt.owner, attempt.session, attempt.character), attempt.record))
|
||||
.filter(snapshot -> current(snapshot.session())).toList();
|
||||
}
|
||||
|
||||
/** Current active holder for a transferable identity, used only to synchronize acknowledged character writes. */
|
||||
public synchronized Optional<Active> activeCharacter(UUID character) {
|
||||
Objects.requireNonNull(character);
|
||||
|
||||
@@ -89,3 +89,56 @@ inspire-base-temporary-health: 4
|
||||
inspire-cooldown-seconds: 15
|
||||
inspire-range-blocks: 12
|
||||
inspire-duration-seconds: 6
|
||||
|
||||
# Mending Light defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
mending-light-base-healing: 6
|
||||
mending-light-cooldown-seconds: 8
|
||||
mending-light-range-blocks: 12
|
||||
|
||||
# Purify defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
purify-cooldown-seconds: 15
|
||||
purify-range-blocks: 12
|
||||
|
||||
# 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
|
||||
|
||||
# Rallying Cry defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
rallying-cry-cooldown-seconds: 25
|
||||
rallying-cry-radius-blocks: 8
|
||||
rallying-cry-duration-seconds: 6
|
||||
rallying-cry-speed-amplifier: 0
|
||||
rallying-cry-damage-bonus-percent: 20
|
||||
|
||||
# Frost Nova defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
frost-nova-base-damage: 5
|
||||
frost-nova-cooldown-seconds: 12
|
||||
frost-nova-radius-blocks: 5
|
||||
frost-nova-slowness-amplifier: 1
|
||||
frost-nova-slowness-duration-seconds: 3
|
||||
|
||||
# Chain Lightning defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
chain-lightning-base-damage: 8
|
||||
chain-lightning-cooldown-seconds: 18
|
||||
chain-lightning-initial-range-blocks: 16
|
||||
chain-lightning-jump-range-blocks: 6
|
||||
chain-lightning-additional-targets: 3
|
||||
chain-lightning-jump-multiplier: 0.75
|
||||
|
||||
# Sanctuary defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
sanctuary-cooldown-seconds: 45
|
||||
sanctuary-radius-blocks: 6
|
||||
sanctuary-vertical-range-blocks: 3
|
||||
sanctuary-duration-seconds: 6
|
||||
sanctuary-pulse-interval-seconds: 1
|
||||
sanctuary-healing-per-pulse: 2
|
||||
sanctuary-damage-reduction-percent: 20
|
||||
@@ -7,10 +7,10 @@ description: Transferable characters, native fantasy health, recoverable equipme
|
||||
author: dmg.games
|
||||
commands:
|
||||
fantasystick:
|
||||
description: Issue or revive character sheets, inspect/recover sessions or configure Fantasy Stick.
|
||||
usage: /fantasystick <give|revive> <online-player> | <inspect|recover> <player-uuid> | config <setting> [value]
|
||||
description: Issue or revive sheets, grant fantasy experience, recover sessions or configure Fantasy Stick.
|
||||
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value]
|
||||
permission: fantasystick.admin
|
||||
permissions:
|
||||
fantasystick.admin:
|
||||
description: Issue/revive sheets, inspect/recover sessions and configure fantasy mode.
|
||||
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
|
||||
default: op
|
||||
@@ -0,0 +1,73 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.session.AdminExperienceGrant;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdminExperienceGrantNativeTest {
|
||||
@Test void acceptedPersistenceCannotRedirectFeedbackAfterCharacterSwitch() {
|
||||
var storage = mock(CharacterStorage.class); var sessions = mock(EquipmentSessions.class);
|
||||
var rules = ProgressionRules.defaults(); CharacterRecord captured = new CharacterFactory().create(rules);
|
||||
var active = new EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), captured.id());
|
||||
var current = new AtomicBoolean(true);
|
||||
when(sessions.current(active)).thenAnswer(call -> current.get());
|
||||
when(sessions.currentRecord(active)).thenReturn(Optional.of(captured));
|
||||
var future = new CompletableFuture<CharacterStorage.ExperienceAward>();
|
||||
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.TEN, rules)).thenReturn(future);
|
||||
var tasks = new ArrayList<Runnable>(); var results = new ArrayList<AdminExperienceGrant.Result>();
|
||||
var playerLookups = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var grant = new AdminExperienceGrant(storage, rules, sessions, tasks::add,
|
||||
ignored -> { playerLookups.incrementAndGet(); return null; }, failure -> fail(failure.cause()));
|
||||
|
||||
assertTrue(grant.grant(active, captured, BigInteger.TEN, results::add));
|
||||
current.set(false);
|
||||
var saved = new CharacterRecord(captured.id(), captured.name(), BigInteger.ONE, captured.stats(), 1,
|
||||
BigInteger.TEN, captured.unspentPoints(), captured.health(), captured.learned(), captured.loadout(),
|
||||
captured.cooldowns(), captured.restrictedKills(), false, captured.deathId(), captured.diedAt());
|
||||
future.complete(new CharacterStorage.ExperienceAward(CharacterStorage.ExperienceAwardStatus.APPLIED,
|
||||
Optional.of(saved), 0));
|
||||
assertEquals(1, tasks.size()); tasks.getFirst().run();
|
||||
|
||||
assertEquals(AdminExperienceGrant.Status.APPLIED, results.getFirst().status());
|
||||
assertEquals(BigInteger.TEN, results.getFirst().applied());
|
||||
verify(sessions, never()).acknowledgeProgress(any(), any(), any());
|
||||
assertEquals(0, playerLookups.get(), "A replacement holder/session cannot receive captured feedback");
|
||||
}
|
||||
|
||||
@Test void persistenceFailureReportsOnceAndClosedLifetimePublishesNoStaleCallback() {
|
||||
var storage = mock(CharacterStorage.class); var sessions = mock(EquipmentSessions.class);
|
||||
var rules = ProgressionRules.defaults(); CharacterRecord captured = new CharacterFactory().create(rules);
|
||||
var active = new EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), captured.id());
|
||||
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(Optional.of(captured));
|
||||
var failed = new CompletableFuture<CharacterStorage.ExperienceAward>();
|
||||
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.ONE, rules)).thenReturn(failed);
|
||||
var tasks = new ArrayList<Runnable>(); var results = new ArrayList<AdminExperienceGrant.Result>();
|
||||
var failures = new ArrayList<AdminExperienceGrant.Failure>();
|
||||
var grant = new AdminExperienceGrant(storage, rules, sessions, tasks::add, ignored -> null, failures::add);
|
||||
assertTrue(grant.grant(active, captured, BigInteger.ONE, results::add));
|
||||
failed.completeExceptionally(new java.io.IOException("save failed"));
|
||||
assertEquals(1, failures.size()); assertEquals(1, tasks.size()); tasks.removeFirst().run();
|
||||
assertEquals(List.of(AdminExperienceGrant.Status.FAILED), results.stream().map(AdminExperienceGrant.Result::status).toList());
|
||||
|
||||
var pending = new CompletableFuture<CharacterStorage.ExperienceAward>();
|
||||
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.TWO, rules)).thenReturn(pending);
|
||||
assertTrue(grant.grant(active, captured, BigInteger.TWO, results::add)); grant.close();
|
||||
pending.complete(new CharacterStorage.ExperienceAward(CharacterStorage.ExperienceAwardStatus.AT_CAP,
|
||||
Optional.of(captured), 0));
|
||||
assertTrue(tasks.isEmpty()); assertEquals(1, results.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AdminExperienceNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredCommandResolvesExactActiveCharacterPersistsThenSynchronizesFeedbackAndBook() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var storage = storage(f);
|
||||
storage.renameCharacter(target.core.record.id(), "Mira 雪").get();
|
||||
f.players.put(target.core.id, target.player); f.activate(target);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var before = storage.find(target.core.record.id()).get().orElseThrow();
|
||||
|
||||
assertEquals(List.of("experience"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"exp"}));
|
||||
assertEquals(List.of("give"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "g"}));
|
||||
assertEquals(List.of("Mira"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mi"}));
|
||||
assertEquals(List.of("雪"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", ""}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", "雪", ""}).isEmpty());
|
||||
assertTrue(f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", "雪", "250"}));
|
||||
storage.find(target.core.record.id()).get(); f.tick();
|
||||
|
||||
var saved = storage.find(target.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(250), saved.experience());
|
||||
assertEquals(3, saved.level());
|
||||
assertEquals(before.unspentPoints().add(BigInteger.TWO), saved.unspentPoints());
|
||||
assertEquals(before.stats(), saved.stats()); assertEquals(before.health(), saved.health());
|
||||
assertEquals(before.learned(), saved.learned()); assertEquals(before.loadout(), saved.loadout());
|
||||
assertEquals(before.cooldowns(), saved.cooldowns()); assertEquals(before.restrictedKills(), saved.restrictedKills());
|
||||
assertTrue(sender.messages.getLast().contains("Mira 雪"));
|
||||
assertTrue(sender.messages.getLast().contains("250")); assertTrue(sender.messages.getLast().contains("level 3"));
|
||||
assertTrue(target.messages.stream().anyMatch(message -> message.contains("250 fantasy XP")));
|
||||
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 2")));
|
||||
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 3")));
|
||||
assertEquals(2, target.sounds.size());
|
||||
assertEquals(2, org.mockito.Mockito.mockingDetails(target.world).getInvocations().stream()
|
||||
.filter(invocation -> invocation.getMethod().getName().equals("spawnParticle")).count());
|
||||
PartyBookNativeTest.open(f, target);
|
||||
String text = bookText(target.books.getLast());
|
||||
assertTrue(text.contains("Level: 3"), text); assertTrue(text.contains("XP: 50/100"), text);
|
||||
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertEquals(BigInteger.valueOf(250), storage(f).find(target.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void commandValidationAmbiguityExactCaseAndAutocompleteFailClosedWithoutMutation() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var first = active(f, data, "Twin Hero"); var second = active(f, data, "Twin Hero");
|
||||
var unicode = active(f, data, "Árvíztűrő Tükörfúrógép");
|
||||
var inactive = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
storage(f).renameCharacter(inactive.core.record.id(), "Hidden Hero").get();
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var firstBefore = storage(f).find(first.core.record.id()).get().orElseThrow();
|
||||
var secondBefore = storage(f).find(second.core.record.id()).get().orElseThrow();
|
||||
|
||||
assertEquals(List.of("Twin"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Tw"}));
|
||||
assertEquals(List.of("Hero"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Twin", "H"}));
|
||||
assertEquals(List.of("Árvíztűrő"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Á"}));
|
||||
assertEquals(List.of("Tükörfúrógép"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Árvíztűrő", "T"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Hidden", ""}).isEmpty());
|
||||
int queued = f.tasks.size();
|
||||
for (var args : List.of(
|
||||
new String[]{"experience", "give", "Twin", "Hero", "5"},
|
||||
new String[]{"experience", "give", "twin", "Hero", "5"},
|
||||
new String[]{"experience", "give", "Missing", "5"},
|
||||
new String[]{"experience", "give", "Hidden", "Hero", "5"},
|
||||
new String[]{"experience", "give", "Account", "5"},
|
||||
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "0"},
|
||||
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "-1"},
|
||||
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "1.5"},
|
||||
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "5", "extra"},
|
||||
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép"},
|
||||
new String[]{"experience", "extra", "Árvíztűrő", "Tükörfúrógép", "5"})) {
|
||||
f.command.execute(sender.port, "fantasystick", args);
|
||||
}
|
||||
assertEquals(queued, f.tasks.size());
|
||||
assertEquals(firstBefore, storage(f).find(first.core.record.id()).get().orElseThrow());
|
||||
assertEquals(secondBefore, storage(f).find(second.core.record.id()).get().orElseThrow());
|
||||
assertTrue(sender.messages.stream().anyMatch(message -> message.contains("ambiguous") && message.contains("Twin Hero")));
|
||||
assertTrue(sender.messages.stream().anyMatch(message -> message.contains("not active") && message.contains("Missing")));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"exp"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "5"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied"));
|
||||
assertEquals(BigInteger.ZERO, storage(f).find(unicode.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void arbitraryPrecisionGrantClampsAtCapPreservesCombatHistoryAndNeverTouchesNativeXp() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data); var storage = storage(f);
|
||||
var position = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(java.util.UUID.randomUUID(),
|
||||
java.math.BigDecimal.ONE, java.math.BigDecimal.TWO, java.math.BigDecimal.TEN);
|
||||
var progressed = storage.awardCombatExperience(target.core.record.id(), target.core.record.revision(), BigInteger.TEN,
|
||||
new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.valueOf(40), BigInteger.valueOf(100), 30),
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), position).get().record().orElseThrow();
|
||||
storage.renameCharacter(target.core.record.id(), "Cap Hero").get();
|
||||
f.players.put(target.core.id, target.player); f.activate(target);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); String huge = "9".repeat(1000);
|
||||
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Cap", "Hero", huge});
|
||||
storage.find(target.core.record.id()).get(); f.tick();
|
||||
var capped = storage.find(target.core.record.id()).get().orElseThrow();
|
||||
assertEquals(30, capped.level()); assertEquals(BigInteger.valueOf(2900), capped.experience());
|
||||
assertEquals(progressed.restrictedKills(), capped.restrictedKills());
|
||||
assertTrue(sender.messages.getLast().contains("applied 2890"));
|
||||
int feedback = target.messages.size(); int sounds = target.sounds.size();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Cap", "Hero", "1"});
|
||||
storage.find(target.core.record.id()).get(); f.tick();
|
||||
assertTrue(sender.messages.getLast().contains("Granted 0"));
|
||||
assertEquals(feedback, target.messages.size()); assertEquals(sounds, target.sounds.size());
|
||||
assertEquals(capped, storage.find(target.core.record.id()).get().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void revisionLogoutFailureAndRepeatedGrantRacesNeverRedirectDuplicateOrClaimFalseSuccess() throws Exception {
|
||||
var staleDir = data.resolve("stale");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(staleDir)) {
|
||||
f.plugin.setEnabled(true); var target = active(f, staleDir, "Before Rename"); var storage = storage(f);
|
||||
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(storage, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var gate = worker.submit(() -> { entered.countDown(); try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException interrupted) { throw new AssertionError(interrupted); } });
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Before", "Rename", "75"});
|
||||
var old = new games.dmg.fantasystick.persistence.YamlCharacterRepository(staleDir).load(target.core.record.id()).orElseThrow();
|
||||
var renamed = copy(old, "After Rename", old.revision().add(BigInteger.ONE));
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(staleDir).replace(renamed, old.revision());
|
||||
release.countDown(); gate.get(); storage.find(old.id()).get(); f.tick();
|
||||
assertTrue(sender.messages.getLast().contains("changed concurrently"));
|
||||
assertEquals(BigInteger.ZERO, storage.find(old.id()).get().orElseThrow().experience());
|
||||
assertTrue(target.messages.stream().noneMatch(message -> message.contains("administrator")));
|
||||
}
|
||||
var logoutDir = data.resolve("logout");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(logoutDir)) {
|
||||
f.plugin.setEnabled(true); var target = active(f, logoutDir, "Departing Hero"); var storage = storage(f);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Departing", "Hero", "75"});
|
||||
target.core.online = false;
|
||||
storage.find(target.core.record.id()).get(); f.tick();
|
||||
assertEquals(BigInteger.valueOf(75), storage.find(target.core.record.id()).get().orElseThrow().experience());
|
||||
assertTrue(sender.messages.getLast().contains("applied 75"));
|
||||
assertTrue(target.messages.stream().noneMatch(message -> message.contains("administrator")));
|
||||
}
|
||||
var repeatDir = data.resolve("repeat");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(repeatDir)) {
|
||||
f.plugin.setEnabled(true); var target = active(f, repeatDir, "One Award"); var storage = storage(f);
|
||||
var first = new EquipmentAdminCommandNativeTest.Sender(); var second = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(first.port, "fantasystick", new String[]{"experience", "give", "One", "Award", "25"});
|
||||
f.command.execute(second.port, "fantasystick", new String[]{"experience", "give", "One", "Award", "25"});
|
||||
storage.find(target.core.record.id()).get(); f.tick(); f.tick();
|
||||
assertEquals(BigInteger.valueOf(25), storage.find(target.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(1, List.of(first.messages.getLast(), second.messages.getLast()).stream()
|
||||
.filter(message -> message.contains("applied 25")).count());
|
||||
assertEquals(1, List.of(first.messages.getLast(), second.messages.getLast()).stream()
|
||||
.filter(message -> message.contains("changed concurrently")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void realFilesystemSaveFailureReportsNoSuccessOrPlayerFeedback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var target = active(f, data, "Broken Save");
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var path = data.resolve("characters").resolve(target.core.record.id() + ".yml");
|
||||
java.nio.file.Files.delete(path); java.nio.file.Files.createDirectory(path);
|
||||
int feedback = target.messages.size();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Broken", "Save", "20"});
|
||||
f.tick();
|
||||
assertTrue(sender.messages.getLast().contains("failed before acknowledgment"));
|
||||
assertEquals(feedback, target.messages.size());
|
||||
assertTrue(sender.messages.stream().noneMatch(message -> message.startsWith("Granted 20")));
|
||||
}
|
||||
}
|
||||
|
||||
private static CharacterRecord copy(CharacterRecord old, String name, BigInteger revision) {
|
||||
return new CharacterRecord(old.id(), name, revision, old.stats(), old.level(), old.experience(), old.unspentPoints(),
|
||||
old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt());
|
||||
}
|
||||
|
||||
private static FantasyStickLifecycleNativeTest.Actor active(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
Path data, String name) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
storage(f).renameCharacter(actor.core.record.id(), name).get();
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor); return actor;
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static String bookText(org.bukkit.inventory.ItemStack book) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
return content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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.*;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
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.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ChainLightningNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void nativeRuntime() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void characterBookPurchaseAssignmentAndEquippedControlExecuteNativeChain() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-cooldown-seconds: 0\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, false);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("ChainLightning")); assertTrue(text.contains("INT20")); assertTrue(text.contains("DEX12"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "chain_lightning")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "chain_lightning")); drain(fixture, 2);
|
||||
var first = mock(LivingEntity.class); var second = mock(LivingEntity.class);
|
||||
locate(first, caster.world, UUID.randomUUID(), 10); locate(second, caster.world, UUID.randomUUID(), 14);
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
doReturn(new Location(caster.world, 0, 65, 0)).when(caster.player).getEyeLocation();
|
||||
doReturn(true).when(caster.player).hasLineOfSight(first);
|
||||
doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(10, 64, 0), first))
|
||||
.when(caster.world).rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
|
||||
doReturn(List.of(second)).when(first).getNearbyEntities(anyDouble(), anyDouble(), anyDouble()); doReturn(true).when(first).hasLineOfSight(second);
|
||||
var damage = new LinkedHashMap<UUID, Double>(); nativeDamage(fixture, first, damage); nativeDamage(fixture, second, damage);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertFalse(damage.isEmpty(), caster.messages.toString());
|
||||
assertEquals(13, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(9.75, damage.get(second.getUniqueId()), 1.0e-10);
|
||||
var stored = ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
|
||||
.find(caster.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning"));
|
||||
assertEquals("chain_lightning", stored.loadout().getFirst()); assertNotNull(stored.cooldowns().get("chain_lightning"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactChainUsesNearestUuidTiesFourTargetLimitDecayAndCancellationContinuation() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); UUID casterId = UUID.randomUUID(); when(caster.getUniqueId()).thenReturn(casterId);
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
var targets = new ArrayList<LivingEntity>();
|
||||
for (int i = 0; i < 5; i++) { var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(new UUID(0, i + 1));
|
||||
when(target.getLocation()).thenReturn(new Location(world, 4 + i * 2, 64, 0)); when(target.hasLineOfSight(any(org.bukkit.entity.Entity.class))).thenReturn(true); targets.add(target); }
|
||||
when(caster.hasLineOfSight(targets.getFirst())).thenReturn(true);
|
||||
when(targets.get(0).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(2), targets.get(1), targets.get(1)));
|
||||
when(targets.get(1).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(3), targets.get(2)));
|
||||
when(targets.get(2).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(4), targets.get(3)));
|
||||
when(targets.get(3).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(4)));
|
||||
var active = new EquipmentSessions.Active(casterId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var execution = execution(caster, active, targets.getFirst()); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(chain.definition());
|
||||
var attempted = new LinkedHashMap<UUID, Double>();
|
||||
for (int i = 0; i < targets.size(); i++) { final int index = i; var target = targets.get(i);
|
||||
doAnswer(call -> { double amount = call.getArgument(0); attempted.put(target.getUniqueId(), amount);
|
||||
if (index != 1) chain.acceptedDamage(new EntityDamageEvent(target, EntityDamageEvent.DamageCause.LIGHTNING,
|
||||
(DamageSource) call.getArgument(1), amount)); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class)); }
|
||||
chain.activate(execution);
|
||||
assertEquals(List.of(targets.get(0).getUniqueId(), targets.get(1).getUniqueId(), targets.get(2).getUniqueId(), targets.get(3).getUniqueId()),
|
||||
List.copyOf(attempted.keySet()));
|
||||
assertArrayEquals(new double[]{13, 9.75, 7.3125, 5.484375}, attempted.values().stream().mapToDouble(Double::doubleValue).toArray(), 1.0e-10);
|
||||
verify(targets.get(4), never()).damage(anyDouble(), any(DamageSource.class));
|
||||
verify(execution, times(3)).report(eq(AbilityReport.Type.DAMAGE), any(), any());
|
||||
}
|
||||
|
||||
@Test void boundsVisibilityExclusionsAndEligibilityRaceStopInvalidStrikes() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
var initial = mock(LivingEntity.class); locate(initial, world, UUID.randomUUID(), 16); when(caster.hasLineOfSight(initial)).thenReturn(true);
|
||||
var blocked = mock(LivingEntity.class); locate(blocked, world, UUID.randomUUID(), 18); when(initial.hasLineOfSight(blocked)).thenReturn(false);
|
||||
var outside = mock(LivingEntity.class); locate(outside, world, UUID.randomUUID(), 22.01); when(initial.hasLineOfSight(outside)).thenReturn(true);
|
||||
var ordinary = mock(Player.class); locate(ordinary, world, UUID.randomUUID(), 18); when(initial.hasLineOfSight(ordinary)).thenReturn(true);
|
||||
when(initial.getNearbyEntities(6, 6, 6)).thenReturn(List.of(blocked, outside, ordinary));
|
||||
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
var execution = execution(caster, active, initial); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(chain.definition()); chain.activate(execution);
|
||||
verify(initial).damage(anyDouble(), any(DamageSource.class)); verify(blocked, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
verify(outside, never()).damage(anyDouble(), any(DamageSource.class)); verify(ordinary, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
when(execution.stillCurrent()).thenReturn(false); chain.activate(execution); verify(initial, times(1)).damage(anyDouble(), any(DamageSource.class));
|
||||
}
|
||||
|
||||
@Test void jumpInspectionStopsAtSharedCandidateCeiling() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
var initial = mock(LivingEntity.class); locate(initial, world, UUID.randomUUID(), 10); when(caster.hasLineOfSight(initial)).thenReturn(true);
|
||||
when(initial.hasLineOfSight(any(org.bukkit.entity.Entity.class))).thenReturn(true);
|
||||
var candidates = new ArrayList<org.bukkit.entity.Entity>(); LivingEntity beyond = null;
|
||||
for (int index = 0; index < 257; index++) {
|
||||
var target = mock(LivingEntity.class); locate(target, world, new UUID(1, index + 1), index == 256 ? 10.1 : 15);
|
||||
candidates.add(target); if (index == 256) beyond = target;
|
||||
}
|
||||
when(initial.getNearbyEntities(6, 6, 6)).thenReturn(candidates);
|
||||
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
var execution = execution(caster, active, initial); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(chain.definition()); chain.activate(execution);
|
||||
verify(initial).damage(anyDouble(), any(DamageSource.class)); verify(beyond, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
}
|
||||
|
||||
@Test void definitionFormulaAndArbitraryPrecisionAreExact() {
|
||||
var chain = new ChainLightning(ChainLightning.Settings.defaults(), mock(EquipmentSessions.class), mock(PartyRoster.class));
|
||||
var definition = chain.definition(); assertEquals("chain_lightning", definition.id()); assertEquals(org.bukkit.Material.LIGHTNING_ROD, definition.icon());
|
||||
assertEquals(java.time.Duration.ofSeconds(18), definition.cooldown()); assertEquals(BigInteger.ONE, definition.pointCost());
|
||||
assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.INT)); assertEquals(BigInteger.valueOf(12), definition.requirements().get(Stat.DEX));
|
||||
assertEquals(BigInteger.valueOf(13), ChainLightning.initialDamage(BigInteger.valueOf(20)));
|
||||
assertEquals(BigInteger.valueOf(7), ChainLightning.initialDamage(BigInteger.valueOf(9)));
|
||||
var huge = BigInteger.TEN.pow(500); assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(8)), ChainLightning.initialDamage(huge));
|
||||
}
|
||||
|
||||
@Test void pluginLoadsEverySettingAndAdminPublishesOnlyAfterDurableSave() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-base-damage: 9\nchain-lightning-cooldown-seconds: 4\n"
|
||||
+ "chain-lightning-initial-range-blocks: 15\nchain-lightning-jump-range-blocks: 5\nchain-lightning-additional-targets: 2\nchain-lightning-jump-multiplier: 0.5\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var definition = registry.find("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("9"), definition.parameters().get("base-damage"));
|
||||
assertEquals(java.time.Duration.ofSeconds(4), definition.cooldown());
|
||||
assertEquals(0, new BigDecimal("15").compareTo(definition.parameters().get("initial-range")));
|
||||
assertEquals(0, new BigDecimal("5").compareTo(definition.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("2"), definition.parameters().get("additional-targets"));
|
||||
assertEquals(new BigDecimal("0.5"), definition.parameters().get("jump-multiplier"));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("chain-lightning-additional-targets", "chain-lightning-base-damage", "chain-lightning-cooldown-seconds",
|
||||
"chain-lightning-initial-range-blocks", "chain-lightning-jump-multiplier", "chain-lightning-jump-range-blocks"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}));
|
||||
assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "chain-lightning-jump-range-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=5"));
|
||||
for (String[] update : List.of(new String[]{"chain-lightning-base-damage", "10"},
|
||||
new String[]{"chain-lightning-cooldown-seconds", "3"}, new String[]{"chain-lightning-initial-range-blocks", "14"},
|
||||
new String[]{"chain-lightning-jump-range-blocks", "7"}, new String[]{"chain-lightning-additional-targets", "1"},
|
||||
new String[]{"chain-lightning-jump-multiplier", "0.25"})) {
|
||||
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("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("10"), configured.parameters().get("base-damage"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
|
||||
assertEquals(0, new BigDecimal("14").compareTo(configured.parameters().get("initial-range")));
|
||||
assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("1"), configured.parameters().get("additional-targets"));
|
||||
assertEquals(new BigDecimal("0.25"), configured.parameters().get("jump-multiplier"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var saved = reloaded.find("chain_lightning").orElseThrow();
|
||||
assertEquals(0, new BigDecimal("7").compareTo(saved.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("0.25"), saved.parameters().get("jump-multiplier"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
}
|
||||
}
|
||||
|
||||
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
|
||||
doAnswer(call -> { double amount = call.getArgument(0); DamageSource source = call.getArgument(1);
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getCausingEntity(), target,
|
||||
EntityDamageEvent.DamageCause.LIGHTNING, source, amount);
|
||||
fixture.dispatcher.accept(event); if (!event.isCancelled()) damage.put(target.getUniqueId(), event.getFinalDamage()); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
}
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
|
||||
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
|
||||
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
private static net.minecraft.network.chat.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 net.minecraft.network.chat.ClickEvent.Custom button(
|
||||
net.minecraft.network.chat.Component component, String label, String ability) {
|
||||
if (component.getString().equals(label)
|
||||
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.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 prepareCaster(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.INT, BigInteger.valueOf(20)); stats.put(Stat.DEX, BigInteger.valueOf(12));
|
||||
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("chain_lightning") : old.learned(), learned ? List.of("chain_lightning", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static AbilityExecution execution(Player source, EquipmentSessions.Active active, LivingEntity target) {
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active); when(input.target()).thenReturn(target);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
|
||||
var record = new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(record.stats()); stats.put(Stat.INT, BigInteger.valueOf(20));
|
||||
when(execution.character()).thenReturn(new CharacterRecord(active.character(), record.name(), record.revision(), stats,
|
||||
record.level(), record.experience(), record.unspentPoints(), record.health(), Set.of("chain_lightning"),
|
||||
List.of("chain_lightning", "", "", "", "", "", ""), record.cooldowns(), record.restrictedKills(), false, null, null));
|
||||
return execution;
|
||||
}
|
||||
private static void locate(LivingEntity entity, org.bukkit.World world, UUID id, double x) {
|
||||
when(entity.getUniqueId()).thenReturn(id); when(entity.getLocation()).thenReturn(new Location(world, x, 64, 0));
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class EquipmentAdminCommandNativeTest {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
|
||||
var sender=new Sender();
|
||||
assertEquals(List.of("config","give","inspect","recover"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("config","experience","give","inspect","recover"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
|
||||
@@ -94,7 +94,7 @@ class EquipmentAdminCommandNativeTest {
|
||||
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
|
||||
var sender = new Sender();
|
||||
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
|
||||
assertEquals(List.of("config", "give", "inspect", "recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("config", "experience", "give", "inspect", "recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
|
||||
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
|
||||
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
|
||||
|
||||
@@ -1285,10 +1285,10 @@ class EquipmentSessionEventsNativeTest {
|
||||
|
||||
@Test void selectedAbilityUseReachesItsHandlerWithoutVanillaThrowDrinkEquipBlockOrPlacement() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
String[] abilities = {"fireball", "purify", "guardian_bond", "shield_bash", "rallying_cry", "war_banner", "revive"};
|
||||
String[] abilities = {"chain_lightning", "purify", "guardian_bond", "shield_bash", "rallying_cry", "frost_nova", "revive"};
|
||||
f.loadout(abilities);
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var target = port(org.bukkit.entity.Player.class);
|
||||
var target = port(org.bukkit.entity.Player.class); a.targetEntity = target;
|
||||
for (int index = 0; index < abilities.length; index++) {
|
||||
int slot = index + 2; a.inventory.getInventory().setSelectedSlot(slot);
|
||||
var item = a.inventory.getItem(slot);
|
||||
|
||||
@@ -1688,6 +1688,8 @@ class EquipmentSessionsNativeTest {
|
||||
Runnable beforeHealth = () -> {};
|
||||
Runnable healthMutation = () -> {};
|
||||
float attackStrength = 1;
|
||||
boolean sneaking;
|
||||
org.bukkit.entity.Entity targetEntity;
|
||||
ItemStack activeItem = new ItemStack(Material.AIR);
|
||||
int useStarts;
|
||||
int useClears;
|
||||
@@ -1719,10 +1721,14 @@ class EquipmentSessionsNativeTest {
|
||||
}
|
||||
case "getOpenInventory" -> view;
|
||||
case "getLocation" -> location.clone();
|
||||
case "getBoundingBox" -> new org.bukkit.util.BoundingBox(location.getX() - 0.3, location.getY(),
|
||||
location.getZ() - 0.3, location.getX() + 0.3, location.getY() + 1.8, location.getZ() + 0.3);
|
||||
case "getGameMode" -> gameMode;
|
||||
case "setGameMode" -> { beforeMode.run(); gameMode = (org.bukkit.GameMode) args[0]; yield null; }
|
||||
case "getHealth" -> health;
|
||||
case "getAttackCooldown" -> attackStrength;
|
||||
case "isSneaking" -> sneaking;
|
||||
case "getTargetEntity" -> targetEntity;
|
||||
case "getActiveItem" -> activeItem.clone();
|
||||
case "hasActiveItem" -> !activeItem.isEmpty();
|
||||
case "getActiveItemHand" -> org.bukkit.inventory.EquipmentSlot.HAND;
|
||||
|
||||
@@ -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(7, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally 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,324 @@
|
||||
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.FrostNova;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import 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.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import 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.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class FrostNovaNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedBurstDamagesSlowsEligibleTargetsOnceAndRetainsCooldown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
assertTrue(java.util.Arrays.stream(EntityDamageEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getListener() instanceof FrostNova));
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data); var opponent = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, false); fixture.players.put(caster.core.id, caster.player);
|
||||
fixture.players.put(ally.core.id, ally.player); fixture.players.put(opponent.core.id, opponent.player);
|
||||
fixture.activate(caster); fixture.activate(ally); fixture.activate(opponent); party(fixture, caster, ally);
|
||||
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getLocation()).thenReturn(new Location(caster.world, 3, 64, 0)); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
var vetoed = mock(LivingEntity.class); when(vetoed.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(vetoed.getLocation()).thenReturn(new Location(caster.world, 2.5, 64, 0));
|
||||
doReturn(true).when(caster.player).hasLineOfSight(enemy); doReturn(true).when(caster.player).hasLineOfSight(vetoed);
|
||||
locate(caster, ally.player, 2, true); locate(caster, opponent.player, 4, true);
|
||||
var ordinary = mock(Player.class); when(ordinary.getUniqueId()).thenReturn(UUID.randomUUID()); locate(caster, ordinary, 2, true);
|
||||
doReturn(List.of(enemy, enemy, vetoed, ally.player, ordinary, opponent.player)).when(caster.player)
|
||||
.getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
var enemyEffects = effects(enemy); var vetoedEffects = effects(vetoed);
|
||||
var opponentEffects = effects(opponent.player); effects(ally.player); effects(ordinary);
|
||||
var damage = new HashMap<UUID, Double>(); damage(fixture, enemy, damage); damage(fixture, vetoed, damage); damage(fixture, opponent.player, damage);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
|
||||
public void cancel(EntityDamageEvent event) { if (event.getEntity() == vetoed) event.setCancelled(true); }
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin);
|
||||
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("FrostNova")); assertTrue(text.contains("INT16")); assertTrue(text.contains("DEX10"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("5")); assertTrue(text.contains("SlownessII"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "frost_nova")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "frost_nova")); drain(fixture, 2);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(8, damage.get(enemy.getUniqueId()), 1.0e-9); assertEquals(8, damage.get(opponent.core.id), 1.0e-9);
|
||||
assertEquals(2, damage.size(), "Duplicate, cancelled, party and ordinary targets cannot add accepted hits");
|
||||
assertFalse(vetoedEffects.containsKey(PotionEffectType.SLOWNESS));
|
||||
assertEquals(1, enemyEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
|
||||
assertEquals(1, opponentEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
|
||||
var stored = storage(fixture).find(caster.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertNotNull(stored.cooldowns().get("frost_nova"));
|
||||
assertEquals(60L, fixture.delayedTicks.getLast()); fixture.delayed.getLast().run();
|
||||
assertFalse(enemyEffects.containsKey(PotionEffectType.SLOWNESS)); assertFalse(opponentEffects.containsKey(PotionEffectType.SLOWNESS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void spherePathOrderingCandidateAndTargetBoundsAreDeterministic() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(source.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0)); when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(source.getUniqueId())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
var candidates = new ArrayList<Entity>(); var hitOrder = new ArrayList<UUID>();
|
||||
for (int index = 0; index < 270; index++) {
|
||||
var target = mock(LivingEntity.class); var id = new UUID(0, 300 - index); when(target.getUniqueId()).thenReturn(id);
|
||||
double distance = index < 70 ? 1 + index / 100.0 : 4;
|
||||
when(target.getLocation()).thenReturn(new Location(world, distance, 64, 0)); when(source.hasLineOfSight(target)).thenReturn(index != 1);
|
||||
doAnswer(call -> { hitOrder.add(id); return null; }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
effects(target); candidates.add(target);
|
||||
}
|
||||
when(source.getNearbyEntities(5, 5, 5)).thenReturn(candidates);
|
||||
var execution = execution(source, active); var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(nova.definition()); nova.activate(execution);
|
||||
assertEquals(64, hitOrder.size()); assertFalse(hitOrder.contains(new UUID(0, 299)), "Obstructed target excluded");
|
||||
var sorted = new ArrayList<>(hitOrder); sorted.sort(UUID::compareTo);
|
||||
assertNotEquals(sorted, hitOrder, "Distance precedes UUID while UUID breaks equal-distance ties");
|
||||
verify((LivingEntity) candidates.getLast(), never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
verify(execution, atMost(64)).report(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test void exactSphereBoundaryIsIncludedWhileOutsideAndObstructedTargetsAreExcluded() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(source.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(source.getUniqueId())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
var boundary = mock(LivingEntity.class); var outside = mock(LivingEntity.class); var blocked = mock(LivingEntity.class);
|
||||
for (var target : List.of(boundary, outside, blocked)) when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(boundary.getLocation()).thenReturn(new Location(world, 5, 64, 0));
|
||||
when(outside.getLocation()).thenReturn(new Location(world, 5.01, 64, 0));
|
||||
when(blocked.getLocation()).thenReturn(new Location(world, 4, 64, 0));
|
||||
when(source.hasLineOfSight(boundary)).thenReturn(true); when(source.hasLineOfSight(outside)).thenReturn(true);
|
||||
when(source.hasLineOfSight(blocked)).thenReturn(true); when(source.getNearbyEntities(5, 5, 5)).thenReturn(List.of(boundary, outside, blocked));
|
||||
when(world.rayTraceBlocks(any(Location.class), any(org.bukkit.util.Vector.class), eq(4.0),
|
||||
eq(org.bukkit.FluidCollisionMode.NEVER), eq(true))).thenReturn(mock(org.bukkit.util.RayTraceResult.class));
|
||||
effects(boundary); effects(outside); effects(blocked);
|
||||
var execution = execution(source, active); var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(nova.definition()); nova.activate(execution);
|
||||
verify(boundary).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
verify(outside, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
verify(blocked, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
}
|
||||
|
||||
@Test void cancelledDamageAndPartyOrModeRaceApplyNoSlownessOrCredit() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
|
||||
var target = mock(Player.class); var world = mock(org.bukkit.World.class); UUID sourceId = UUID.randomUUID(), targetId = UUID.randomUUID();
|
||||
when(source.getUniqueId()).thenReturn(sourceId); when(target.getUniqueId()).thenReturn(targetId);
|
||||
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0)); when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(target.getLocation()).thenReturn(new Location(world, 3, 64, 0)); when(source.hasLineOfSight(target)).thenReturn(true);
|
||||
when(source.getNearbyEntities(5, 5, 5)).thenReturn(List.of(target));
|
||||
var sourceActive = new EquipmentSessions.Active(sourceId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var targetActive = new EquipmentSessions.Active(targetId, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(sourceId)).thenReturn(java.util.Optional.of(sourceActive)); when(sessions.current(sourceActive)).thenReturn(true);
|
||||
when(sessions.active(targetId)).thenReturn(java.util.Optional.of(targetActive)); when(sessions.current(targetActive)).thenReturn(true);
|
||||
var targetEffects = effects(target); var execution = execution(source, sourceActive);
|
||||
var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties); when(execution.definition()).thenReturn(nova.definition());
|
||||
doAnswer(call -> { when(parties.partyOf(sourceId)).thenReturn(java.util.Optional.of(
|
||||
new PartyRoster.Party(UUID.randomUUID(), sourceId, List.of(sourceId, targetId)))); return null; })
|
||||
.when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
nova.activate(execution); assertTrue(targetEffects.isEmpty()); verify(execution, never()).report(any(), any(), any());
|
||||
|
||||
when(parties.partyOf(sourceId)).thenReturn(java.util.Optional.empty()); when(sessions.current(targetActive)).thenReturn(false);
|
||||
nova.activate(execution); verify(target, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
}
|
||||
|
||||
@Test void ownedSlownessRestoresWeakerPriorStateButPreservesNewerStrongerState() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
|
||||
var target = mock(LivingEntity.class); var world = mock(org.bukkit.World.class);
|
||||
when(source.getUniqueId()).thenReturn(UUID.randomUUID()); when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(source.getEyeLocation()).thenReturn(new Location(world, 0, 64, 0)); when(target.getLocation()).thenReturn(new Location(world, 2, 64, 0));
|
||||
when(source.hasLineOfSight(target)).thenReturn(true); when(source.getNearbyEntities(5, 5, 5)).thenReturn(List.of(target));
|
||||
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(source.getUniqueId())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
var targetEffects = effects(target); targetEffects.put(PotionEffectType.SLOWNESS,
|
||||
new PotionEffect(PotionEffectType.SLOWNESS, 200, 0));
|
||||
var execution = execution(source, active); var nova = new FrostNova(FrostNova.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(nova.definition());
|
||||
doAnswer(call -> { var damageSource = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new EntityDamageEvent(target, EntityDamageEvent.DamageCause.FREEZE, damageSource, (Double) call.getArgument(0));
|
||||
nova.acceptedDamage(event); return null; }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
nova.activate(execution); assertEquals(1, targetEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
|
||||
var resource = org.mockito.ArgumentCaptor.forClass(AutoCloseable.class); verify(execution).own(resource.capture());
|
||||
resource.getValue().close(); assertEquals(0, targetEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
|
||||
|
||||
var execution2 = execution(source, active); when(execution2.definition()).thenReturn(nova.definition());
|
||||
nova.activate(execution2); targetEffects.put(PotionEffectType.SLOWNESS, new PotionEffect(PotionEffectType.SLOWNESS, 400, 3));
|
||||
var resource2 = org.mockito.ArgumentCaptor.forClass(AutoCloseable.class); verify(execution2).own(resource2.capture());
|
||||
resource2.getValue().close(); assertEquals(3, targetEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"frost-nova-base-damage: 7\nfrost-nova-cooldown-seconds: 2\nfrost-nova-radius-blocks: 8\n"
|
||||
+ "frost-nova-slowness-amplifier: 2\nfrost-nova-slowness-duration-seconds: 5\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("frost_nova").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(new BigDecimal("7"), definition.parameters().get("base-damage")); assertEquals(new BigDecimal("8"), definition.parameters().get("radius"));
|
||||
assertEquals(new BigDecimal("2"), definition.parameters().get("slowness-amplifier"));
|
||||
assertEquals(0, new BigDecimal("5").compareTo(definition.parameters().get("slowness-duration-seconds")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-nova-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "10"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("frost-nova-base-damage", "frost-nova-cooldown-seconds", "frost-nova-radius-blocks",
|
||||
"frost-nova-slowness-amplifier", "frost-nova-slowness-duration-seconds"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-nova-"}));
|
||||
assertEquals(List.of("5", "8"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "frost-nova-radius-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=8"));
|
||||
for (String[] update : List.of(new String[]{"frost-nova-base-damage", "9"},
|
||||
new String[]{"frost-nova-cooldown-seconds", "3"}, new String[]{"frost-nova-radius-blocks", "10"},
|
||||
new String[]{"frost-nova-slowness-amplifier", "3"}, new String[]{"frost-nova-slowness-duration-seconds", "6"})) {
|
||||
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("frost_nova").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
|
||||
assertEquals(new BigDecimal("9"), configured.parameters().get("base-damage"));
|
||||
assertEquals(0, new BigDecimal("10").compareTo(configured.parameters().get("radius")));
|
||||
assertEquals(new BigDecimal("3"), configured.parameters().get("slowness-amplifier"));
|
||||
assertEquals(0, new BigDecimal("6").compareTo(configured.parameters().get("slowness-duration-seconds")));
|
||||
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, true);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
|
||||
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getLocation()).thenReturn(new Location(caster.world, 9, 64, 0)); doReturn(true).when(caster.player).hasLineOfSight(enemy);
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getEyeLocation();
|
||||
doReturn(List.of(enemy)).when(caster.player).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
var enemyEffects = effects(enemy); var applied = new HashMap<UUID, Double>(); damage(fixture, enemy, applied);
|
||||
use(fixture, caster); drain(fixture, 2); assertEquals(12, applied.get(enemy.getUniqueId()), 1.0e-9);
|
||||
assertEquals(3, enemyEffects.get(PotionEffectType.SLOWNESS).getAmplifier()); assertEquals(120L, fixture.delayedTicks.getLast());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "10", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var saved = ((games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry"))
|
||||
.find("frost_nova").orElseThrow(); assertEquals(new BigDecimal("9"), saved.parameters().get("base-damage"));
|
||||
assertEquals(0, new BigDecimal("10").compareTo(saved.parameters().get("radius")));
|
||||
assertEquals(new BigDecimal("3"), saved.parameters().get("slowness-amplifier"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
assertEquals(0, new BigDecimal("6").compareTo(saved.parameters().get("slowness-duration-seconds")));
|
||||
}
|
||||
for (String invalid : List.of("frost-nova-base-damage: -1\n", "frost-nova-cooldown-seconds: -1\n",
|
||||
"frost-nova-radius-blocks: 0\n", "frost-nova-slowness-amplifier: -1\n", "frost-nova-slowness-duration-seconds: 0\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void arbitraryPrecisionFormulaUsesFloorModifier() {
|
||||
assertEquals(BigInteger.valueOf(8), FrostNova.damage(BigInteger.valueOf(16)));
|
||||
assertEquals(BigInteger.valueOf(4), FrostNova.damage(BigInteger.valueOf(9)));
|
||||
var huge = BigInteger.TEN.pow(500); assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(5)), FrostNova.damage(huge));
|
||||
}
|
||||
|
||||
private static AbilityExecution execution(Player source, EquipmentSessions.Active active) {
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
|
||||
var record = new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(record.stats()); stats.put(Stat.INT, BigInteger.valueOf(16));
|
||||
when(execution.character()).thenReturn(new CharacterRecord(active.character(), record.name(), record.revision(), stats,
|
||||
record.level(), record.experience(), record.unspentPoints(), record.health(), Set.of("frost_nova"),
|
||||
List.of("frost_nova", "", "", "", "", "", ""), record.cooldowns(), record.restrictedKills(), false, null, null));
|
||||
return execution;
|
||||
}
|
||||
private static Map<org.bukkit.potion.PotionEffectType, PotionEffect> effects(LivingEntity target) {
|
||||
var effects = new HashMap<org.bukkit.potion.PotionEffectType, PotionEffect>();
|
||||
doAnswer(call -> effects.get(call.getArgument(0))).when(target).getPotionEffect(any());
|
||||
doAnswer(call -> { PotionEffect effect = call.getArgument(0); effects.put(effect.getType(), effect); return true; })
|
||||
.when(target).addPotionEffect(any());
|
||||
doAnswer(call -> { effects.remove(call.getArgument(0)); return null; }).when(target).removePotionEffect(any()); return effects;
|
||||
}
|
||||
private static void damage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
|
||||
doAnswer(call -> { double amount = call.getArgument(0); org.bukkit.damage.DamageSource source = call.getArgument(1);
|
||||
var event = new EntityDamageByEntityEvent(source.getCausingEntity(), target, EntityDamageEvent.DamageCause.FREEZE, source, amount);
|
||||
fixture.dispatcher.accept(event); if (!event.isCancelled()) damage.merge(target.getUniqueId(), event.getFinalDamage(), Double::sum); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
}
|
||||
private static void locate(FantasyStickLifecycleNativeTest.Actor caster, Player target, double distance, boolean visible) {
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getEyeLocation();
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
doReturn(new Location(caster.world, distance, 64, 0)).when(target).getLocation(); doReturn(visible).when(caster.player).hasLineOfSight(target);
|
||||
}
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
}
|
||||
private static void 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()));
|
||||
}
|
||||
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 prepareCaster(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.INT, BigInteger.valueOf(16)); stats.put(Stat.DEX, 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("frost_nova") : old.learned(), learned ? List.of("frost_nova", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -348,7 +348,7 @@ class LongbowNativeTest {
|
||||
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
|
||||
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(java.util.List.of("config", "give", "inspect", "recover"),
|
||||
assertEquals(java.util.List.of("config", "experience", "give", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
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.MendingLight;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.MendingLightSettings;
|
||||
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.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
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 MendingLightNativeTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionAimedPartyHealPersistsCapsAndKeepsCooldownAcrossReloadAndRelearn() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, false);
|
||||
setHealth(data, ally, BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id, caster.player);
|
||||
fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
|
||||
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("MendingLight")); assertTrue(text.contains("WIS12"));
|
||||
assertTrue(text.contains("CHR10")); assertTrue(text.contains("Cost:1"));
|
||||
assertTrue(text.contains("12")); assertTrue(text.contains("8"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "mending_light"));
|
||||
drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "mending_light"));
|
||||
drain(fixture, 2);
|
||||
|
||||
use(fixture, caster); drain(fixture, 3);
|
||||
var storage = storage(fixture);
|
||||
var healed = storage.find(ally.core.record.id()).get().orElseThrow();
|
||||
assertEquals(0, BigDecimal.TEN.compareTo(healed.health()), "6 + WIS modifier caps at ally maximum");
|
||||
assertEquals(10, ally.player.getHealth());
|
||||
var deadline = storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light");
|
||||
assertNotNull(deadline);
|
||||
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
var unlearn = button(caster, "[Unlearn]", "mending_light");
|
||||
PartyBookNativeTest.click(fixture, caster, unlearn); PartyBookNativeTest.click(fixture, caster, unlearn);
|
||||
drain(fixture, 2);
|
||||
assertEquals(deadline, storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light"));
|
||||
learnAndAssign(fixture, caster); use(fixture, caster);
|
||||
assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
|
||||
var allySheet = ally.core.inventory.getItemInOffHand().clone();
|
||||
ally.core.inventory.setItemInOffHand(null); fixture.changed(ally); drain(fixture, 1); fixture.awaitCharacterAndReceipt(ally);
|
||||
var resumed = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
resumed.core.inventory.setItemInOffHand(allySheet); fixture.players.put(resumed.core.id, resumed.player); fixture.activate(resumed);
|
||||
assertEquals(10, resumed.player.getHealth(), "Healed health survives exit, transfer and resume");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualHealingCreatesOnePersistedSupportShareForAnAllyDirectContributor() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
|
||||
use(fixture, caster); drain(fixture, 3);
|
||||
var sourceSheet = caster.core.inventory.getItemInOffHand().clone();
|
||||
caster.core.inventory.setItemInOffHand(null); fixture.changed(caster); drain(fixture, 1); fixture.awaitCharacterAndReceipt(caster);
|
||||
var currentHolder = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
currentHolder.core.inventory.setItemInOffHand(sourceSheet);
|
||||
fixture.players.put(currentHolder.core.id, currentHolder.player); fixture.activate(currentHolder);
|
||||
|
||||
var enemy = mock(LivingEntity.class);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(caster.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getLocation()).thenReturn(new Location(caster.world, 8, 64, 0));
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
fixture.dispatcher.accept(new EntityDamageByEntityEvent(ally.player, enemy,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 3));
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0));
|
||||
drain(fixture, 4);
|
||||
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
assertTrue(currentHolder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")),
|
||||
"Effective healing follows the living source character through transfer");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void selfOrdinaryNonPartyFullHealthRangeAndLineOfSightRefuseBeforeCooldown() throws Exception {
|
||||
for (int route = 0; route < 6; route++) {
|
||||
var directory = data.resolve("refusal-" + route);
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(directory); prepareCaster(directory, caster, true);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
|
||||
Player target;
|
||||
if (route == 0) target = caster.player;
|
||||
else if (route == 1) { target = mock(Player.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID()); }
|
||||
else {
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(directory);
|
||||
setHealth(directory, ally, route == 3 ? BigDecimal.TEN : BigDecimal.valueOf(4));
|
||||
fixture.players.put(ally.core.id, ally.player); fixture.activate(ally); target = ally.player;
|
||||
if (route != 2) party(fixture, caster, ally);
|
||||
}
|
||||
aim(caster, target, route == 4 ? 13 : 8, route != 5);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow()
|
||||
.cooldowns().containsKey("mending_light"), "route " + route + " must refuse before cooldown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledHealingConsumesCooldownButAppliesNoHealthOrSupport() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == ally.player) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin);
|
||||
use(fixture, caster); drain(fixture, 3);
|
||||
assertEquals(4, ally.player.getHealth());
|
||||
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void targetSaveFailureOrSessionReplacementPublishesNoNativeHealingOrSupport() {
|
||||
var characters = mock(CharacterStorage.class); var sessions = mock(EquipmentSessions.class);
|
||||
var parties = mock(PartyRoster.class); var source = mock(Player.class); var target = mock(Player.class);
|
||||
var attribute = mock(AttributeInstance.class); when(target.getAttribute(Attribute.MAX_HEALTH)).thenReturn(attribute);
|
||||
when(attribute.getValue()).thenReturn(10.0); when(target.getHealth()).thenReturn(4.0);
|
||||
when(source.getUniqueId()).thenReturn(UUID.randomUUID()); when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var world = mock(org.bukkit.World.class);
|
||||
when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(target.getLocation()).thenReturn(new Location(world, 8, 64, 0));
|
||||
when(source.hasLineOfSight(target)).thenReturn(true);
|
||||
var casterRecord = recordWith(UUID.randomUUID(), "mending_light", BigDecimal.TEN);
|
||||
var targetRecord = recordWith(UUID.randomUUID(), null, BigDecimal.valueOf(4));
|
||||
var casterActive = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), casterRecord.id());
|
||||
var targetActive = new EquipmentSessions.Active(target.getUniqueId(), UUID.randomUUID(), targetRecord.id());
|
||||
when(sessions.active(target.getUniqueId())).thenReturn(java.util.Optional.of(targetActive));
|
||||
when(sessions.current(targetActive)).thenReturn(true); when(sessions.currentRecord(targetActive)).thenReturn(java.util.Optional.of(targetRecord));
|
||||
var party = new PartyRoster.Party(UUID.randomUUID(), source.getUniqueId(), List.of(source.getUniqueId(), target.getUniqueId()));
|
||||
when(parties.partyOf(source.getUniqueId())).thenReturn(java.util.Optional.of(party));
|
||||
when(parties.partyOf(target.getUniqueId())).thenReturn(java.util.Optional.of(party));
|
||||
var input = mock(ControlInput.class); when(input.player()).thenReturn(source); when(input.target()).thenReturn(target); when(input.session()).thenReturn(casterActive);
|
||||
var execution = mock(AbilityExecution.class); when(execution.stillCurrent()).thenReturn(true);
|
||||
when(execution.input()).thenReturn(input); when(execution.character()).thenReturn(casterRecord);
|
||||
var supports = new ArrayList<MendingLight.Support>();
|
||||
var effect = new MendingLight(MendingLight.Settings.defaults(), characters, sessions, parties, Runnable::run,
|
||||
ignored -> {}, () -> BigDecimal.valueOf(1024), supports::add);
|
||||
when(execution.definition()).thenReturn(effect.definition());
|
||||
var failed = new CompletableFuture<java.util.Optional<CharacterRecord>>();
|
||||
when(characters.saveHealth(eq(targetRecord.id()), eq(targetRecord.revision()), any(), any())).thenReturn(failed);
|
||||
effect.activate(execution); failed.completeExceptionally(new java.io.IOException("simulated"));
|
||||
verify(target, never()).setHealth(anyDouble()); assertTrue(supports.isEmpty()); verify(execution).finish();
|
||||
|
||||
var acknowledged = new CharacterRecord(targetRecord.id(), targetRecord.name(), targetRecord.revision().add(BigInteger.ONE),
|
||||
targetRecord.stats(), targetRecord.level(), targetRecord.experience(), targetRecord.unspentPoints(), BigDecimal.TEN,
|
||||
targetRecord.learned(), targetRecord.loadout(), targetRecord.cooldowns(), targetRecord.restrictedKills(), false, null, null);
|
||||
var delayed = new CompletableFuture<java.util.Optional<CharacterRecord>>();
|
||||
when(characters.saveHealth(eq(targetRecord.id()), eq(targetRecord.revision()), any(), any())).thenReturn(delayed);
|
||||
when(sessions.current(targetActive)).thenReturn(true);
|
||||
effect.activate(execution);
|
||||
when(sessions.current(targetActive)).thenReturn(false);
|
||||
delayed.complete(java.util.Optional.of(acknowledged));
|
||||
verify(target, never()).setHealth(anyDouble()); assertTrue(supports.isEmpty());
|
||||
verify(sessions, never()).acknowledgeProgress(eq(targetActive), any(), any());
|
||||
verify(execution, times(2)).finish();
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"mending-light-base-healing: 8\nmending-light-cooldown-seconds: 0\nmending-light-range-blocks: 16\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var definition = registry.find("mending_light").orElseThrow();
|
||||
assertEquals(java.time.Duration.ZERO, definition.cooldown());
|
||||
assertEquals(new BigDecimal("8"), definition.parameters().get("base-healing"));
|
||||
assertEquals(0, new BigDecimal("16").compareTo(definition.parameters().get("range")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "mending-light-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("mending-light-base-healing", "mending-light-cooldown-seconds", "mending-light-range-blocks"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "mending-light-"}));
|
||||
assertEquals(List.of("6", "8"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "mending-light-base-healing", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing"});
|
||||
assertTrue(sender.messages.getLast().contains("=8"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9"});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(new BigDecimal("9"), registry.find("mending_light").orElseThrow().parameters().get("base-healing"));
|
||||
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(1));
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 15, true);
|
||||
use(fixture, caster); drain(fixture, 3); assertEquals(10, ally.player.getHealth());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "-1"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
assertEquals(new BigDecimal("9"), reloaded.find("mending_light").orElseThrow().parameters().get("base-healing"));
|
||||
}
|
||||
for (String invalid : List.of("mending-light-base-healing: -1\n", "mending-light-cooldown-seconds: -1\n",
|
||||
"mending-light-range-blocks: 0\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactFormulaSupportsUncappedWisdomAndFloorDivision() {
|
||||
assertEquals(BigInteger.valueOf(7), MendingLight.healing(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.valueOf(5), MendingLight.healing(BigInteger.valueOf(9)));
|
||||
var huge = BigInteger.TEN.pow(500);
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)), MendingLight.healing(huge));
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "mending_light")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "mending_light")); drain(fixture, 2);
|
||||
}
|
||||
|
||||
private static PartyRoster.Party party(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor caster,
|
||||
FantasyStickLifecycleNativeTest.Actor ally) {
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(caster.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(ally.core.id, parties.invite(caster.core.id, group.id(), ally.core.id).orElseThrow().id()));
|
||||
return parties.partyOf(caster.core.id).orElseThrow();
|
||||
}
|
||||
|
||||
private static void aim(FantasyStickLifecycleNativeTest.Actor caster,
|
||||
FantasyStickLifecycleNativeTest.Actor target, double distance, boolean visible) {
|
||||
aim(caster, target.player, distance, visible);
|
||||
}
|
||||
|
||||
private static void aim(FantasyStickLifecycleNativeTest.Actor caster, Player target, double distance, boolean visible) {
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
doReturn(new Location(caster.world, distance, 64, 0)).when(target).getLocation();
|
||||
doReturn(visible).when(caster.player).hasLineOfSight(target);
|
||||
doReturn(target).when(caster.player).getTargetEntity(64);
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
}
|
||||
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
|
||||
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(book);
|
||||
for (var page : book.pages()) { var found = button(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + ability);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom button(net.minecraft.network.chat.Component component, String label, String ability) {
|
||||
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
|
||||
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
|
||||
&& payload.value().contains(":" + ability)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = button(child, label, ability); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.WIS, BigInteger.valueOf(12)); stats.put(Stat.CHR, BigInteger.TEN);
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
|
||||
learned ? Set.of("mending_light") : old.learned(),
|
||||
learned ? List.of("mending_light", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static void setHealth(Path directory, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static CharacterRecord recordWith(UUID id, String ability, BigDecimal health) {
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(base.stats()); stats.put(Stat.WIS, BigInteger.valueOf(12)); stats.put(Stat.CHR, BigInteger.TEN);
|
||||
return new CharacterRecord(id, base.name(), base.revision(), stats, base.level(), base.experience(), base.unspentPoints(),
|
||||
health, ability == null ? Set.of() : Set.of(ability), ability == null ? base.loadout() : List.of(ability, "", "", "", "", "", ""),
|
||||
base.cooldowns(), base.restrictedKills(), false, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityExecution;
|
||||
import games.dmg.fantasystick.ability.AbilityReport;
|
||||
import games.dmg.fantasystick.ability.OutgoingDamageBonus;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class OutgoingDamageBonusNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void strongestDeterministicGrantAppliesOnceAndReportsOnlyAcceptedAllyUse() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
|
||||
var recipient = mock(Player.class); var enemy = mock(LivingEntity.class);
|
||||
var recipientId = UUID.randomUUID(); when(recipient.getUniqueId()).thenReturn(recipientId);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var aided = new EquipmentSessions.Active(recipientId, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(recipientId)).thenReturn(java.util.Optional.of(aided)); when(sessions.current(aided)).thenReturn(true);
|
||||
var sourcePlayer = mock(Player.class); when(sourcePlayer.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var party = new PartyRoster.Party(UUID.randomUUID(), sourcePlayer.getUniqueId(), List.of(sourcePlayer.getUniqueId(), recipientId));
|
||||
when(parties.partyOf(sourcePlayer.getUniqueId())).thenReturn(java.util.Optional.of(party));
|
||||
when(parties.partyOf(recipientId)).thenReturn(java.util.Optional.of(party));
|
||||
var supports = new ArrayList<OutgoingDamageBonus.Support>();
|
||||
var bonuses = new OutgoingDamageBonus(sessions, parties, supports::add);
|
||||
var first = execution(sourcePlayer, UUID.randomUUID()); var strongest = execution(sourcePlayer, UUID.randomUUID());
|
||||
var tiedLater = execution(sourcePlayer, UUID.randomUUID());
|
||||
var firstGrant = bonuses.grant(first, recipient, new BigDecimal("20"), () -> true);
|
||||
var strongestGrant = bonuses.grant(strongest, recipient, new BigDecimal("25"), () -> true);
|
||||
var tiedGrant = bonuses.grant(tiedLater, recipient, new BigDecimal("25"), () -> true);
|
||||
|
||||
var hit = hit(recipient, enemy, 10); bonuses.increase(hit); assertEquals(12.5, hit.getDamage(), 1.0e-9);
|
||||
bonuses.accepted(hit);
|
||||
assertEquals(1, supports.size()); assertEquals(0, new BigDecimal("2.50").compareTo(supports.getFirst().amount()));
|
||||
assertEquals(strongest.input().session().character(), supports.getFirst().supportingCharacter());
|
||||
verify(strongest).report(eq(AbilityReport.Type.BOOST_USED), eq(enemy),
|
||||
argThat(amount -> amount.compareTo(new BigDecimal("2.50")) == 0));
|
||||
verify(first, never()).report(any(), any(), any()); verify(tiedLater, never()).report(any(), any(), any());
|
||||
|
||||
var cancelled = hit(recipient, enemy, 10); bonuses.increase(cancelled); cancelled.setCancelled(true); bonuses.accepted(cancelled);
|
||||
assertEquals(1, supports.size());
|
||||
firstGrant.close(); strongestGrant.close(); tiedGrant.close(); bonuses.close();
|
||||
}
|
||||
|
||||
@Test void invalidOrSelfUseAndMarkedGuardianRedirectionCreateNoSupportOrRepeatedBonus() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
|
||||
var player = mock(Player.class); var enemy = mock(LivingEntity.class); var id = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(id)).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
var supports = new ArrayList<OutgoingDamageBonus.Support>(); var bonuses = new OutgoingDamageBonus(sessions, parties, supports::add);
|
||||
var valid = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var self = execution(player, active.character()); bonuses.grant(self, player, new BigDecimal("20"), valid::get);
|
||||
var hit = hit(player, enemy, 10); bonuses.increase(hit); bonuses.accepted(hit);
|
||||
assertEquals(12, hit.getDamage(), 1.0e-9); assertTrue(supports.isEmpty());
|
||||
when(sessions.current(active)).thenReturn(false); valid.set(false);
|
||||
var stale = hit(player, enemy, 10); bonuses.increase(stale); assertEquals(10, stale.getDamage(), 1.0e-9);
|
||||
bonuses.close();
|
||||
}
|
||||
|
||||
private static AbilityExecution execution(Player source, UUID character) {
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
var active = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), character);
|
||||
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true); return execution;
|
||||
}
|
||||
private static EntityDamageByEntityEvent hit(Player attacker, LivingEntity 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PurifyNativeTest {
|
||||
private static List<PotionEffectType> harmful;
|
||||
@TempDir Path data;
|
||||
|
||||
@BeforeAll static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
harmful = List.of(PotionEffectType.POISON, PotionEffectType.WITHER,
|
||||
PotionEffectType.SLOWNESS, PotionEffectType.WEAKNESS);
|
||||
}
|
||||
|
||||
@Test void bookAcquisitionSneakUseRemovesEveryHarmfulLayerAndPreservesBeneficialEffectsAndCooldown() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, false);
|
||||
fixture.players.put(actor.core.id, actor.player); fixture.activate(actor);
|
||||
var effects = effects(fixture, actor.player);
|
||||
harmful.forEach(type -> effects.put(type, layered(type)));
|
||||
effects.put(PotionEffectType.REGENERATION, instance(PotionEffectType.REGENERATION, 200, 1));
|
||||
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
String book = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("Purify")); assertTrue(book.contains("WIS14")); assertTrue(book.contains("CHR10"));
|
||||
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("15")); assertTrue(book.contains("12"));
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
|
||||
doReturn(true).when(actor.player).isSneaking(); use(fixture, actor); drain(fixture, 2);
|
||||
|
||||
harmful.forEach(type -> assertFalse(effects.containsKey(type), type + " and its hidden chain must be gone"));
|
||||
assertTrue(effects.containsKey(PotionEffectType.REGENERATION));
|
||||
var storage = storage(fixture); var saved = storage.find(actor.core.record.id()).get().orElseThrow();
|
||||
var deadline = saved.cooldowns().get("purify"); assertNotNull(deadline);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
var unlearn = button(actor, "[Unlearn]", "purify");
|
||||
PartyBookNativeTest.click(fixture, actor, unlearn); PartyBookNativeTest.click(fixture, actor, unlearn); drain(fixture, 2);
|
||||
assertEquals(deadline, storage.find(actor.core.record.id()).get().orElseThrow().cooldowns().get("purify"));
|
||||
learnAndAssign(fixture, actor); use(fixture, actor);
|
||||
assertTrue(actor.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void aimedAllyActualCleanseCreatesPersistedSupportShareThatFollowsSourceCharacter() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepare(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally.player, 8, true);
|
||||
var effects = effects(fixture, ally.player); effects.put(PotionEffectType.POISON, layered(PotionEffectType.POISON));
|
||||
doReturn(false).when(caster.player).isSneaking(); use(fixture, caster); drain(fixture, 2);
|
||||
assertFalse(effects.containsKey(PotionEffectType.POISON));
|
||||
|
||||
var sheet = caster.core.inventory.getItemInOffHand().clone(); caster.core.inventory.setItemInOffHand(null);
|
||||
fixture.changed(caster); drain(fixture, 1); fixture.awaitCharacterAndReceipt(caster);
|
||||
var holder = new FantasyStickLifecycleNativeTest.Actor(data); holder.core.inventory.setItemInOffHand(sheet);
|
||||
fixture.players.put(holder.core.id, holder.player); fixture.activate(holder);
|
||||
|
||||
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(caster.world.getUID()).thenReturn(UUID.randomUUID()); when(enemy.getLocation()).thenReturn(new Location(caster.world, 8, 64, 0));
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
fixture.dispatcher.accept(new EntityDamageByEntityEvent(ally.player, enemy,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 3));
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
assertTrue(holder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void normalSelfOrdinaryNonPartyOutOfRangeObstructedAndNoOpTargetsRefuseBeforeCooldown() throws Exception {
|
||||
for (int route = 0; route < 6; route++) {
|
||||
var directory = data.resolve("refusal-" + route);
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(directory); prepare(directory, caster, true);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
|
||||
Player target;
|
||||
if (route == 0) { target = caster.player; doReturn(false).when(caster.player).isSneaking(); }
|
||||
else if (route == 1) { target = mock(Player.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID()); }
|
||||
else {
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(directory); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(ally); target = ally.player; effects(fixture, ally.player);
|
||||
if (route != 2) party(fixture, caster, ally);
|
||||
}
|
||||
if (route != 5 && route > 1) effectMap(target).put(PotionEffectType.WEAKNESS, instance(PotionEffectType.WEAKNESS, 200, 0));
|
||||
aim(caster, target, route == 3 ? 13 : 8, route != 4); doReturn(false).when(caster.player).isSneaking();
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("purify"),
|
||||
"route " + route + " must refuse before cooldown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledRemovalAndSelfCleanseCreateNoSupportWhileAcknowledgedCooldownRemains() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepare(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally.player, 8, true);
|
||||
var effects = effects(fixture, ally.player); effects.put(PotionEffectType.POISON, layered(PotionEffectType.POISON));
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void removal(EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() == ally.player && event.getAction() == EntityPotionEffectEvent.Action.REMOVED) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin); doReturn(false).when(caster.player).isSneaking();
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertTrue(effects.containsKey(PotionEffectType.POISON));
|
||||
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("purify"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void selfCleanseNeverEmitsSupportEvidence() {
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var parties = mock(games.dmg.fantasystick.party.PartyRoster.class); var player = mock(Player.class);
|
||||
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
|
||||
when(player.getUniqueId()).thenReturn(active.player()); when(player.isSneaking()).thenReturn(true);
|
||||
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
var poison = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
when(player.getPotionEffect(PotionEffectType.POISON)).thenAnswer(ignored -> poison.get()
|
||||
? new PotionEffect(PotionEffectType.POISON, 200, 0) : null);
|
||||
doAnswer(ignored -> { poison.set(false); return null; }).when(player).removePotionEffect(PotionEffectType.POISON);
|
||||
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
|
||||
when(input.player()).thenReturn(player); when(input.target()).thenReturn(player); when(input.session()).thenReturn(active);
|
||||
var supports = new ArrayList<games.dmg.fantasystick.ability.Purify.Support>();
|
||||
var purify = new games.dmg.fantasystick.ability.Purify(
|
||||
games.dmg.fantasystick.ability.Purify.Settings.defaults(), sessions, parties, supports::add);
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
when(execution.stillCurrent()).thenReturn(true); when(execution.input()).thenReturn(input);
|
||||
when(execution.definition()).thenReturn(purify.definition());
|
||||
purify.activate(execution);
|
||||
assertFalse(poison.get()); assertTrue(supports.isEmpty()); verify(player).removePotionEffect(PotionEffectType.POISON);
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "purify-cooldown-seconds: 0\npurify-range-blocks: 16\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var definition = registry.find("purify").orElseThrow(); assertEquals(java.time.Duration.ZERO, definition.cooldown());
|
||||
assertEquals(0, new BigDecimal("16").compareTo(definition.parameters().get("range")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "purify-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("purify-cooldown-seconds", "purify-range-blocks"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "purify-"}));
|
||||
assertEquals(List.of("12", "16"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "purify-range-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=16"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20"});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(0, new BigDecimal("20").compareTo(registry.find("purify").orElseThrow().parameters().get("range")));
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepare(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally.player, 19, true);
|
||||
effects(fixture, ally.player).put(PotionEffectType.SLOWNESS, instance(PotionEffectType.SLOWNESS, 200, 0));
|
||||
doReturn(false).when(caster.player).isSneaking(); use(fixture, caster); drain(fixture, 2);
|
||||
assertNull(ally.player.getPotionEffect(PotionEffectType.SLOWNESS));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
assertEquals(0, new BigDecimal("20").compareTo(reloaded.find("purify").orElseThrow().parameters().get("range")));
|
||||
}
|
||||
for (String invalid : List.of("purify-cooldown-seconds: -1\n", "purify-range-blocks: 0\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
|
||||
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<Player, Map<PotionEffectType, MobEffectInstance>> EFFECTS = new java.util.WeakHashMap<>();
|
||||
|
||||
private static Map<PotionEffectType, MobEffectInstance> effects(FantasyStickLifecycleNativeTest.Fixture fixture, Player player) {
|
||||
var active = new HashMap<PotionEffectType, MobEffectInstance>(); EFFECTS.put(player, active);
|
||||
doAnswer(call -> {
|
||||
var effect = active.get(call.getArgument(0));
|
||||
return effect == null ? null : new PotionEffect(call.getArgument(0), effect.getDuration(), effect.getAmplifier());
|
||||
}).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> {
|
||||
PotionEffectType type = call.getArgument(0); var current = active.get(type);
|
||||
if (current == null) return null;
|
||||
var old = new PotionEffect(type, current.getDuration(), current.getAmplifier());
|
||||
var event = new EntityPotionEffectEvent(player, old, null, null, EntityPotionEffectEvent.Cause.MILK,
|
||||
EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
fixture.dispatcher.accept(event); if (!event.isCancelled()) active.remove(type); return null;
|
||||
}).when(player).removePotionEffect(any());
|
||||
return active;
|
||||
}
|
||||
|
||||
private static Map<PotionEffectType, MobEffectInstance> effectMap(Player player) {
|
||||
return EFFECTS.computeIfAbsent(player, ignored -> new HashMap<>());
|
||||
}
|
||||
|
||||
private static MobEffectInstance layered(PotionEffectType type) {
|
||||
var current = instance(type, 200, 0); current.update(instance(type, 40, 1));
|
||||
assertNotNull(current.hiddenEffect); return current;
|
||||
}
|
||||
|
||||
private static MobEffectInstance instance(PotionEffectType type, int duration, int amplifier) {
|
||||
return new MobEffectInstance(org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(type), duration, amplifier);
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
|
||||
}
|
||||
|
||||
private static void party(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor caster,
|
||||
FantasyStickLifecycleNativeTest.Actor ally) {
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(caster.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(ally.core.id, parties.invite(caster.core.id, group.id(), ally.core.id).orElseThrow().id()));
|
||||
}
|
||||
|
||||
private static void aim(FantasyStickLifecycleNativeTest.Actor caster, Player target, double distance, boolean visible) {
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
doReturn(new Location(caster.world, distance, 64, 0)).when(target).getLocation();
|
||||
doReturn(visible).when(caster.player).hasLineOfSight(target); doReturn(target).when(caster.player).getTargetEntity(64);
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
}
|
||||
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
|
||||
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
|
||||
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(book);
|
||||
for (var page : book.pages()) { var found = button(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + ability);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom button(net.minecraft.network.chat.Component component, String label, String ability) {
|
||||
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
|
||||
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
|
||||
&& payload.value().contains(":" + ability)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = button(child, label, ability); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void prepare(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.WIS, BigInteger.valueOf(14)); stats.put(Stat.CHR, BigInteger.TEN);
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
|
||||
learned ? Set.of("purify") : old.learned(), learned ? List.of("purify", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,7 @@ class QuickstepNativeTest {
|
||||
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
|
||||
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("config", "give", "inspect", "recover"),
|
||||
assertEquals(List.of("config", "experience", "give", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
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.OutgoingDamageBonus;
|
||||
import games.dmg.fantasystick.ability.RallyingCry;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import 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.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
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.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class RallyingCryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseBuffsCasterAndNearbyPartyAllyAndRewardsActualUse() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, false);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); nearby(caster, List.of(ally.player));
|
||||
var casterEffects = effects(caster.player); var allyEffects = effects(ally.player);
|
||||
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("RallyingCry")); assertTrue(text.contains("CHR16")); assertTrue(text.contains("CON12"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("20%")); assertTrue(text.contains("6seconds"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "rallying_cry")); drain(fixture, 2);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(0, casterEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
assertEquals(0, allyEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry"));
|
||||
|
||||
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(caster.world, 4, 64, 0)); when(caster.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
var hit = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.dispatcher.accept(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
fixture.delayed.getLast().run();
|
||||
assertFalse(casterEffects.containsKey(PotionEffectType.SPEED)); assertFalse(allyEffects.containsKey(PotionEffectType.SPEED));
|
||||
var expired = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.dispatcher.accept(expired); assertEquals(10, expired.getDamage(), 1.0e-9);
|
||||
var deadline = storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry");
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
|
||||
var unlearn = button(caster, "[Unlearn]", "rallying_cry");
|
||||
PartyBookNativeTest.click(fixture, caster, unlearn); PartyBookNativeTest.click(fixture, caster, unlearn); drain(fixture, 2);
|
||||
assertEquals(deadline, storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry"));
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "rallying_cry")); drain(fixture, 2);
|
||||
use(fixture, caster); assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void dynamicSphereMembershipSessionAndSourceValidityRecheckOwnedBenefits() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
|
||||
var bonus = mock(OutgoingDamageBonus.class); var sourceGrant = mock(AutoCloseable.class); var allyGrant = mock(AutoCloseable.class);
|
||||
when(bonus.grant(any(), any(), any(), any())).thenReturn(sourceGrant, allyGrant);
|
||||
var source = mock(Player.class); var ally = mock(Player.class); var outsider = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); var sourceId = UUID.randomUUID(); var allyId = UUID.randomUUID(); var outsiderId = UUID.randomUUID();
|
||||
when(source.getUniqueId()).thenReturn(sourceId); when(ally.getUniqueId()).thenReturn(allyId); when(outsider.getUniqueId()).thenReturn(outsiderId);
|
||||
when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(ally.getLocation()).thenReturn(new Location(world, 7, 64, 0));
|
||||
when(outsider.getLocation()).thenReturn(new Location(world, 2, 64, 0));
|
||||
when(source.getNearbyEntities(8, 8, 8)).thenReturn(List.of(ally, outsider));
|
||||
var sourceEffects = effects(source); var allyEffects = effects(ally); effects(outsider);
|
||||
var sourceActive = new EquipmentSessions.Active(sourceId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var allyActive = new EquipmentSessions.Active(allyId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var outsiderActive = new EquipmentSessions.Active(outsiderId, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(sourceId)).thenReturn(java.util.Optional.of(sourceActive)); when(sessions.current(sourceActive)).thenReturn(true);
|
||||
when(sessions.active(allyId)).thenReturn(java.util.Optional.of(allyActive)); when(sessions.current(allyActive)).thenReturn(true);
|
||||
when(sessions.active(outsiderId)).thenReturn(java.util.Optional.of(outsiderActive)); when(sessions.current(outsiderActive)).thenReturn(true);
|
||||
var party = new PartyRoster.Party(UUID.randomUUID(), sourceId, List.of(sourceId, allyId));
|
||||
when(parties.partyOf(sourceId)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party));
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
when(input.player()).thenReturn(source); when(input.session()).thenReturn(sourceActive);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
|
||||
var rally = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties, bonus); when(execution.definition()).thenReturn(rally.definition());
|
||||
rally.activate(execution);
|
||||
assertTrue(sourceEffects.containsKey(PotionEffectType.SPEED)); assertTrue(allyEffects.containsKey(PotionEffectType.SPEED));
|
||||
verify(bonus, times(2)).grant(eq(execution), any(), eq(new BigDecimal("20")), any());
|
||||
|
||||
when(ally.getLocation()).thenReturn(new Location(world, 9, 64, 0)); rally.sweep();
|
||||
assertFalse(allyEffects.containsKey(PotionEffectType.SPEED)); verify(allyGrant).close();
|
||||
when(ally.getLocation()).thenReturn(new Location(world, 7, 64, 0)); rally.sweep();
|
||||
assertTrue(allyEffects.containsKey(PotionEffectType.SPEED), "A valid party ally re-entering the active cry is rechecked");
|
||||
when(parties.partyOf(allyId)).thenReturn(java.util.Optional.empty()); rally.sweep();
|
||||
assertFalse(allyEffects.containsKey(PotionEffectType.SPEED));
|
||||
when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party)); rally.sweep();
|
||||
assertTrue(allyEffects.containsKey(PotionEffectType.SPEED));
|
||||
when(sessions.current(allyActive)).thenReturn(false); rally.sweep();
|
||||
assertFalse(allyEffects.containsKey(PotionEffectType.SPEED));
|
||||
when(sessions.current(sourceActive)).thenReturn(false); rally.sweep(); verify(execution).finish(); verify(sourceGrant).close();
|
||||
}
|
||||
|
||||
@Test void duplicateRalliesDoNotStackSpeedOrDamageAndPreserveStrongerUnrelatedSpeed() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class);
|
||||
var supports = new ArrayList<OutgoingDamageBonus.Support>(); var bonus = new OutgoingDamageBonus(sessions, parties, supports::add);
|
||||
var sourceA = mock(Player.class); var sourceB = mock(Player.class); var ally = mock(Player.class); var enemy = mock(LivingEntity.class);
|
||||
var world = mock(org.bukkit.World.class); UUID a = UUID.randomUUID(), b = UUID.randomUUID(), c = UUID.randomUUID();
|
||||
when(sourceA.getUniqueId()).thenReturn(a); when(sourceB.getUniqueId()).thenReturn(b); when(ally.getUniqueId()).thenReturn(c);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
for (var player : List.of(sourceA, sourceB, ally)) when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(sourceA.getNearbyEntities(8, 8, 8)).thenReturn(List.of(sourceB, ally));
|
||||
when(sourceB.getNearbyEntities(8, 8, 8)).thenReturn(List.of(sourceA, ally));
|
||||
effects(sourceA); effects(sourceB); var allyEffects = effects(ally);
|
||||
allyEffects.put(PotionEffectType.SPEED, new PotionEffect(PotionEffectType.SPEED, 200, 1));
|
||||
var aa = new EquipmentSessions.Active(a, UUID.randomUUID(), UUID.randomUUID());
|
||||
var ba = new EquipmentSessions.Active(b, UUID.randomUUID(), UUID.randomUUID());
|
||||
var ca = new EquipmentSessions.Active(c, UUID.randomUUID(), UUID.randomUUID());
|
||||
when(sessions.active(a)).thenReturn(java.util.Optional.of(aa)); when(sessions.active(b)).thenReturn(java.util.Optional.of(ba));
|
||||
when(sessions.active(c)).thenReturn(java.util.Optional.of(ca)); when(sessions.current(any())).thenReturn(true);
|
||||
var party = new PartyRoster.Party(UUID.randomUUID(), a, List.of(a, b, c));
|
||||
when(parties.partyOf(a)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(b)).thenReturn(java.util.Optional.of(party));
|
||||
when(parties.partyOf(c)).thenReturn(java.util.Optional.of(party));
|
||||
var rally = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties, bonus);
|
||||
var first = execution(sourceA, aa, rally); var second = execution(sourceB, ba, rally); rally.activate(first); rally.activate(second);
|
||||
var hit = hit(ally, enemy, 10); bonus.increase(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
|
||||
assertEquals(1, allyEffects.get(PotionEffectType.SPEED).getAmplifier(), "Stronger unrelated Speed is untouched");
|
||||
rally.close(); bonus.close(); assertEquals(1, allyEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"rallying-cry-cooldown-seconds: 2\nrallying-cry-radius-blocks: 10\nrallying-cry-duration-seconds: 8\n"
|
||||
+ "rallying-cry-speed-amplifier: 1\nrallying-cry-damage-bonus-percent: 30\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("rallying_cry").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(0, new BigDecimal("10").compareTo(definition.parameters().get("radius")));
|
||||
assertEquals(0, new BigDecimal("8").compareTo(definition.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("1"), definition.parameters().get("speed-amplifier"));
|
||||
assertEquals(new BigDecimal("30"), definition.parameters().get("damage-bonus-percent"));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "rallying-cry-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks", "12"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("rallying-cry-cooldown-seconds", "rallying-cry-damage-bonus-percent",
|
||||
"rallying-cry-duration-seconds", "rallying-cry-radius-blocks", "rallying-cry-speed-amplifier"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "rallying-cry-"}));
|
||||
assertEquals(List.of("10", "8"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "rallying-cry-radius-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=10"));
|
||||
for (String[] update : List.of(new String[]{"rallying-cry-cooldown-seconds", "3"},
|
||||
new String[]{"rallying-cry-radius-blocks", "12"}, new String[]{"rallying-cry-duration-seconds", "9"},
|
||||
new String[]{"rallying-cry-speed-amplifier", "2"}, new String[]{"rallying-cry-damage-bonus-percent", "40"})) {
|
||||
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("rallying_cry").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
|
||||
assertEquals(0, new BigDecimal("12").compareTo(configured.parameters().get("radius")));
|
||||
assertEquals(0, new BigDecimal("9").compareTo(configured.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("2"), configured.parameters().get("speed-amplifier"));
|
||||
assertEquals(new BigDecimal("40"), configured.parameters().get("damage-bonus-percent"));
|
||||
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); nearby(caster, List.of(ally.player));
|
||||
var casterEffects = effects(caster.player); var allyEffects = effects(ally.player); use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(2, casterEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
assertEquals(2, allyEffects.get(PotionEffectType.SPEED).getAmplifier()); assertEquals(180L, fixture.delayedTicks.getLast());
|
||||
var futureTarget = mock(LivingEntity.class); when(futureTarget.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var future = hit(ally.player, futureTarget, 10); fixture.dispatcher.accept(future);
|
||||
assertEquals(14, future.getDamage(), 1.0e-9);
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-duration-seconds", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks", "12", "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 saved = reloaded.find("rallying_cry").orElseThrow(); assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
assertEquals(new BigDecimal("12"), saved.parameters().get("radius")); assertEquals(new BigDecimal("40"), saved.parameters().get("damage-bonus-percent"));
|
||||
assertEquals(0, new BigDecimal("9").compareTo(saved.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("2"), saved.parameters().get("speed-amplifier"));
|
||||
}
|
||||
for (String invalid : List.of("rallying-cry-cooldown-seconds: -1\n", "rallying-cry-radius-blocks: 0\n",
|
||||
"rallying-cry-duration-seconds: 0\n", "rallying-cry-speed-amplifier: -1\n",
|
||||
"rallying-cry-damage-bonus-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 static AbilityExecution execution(Player player, EquipmentSessions.Active active, RallyingCry rally) {
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
when(input.player()).thenReturn(player); when(input.session()).thenReturn(active);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true); when(execution.definition()).thenReturn(rally.definition());
|
||||
return execution;
|
||||
}
|
||||
private static Map<org.bukkit.potion.PotionEffectType, PotionEffect> effects(Player player) {
|
||||
var effects = new HashMap<org.bukkit.potion.PotionEffectType, PotionEffect>();
|
||||
doAnswer(call -> effects.get(call.getArgument(0))).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> { PotionEffect effect = call.getArgument(0); effects.put(effect.getType(), effect); return true; })
|
||||
.when(player).addPotionEffect(any(), eq(true));
|
||||
doAnswer(call -> { effects.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return effects;
|
||||
}
|
||||
private static void nearby(FantasyStickLifecycleNativeTest.Actor source, List<? extends org.bukkit.entity.Entity> entities) {
|
||||
doReturn(new Location(source.world, 0, 64, 0)).when(source.player).getLocation();
|
||||
doReturn(new ArrayList<>(entities)).when(source.player).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
for (var entity : entities) if (entity instanceof Player player) doReturn(new Location(source.world, 4, 64, 0)).when(player).getLocation();
|
||||
}
|
||||
private static EntityDamageByEntityEvent hit(Player attacker, LivingEntity 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 group;
|
||||
}
|
||||
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 prepareCaster(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.CHR, BigInteger.valueOf(16)); stats.put(Stat.CON, BigInteger.valueOf(12));
|
||||
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("rallying_cry") : old.learned(), learned ? List.of("rallying_cry", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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.*;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.*;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SanctuaryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedAreaHealingCreatesPersistedSupportReward() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data,caster,false); setHealth(data,ally,BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id,caster.player);fixture.players.put(ally.core.id,ally.player);fixture.activate(caster);fixture.activate(ally);party(fixture,caster,ally);
|
||||
caster.core.location=new Location(caster.world,0,64,0);ally.core.location=new Location(caster.world,2,64,0);
|
||||
doReturn(List.of(ally.player)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(fixture,caster);
|
||||
String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Sanctuary"));assertTrue(text.contains("WIS24"));assertTrue(text.contains("CHR20"));assertTrue(text.contains("CON10"));
|
||||
PartyBookNativeTest.click(fixture,caster,button(caster,"[Learn]","sanctuary"));drain(fixture,2);
|
||||
PartyBookNativeTest.click(fixture,caster,button(caster,"[3]","sanctuary"));drain(fixture,2);
|
||||
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
|
||||
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(caster.world.getUID()).thenReturn(UUID.randomUUID());when(enemy.getLocation()).thenReturn(new Location(caster.world,8,64,0));
|
||||
var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(fixture,4);
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"characters");
|
||||
assertEquals(BigInteger.valueOf(5),storage.find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5),storage.find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fixedCylinderHasNoImmediatePulseThenSixOwnedHealingAndMitigationPulses() {
|
||||
var fixture = directFixture(); fixture.sanctuary.activate(fixture.execution);
|
||||
assertEquals(10, fixture.allyHealth.get(), 0); assertEquals(1, fixture.scheduled.size());
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class);
|
||||
var source = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9); fixture.mitigation.accepted(hit);
|
||||
verify(fixture.execution).report(eq(AbilityReport.Type.PREVENTION), eq(fixture.ally), eq(BigDecimal.valueOf(2.0)));
|
||||
run(fixture.scheduled, 9); assertEquals(10, fixture.allyHealth.get(), 0);
|
||||
run(fixture.scheduled, 1); assertEquals(12, fixture.allyHealth.get(), 0); assertEquals(1, fixture.supports.size());
|
||||
run(fixture.scheduled, 50); assertEquals(20, fixture.allyHealth.get(), 0, "Six two-point pulses cap at maximum health");
|
||||
assertEquals(5, fixture.supports.size(), "The sixth overhealing pulse creates no support evidence");
|
||||
verify(fixture.execution, atLeastOnce()).finish();
|
||||
}
|
||||
|
||||
@Test void exactHorizontalAndVerticalCylinderBoundaryIsIncludedButOutsideIsRemoved() {
|
||||
var fixture=directFixture();fixture.allyLocation.set(new Location(fixture.world,6,67,0));fixture.sanctuary.activate(fixture.execution);
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);var source=DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var boundary=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,fixture.ally,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);
|
||||
fixture.mitigation.reduce(boundary);assertEquals(8,boundary.getDamage(),0);
|
||||
fixture.allyLocation.set(new Location(fixture.world,6.01,67,0));run(fixture.scheduled,1);
|
||||
var outside=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,fixture.ally,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);
|
||||
fixture.mitigation.reduce(outside);assertEquals(10,outside.getDamage(),0);
|
||||
}
|
||||
|
||||
@Test void leavingCylinderOrObstructionEndsBenefitsAndSourceInvalidationCleansUp() {
|
||||
var fixture = directFixture(); fixture.sanctuary.activate(fixture.execution); run(fixture.scheduled, 10);
|
||||
doReturn(mock(org.bukkit.util.RayTraceResult.class)).when(fixture.world).rayTraceBlocks(any(Location.class),
|
||||
any(org.bukkit.util.Vector.class), anyDouble(), eq(org.bukkit.FluidCollisionMode.NEVER), eq(true));
|
||||
run(fixture.scheduled, 1);
|
||||
fixture.allyLocation.set(new Location(fixture.world, 2, 68, 0));
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); var source = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.mitigation.reduce(hit); assertEquals(10, hit.getDamage(), 0);
|
||||
when(fixture.execution.stillCurrent()).thenReturn(false); fixture.scheduled.removeFirst().run(); verify(fixture.execution).finish();
|
||||
}
|
||||
|
||||
@Test void overlappingSanctuariesUseEarliestSourceForOnePulseAndStrongestReduction() {
|
||||
var fixture = directFixture(); var second = execution(fixture.source, fixture.sourceActive);
|
||||
when(second.definition()).thenReturn(fixture.sanctuary.definition()); var character = fixture.execution.character();
|
||||
when(second.character()).thenReturn(character);
|
||||
doAnswer(call->{fixture.scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);})
|
||||
.when(second).later(any(),any());
|
||||
fixture.sanctuary.activate(fixture.execution); fixture.sanctuary.activate(second);
|
||||
for (int i = 0; i < 20; i++) fixture.scheduled.removeFirst().run();
|
||||
assertEquals(12, fixture.allyHealth.get(), 0); assertEquals(1, fixture.supports.size(), "Overlap supplies one healing pulse");
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); var damageSource = DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally, EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource, 10);
|
||||
fixture.mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 0, "Two sanctuaries do not multiply reduction");
|
||||
fixture.mitigation.accepted(hit); verify(fixture.execution).report(eq(AbilityReport.Type.PREVENTION), eq(fixture.ally), any());
|
||||
verify(second, never()).report(eq(AbilityReport.Type.PREVENTION), any(), any());
|
||||
var stone = mock(AbilityExecution.class); when(stone.stillCurrent()).thenReturn(true);
|
||||
fixture.mitigation.grant(stone, fixture.ally, BigDecimal.valueOf(50));
|
||||
var stronger = new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy, fixture.ally,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource, 10);
|
||||
fixture.mitigation.reduce(stronger); assertEquals(5, stronger.getDamage(), 0, "Stronger Stone Skin-style mitigation supersedes Sanctuary");
|
||||
}
|
||||
|
||||
@Test void definitionAndPluginConfigurationExposeAllApprovedValues() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "sanctuary-cooldown-seconds: 30\nsanctuary-radius-blocks: 7\n"
|
||||
+ "sanctuary-vertical-range-blocks: 4\nsanctuary-duration-seconds: 4\nsanctuary-pulse-interval-seconds: 1\n"
|
||||
+ "sanctuary-healing-per-pulse: 3\nsanctuary-damage-reduction-percent: 25\n");
|
||||
try (var plugin = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
plugin.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin, "abilityRegistry");
|
||||
var definition = registry.find("sanctuary").orElseThrow(); assertEquals(org.bukkit.Material.SEA_LANTERN, definition.icon());
|
||||
assertEquals(BigInteger.valueOf(24), definition.requirements().get(Stat.WIS)); assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.CHR));
|
||||
assertEquals(BigInteger.TEN, definition.requirements().get(Stat.CON)); assertEquals(java.time.Duration.ofSeconds(30), definition.cooldown());
|
||||
assertEquals(new BigDecimal("3"), definition.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("25"), definition.parameters().get("damage-reduction-percent"));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-"}).isEmpty());
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("sanctuary-cooldown-seconds","sanctuary-damage-reduction-percent","sanctuary-duration-seconds",
|
||||
"sanctuary-healing-per-pulse","sanctuary-pulse-interval-seconds","sanctuary-radius-blocks","sanctuary-vertical-range-blocks"),
|
||||
plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-"}));
|
||||
assertEquals(List.of("2","3"),plugin.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sanctuary-healing-per-pulse",""}));
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-healing-per-pulse"});assertTrue(sender.messages.getLast().contains("=3"));
|
||||
for(String[] update:List.of(new String[]{"sanctuary-cooldown-seconds","20"},new String[]{"sanctuary-radius-blocks","8"},
|
||||
new String[]{"sanctuary-vertical-range-blocks","5"},new String[]{"sanctuary-duration-seconds","5"},
|
||||
new String[]{"sanctuary-pulse-interval-seconds","1"},new String[]{"sanctuary-healing-per-pulse","4"},
|
||||
new String[]{"sanctuary-damage-reduction-percent","30"})){
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
|
||||
var configured=registry.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),configured.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("30"),configured.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),configured.cooldown());
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8","extra"});assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
plugin.plugin.setEnabled(false);plugin.plugin.setEnabled(true);
|
||||
var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin,"abilityRegistry");
|
||||
var saved=reloaded.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),saved.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("30"),saved.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),saved.cooldown());
|
||||
}
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor){actor.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,actor.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,org.bukkit.inventory.EquipmentSlot.HAND));}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture,int count)throws Exception{for(int i=0;i<count;i++)PartyBookNativeTest.drain(fixture);}
|
||||
private static void 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()));}
|
||||
private static net.minecraft.network.chat.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);}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom button(net.minecraft.network.chat.Component component,String label,String ability){if(component.getString().equals(label)&&component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.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 prepareCaster(Path directory,FantasyStickLifecycleNativeTest.Actor actor,boolean learned)throws Exception{java.nio.file.Files.createDirectories(directory);var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();var stats=new EnumMap<>(old.stats());stats.put(Stat.WIS,BigInteger.valueOf(24));stats.put(Stat.CHR,BigInteger.valueOf(20));stats.put(Stat.CON,BigInteger.TEN);repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),BigInteger.valueOf(learned?0:1),old.health(),learned?Set.of("sanctuary"):old.learned(),learned?List.of("sanctuary","","","","","",""):old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
|
||||
private static void setHealth(Path directory,FantasyStickLifecycleNativeTest.Actor actor,BigDecimal health)throws Exception{var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory);var old=repository.load(actor.core.record.id()).orElseThrow();repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),health,old.learned(),old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());actor.core.health=health.doubleValue();}
|
||||
|
||||
private static void run(ArrayDeque<Runnable> scheduled,int count){for(int i=0;i<count;i++){assertFalse(scheduled.isEmpty());scheduled.removeFirst().run();}}
|
||||
|
||||
private DirectFixture directFixture() {
|
||||
var source=mock(Player.class);var ally=mock(Player.class);var world=mock(org.bukkit.World.class);
|
||||
UUID sourceId=UUID.randomUUID(),allyId=UUID.randomUUID();when(source.getUniqueId()).thenReturn(sourceId);when(ally.getUniqueId()).thenReturn(allyId);
|
||||
var sourceLocation=new AtomicReference<>(new Location(world,0,64,0));var allyLocation=new AtomicReference<>(new Location(world,2,64,0));
|
||||
when(source.getLocation()).thenAnswer(call->sourceLocation.get().clone());when(ally.getLocation()).thenAnswer(call->allyLocation.get().clone());
|
||||
when(source.getBoundingBox()).thenAnswer(call->box(sourceLocation.get()));when(ally.getBoundingBox()).thenAnswer(call->box(allyLocation.get()));
|
||||
when(source.getWorld()).thenReturn(world);when(ally.getWorld()).thenReturn(world);when(world.getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble())).thenReturn(List.of(ally));
|
||||
var sourceHealth=new AtomicReference<>(20.0);var allyHealth=new AtomicReference<>(10.0);health(source,sourceHealth);health(ally,allyHealth);
|
||||
var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var characters=mock(CharacterStorage.class);
|
||||
var sourceActive=new EquipmentSessions.Active(sourceId,UUID.randomUUID(),UUID.randomUUID());var allyActive=new EquipmentSessions.Active(allyId,UUID.randomUUID(),UUID.randomUUID());
|
||||
when(sessions.active(sourceId)).thenReturn(Optional.of(sourceActive));when(sessions.active(allyId)).thenReturn(Optional.of(allyActive));when(sessions.current(any())).thenReturn(true);
|
||||
var records=new HashMap<UUID,AtomicReference<CharacterRecord>>();records.put(sourceId,new AtomicReference<>(record(sourceActive.character(),BigDecimal.valueOf(20))));records.put(allyId,new AtomicReference<>(record(allyActive.character(),BigDecimal.TEN)));
|
||||
when(sessions.currentRecord(sourceActive)).thenAnswer(call->Optional.of(records.get(sourceId).get()));when(sessions.currentRecord(allyActive)).thenAnswer(call->Optional.of(records.get(allyId).get()));
|
||||
when(sessions.acknowledgeProgress(any(),any(),any())).thenAnswer(call->{EquipmentSessions.Active active=call.getArgument(0);CharacterRecord saved=call.getArgument(2);records.get(active.player()).set(saved);return true;});
|
||||
when(characters.saveHealth(any(),any(),any(),any())).thenAnswer(call->{UUID character=call.getArgument(0);BigInteger expected=call.getArgument(1);BigDecimal health=call.getArgument(2);
|
||||
var entry=records.values().stream().filter(ref->ref.get().id().equals(character)).findFirst().orElseThrow();var old=entry.get();
|
||||
if(!old.revision().equals(expected))return CompletableFuture.completedFuture(Optional.empty());
|
||||
var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),health,old.learned(),old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null);
|
||||
return CompletableFuture.completedFuture(Optional.of(saved));});
|
||||
var party=new PartyRoster.Party(UUID.randomUUID(),sourceId,List.of(sourceId,allyId));when(parties.partyOf(sourceId)).thenReturn(Optional.of(party));when(parties.partyOf(allyId)).thenReturn(Optional.of(party));
|
||||
var mitigation=new CombatMitigation();var scheduled=new ArrayDeque<Runnable>();var supports=new ArrayList<MendingLight.Support>();
|
||||
var sanctuary=new Sanctuary(Sanctuary.Settings.defaults(),characters,sessions,parties,mitigation,Runnable::run,event->{},()->BigDecimal.valueOf(Double.MAX_VALUE),supports::add);
|
||||
var execution=execution(source,sourceActive);when(execution.definition()).thenReturn(sanctuary.definition());
|
||||
var character=records.get(sourceId).get();when(execution.character()).thenReturn(character);
|
||||
doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());
|
||||
return new DirectFixture(source,ally,world,sourceActive,allyLocation,allyHealth,execution,sanctuary,mitigation,scheduled,supports);
|
||||
}
|
||||
private record DirectFixture(Player source,Player ally,org.bukkit.World world,EquipmentSessions.Active sourceActive,
|
||||
AtomicReference<Location> allyLocation,AtomicReference<Double> allyHealth,AbilityExecution execution,Sanctuary sanctuary,
|
||||
CombatMitigation mitigation,ArrayDeque<Runnable> scheduled,List<MendingLight.Support> supports){}
|
||||
private static AbilityExecution execution(Player source,EquipmentSessions.Active active){var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(source);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);return execution;}
|
||||
private static CharacterRecord record(UUID id,BigDecimal health){var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var stats=new EnumMap<>(base.stats());stats.put(Stat.CON,BigInteger.TEN);return new CharacterRecord(id,base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),health,Set.of("sanctuary"),List.of("sanctuary","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);}
|
||||
private static BoundingBox box(Location at){return new BoundingBox(at.getX()-.3,at.getY(),at.getZ()-.3,at.getX()+.3,at.getY()+1.8,at.getZ()+.3);}
|
||||
private static void health(Player player,AtomicReference<Double> health){var attribute=mock(AttributeInstance.class);when(attribute.getValue()).thenReturn(20.0);when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(attribute);when(player.getHealth()).thenAnswer(call->health.get());doAnswer(call->{health.set(call.getArgument(0));return null;}).when(player).setHealth(anyDouble());}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class SecondWindNativeTest {
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
|
||||
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("config", "give", "inspect", "recover"),
|
||||
assertEquals(List.of("config", "experience", "give", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
|
||||
|
||||
@@ -2,7 +2,7 @@ package games.dmg.fantasystick;
|
||||
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.*;import static org.mockito.Mockito.*;import java.nio.file.Path;import java.util.*;import net.kyori.adventure.text.Component;import org.bukkit.event.player.PlayerResourcePackStatusEvent;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class SheetResourcePackNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
record Request(UUID id,String url,byte[] hash,String prompt,boolean required){}
|
||||
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertTrue(request.url().contains("/v0.1.0-SNAPSHOT/purpur-fantasy-stick-0.1.0-SNAPSHOT-resource-pack.zip"));assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
|
||||
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertEquals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults().url(),request.url());assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
|
||||
@Test void invalidStartupAndFailedRegisteredWriteNeverDeliverOrChangeEffectiveDefaults()throws Exception{int route=0;for(String value:List.of("sheet-resource-pack-url: 'http://bad.test/x.zip'\n","sheet-resource-pack-sha1: bad\n")){var dir=data.resolve("invalid-"+route++);java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),value);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty(),"native registration order "+f.order);assertTrue(f.tasks.isEmpty(),"queued tasks");}}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"),sender.messages::toString);assertTrue(a.resourcePacks.isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("git.garvis.dev"));}}
|
||||
@Test void registeredConfigurationDurablyPublishesAndRedeliversWhileFailureRetainsPair()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(List.of("sheet-resource-pack-sha1","sheet-resource-pack-url"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}));assertEquals(1,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url",""}).size());sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/sheet.zip"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip","extra"});assertTrue(sender.messages.getLast().contains("Usage"));for(var pair:List.of(new String[]{"sheet-resource-pack-url","http://bad.test/x.zip"},new String[]{"sheet-resource-pack-sha1","bad"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",pair[0],pair[1]});assertTrue(sender.messages.getLast().contains("invalid"));}save(f,sender,"sheet-resource-pack-url","https://packs.example.test/sheet.zip");assertEquals(1,a.resourcePacks.size());assertEquals("https://packs.example.test/sheet.zip",request(a.resourcePacks.getLast()).url());save(f,sender,"sheet-resource-pack-sha1","a".repeat(40));assertEquals(2,a.resourcePacks.size());assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),request(a.resourcePacks.getLast()).hash());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("https://packs.example.test/sheet.zip"));}}
|
||||
static void awaitPacked(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(10);while(System.nanoTime()<deadline){var marker=actor.core.marker();if(marker!=null&&marker.endsWith("|PACKED"))return;var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();}fail("Session did not activate; notices="+actor.messages);}
|
||||
|
||||
@@ -31,6 +31,7 @@ class DistributionTest {
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
|
||||
assertEquals(java.util.Set.of("fantasystick"), metadata.getCommands().keySet());
|
||||
assertEquals("fantasystick.admin", metadata.getCommands().get("fantasystick").get("permission"));
|
||||
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("experience give"));
|
||||
assertEquals(1, metadata.getPermissions().size());
|
||||
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
|
||||
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ChainLightningSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void defaultsAndParsingRetainExactDecay() {
|
||||
var settings = ChainLightningSettings.parse(defaults());
|
||||
assertEquals(java.math.BigInteger.valueOf(8), settings.baseDamage());
|
||||
assertEquals(java.time.Duration.ofSeconds(18), settings.cooldown());
|
||||
assertEquals(16, settings.initialRange(), 0); assertEquals(6, settings.jumpRange(), 0);
|
||||
assertEquals(3, settings.additionalTargets());
|
||||
assertEquals(new java.math.BigDecimal("0.75"), settings.jumpMultiplier());
|
||||
}
|
||||
|
||||
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new ChainLightningSettings(data, defaults())) {
|
||||
var range = settings.set(ChainLightningSettings.JUMP_RANGE, "7").toCompletableFuture();
|
||||
var cooldown = settings.set(ChainLightningSettings.COOLDOWN, "24").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(6, settings.settings().jumpRange(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertEquals(7, settings.settings().jumpRange(), 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("7", String.valueOf(yaml.get(ChainLightningSettings.JUMP_RANGE)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new ChainLightningSettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
ChainLightningSettings.COOLDOWN, "-1", ChainLightningSettings.INITIAL_RANGE, "0",
|
||||
ChainLightningSettings.JUMP_RANGE, "0", ChainLightningSettings.ADDITIONAL, "-1",
|
||||
ChainLightningSettings.BASE, "-1", ChainLightningSettings.MULTIPLIER, "0").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(ChainLightningSettings.COOLDOWN, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new ChainLightningSettings(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(ChainLightningSettings.JUMP_RANGE, "7").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("6", settings.value(ChainLightningSettings.JUMP_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 ChainLightningSettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(ChainLightningSettings.MULTIPLIER, "0.5").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new ChainLightningSettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(ChainLightningSettings.BASE, "9").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 : ChainLightningSettings.keys()) values.put(key, ChainLightningSettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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 FrostNovaSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new FrostNovaSettings(data, defaults())) {
|
||||
var range = settings.set(FrostNovaSettings.RADIUS, "7").toCompletableFuture();
|
||||
var cooldown = settings.set(FrostNovaSettings.COOLDOWN, "24").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(5, settings.settings().radius(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertEquals(7, settings.settings().radius(), 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("7", String.valueOf(yaml.get(FrostNovaSettings.RADIUS)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new FrostNovaSettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
FrostNovaSettings.COOLDOWN, "-1",
|
||||
FrostNovaSettings.RADIUS, "0",
|
||||
FrostNovaSettings.DURATION, "0",
|
||||
FrostNovaSettings.BASE, "-1",
|
||||
FrostNovaSettings.SLOWNESS, "-1").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(FrostNovaSettings.COOLDOWN, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new FrostNovaSettings(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(FrostNovaSettings.RADIUS, "7").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("5", settings.value(FrostNovaSettings.RADIUS));
|
||||
}
|
||||
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 FrostNovaSettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(FrostNovaSettings.DURATION, "8").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new FrostNovaSettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(FrostNovaSettings.BASE, "8").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 : FrostNovaSettings.keys()) values.put(key, FrostNovaSettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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 MendingLightSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new MendingLightSettings(data, defaults())) {
|
||||
var range = settings.set(MendingLightSettings.RANGE, "16").toCompletableFuture();
|
||||
var base = settings.set(MendingLightSettings.BASE_HEALING, "8").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedBase = base.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(12, settings.settings().range(), 0);
|
||||
settings.publish(savedBase); settings.publish(savedRange);
|
||||
assertEquals(16, settings.settings().range(), 0);
|
||||
assertEquals(new java.math.BigInteger("8"), settings.settings().baseHealing());
|
||||
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(MendingLightSettings.RANGE)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new MendingLightSettings(data, defaults())) {
|
||||
for (var entry : Map.of(MendingLightSettings.COOLDOWN, "-1", MendingLightSettings.RANGE, "0",
|
||||
MendingLightSettings.BASE_HEALING, "-1").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(MendingLightSettings.COOLDOWN, "0.0000000001"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(MendingLightSettings.BASE_HEALING, "1.5"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new MendingLightSettings(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(MendingLightSettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("12", settings.value(MendingLightSettings.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 MendingLightSettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(MendingLightSettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new MendingLightSettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(MendingLightSettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var values = new LinkedHashMap<String, String>();
|
||||
for (String key : MendingLightSettings.keys()) values.put(key, MendingLightSettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PurifySettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new PurifySettings(data, defaults())) {
|
||||
var range = settings.set(PurifySettings.RANGE, "16").toCompletableFuture();
|
||||
var cooldown = settings.set(PurifySettings.COOLDOWN, "20").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(12, settings.settings().range(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertEquals(16, settings.settings().range(), 0);
|
||||
assertEquals(java.time.Duration.ofSeconds(20), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated")); assertEquals("16", String.valueOf(yaml.get(PurifySettings.RANGE)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new PurifySettings(data, defaults())) {
|
||||
for (var entry : Map.of(PurifySettings.COOLDOWN, "-1", PurifySettings.RANGE, "0").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(PurifySettings.COOLDOWN, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new PurifySettings(data, defaults(), (from, to) -> {
|
||||
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
|
||||
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(PurifySettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("12", settings.value(PurifySettings.RANGE));
|
||||
}
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var unsafe = data.resolve("unsafe"); Files.createDirectories(unsafe);
|
||||
var outside = data.resolve("outside.yml"); Files.writeString(outside, "safe\n");
|
||||
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
|
||||
try (var settings = new PurifySettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(PurifySettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new PurifySettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(PurifySettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var values = new LinkedHashMap<String, String>();
|
||||
for (String key : PurifySettings.keys()) values.put(key, PurifySettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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 RallyingCrySettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new RallyingCrySettings(data, defaults())) {
|
||||
var range = settings.set(RallyingCrySettings.RADIUS, "12").toCompletableFuture();
|
||||
var cooldown = settings.set(RallyingCrySettings.COOLDOWN, "24").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(8, settings.settings().radius(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertEquals(12, settings.settings().radius(), 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("12", String.valueOf(yaml.get(RallyingCrySettings.RADIUS)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new RallyingCrySettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
RallyingCrySettings.COOLDOWN, "-1",
|
||||
RallyingCrySettings.RADIUS, "0",
|
||||
RallyingCrySettings.DURATION, "0",
|
||||
RallyingCrySettings.SPEED, "-1",
|
||||
RallyingCrySettings.BONUS, "101").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(RallyingCrySettings.COOLDOWN, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new RallyingCrySettings(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(RallyingCrySettings.RADIUS, "12").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("8", settings.value(RallyingCrySettings.RADIUS));
|
||||
}
|
||||
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 RallyingCrySettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(RallyingCrySettings.DURATION, "8").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new RallyingCrySettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(RallyingCrySettings.BONUS, "30").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 : RallyingCrySettings.keys()) values.put(key, RallyingCrySettings.defaultValue(key));
|
||||
return values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SanctuarySettingsTest {
|
||||
@TempDir Path data;
|
||||
@Test void defaultsAndParsingMatchApprovedPolicy() {
|
||||
var value = SanctuarySettings.parse(defaults());
|
||||
assertEquals(java.time.Duration.ofSeconds(45), value.cooldown()); assertEquals(6, value.radius(), 0);
|
||||
assertEquals(3, value.verticalRange(), 0); assertEquals(java.time.Duration.ofSeconds(6), value.duration());
|
||||
assertEquals(java.time.Duration.ofSeconds(1), value.pulseInterval()); assertEquals(new java.math.BigDecimal("2"), value.healingPerPulse());
|
||||
assertEquals(new java.math.BigDecimal("20"), value.damageReductionPercent());
|
||||
}
|
||||
@Test void serializesPreservesUnrelatedAndPublishesAcknowledgedOrder() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new SanctuarySettings(data, defaults())) {
|
||||
var radius = settings.set(SanctuarySettings.RADIUS, "7").toCompletableFuture();
|
||||
var cooldown = settings.set(SanctuarySettings.COOLDOWN, "30").toCompletableFuture();
|
||||
var savedRadius = radius.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(6, settings.settings().radius(), 0); settings.publish(savedCooldown); settings.publish(savedRadius);
|
||||
assertEquals(7, settings.settings().radius(), 0); assertEquals(java.time.Duration.ofSeconds(30), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated")); assertEquals("7", String.valueOf(yaml.get(SanctuarySettings.RADIUS)));
|
||||
}
|
||||
}
|
||||
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
|
||||
try (var settings = new SanctuarySettings(data, defaults())) {
|
||||
for (var e : Map.of(SanctuarySettings.COOLDOWN,"-1",SanctuarySettings.RADIUS,"0",SanctuarySettings.VERTICAL,"-1",
|
||||
SanctuarySettings.DURATION,"0",SanctuarySettings.INTERVAL,"0",SanctuarySettings.HEALING,"-1",SanctuarySettings.REDUCTION,"101").entrySet())
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(e.getKey(), e.getValue()));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(SanctuarySettings.INTERVAL,"0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new SanctuarySettings(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(SanctuarySettings.RADIUS,"7").toCompletableFuture().get(5,TimeUnit.SECONDS));
|
||||
assertEquals("6",settings.value(SanctuarySettings.RADIUS));
|
||||
}
|
||||
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 SanctuarySettings(unsafe,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(SanctuarySettings.HEALING,"3").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("safe\n",Files.readString(outside));}
|
||||
}
|
||||
var closed=new SanctuarySettings(data.resolve("closed"),defaults());closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->closed.set(SanctuarySettings.HEALING,"3").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:SanctuarySettings.keys())values.put(key,SanctuarySettings.defaultValue(key));return values;}
|
||||
}
|
||||
@@ -36,6 +36,22 @@ class CombatContributionsTest {
|
||||
assertEquals(Set.of(ally), ledger.consume(target, now.plusSeconds(10).plusNanos(1)));
|
||||
}
|
||||
|
||||
@Test void effectiveHealingSupportsEveryDirectTargetWithinTheExactTenSecondWindow() {
|
||||
var now = Instant.parse("2026-09-20T20:30:00Z");
|
||||
var first = UUID.randomUUID(); var second = UUID.randomUUID();
|
||||
var ally = UUID.randomUUID(); var supporter = UUID.randomUUID(); var other = UUID.randomUUID();
|
||||
var ledger = new CombatContributions(CombatRewardRules.defaults());
|
||||
ledger.support(ally, supporter, now);
|
||||
ledger.support(other, UUID.randomUUID(), now);
|
||||
ledger.accept(first, ally, now.plusSeconds(1));
|
||||
ledger.accept(first, supporter, now.plusSeconds(1));
|
||||
ledger.accept(second, ally, now.plusSeconds(2));
|
||||
assertEquals(Set.of(ally, supporter), ledger.consume(first, now.plusSeconds(10)),
|
||||
"Healing is effective support for a directly contributing ally and deduplicates direct/support roles");
|
||||
assertEquals(Set.of(ally), ledger.consume(second, now.plusSeconds(10).plusNanos(1)),
|
||||
"Healing support expires immediately after ten seconds");
|
||||
}
|
||||
|
||||
@Test void invalidationAndCloseBoundMemoryAndRefuseLaterDamage() {
|
||||
var ledger = new CombatContributions(CombatRewardRules.defaults());
|
||||
var target = UUID.randomUUID(); var character = UUID.randomUUID();
|
||||
|
||||
Reference in new issue
Block a user