Compare commits

...
1 Commits
Author SHA1 Message Date
dmg e850e33975 feat(ability): add Inspire
Release / release (push) Successful in 7m18s
2026-09-20 13:02:56 -04:00
10 changed files with 367 additions and 5 deletions

No files matched your search

@@ -62,6 +62,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
private games.dmg.fantasystick.ability.OutgoingDamageMitigation outgoingDamageMitigation;
private games.dmg.fantasystick.ability.Hex hex;
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.persistence.HexSettings hexSettings;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
@@ -110,6 +113,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var shieldBashInitial = games.dmg.fantasystick.persistence.ShieldBashSettings.parse(shieldBashConfiguration);
var hexConfiguration = hexConfiguration();
var hexInitial = games.dmg.fantasystick.persistence.HexSettings.parse(hexConfiguration);
var inspireConfiguration = inspireConfiguration();
var inspireInitial = games.dmg.fantasystick.persistence.InspireSettings.parse(inspireConfiguration);
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
@@ -186,6 +191,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
hexSettings = new games.dmg.fantasystick.persistence.HexSettings(getDataFolder().toPath(), hexConfiguration);
hex = new games.dmg.fantasystick.ability.Hex(hexInitial, outgoingDamageMitigation);
temporaryHealth = new games.dmg.fantasystick.ability.TemporaryHealth(sessions, parties, nextTick,
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);
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,
@@ -199,6 +208,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityRegistry.register(stoneSkin.definition());
abilityRegistry.register(shieldBash.definition());
abilityRegistry.register(hex.definition());
abilityRegistry.register(inspire.definition());
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
@@ -235,7 +245,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
+ " for player " + problem.player(), problem.cause()), adventureSettings,
frostShardSettings, frostShard, longbowSettings, longbow,
quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex, abilityRegistry);
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
inspireSettings, inspire, abilityRegistry);
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
@@ -256,6 +267,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(combatMitigation, this);
getServer().getPluginManager().registerEvents(shieldBash, this);
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
getServer().getPluginManager().registerEvents(temporaryHealth, this);
getServer().getPluginManager().registerEvents(greatSword, this);
getServer().getPluginManager().registerEvents(revive, this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
@@ -311,6 +323,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingStoneSkin = stoneSkin; stoneSkin = null;
var closingShieldBash = shieldBash; shieldBash = null;
var closingHex = hex; hex = null;
var closingInspire = inspire; inspire = null;
var closingInspireSettings = inspireSettings; inspireSettings = null;
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
var closingHexSettings = hexSettings; hexSettings = null;
var closingOutgoingDamageMitigation = outgoingDamageMitigation; outgoingDamageMitigation = null;
var closingShieldBashSettings = shieldBashSettings; shieldBashSettings = null;
@@ -334,6 +349,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingStoneSkinSettings != null) cleanup("Stone Skin configuration", closingStoneSkinSettings::close);
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);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
@@ -350,7 +366,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingShieldBashSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingShieldBashSettings.drained().toCompletableFuture(),
closingHexSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingHexSettings.drained().toCompletableFuture());
: closingHexSettings.drained().toCompletableFuture(),
closingInspireSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingInspireSettings.drained().toCompletableFuture());
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
@@ -361,6 +379,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingStoneSkin != null) cleanup("Stone Skin", closingStoneSkin::close);
if (closingShieldBash != null) cleanup("Shield Bash", closingShieldBash::close);
if (closingHex != null) cleanup("Hex", closingHex::close);
if (closingInspire != null) cleanup("Inspire", closingInspire::close);
if (closingTemporaryHealth != null) cleanup("temporary health", closingTemporaryHealth::close);
if (closingOutgoingDamageMitigation != null) cleanup("outgoing damage mitigation", closingOutgoingDamageMitigation::close);
if (closingCombatMitigation != null) cleanup("combat mitigation", closingCombatMitigation::close);
if (closingGreatSword != null) cleanup("Great Sword", closingGreatSword::close);
@@ -386,6 +406,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingCombatMitigation);
unregister(closingShieldBash);
unregister(closingOutgoingDamageMitigation);
unregister(closingTemporaryHealth);
unregister(closingGreatSword);
unregister(closingRevive);
unregister(closingBasicAttack);
@@ -527,6 +548,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> inspireConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.InspireSettings.keys()) {
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.InspireSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.InspireSettings.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,16 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;import games.dmg.fantasystick.session.ControlInput;import java.math.*;import java.time.Duration;import java.util.*;import org.bukkit.*;import org.bukkit.entity.Player;
/** Inspire's self-or-aimed fantasy-player temporary health grant. */
public final class Inspire implements AbilityEffect,AutoCloseable{
public record Settings(BigInteger baseTemporaryHealth,Duration cooldown,double range,Duration duration){public Settings{Objects.requireNonNull(baseTemporaryHealth);Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);if(baseTemporaryHealth.signum()<0||baseTemporaryHealth.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger())>0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0)throw new IllegalArgumentException("Invalid Inspire settings");}public static Settings defaults(){return new Settings(BigInteger.valueOf(4),Duration.ofSeconds(15),12,Duration.ofSeconds(6));}}
private volatile Settings settings;private final TemporaryHealth temporaryHealth;private boolean closed;
public Inspire(Settings settings,TemporaryHealth temporaryHealth){this.settings=Objects.requireNonNull(settings);this.temporaryHealth=Objects.requireNonNull(temporaryHealth);}public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.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 void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
private static boolean inRange(Player source,Player target,double range){try{var a=source.getLocation();var b=target.getLocation();return a.getWorld()==b.getWorld()&&a.distanceSquared(b)<=range*range&&source.hasLineOfSight(target);}catch(RuntimeException invalid){return false;}}
private static Settings from(AbilityDefinition d){var p=d.parameters();return new Settings(p.get("base-temporary-health").toBigIntegerExact(),d.cooldown(),p.get("range").doubleValue(),Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()));}
private static String seconds(Duration d){return BigDecimal.valueOf(d.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
@Override public void close(){closed=true;}
}
@@ -0,0 +1,33 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.entity.*;
import org.bukkit.event.*;
import org.bukkit.event.entity.*;
import org.bukkit.projectiles.ProjectileSource;
/** Plugin-owned temporary-health ledger layered over unrelated native absorption. */
public final class TemporaryHealth 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 target;double remaining;boolean closed;
Grant(AbilityExecution e,Player t,double amount){sequence=++sequences;execution=e;target=t;remaining=amount;}
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));}}
private final EquipmentSessions sessions;private final PartyRoster parties;private final Consumer<Runnable> nextTick;private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();long ownerSequence=owner.sequence;UUID source=owner.execution.input().session().character();
try{nextTick.accept(()->consume(target,before,owned,ownerSequence,source,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
private void consume(Player target,double before,double ownedBefore,long ownerSequence,UUID source,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,source,aided,BigDecimal.valueOf(consumed).stripTrailingZeros()));}
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
private static Entity attacker(EntityDamageByEntityEvent event){Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;if(event.getDamager() instanceof Projectile p){ProjectileSource shooter=p.getShooter();if(shooter instanceof Entity e)return e;}return event.getDamager();}
private static void addNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount(),next=current+amount;if(!Double.isFinite(current)||current<0||!Double.isFinite(next))throw new IllegalStateException("Temporary health exceeds native range");target.setAbsorptionAmount(next);}
private static void removeNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount();if(Double.isFinite(current)&&current>=0)target.setAbsorptionAmount(Math.max(0,current-amount));}
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();}
}
@@ -0,0 +1,191 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Inspire;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Inspire settings; publication remains a server-thread caller decision. */
public final class InspireSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE = "inspire-base-temporary-health";
public static final String COOLDOWN = "inspire-cooldown-seconds";
public static final String RANGE = "inspire-range-blocks";
public static final String DURATION = "inspire-duration-seconds";
private static final List<String> KEYS = List.of(BASE, COOLDOWN, RANGE, DURATION);
private static final Map<String, String> DEFAULTS = Map.of(BASE, "4", COOLDOWN, "15", RANGE, "12", DURATION, "6");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Inspire.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-inspire-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 InspireSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
InspireSettings(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 Inspire.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Inspire 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)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Inspire.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE));
var cooldown = seconds(values.get(COOLDOWN));
var range = finite(values.get(RANGE));
var duration = seconds(values.get(DURATION));
return new Inspire.Settings(base, cooldown, range, duration);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Inspire settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -36,6 +36,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
private games.dmg.fantasystick.ability.Hex hex;
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
private games.dmg.fantasystick.ability.Inspire inspire;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -179,6 +181,32 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
this.hexSettings = Objects.requireNonNull(hexSettings); this.hex = Objects.requireNonNull(hex);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings,
games.dmg.fantasystick.ability.ShieldBash shieldBash,
games.dmg.fantasystick.persistence.HexSettings hexSettings,
games.dmg.fantasystick.ability.Hex hex,
games.dmg.fantasystick.persistence.InspireSettings inspireSettings,
games.dmg.fantasystick.ability.Inspire inspire,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex, abilities);
this.inspireSettings=Objects.requireNonNull(inspireSettings);this.inspire=Objects.requireNonNull(inspire);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
@@ -270,6 +298,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (hexSettings != null && games.dmg.fantasystick.persistence.HexSettings.keys().contains(args[1])) {
hexConfiguration(sender, args); return;
}
if (inspireSettings != null && games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])) {
inspireConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -367,6 +398,8 @@ 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 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;
@@ -502,6 +535,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
keys.addAll(games.dmg.fantasystick.persistence.StoneSkinSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.ShieldBashSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.HexSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.InspireSettings.keys());
return matching(keys, args[1]);
}
if (args.length == 3 && args[0].equals("config")) {
@@ -540,6 +574,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
String current = hexSettings.value(args[1]); String defaults = games.dmg.fantasystick.persistence.HexSettings.defaultValue(args[1]);
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]);}
}
return List.of();
}
@@ -345,7 +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")
? event.getPlayer().getTargetEntity(64) : null;
? event.getPlayer().getTargetEntity(64)
: selected.ability().equals("inspire")
? event.getPlayer().isSneaking() ? event.getPlayer() : event.getPlayer().getTargetEntity(64)
: null;
emit(event.getPlayer(), event.getItem(), selected, ControlInput.Gesture.USE, target, 0, 0);
}
else if (selected != null && selected.ability().equals("longbow")) beginDraw(event.getPlayer(), event.getItem(), selected);
@@ -437,8 +440,11 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
if (special(item)) event.setCancelled(true);
if (event.getHand() == EquipmentSlot.HAND) {
var selected = sessions.selectedControl(player, item).orElse(null);
if (selected != null && useGesture(selected)) emit(player, item, selected,
ControlInput.Gesture.USE, event.getRightClicked(), 0, 0);
if (selected != null && useGesture(selected)) {
org.bukkit.entity.Entity target = selected.ability().equals("inspire") && player.isSneaking()
? player : event.getRightClicked();
emit(player, item, selected, ControlInput.Gesture.USE, target, 0, 0);
}
}
}
+7
View File
@@ -77,3 +77,10 @@ hex-cooldown-seconds: 10
hex-range-blocks: 12
hex-duration-seconds: 4
hex-damage-reduction-percent: 20
# Inspire defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
inspire-base-temporary-health: 4
inspire-cooldown-seconds: 15
inspire-range-blocks: 12
inspire-duration-seconds: 6
@@ -0,0 +1,27 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.*;import games.dmg.fantasystick.progression.Stat;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import net.minecraft.network.chat.ClickEvent;import org.bukkit.event.player.PlayerInteractEvent;import org.bukkit.inventory.EquipmentSlot;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);f.players.put(a.core.id,a.player);f.activate(a);a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);String book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertTrue(book.contains("Cost:1"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
double maximum=a.maximumHealth.getBaseValue();var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);assertEquals(maximum,a.maximumHealth.getBaseValue(),1e-9);assertEquals(120L,f.delayedTicks.getLast());f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3));absorption.set(2.0);PartyBookNativeTest.drain(f);var sourceSheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}
@Test void normalSelfOrdinaryOutOfRangeAndObstructedTargetsRefuseBeforeCooldown()throws Exception{for(int route=0;route<4;route++){var dir=data.resolve("refuse-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;if(route==0)target=a.player;else if(route==1){target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}else{var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);}doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,route==2?13:8,64,0)).when(target).getLocation();doReturn(route!=3).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("inspire"));}}}
@Test void nonPartyTargetIsAllowedAndStrongestRemainingCopiesPreserveUnrelatedAbsorption()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var c=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);withInspire(data,c,true);f.players.put(a.core.id,a.player);f.players.put(c.core.id,c.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(c);f.activate(b);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());for(var caster:List.of(a,c)){doReturn(false).when(caster.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(b.player);doReturn(b.player).when(caster.player).getTargetEntity(64);}doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","6"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();use(f,c);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(9,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));absorption.set(6.0);PartyBookNativeTest.drain(f);f.delayed.getLast().run();assertEquals(4,absorption.get(),1e-9);f.delayed.getFirst().run();assertEquals(2,absorption.get(),1e-9);}}
@Test void cancelledOrSelfOnlyProtectionCannotManufactureSupportShare()throws Exception{for(int route=0;route<2;route++){var dir=data.resolve("no-support-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);var b=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));var target=route==0?b.player:a.player;var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(x->absorption.get()).when(target).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(target).setAbsorptionAmount(anyDouble());doReturn(route==1).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,target,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3);if(route==0)hit.setCancelled(true);f.dispatcher.accept(hit);if(route==1){absorption.set(2.0);PartyBookNativeTest.drain(f);}var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.ZERO,storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.TEN,storage.find(b.core.record.id()).get().orElseThrow().experience());}}}
@Test void exactFormulaSupportsFiniteUncappedCharisma(){assertEquals(BigInteger.valueOf(5),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(12)));assertEquals(BigInteger.valueOf(3),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(9)));var huge=BigInteger.TEN.pow(500);assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),games.dmg.fantasystick.ability.Inspire.amount(huge));}
@Test void failedCooldownPersistencePublishesNoTemporaryHealth(){var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);var temporary=new games.dmg.fantasystick.ability.TemporaryHealth(sessions,parties,Runnable::run,ignored->{});var inspire=new games.dmg.fantasystick.ability.Inspire(games.dmg.fantasystick.ability.Inspire.Settings.defaults(),temporary);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(inspire.definition());var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var stats=new EnumMap<>(base.stats());stats.put(Stat.CHR,BigInteger.valueOf(12));stats.put(Stat.WIS,BigInteger.TEN);var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),Set.of("inspire"),List.of("inspire","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),record.id());var player=mock(org.bukkit.entity.Player.class);when(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);when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));var failed=new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();failed.completeExceptionally(new java.io.IOException("simulated"));when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any())).thenReturn(failed);var engine=new games.dmg.fantasystick.ability.AbilityEngine(registry,storage,sessions,parties,java.time.Clock.systemUTC(),Runnable::run,(delay,action)->()->{},ignored->{},ignored->{});engine.accept(new games.dmg.fantasystick.session.ControlInput(player,active,2,"inspire",games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.USE,player,0,0,()->true));verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any());verify(player,never()).setAbsorptionAmount(anyDouble());engine.close();inspire.close();temporary.close();parties.close();}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedTemporaryHealth()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire");a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=button(a,"[Unlearn]","inspire");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("inspire"));learnAndAssign(f,a);use(f,a);assertTrue(a.messages.stream().anyMatch(m->m.contains("ready in")));var sheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var b=new FantasyStickLifecycleNativeTest.Actor(data);b.core.inventory.setItemInOffHand(sheet);f.players.put(b.core.id,b.player);f.activate(b);doReturn(true).when(b.player).isSneaking();use(f,b);assertTrue(b.messages.stream().anyMatch(m->m.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void configuredDefinitionAdminRestartFutureCastAndStartupValidationCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-base-temporary-health: 8\ninspire-cooldown-seconds: 0\ninspire-range-blocks: 16\ninspire-duration-seconds: 8\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var d=registry.find("inspire").orElseThrow();assertEquals(java.time.Duration.ZERO,d.cooldown());assertEquals(new java.math.BigDecimal("8"),d.parameters().get("base-temporary-health"));assertEquals(0,new java.math.BigDecimal("16").compareTo(d.parameters().get("range")));assertEquals(0,new java.math.BigDecimal("8").compareTo(d.parameters().get("duration-seconds")));
var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(List.of("inspire-base-temporary-health","inspire-cooldown-seconds","inspire-duration-seconds","inspire-range-blocks"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}));assertEquals(List.of("4","8"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health"});assertTrue(sender.messages.getLast().contains("=8"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(new java.math.BigDecimal("9"),registry.find("inspire").orElseThrow().parameters().get("base-temporary-health"));
var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,absorption.get(),1e-9);assertEquals(160L,f.delayedTicks.getLast());
f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","-1"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reload=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new java.math.BigDecimal("9"),reload.find("inspire").orElseThrow().parameters().get("base-temporary-health"));}
for(String invalid:List.of("inspire-base-temporary-health: -1\n","inspire-cooldown-seconds: -1\n","inspire-range-blocks: 0\n","inspire-duration-seconds: 0\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a,String label,String ability){var b=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(b);for(var p:b.pages()){var x=button(p.raw(),label,ability);if(x!=null)return x;}throw new AssertionError("missing action");}
private static ClickEvent.Custom button(net.minecraft.network.chat.Component c,String label,String ability){if(c.getString().equals(label)&&c.getStyle().getClickEvent() instanceof ClickEvent.Custom x&&x.payload().orElse(null) instanceof net.minecraft.nbt.StringTag p&&p.value().contains(":"+ability))return x;for(var k:c.getSiblings()){var x=button(k,label,ability);if(x!=null)return x;}return null;}
private static void withInspire(Path d,FantasyStickLifecycleNativeTest.Actor a,boolean learned)throws Exception{java.nio.file.Files.createDirectories(d);var r=new YamlCharacterRepository(d);var o=r.load(a.core.record.id()).orElseThrow();var s=new EnumMap<>(o.stats());s.put(Stat.CHR,BigInteger.valueOf(12));s.put(Stat.WIS,BigInteger.TEN);r.replace(new CharacterRecord(o.id(),o.name(),o.revision().add(BigInteger.ONE),s,o.level(),o.experience(),BigInteger.valueOf(learned?39:40),o.health(),learned?Set.of("inspire"):o.learned(),learned?List.of("inspire","","","","","",""):o.loadout(),o.cooldowns(),o.restrictedKills(),false,null,null),o.revision());}}
@@ -77,6 +77,10 @@ class DistributionTest {
"hex-duration-seconds: 4", "hex-damage-reduction-percent: 20")) {
assertTrue(defaults.contains(setting), "Missing packaged Hex default: " + setting);
}
for (String setting : java.util.List.of("inspire-base-temporary-health: 4",
"inspire-cooldown-seconds: 15", "inspire-range-blocks: 12", "inspire-duration-seconds: 6")) {
assertTrue(defaults.contains(setting), "Missing packaged Inspire default: " + setting);
}
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
assertNotNull(entrypoint);
@@ -0,0 +1,13 @@
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 InspireSettingsTest{@TempDir Path data;
@Test void serializesPreservesAndPublishesAcknowledgedSnapshots()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: retained\n");try(var s=new InspireSettings(data,defaults())){
var r=s.set(InspireSettings.RANGE,"16").toCompletableFuture();var p=s.set(InspireSettings.BASE,"8").toCompletableFuture();var rr=r.get(5,TimeUnit.SECONDS);var pp=p.get(5,TimeUnit.SECONDS);
assertEquals(12,s.settings().range(),0);s.publish(pp);s.publish(rr);assertEquals(16,s.settings().range(),0);assertEquals(new java.math.BigInteger("8"),s.settings().baseTemporaryHealth());
var y=(Map<?,?>)new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));assertEquals("retained",y.get("unrelated"));assertEquals("16",String.valueOf(y.get(InspireSettings.RANGE)));}}
@Test void invalidAndFailedWritesRetainState()throws Exception{try(var s=new InspireSettings(data,defaults())){for(var e:Map.of(InspireSettings.COOLDOWN,"-1",InspireSettings.RANGE,"0",InspireSettings.DURATION,"0",InspireSettings.BASE,"-1").entrySet())assertThrows(IllegalArgumentException.class,()->s.set(e.getKey(),e.getValue()));assertThrows(IllegalArgumentException.class,()->s.set(InspireSettings.COOLDOWN,"0.0000000001"));assertThrows(IllegalArgumentException.class,()->s.set(InspireSettings.BASE,"1.5"));}
var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var s=new InspireSettings(data,defaults(),(a,b)->{if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(a,b,StandardCopyOption.REPLACE_EXISTING);})){assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(InspireSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("12",s.value(InspireSettings.RANGE));}}
@Test void unsafeAndClosedStoresFailSafely()throws Exception{if(!System.getProperty("os.name","").toLowerCase().contains("win")){var out=data.resolveSibling(data.getFileName()+"-outside");Files.writeString(out,"safe\n");Files.createSymbolicLink(data.resolve("config.yml"),out);try(var s=new InspireSettings(data,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(InspireSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("safe\n",Files.readString(out));}}
var s=new InspireSettings(data.resolve("closed"),defaults());s.close();assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(InspireSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));s.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);}
private static Map<String,String>defaults(){var m=new LinkedHashMap<String,String>();for(String k:InspireSettings.keys())m.put(k,InspireSettings.defaultValue(k));return m;}}