Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e850e33975 | ||
|
|
20abd2fa6e | ||
|
|
e054a78762 | ||
|
|
63da22e37d | ||
|
|
b2e8da536a | ||
|
|
c77246321c | ||
|
|
438d6acfa4 |
No files matched your search
@@ -51,6 +51,23 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.ability.Fireball fireball;
|
||||
private games.dmg.fantasystick.ability.FrostShard frostShard;
|
||||
private games.dmg.fantasystick.persistence.FrostShardSettings frostShardSettings;
|
||||
private games.dmg.fantasystick.ability.Longbow longbow;
|
||||
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
|
||||
private games.dmg.fantasystick.ability.Quickstep quickstep;
|
||||
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
|
||||
private games.dmg.fantasystick.ability.SecondWind secondWind;
|
||||
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
|
||||
private games.dmg.fantasystick.ability.CombatMitigation combatMitigation;
|
||||
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
|
||||
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;
|
||||
private games.dmg.fantasystick.ability.GreatSword greatSword;
|
||||
private games.dmg.fantasystick.ability.Revive revive;
|
||||
private games.dmg.fantasystick.persistence.RevivalStore revivals;
|
||||
@@ -84,6 +101,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var greatSwordSettings = greatSwordSettings();
|
||||
var frostShardConfiguration = frostShardConfiguration();
|
||||
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
|
||||
var longbowConfiguration = longbowConfiguration();
|
||||
var longbowInitial = games.dmg.fantasystick.persistence.LongbowSettings.parse(longbowConfiguration);
|
||||
var quickstepConfiguration = quickstepConfiguration();
|
||||
var quickstepInitial = games.dmg.fantasystick.persistence.QuickstepSettings.parse(quickstepConfiguration);
|
||||
var secondWindConfiguration = secondWindConfiguration();
|
||||
var secondWindInitial = games.dmg.fantasystick.persistence.SecondWindSettings.parse(secondWindConfiguration);
|
||||
var stoneSkinConfiguration = stoneSkinConfiguration();
|
||||
var stoneSkinInitial = games.dmg.fantasystick.persistence.StoneSkinSettings.parse(stoneSkinConfiguration);
|
||||
var shieldBashConfiguration = shieldBashConfiguration();
|
||||
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),
|
||||
@@ -122,24 +153,6 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
|
||||
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
|
||||
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
|
||||
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
|
||||
getDataFolder().toPath(), frostShardConfiguration);
|
||||
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
|
||||
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,
|
||||
(size, title) -> getServer().createInventory(null, size, title),
|
||||
problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem));
|
||||
abilityRegistry.register(fireball.definition());
|
||||
abilityRegistry.register(frostShard.definition());
|
||||
abilityRegistry.register(greatSword.definition());
|
||||
abilityRegistry.register(revive.definition());
|
||||
sessions.attachAbilityRegistry(abilityRegistry);
|
||||
vitals = new games.dmg.fantasystick.session.SessionVitals();
|
||||
sessions.attachVitals(vitals);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
|
||||
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
|
||||
var player = getServer().getPlayer(id);
|
||||
return player != null && player.isOnline();
|
||||
@@ -149,6 +162,59 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
|
||||
sessions, Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
|
||||
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
|
||||
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
|
||||
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
|
||||
getDataFolder().toPath(), frostShardConfiguration);
|
||||
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
|
||||
longbowSettings = new games.dmg.fantasystick.persistence.LongbowSettings(
|
||||
getDataFolder().toPath(), longbowConfiguration);
|
||||
longbow = new games.dmg.fantasystick.ability.Longbow(longbowInitial);
|
||||
quickstepSettings = new games.dmg.fantasystick.persistence.QuickstepSettings(
|
||||
getDataFolder().toPath(), quickstepConfiguration);
|
||||
quickstep = new games.dmg.fantasystick.ability.Quickstep(quickstepInitial,
|
||||
event -> getServer().getPluginManager().callEvent(event));
|
||||
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
|
||||
getDataFolder().toPath(), secondWindConfiguration);
|
||||
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
|
||||
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
|
||||
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
|
||||
getDataFolder().toPath(), stoneSkinConfiguration);
|
||||
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
stoneSkin = new games.dmg.fantasystick.ability.StoneSkin(stoneSkinInitial, combatMitigation);
|
||||
shieldBashSettings = new games.dmg.fantasystick.persistence.ShieldBashSettings(
|
||||
getDataFolder().toPath(), shieldBashConfiguration);
|
||||
shieldBash = new games.dmg.fantasystick.ability.ShieldBash(
|
||||
shieldBashInitial,
|
||||
event -> getServer().getPluginManager().callEvent(event));
|
||||
outgoingDamageMitigation = new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions, parties,
|
||||
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,
|
||||
(size, title) -> getServer().createInventory(null, size, title),
|
||||
problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem));
|
||||
abilityRegistry.register(fireball.definition());
|
||||
abilityRegistry.register(frostShard.definition());
|
||||
abilityRegistry.register(longbow.definition());
|
||||
abilityRegistry.register(quickstep.definition());
|
||||
abilityRegistry.register(secondWind.definition());
|
||||
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);
|
||||
vitals = new games.dmg.fantasystick.session.SessionVitals();
|
||||
sessions.attachVitals(vitals);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
|
||||
games.dmg.fantasystick.ability.AbilityScheduler abilityScheduler = (delay, action) -> {
|
||||
long ticks = Math.max(1, (delay.toNanos() + 49_999_999L) / 50_000_000L);
|
||||
var task = getServer().getScheduler().runTaskLater(this, action, ticks);
|
||||
@@ -177,7 +243,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings,
|
||||
frostShardSettings, frostShard, abilityRegistry);
|
||||
frostShardSettings, frostShard, longbowSettings, longbow,
|
||||
quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
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);
|
||||
@@ -193,6 +262,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
combatExperience.install(this);
|
||||
getServer().getPluginManager().registerEvents(fireball, this);
|
||||
getServer().getPluginManager().registerEvents(frostShard, this);
|
||||
getServer().getPluginManager().registerEvents(longbow, this);
|
||||
getServer().getPluginManager().registerEvents(quickstep, this);
|
||||
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);
|
||||
@@ -240,6 +315,23 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingFireball = fireball; fireball = null;
|
||||
var closingFrostShard = frostShard; frostShard = null;
|
||||
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
|
||||
var closingLongbow = longbow; longbow = null;
|
||||
var closingLongbowSettings = longbowSettings; longbowSettings = null;
|
||||
var closingQuickstep = quickstep; quickstep = null;
|
||||
var closingQuickstepSettings = quickstepSettings; quickstepSettings = null;
|
||||
secondWind = null;
|
||||
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;
|
||||
var closingCombatMitigation = combatMitigation; combatMitigation = null;
|
||||
var closingStoneSkinSettings = stoneSkinSettings; stoneSkinSettings = null;
|
||||
var closingSecondWindSettings = secondWindSettings; secondWindSettings = null;
|
||||
var closingGreatSword = greatSword; greatSword = null;
|
||||
var closingRevive = revive; revive = null;
|
||||
var closingRevivals = revivals; revivals = null;
|
||||
@@ -251,16 +343,46 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
|
||||
if (closingSettings != null) cleanup("configuration", closingSettings::close);
|
||||
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
|
||||
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
|
||||
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
|
||||
if (closingSecondWindSettings != null) cleanup("Second Wind configuration", closingSecondWindSettings::close);
|
||||
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(),
|
||||
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFrostSettings.drained().toCompletableFuture());
|
||||
: closingFrostSettings.drained().toCompletableFuture(),
|
||||
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingLongbowSettings.drained().toCompletableFuture(),
|
||||
closingQuickstepSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingQuickstepSettings.drained().toCompletableFuture(),
|
||||
closingSecondWindSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSecondWindSettings.drained().toCompletableFuture(),
|
||||
closingStoneSkinSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingStoneSkinSettings.drained().toCompletableFuture(),
|
||||
closingShieldBashSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingShieldBashSettings.drained().toCompletableFuture(),
|
||||
closingHexSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: 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);
|
||||
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
|
||||
if (closingFrostShard != null) cleanup("Frost Shard", closingFrostShard::close);
|
||||
if (closingLongbow != null) cleanup("Longbow", closingLongbow::close);
|
||||
if (closingQuickstep != null) cleanup("Quickstep", closingQuickstep::close);
|
||||
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);
|
||||
if (closingRevive != null) cleanup("Revive", closingRevive::close);
|
||||
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
|
||||
@@ -279,6 +401,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingFireball);
|
||||
unregister(closingFrostShard);
|
||||
unregister(closingLongbow);
|
||||
unregister(closingQuickstep);
|
||||
unregister(closingCombatMitigation);
|
||||
unregister(closingShieldBash);
|
||||
unregister(closingOutgoingDamageMitigation);
|
||||
unregister(closingTemporaryHealth);
|
||||
unregister(closingGreatSword);
|
||||
unregister(closingRevive);
|
||||
unregister(closingBasicAttack);
|
||||
@@ -361,6 +489,74 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> longbowConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.LongbowSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.LongbowSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> quickstepConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.QuickstepSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> secondWindConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.SecondWindSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> stoneSkinConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.StoneSkinSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.StoneSkinSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> shieldBashConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.ShieldBashSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.ShieldBashSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> hexConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.HexSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.HexSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.HexSettings.parse(values);
|
||||
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,77 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
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 org.bukkit.entity.Player;
|
||||
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;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
var owned = grants.get(player.getUniqueId());
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Prevention(Grant grant, BigDecimal amount) { }
|
||||
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent);
|
||||
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);
|
||||
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void reduce(EntityDamageByEntityEvent event) {
|
||||
if (closed || !(event.getEntity() instanceof Player player)) return;
|
||||
Grant strongest = null;
|
||||
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 (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
|
||||
}
|
||||
double original = event.getDamage();
|
||||
if (strongest == null || !Double.isFinite(original) || original <= 0) return;
|
||||
double reduced = BigDecimal.valueOf(original)
|
||||
.multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
|
||||
if (!Double.isFinite(reduced) || reduced < 0) return;
|
||||
event.setDamage(reduced);
|
||||
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
|
||||
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
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.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
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 org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
|
||||
/** Hex's aimed, owned outgoing combat-damage penalty. */
|
||||
public final class Hex implements AbilityEffect,AutoCloseable{
|
||||
public record Settings(Duration cooldown,double range,Duration duration,BigDecimal reductionPercent){
|
||||
public Settings{Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);Objects.requireNonNull(reductionPercent);
|
||||
if(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
|
||||
||reductionPercent.signum()<0||reductionPercent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid Hex settings");}
|
||||
public static Settings defaults(){return new Settings(Duration.ofSeconds(10),12,Duration.ofSeconds(4),BigDecimal.valueOf(20));}}
|
||||
private final class State implements AutoCloseable{final AbilityExecution execution;final LivingEntity target;final AutoCloseable grant;boolean closed;
|
||||
State(AbilityExecution execution,LivingEntity target,AutoCloseable grant){this.execution=execution;this.target=target;this.grant=grant;}
|
||||
@Override public void close(){if(closed)return;closed=true;hexes.remove(target.getUniqueId(),this);try{grant.close();}catch(Exception failure){throw new IllegalStateException(failure);}}}
|
||||
private volatile Settings settings;private final OutgoingDamageMitigation mitigation;private final Map<UUID,State> hexes=new java.util.HashMap<>();private boolean closed;
|
||||
public Hex(Settings settings,OutgoingDamageMitigation mitigation){this.settings=Objects.requireNonNull(settings);this.mitigation=Objects.requireNonNull(mitigation);}
|
||||
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
|
||||
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("hex","Hex",Material.SPIDER_EYE,
|
||||
List.of("Reduce an aimed hostile's combat damage by "+current.reductionPercent().stripTrailingZeros().toPlainString()+"%.",
|
||||
"Range "+BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString()+" blocks; lasts "+seconds(current.duration())+" seconds."),
|
||||
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),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),"damage-reduction-percent",current.reductionPercent()),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE,this);}
|
||||
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
|
||||
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())){execution.finish();return;}
|
||||
var state=new State(execution,target,mitigation.grant(execution,target,current.reductionPercent()));var previous=hexes.put(target.getUniqueId(),state);
|
||||
if(previous!=null)previous.execution.finish();execution.own(state);execution.later(current.duration(),execution::finish);}
|
||||
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
|
||||
return source.getWorld()==destination.getWorld()&&source.distanceSquared(destination)<=range*range&&input.player().hasLineOfSight(target);
|
||||
}catch(RuntimeException invalid){return false;}}
|
||||
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(definition.cooldown(),p.get("range").doubleValue(),
|
||||
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-reduction-percent"));}
|
||||
private static String seconds(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values()))state.execution.finish();hexes.clear();}
|
||||
}
|
||||
@@ -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&÷d[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,220 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.math.MathContext;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
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.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.AbstractArrow;
|
||||
import org.bukkit.entity.Arrow;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
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.event.entity.ProjectileHitEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Longbow's ammunition-free native draw and bounded custom-arrow damage adapter. */
|
||||
public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
private static final BigInteger MAX_NATIVE_BASE = BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger();
|
||||
|
||||
public record Settings(BigInteger baseDamage, Duration recovery, int minimumDrawTicks, int fullDrawTicks,
|
||||
double range, Duration lifetime, double fullSpeedPerTick) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(recovery); Objects.requireNonNull(lifetime);
|
||||
if (baseDamage.signum() < 0 || baseDamage.compareTo(MAX_NATIVE_BASE) > 0
|
||||
|| recovery.compareTo(Duration.ofSeconds(1)) < 0
|
||||
|| recovery.compareTo(Duration.ofDays(365)) > 0
|
||||
|| minimumDrawTicks < 1 || minimumDrawTicks > 72_000
|
||||
|| fullDrawTicks < minimumDrawTicks || fullDrawTicks > 72_000
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 1024
|
||||
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|
||||
|| !Double.isFinite(fullSpeedPerTick) || fullSpeedPerTick < 0.01 || fullSpeedPerTick > 64) {
|
||||
throw new IllegalArgumentException("Invalid Longbow settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(5), Duration.ofSeconds(1), 3, 20, 64,
|
||||
Duration.ofSeconds(10), 3);
|
||||
}
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final Arrow arrow;
|
||||
final AbilityExecution execution;
|
||||
final Location origin;
|
||||
final Settings settings;
|
||||
final BigDecimal damage;
|
||||
LivingEntity target;
|
||||
boolean accepted;
|
||||
boolean finished;
|
||||
State(Arrow arrow, AbilityExecution execution, Location origin, Settings settings, BigDecimal damage) {
|
||||
this.arrow = arrow; this.execution = execution; this.origin = origin; this.settings = settings; this.damage = damage;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true; arrows.remove(arrow.getUniqueId(), this); arrow.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final Map<UUID, State> arrows = new HashMap<>();
|
||||
private State damaging;
|
||||
private boolean closed;
|
||||
|
||||
public Longbow(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("longbow", "Longbow", Material.BOW,
|
||||
List.of("Draw and release an ammunition-free owned arrow.",
|
||||
"A full draw deals " + current.baseDamage() + " + DEX modifier damage."),
|
||||
"Draw by holding use; release after at least " + current.minimumDrawTicks() + " ticks.",
|
||||
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.DRAW_RELEASE, current.recovery(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"minimum-draw-ticks", BigDecimal.valueOf(current.minimumDrawTicks()),
|
||||
"full-draw-ticks", BigDecimal.valueOf(current.fullDrawTicks()),
|
||||
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
|
||||
"lifetime", seconds(current.lifetime()),
|
||||
"full-projectile-speed", BigDecimal.valueOf(current.fullSpeedPerTick()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return input.gesture() == ControlInput.Gesture.DRAW_RELEASE && input.heldTicks() >= settings.minimumDrawTicks();
|
||||
}
|
||||
|
||||
public static BigDecimal power(int heldTicks, int fullDrawTicks) {
|
||||
if (heldTicks < 0 || fullDrawTicks < 1) throw new IllegalArgumentException("Invalid Longbow draw");
|
||||
var x = BigDecimal.valueOf(heldTicks).divide(BigDecimal.valueOf(fullDrawTicks), MathContext.DECIMAL128);
|
||||
var power = x.multiply(x, MathContext.DECIMAL128).add(x.multiply(BigDecimal.TWO), MathContext.DECIMAL128)
|
||||
.divide(BigDecimal.valueOf(3), MathContext.DECIMAL128);
|
||||
return power.min(BigDecimal.ONE);
|
||||
}
|
||||
|
||||
public static BigInteger fullDamage(BigInteger dexterity) {
|
||||
return fullDamage(BigInteger.valueOf(5), dexterity);
|
||||
}
|
||||
|
||||
static BigInteger fullDamage(BigInteger base, BigInteger dexterity) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(dexterity);
|
||||
if (base.signum() < 0 || dexterity.signum() < 0) throw new IllegalArgumentException("Invalid Longbow damage input");
|
||||
BigInteger delta = dexterity.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 (closed || !execution.stillCurrent()) return;
|
||||
Settings shot = from(execution.definition());
|
||||
BigDecimal power = power(execution.input().heldTicks(), shot.fullDrawTicks());
|
||||
var player = execution.input().player();
|
||||
Location origin = player.getEyeLocation().clone();
|
||||
Vector direction = origin.getDirection();
|
||||
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|
||||
|| direction.lengthSquared() == 0) throw new IllegalStateException("Longbow direction is unavailable");
|
||||
double speed = BigDecimal.valueOf(shot.fullSpeedPerTick()).multiply(power, MathContext.DECIMAL128).doubleValue();
|
||||
var arrow = player.launchProjectile(Arrow.class, direction.normalize().multiply(speed));
|
||||
arrow.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
|
||||
arrow.setDamage(0); arrow.setKnockbackStrength(0); arrow.setPierceLevel(0); arrow.setCritical(false);
|
||||
var damage = new BigDecimal(fullDamage(shot.baseDamage(), execution.character().stats().get(Stat.DEX)))
|
||||
.multiply(power, MathContext.DECIMAL128);
|
||||
var state = new State(arrow, execution, origin, shot, damage);
|
||||
if (arrows.putIfAbsent(arrow.getUniqueId(), state) != null) {
|
||||
arrow.remove(); throw new IllegalStateException("Duplicate Longbow arrow identity");
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(shot.range() / speed));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
execution.later(shot.lifetime(), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void containNativeDamage(EntityDamageByEntityEvent event) {
|
||||
if (!(event.getDamager() instanceof Arrow arrow)) return;
|
||||
var state = arrows.get(arrow.getUniqueId());
|
||||
if (state != null && damaging != state) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void hit(ProjectileHitEvent event) {
|
||||
var state = arrows.remove(event.getEntity().getUniqueId());
|
||||
if (state == null) return;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target)
|
||||
|| !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
double nativeDamage = state.damage.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
? Double.MAX_VALUE : state.damage.doubleValue();
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) return;
|
||||
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
|
||||
state.target = target; damaging = state;
|
||||
try { target.damage(nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
} finally {
|
||||
state.close(); state.execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean inRange(State state, Location current) {
|
||||
return current.getWorld() != null && state.origin.getWorld() != null
|
||||
&& current.getWorld().equals(state.origin.getWorld())
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.arrow && (!(event instanceof EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.arrow)) return;
|
||||
state.accepted = true;
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
p.get("minimum-draw-ticks").intValueExact(), p.get("full-draw-ticks").intValueExact(),
|
||||
p.get("range").doubleValue(), duration(p.get("lifetime")),
|
||||
p.get("full-projectile-speed").doubleValue());
|
||||
}
|
||||
|
||||
private static Duration duration(BigDecimal seconds) {
|
||||
return Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
}
|
||||
private static BigDecimal seconds(Duration duration) {
|
||||
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var state : List.copyOf(arrows.values())) state.execution.finish();
|
||||
arrows.clear(); damaging = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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.Consumer;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
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;
|
||||
|
||||
/** Internal pre-recipient strongest outgoing-damage reduction stage. */
|
||||
public final class OutgoingDamageMitigation 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 LivingEntity target; final BigDecimal percent; boolean closed;
|
||||
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
|
||||
this.sequence=++sequences;this.execution=execution;this.target=target;this.percent=percent;
|
||||
}
|
||||
@Override public void close(){if(closed)return;closed=true;var owned=grants.get(target.getUniqueId());
|
||||
if(owned!=null){owned.remove(this);if(owned.isEmpty())grants.remove(target.getUniqueId());}}
|
||||
}
|
||||
private record Prevention(Grant grant, Entity recipient, 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,Prevention> pending=new IdentityHashMap<>();
|
||||
private long sequences; private boolean closed;
|
||||
public OutgoingDamageMitigation(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,LivingEntity target,BigDecimal percent){
|
||||
Objects.requireNonNull(execution);Objects.requireNonNull(target);Objects.requireNonNull(percent);
|
||||
if(closed||percent.signum()<0||percent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid outgoing mitigation grant");
|
||||
var grant=new Grant(execution,target,percent);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);return grant;}
|
||||
|
||||
@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;
|
||||
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;}
|
||||
double original=event.getDamage();if(strongest==null||!Double.isFinite(original)||original<=0)return;
|
||||
double reduced=BigDecimal.valueOf(original).multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
|
||||
if(!Double.isFinite(reduced)||reduced<0)return;event.setDamage(reduced);var prevented=BigDecimal.valueOf(original-reduced);
|
||||
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),prevented));
|
||||
}
|
||||
|
||||
@EventHandler(priority=EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event){
|
||||
var prevention=pending.remove(event);if(prevention==null||event.isCancelled()||!prevention.grant.execution.stillCurrent())return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION,prevention.grant.target,prevention.amount);
|
||||
if(!(prevention.recipient instanceof Player player))return;
|
||||
var aided=sessions.active(player.getUniqueId()).orElse(null);if(aided==null||!sessions.current(aided))return;
|
||||
var input=prevention.grant.execution.input();if(aided.character().equals(input.session().character()))return;
|
||||
var sourceParty=parties.partyOf(input.player().getUniqueId()).orElse(null);
|
||||
var aidedParty=parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
if(sourceParty==null||aidedParty==null||!sourceParty.id().equals(aidedParty.id()))return;
|
||||
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,prevention.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();}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerVelocityEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Quickstep's grounded, collision-respecting, owned horizontal velocity adapter. */
|
||||
public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double distance, double speedPerTick) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(distance) || distance <= 0 || distance > 64
|
||||
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 8) {
|
||||
throw new IllegalArgumentException("Invalid Quickstep settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(Duration.ofSeconds(5), 4, 1); }
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution;
|
||||
final Player player;
|
||||
final Location origin;
|
||||
final Vector horizontal;
|
||||
final Settings settings;
|
||||
boolean owned = true;
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
|
||||
this.execution = execution; this.player = player; this.origin = origin;
|
||||
this.horizontal = horizontal; this.settings = settings;
|
||||
}
|
||||
boolean stillOwns(Vector current) {
|
||||
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
|
||||
double magnitude = horizontalCurrent.length();
|
||||
if (magnitude < 1.0e-6) return true;
|
||||
return magnitude <= horizontal.length() * 1.05 + 1.0e-6
|
||||
&& horizontalCurrent.clone().normalize().dot(horizontal.clone().normalize()) >= 0.99;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true; dashes.remove(player.getUniqueId(), this);
|
||||
if (!owned) return;
|
||||
Vector current = player.getVelocity();
|
||||
if (!stillOwns(current)) return;
|
||||
player.setVelocity(new Vector(0, current.getY(), 0));
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final Consumer<PlayerVelocityEvent> velocityEvents;
|
||||
private final Map<UUID, State> dashes = new HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public Quickstep(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
|
||||
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("quickstep", "Quickstep", Material.RABBIT_FOOT,
|
||||
List.of("Dash forward with native horizontal movement.",
|
||||
"Travel up to " + decimal(current.distance()) + " blocks while obstacles remain authoritative."),
|
||||
"Use while grounded, on foot, and not flying or gliding.",
|
||||
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
|
||||
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.SELF, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
var player = input.player();
|
||||
if (!player.isOnGround() || player.isFlying() || player.isGliding() || player.isInsideVehicle()) return false;
|
||||
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0);
|
||||
return finite(direction) && direction.lengthSquared() > 1.0e-12;
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition());
|
||||
var player = execution.input().player();
|
||||
if (!accepts(execution.input())) { execution.finish(); return; }
|
||||
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
|
||||
Vector before = player.getVelocity();
|
||||
Vector horizontal = direction.multiply(current.speedPerTick());
|
||||
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
|
||||
var event = new PlayerVelocityEvent(player, proposed.clone());
|
||||
velocityEvents.accept(event);
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); return; }
|
||||
Vector accepted = event.getVelocity().clone(); accepted.setY(before.getY());
|
||||
Vector acceptedHorizontal = new Vector(accepted.getX(), 0, accepted.getZ());
|
||||
if (acceptedHorizontal.lengthSquared() < 1.0e-12) { execution.finish(); return; }
|
||||
var state = new State(execution, player, player.getLocation().clone(), acceptedHorizontal, current);
|
||||
var previous = dashes.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
execution.own(state);
|
||||
player.setVelocity(accepted);
|
||||
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), execution::finish);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void moved(PlayerMoveEvent event) {
|
||||
var state = dashes.get(event.getPlayer().getUniqueId());
|
||||
if (state == null || state.player != event.getPlayer()) return;
|
||||
var to = event.getTo();
|
||||
if (to == null || !state.execution.stillCurrent()) { state.execution.finish(); return; }
|
||||
Vector current = state.player.getVelocity();
|
||||
if (!state.stillOwns(current)) {
|
||||
state.owned = false; state.execution.finish(); return;
|
||||
}
|
||||
double dx = to.getX() - state.origin.getX(); double dz = to.getZ() - state.origin.getZ();
|
||||
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()
|
||||
|| new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
|
||||
state.execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean finite(Vector vector) {
|
||||
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
return new Settings(definition.cooldown(), definition.parameters().get("distance").doubleValue(),
|
||||
definition.parameters().get("speed").doubleValue());
|
||||
}
|
||||
private static BigDecimal decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var state : List.copyOf(dashes.values())) state.execution.finish();
|
||||
dashes.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
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.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Second Wind's validated persist-before-publication self-heal. */
|
||||
public final class SecondWind implements AbilityEffect {
|
||||
public record Settings(BigInteger baseHealing, Duration cooldown) {
|
||||
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) {
|
||||
throw new IllegalArgumentException("Invalid Second Wind settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(15)); }
|
||||
}
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents;
|
||||
private final Supplier<BigDecimal> nativeLimit;
|
||||
private volatile Settings settings;
|
||||
|
||||
public SecondWind(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
|
||||
Supplier<BigDecimal> nativeLimit) {
|
||||
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("second_wind", "Second Wind", Material.GHAST_TEAR,
|
||||
List.of("Restore missing fantasy health.",
|
||||
"Heals " + current.baseHealing() + " + WIS modifier, capped at maximum health."),
|
||||
"Use while injured; full health refuses without spending cooldown.",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-healing", new BigDecimal(current.baseHealing())),
|
||||
AbilityDefinition.TargetPolicy.SELF, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
double health = input.player().getHealth();
|
||||
var attribute = input.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
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 Second Wind 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 definition = execution.definition();
|
||||
BigInteger base = definition.parameters().get("base-healing").toBigIntegerExact();
|
||||
var player = execution.input().player();
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) { execution.finish(); return; }
|
||||
double current = 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(base, 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(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(missing, event.getAmount());
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
|
||||
var record = execution.character(); var expected = record.revision();
|
||||
BigDecimal target = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
|
||||
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
|
||||
}
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
|
||||
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
|
||||
}
|
||||
double publish = Math.min(attribute.getValue(), Math.max(player.getHealth(), persisted.health().doubleValue()));
|
||||
player.setHealth(publish);
|
||||
} finally { execution.finish(); }
|
||||
});
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
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.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
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.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.player.PlayerVelocityEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Shield Bash's contained short-range custom strike and post-damage knockback. */
|
||||
public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double range,
|
||||
double horizontalKnockback, double minimumVerticalKnockback) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64
|
||||
|| !Double.isFinite(horizontalKnockback) || horizontalKnockback < 0 || horizontalKnockback > 8
|
||||
|| !Double.isFinite(minimumVerticalKnockback) || minimumVerticalKnockback < 0
|
||||
|| minimumVerticalKnockback > 8) throw new IllegalArgumentException("Invalid Shield Bash settings");
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(6), 3, 0.8, 0.35);
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
|
||||
this.execution = execution; this.target = target; this.settings = settings;
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final Consumer<PlayerVelocityEvent> velocityEvents;
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public ShieldBash(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
|
||||
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("shield_bash", "Shield Bash", Material.SHIELD,
|
||||
List.of("Strike within " + decimal(current.range()) + " blocks for " + current.baseDamage() + " + STR modifier damage.",
|
||||
"Accepted damage adds knockback; this shield never enables ordinary blocking."),
|
||||
"Attack a visible hostile at short range.", Map.of(Stat.STR, BigInteger.valueOf(12), Stat.CON, BigInteger.valueOf(12)),
|
||||
BigInteger.ONE, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()), "range", decimalValue(current.range()),
|
||||
"horizontal-knockback", decimalValue(current.horizontalKnockback()),
|
||||
"minimum-vertical-knockback", decimalValue(current.minimumVerticalKnockback())),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return !closed && input.target() instanceof LivingEntity target && inRange(input.player(), target, settings.range());
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger strength) { return damage(BigInteger.valueOf(4), strength); }
|
||||
static BigInteger damage(BigInteger base, BigInteger strength) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(strength);
|
||||
if (base.signum() < 0 || strength.signum() < 0) throw new IllegalArgumentException("Invalid Shield Bash damage input");
|
||||
var delta = strength.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 LivingEntity target)) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
if (!inRange(player, target, current.range())) { execution.finish(); return; }
|
||||
var exact = damage(current.baseDamage(), execution.character().stats().get(Stat.STR));
|
||||
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
|
||||
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
|
||||
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
|
||||
var attempt = new Attempt(execution, target, current); damaging = attempt;
|
||||
try { target.damage(amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; execution.finish(); }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = damaging;
|
||||
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.execution.input().player()
|
||||
|| event.getEntity() != attempt.target || !attempt.execution.stillCurrent()) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
|
||||
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
knockback(attempt);
|
||||
}
|
||||
|
||||
private void knockback(Attempt attempt) {
|
||||
var player = attempt.execution.input().player(); var target = attempt.target;
|
||||
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
|
||||
if (!finite(away) || away.lengthSquared() < 1.0e-12) return;
|
||||
Vector current = target.getVelocity();
|
||||
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
|
||||
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
|
||||
if (!finite(proposed)) return;
|
||||
if (target instanceof Player targetPlayer) {
|
||||
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) return;
|
||||
proposed = event.getVelocity().clone();
|
||||
}
|
||||
target.setVelocity(proposed);
|
||||
}
|
||||
|
||||
private static boolean inRange(Player player, LivingEntity target, double range) {
|
||||
try {
|
||||
Location source = player.getLocation(); Location destination = target.getLocation();
|
||||
return source.getWorld() == destination.getWorld() && source.distanceSquared(destination) <= range * range
|
||||
&& player.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static boolean finite(Vector value) {
|
||||
return Double.isFinite(value.getX()) && Double.isFinite(value.getY()) && Double.isFinite(value.getZ());
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("range").doubleValue(),
|
||||
p.get("horizontal-knockback").doubleValue(), p.get("minimum-vertical-knockback").doubleValue());
|
||||
}
|
||||
private static BigDecimal decimalValue(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
|
||||
private static String decimal(double value) { return decimalValue(value).toPlainString(); }
|
||||
@Override public void close() { closed = true; damaging = null; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
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 org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Stone Skin's captured, owned grant into the shared strongest-reduction stage. */
|
||||
public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
public record Settings(Duration cooldown, Duration duration, BigDecimal reductionPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(reductionPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| reductionPercent.signum() < 0 || reductionPercent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid Stone Skin settings");
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(14), Duration.ofSeconds(4), BigDecimal.valueOf(25));
|
||||
}
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player player; final AutoCloseable grant; boolean closed;
|
||||
State(AbilityExecution execution, Player player, AutoCloseable grant) {
|
||||
this.execution = execution; this.player = player; this.grant = grant;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; protections.remove(player.getUniqueId(), this);
|
||||
try { grant.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final CombatMitigation mitigation;
|
||||
private final Map<UUID, State> protections = new java.util.HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public StoneSkin(Settings settings, CombatMitigation mitigation) {
|
||||
this.settings = Objects.requireNonNull(settings); this.mitigation = Objects.requireNonNull(mitigation);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("stone_skin", "Stone Skin", Material.BRICK,
|
||||
List.of("Reduce entity and projectile combat damage by " + decimal(current.reductionPercent()) + "%.",
|
||||
"Lasts " + decimal(BigDecimal.valueOf(current.duration().toMillis()).movePointLeft(3)) + " seconds without stacking."),
|
||||
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
|
||||
"damage-reduction-percent", current.reductionPercent()), AbilityDefinition.TargetPolicy.SELF, this);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
var grant = mitigation.grant(execution, player, current.reductionPercent());
|
||||
var state = new State(execution, player, grant);
|
||||
var previous = protections.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
execution.own(state); execution.later(current.duration(), execution::finish);
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
return new Settings(definition.cooldown(),
|
||||
Duration.ofNanos(definition.parameters().get("duration-seconds").movePointRight(9).longValueExact()),
|
||||
definition.parameters().get("damage-reduction-percent"));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var state : List.copyOf(protections.values())) state.execution.finish();
|
||||
protections.clear();
|
||||
}
|
||||
}
|
||||
@@ -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)&¤t>=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();}
|
||||
}
|
||||
@@ -93,6 +93,13 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
|
||||
}
|
||||
|
||||
/** Records target-specific, effective support; only a direct contributor can activate it at defeat. */
|
||||
public void support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (closed || amount.signum() <= 0 || !sessions.current(aided) || supportingCharacter.equals(aided.character())) return;
|
||||
contributions.support(target, aided.character(), supportingCharacter, clock.instant());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || event.getFinalDamage() <= 0) return;
|
||||
|
||||
@@ -441,12 +441,19 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
return saveHealth(characterId, null, health, nativeLimit);
|
||||
}
|
||||
|
||||
/** Ability-owned health mutation that refuses a changed character revision rather than healing stale state. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, BigInteger expectedRevision,
|
||||
java.math.BigDecimal health, java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId);
|
||||
if (health.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
|
||||
var old = found.orElseThrow();
|
||||
if (expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var bounded = health.min(old.maximumHealth()).min(nativeLimit);
|
||||
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Hex;
|
||||
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 Hex settings; publication remains a server-thread caller decision. */
|
||||
public final class HexSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "hex-cooldown-seconds";
|
||||
public static final String RANGE = "hex-range-blocks";
|
||||
public static final String DURATION = "hex-duration-seconds";
|
||||
public static final String REDUCTION = "hex-damage-reduction-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, RANGE, DURATION, REDUCTION);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "10", RANGE, "12", DURATION, "4", REDUCTION, "20");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Hex.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-hex-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 HexSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
HexSettings(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 Hex.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("Hex 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 {
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static Hex.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
var range = finite(values.get(RANGE));
|
||||
var duration = seconds(values.get(DURATION));
|
||||
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
|
||||
return new Hex.Settings(cooldown, range, duration, reduction);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Hex 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Longbow;
|
||||
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 Longbow settings; publication remains a server-thread caller decision. */
|
||||
public final class LongbowSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_DAMAGE = "longbow-base-damage";
|
||||
public static final String RECOVERY = "longbow-recovery-seconds";
|
||||
public static final String MINIMUM_DRAW = "longbow-minimum-draw-ticks";
|
||||
public static final String FULL_DRAW = "longbow-full-draw-ticks";
|
||||
public static final String RANGE = "longbow-range-blocks";
|
||||
public static final String LIFETIME = "longbow-lifetime-seconds";
|
||||
public static final String FULL_SPEED = "longbow-full-projectile-speed-blocks-per-tick";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, RECOVERY, MINIMUM_DRAW, FULL_DRAW, RANGE,
|
||||
LIFETIME, FULL_SPEED);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "5", RECOVERY, "1",
|
||||
MINIMUM_DRAW, "3", FULL_DRAW, "20", RANGE, "64", LIFETIME, "10", FULL_SPEED, "3");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Longbow.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-longbow-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 LongbowSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
LongbowSettings(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 Longbow.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("Longbow configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String key, String raw) {
|
||||
try {
|
||||
if (key.equals(BASE_DAMAGE) || key.equals(MINIMUM_DRAW) || key.equals(FULL_DRAW)) {
|
||||
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 Longbow.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
|
||||
var recovery = seconds(values.get(RECOVERY));
|
||||
int minimumDraw = new java.math.BigInteger(values.get(MINIMUM_DRAW)).intValueExact();
|
||||
int fullDraw = new java.math.BigInteger(values.get(FULL_DRAW)).intValueExact();
|
||||
double range = finite(values.get(RANGE));
|
||||
var lifetime = seconds(values.get(LIFETIME));
|
||||
double speed = finite(values.get(FULL_SPEED));
|
||||
return new Longbow.Settings(base, recovery, minimumDraw, fullDraw, range, lifetime, speed);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Longbow 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Quickstep;
|
||||
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 Quickstep settings; publication remains a server-thread caller decision. */
|
||||
public final class QuickstepSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "quickstep-cooldown-seconds";
|
||||
public static final String DISTANCE = "quickstep-distance-blocks";
|
||||
public static final String SPEED = "quickstep-speed-blocks-per-tick";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, DISTANCE, SPEED);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "5", DISTANCE, "4", SPEED, "1");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Quickstep.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-quickstep-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 QuickstepSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
QuickstepSettings(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 Quickstep.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("Quickstep 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 {
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static Quickstep.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
double distance = finite(values.get(DISTANCE));
|
||||
double speed = finite(values.get(SPEED));
|
||||
return new Quickstep.Settings(cooldown, distance, speed);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Quickstep 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.SecondWind;
|
||||
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 SecondWind settings; publication remains a server-thread caller decision. */
|
||||
public final class SecondWindSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_HEALING = "second-wind-base-healing";
|
||||
public static final String COOLDOWN = "second-wind-cooldown-seconds";
|
||||
private static final List<String> KEYS = List.of(BASE_HEALING, COOLDOWN);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_HEALING, "6", COOLDOWN, "15");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, SecondWind.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-second-wind-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 SecondWindSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
SecondWindSettings(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 SecondWind.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("SecondWind 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 java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static SecondWind.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var base = new java.math.BigInteger(values.get(BASE_HEALING));
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
return new SecondWind.Settings(base, cooldown);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("SecondWind 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.ShieldBash;
|
||||
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 Shield Bash settings; publication remains a server-thread caller decision. */
|
||||
public final class ShieldBashSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_DAMAGE = "shield-bash-base-damage";
|
||||
public static final String COOLDOWN = "shield-bash-cooldown-seconds";
|
||||
public static final String RANGE = "shield-bash-range-blocks";
|
||||
public static final String HORIZONTAL = "shield-bash-horizontal-knockback";
|
||||
public static final String VERTICAL = "shield-bash-minimum-vertical-knockback";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, RANGE, HORIZONTAL, VERTICAL);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "6", RANGE, "3",
|
||||
HORIZONTAL, "0.8", VERTICAL, "0.35");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, ShieldBash.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-shield-bash-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 ShieldBashSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
ShieldBashSettings(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 ShieldBash.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("Shield Bash configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String key, String raw) {
|
||||
try {
|
||||
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static ShieldBash.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
return new ShieldBash.Settings(base, cooldown, finite(values.get(RANGE)),
|
||||
finite(values.get(HORIZONTAL)), finite(values.get(VERTICAL)));
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Shield Bash 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.StoneSkin;
|
||||
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 Stone Skin settings; publication remains a server-thread caller decision. */
|
||||
public final class StoneSkinSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "stone-skin-cooldown-seconds";
|
||||
public static final String DURATION = "stone-skin-duration-seconds";
|
||||
public static final String REDUCTION = "stone-skin-damage-reduction-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, DURATION, REDUCTION);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "14", DURATION, "4", REDUCTION, "25");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, StoneSkin.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-stone-skin-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 StoneSkinSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
StoneSkinSettings(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 StoneSkin.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("Stone Skin 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 {
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static StoneSkin.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
var duration = seconds(values.get(DURATION));
|
||||
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
|
||||
return new StoneSkin.Settings(cooldown, duration, reduction);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Stone Skin 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);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import java.util.UUID;
|
||||
|
||||
/** Server-thread bounded contribution ledger keyed by target and originating character. */
|
||||
public final class CombatContributions implements AutoCloseable {
|
||||
private static final java.time.Duration SUPPORT_LIFETIME = java.time.Duration.ofSeconds(10);
|
||||
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 boolean closed;
|
||||
|
||||
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
|
||||
@@ -23,26 +25,53 @@ public final class CombatContributions implements AutoCloseable {
|
||||
(previous, next) -> previous.isAfter(next) ? previous : next);
|
||||
}
|
||||
|
||||
public void support(UUID target, UUID aidedCharacter, UUID supportingCharacter, Instant acceptedAt) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(aidedCharacter);
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(acceptedAt);
|
||||
if (closed) return;
|
||||
supports.computeIfAbsent(target, ignored -> new HashMap<>())
|
||||
.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();
|
||||
var contributions = targets.remove(target);
|
||||
var assistance = supports.remove(target);
|
||||
if (contributions == null) return Set.of();
|
||||
var eligible = new LinkedHashSet<UUID>();
|
||||
contributions.forEach((character, acceptedAt) -> {
|
||||
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) -> {
|
||||
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
|
||||
});
|
||||
}
|
||||
return Set.copyOf(eligible);
|
||||
}
|
||||
|
||||
public void invalidate(UUID target) { if (!closed) targets.remove(Objects.requireNonNull(target)); }
|
||||
public void invalidate(UUID target) {
|
||||
if (!closed) { target = Objects.requireNonNull(target); targets.remove(target); supports.remove(target); }
|
||||
}
|
||||
|
||||
public void sweep(Instant now) {
|
||||
Objects.requireNonNull(now);
|
||||
if (closed) return;
|
||||
targets.values().forEach(contributions -> contributions.entrySet().removeIf(entry -> !rules.contributes(entry.getValue(), now)));
|
||||
targets.values().removeIf(Map::isEmpty);
|
||||
supports.values().forEach(byAlly -> {
|
||||
byAlly.values().forEach(bySupporter -> bySupporter.entrySet().removeIf(entry -> !supports(entry.getValue(), now)));
|
||||
byAlly.values().removeIf(Map::isEmpty);
|
||||
});
|
||||
supports.values().removeIf(Map::isEmpty);
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; targets.clear(); }
|
||||
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(); }
|
||||
}
|
||||
@@ -24,6 +24,20 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
|
||||
private games.dmg.fantasystick.ability.FrostShard frostShard;
|
||||
private games.dmg.fantasystick.ability.AbilityRegistry abilities;
|
||||
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
|
||||
private games.dmg.fantasystick.ability.Longbow longbow;
|
||||
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
|
||||
private games.dmg.fantasystick.ability.Quickstep quickstep;
|
||||
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
|
||||
private games.dmg.fantasystick.ability.SecondWind secondWind;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
|
||||
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
|
||||
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) {
|
||||
@@ -53,6 +67,146 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
this.abilities = Objects.requireNonNull(abilities);
|
||||
}
|
||||
|
||||
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.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard, abilities);
|
||||
this.longbowSettings = Objects.requireNonNull(longbowSettings);
|
||||
this.longbow = Objects.requireNonNull(longbow);
|
||||
}
|
||||
|
||||
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.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, abilities);
|
||||
this.quickstepSettings = Objects.requireNonNull(quickstepSettings);
|
||||
this.quickstep = Objects.requireNonNull(quickstep);
|
||||
}
|
||||
|
||||
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.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, quickstepSettings, quickstep, abilities);
|
||||
this.secondWindSettings = Objects.requireNonNull(secondWindSettings);
|
||||
this.secondWind = Objects.requireNonNull(secondWind);
|
||||
}
|
||||
|
||||
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.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind, abilities);
|
||||
this.stoneSkinSettings = Objects.requireNonNull(stoneSkinSettings);
|
||||
this.stoneSkin = Objects.requireNonNull(stoneSkin);
|
||||
}
|
||||
|
||||
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.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
stoneSkinSettings, stoneSkin, abilities);
|
||||
this.shieldBashSettings = Objects.requireNonNull(shieldBashSettings);
|
||||
this.shieldBash = Objects.requireNonNull(shieldBash);
|
||||
}
|
||||
|
||||
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.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, abilities);
|
||||
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)) {
|
||||
@@ -126,6 +280,27 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private void configuration(CommandSender sender, String[] args) {
|
||||
if (args.length < 2 || args.length > 3) { configurationUsage(sender); return; }
|
||||
if (args[1].equals("adventure-mode")) { adventureConfiguration(sender, args); return; }
|
||||
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
|
||||
longbowConfiguration(sender, args); return;
|
||||
}
|
||||
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
|
||||
quickstepConfiguration(sender, args); return;
|
||||
}
|
||||
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
|
||||
secondWindConfiguration(sender, args); return;
|
||||
}
|
||||
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
|
||||
stoneSkinConfiguration(sender, args); return;
|
||||
}
|
||||
if (shieldBashSettings != null && games.dmg.fantasystick.persistence.ShieldBashSettings.keys().contains(args[1])) {
|
||||
shieldBashConfiguration(sender, args); return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -160,6 +335,161 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private void longbowConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + longbowSettings.value(key) + " (applies to future Longbow shots only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.LongbowSettings.Update> operation;
|
||||
try { operation = longbowSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Longbow 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) {
|
||||
longbow.configure(saved.settings());
|
||||
abilities.replace(longbow.definition());
|
||||
longbowSettings.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 Longbow shots; active shots and deadlines are unchanged."
|
||||
: "Longbow configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void quickstepConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + quickstepSettings.value(key) + " (applies to future Quickstep dashes only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickstepSettings.Update> operation;
|
||||
try { operation = quickstepSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Quickstep 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) {
|
||||
quickstep.configure(saved.settings()); abilities.replace(quickstep.definition());
|
||||
quickstepSettings.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 Quickstep dashes; active movement and deadlines are unchanged."
|
||||
: "Quickstep configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
try{operation=hexSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Hex 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){hex.configure(saved.settings());abilities.replace(hex.definition());hexSettings.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 Hex casts; active effects and deadlines are unchanged."
|
||||
:"Hex configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
|
||||
}
|
||||
|
||||
private void shieldBashConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) { sender.sendMessage(key + "=" + shieldBashSettings.value(key) + " (applies to future Shield Bash strikes only)"); return; }
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ShieldBashSettings.Update> operation;
|
||||
try { operation = shieldBashSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("Shield Bash 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) {
|
||||
shieldBash.configure(saved.settings()); abilities.replace(shieldBash.definition()); shieldBashSettings.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 Shield Bash strikes; active work and deadlines are unchanged."
|
||||
: "Shield Bash configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void stoneSkinConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + stoneSkinSettings.value(key) + " (applies to future Stone Skin casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.StoneSkinSettings.Update> operation;
|
||||
try { operation = stoneSkinSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("Stone Skin 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) {
|
||||
stoneSkin.configure(saved.settings()); abilities.replace(stoneSkin.definition()); stoneSkinSettings.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 Stone Skin casts; active effects and deadlines are unchanged."
|
||||
: "Stone Skin configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void secondWindConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + secondWindSettings.value(key) + " (applies to future Second Wind casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SecondWindSettings.Update> operation;
|
||||
try { operation = secondWindSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Second Wind 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) {
|
||||
secondWind.configure(saved.settings()); abilities.replace(secondWind.definition());
|
||||
secondWindSettings.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 Second Wind casts; active work and deadlines are unchanged."
|
||||
: "Second Wind configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void adventureConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
|
||||
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]"); return;
|
||||
@@ -199,6 +529,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args.length == 2 && args[0].equals("config")) {
|
||||
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.SecondWindSettings.keys());
|
||||
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")) {
|
||||
@@ -208,6 +545,36 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
|
||||
String current = longbowSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
|
||||
String current = quickstepSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
|
||||
String current = secondWindSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
|
||||
String current = stoneSkinSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (shieldBashSettings != null && games.dmg.fantasystick.persistence.ShieldBashSettings.keys().contains(args[1])) {
|
||||
String current = shieldBashSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (hexSettings != null && games.dmg.fantasystick.persistence.HexSettings.keys().contains(args[1])) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -343,8 +343,14 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
if (allowed && event.getHand() == EquipmentSlot.HAND && (event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|
||||
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK)) {
|
||||
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
|
||||
if (selected != null && useGesture(selected)) emit(event.getPlayer(), event.getItem(), selected,
|
||||
ControlInput.Gesture.USE, null, 0, 0);
|
||||
if (selected != null && useGesture(selected)) {
|
||||
org.bukkit.entity.Entity target = selected.ability().equals("hex")
|
||||
? 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);
|
||||
}
|
||||
}
|
||||
@@ -434,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +458,8 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
if (special(item)) event.setCancelled(true);
|
||||
if (willAttack) {
|
||||
var selected = sessions.selectedControl(player, item).orElse(null);
|
||||
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword"))) {
|
||||
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword")
|
||||
|| selected.ability().equals("shield_bash"))) {
|
||||
emit(player, item, selected, ControlInput.Gesture.ATTACK, event.getAttacked(), 0, player.getAttackCooldown());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,3 +35,52 @@ frost-shard-lifetime-seconds: 5
|
||||
frost-shard-projectile-speed-blocks-per-tick: 1
|
||||
frost-shard-slowness-amplifier: 0
|
||||
frost-shard-slowness-duration-seconds: 2
|
||||
|
||||
# Longbow defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
longbow-base-damage: 5
|
||||
longbow-recovery-seconds: 1
|
||||
longbow-minimum-draw-ticks: 3
|
||||
longbow-full-draw-ticks: 20
|
||||
longbow-range-blocks: 64
|
||||
longbow-lifetime-seconds: 10
|
||||
longbow-full-projectile-speed-blocks-per-tick: 3
|
||||
|
||||
# Quickstep defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
quickstep-cooldown-seconds: 5
|
||||
quickstep-distance-blocks: 4
|
||||
quickstep-speed-blocks-per-tick: 1
|
||||
|
||||
# Second Wind defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
second-wind-base-healing: 6
|
||||
second-wind-cooldown-seconds: 15
|
||||
|
||||
# Stone Skin defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
stone-skin-cooldown-seconds: 14
|
||||
stone-skin-duration-seconds: 4
|
||||
stone-skin-damage-reduction-percent: 25
|
||||
|
||||
# Shield Bash defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
shield-bash-base-damage: 4
|
||||
shield-bash-cooldown-seconds: 6
|
||||
shield-bash-range-blocks: 3
|
||||
shield-bash-horizontal-knockback: 0.8
|
||||
shield-bash-minimum-vertical-knockback: 0.35
|
||||
|
||||
# Hex defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
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
|
||||
@@ -7,7 +7,7 @@ description: Transferable characters, native fantasy health, recoverable equipme
|
||||
author: dmg.games
|
||||
commands:
|
||||
fantasystick:
|
||||
description: Inspect/recover equipment sessions or configure fantasy mode.
|
||||
description: Inspect/recover equipment sessions or configure Fantasy Stick.
|
||||
usage: /fantasystick <inspect|recover> <player-uuid> | config <setting> [value]
|
||||
permission: fantasystick.admin
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
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.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
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.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class HexNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookCastActualPreventionAndQualifyingAllyDamagePersistTwoRewardShares() 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);
|
||||
withHex(data, a, false); 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()));
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("Hex")); assertTrue(book.contains("CHR14")); assertTrue(book.contains("WIS12"));
|
||||
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); var enemyId = java.util.UUID.randomUUID();
|
||||
when(enemy.getUniqueId()).thenReturn(enemyId); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(enemy);
|
||||
doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
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);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
|
||||
|
||||
var allySource = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(b.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, allySource, 3));
|
||||
var enemySource = DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var protectedHit = new EntityDamageByEntityEvent(enemy, b.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK, enemySource, 10);
|
||||
f.dispatcher.accept(protectedHit); assertEquals(8, protectedHit.getDamage(), 1.0e-9);
|
||||
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);
|
||||
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);
|
||||
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 replacementOldExpiryAndSourceExitPreserveOnlyCurrentOwnedPenalty() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 0\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
|
||||
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);
|
||||
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
assertEquals(2,f.delayed.size());f.delayed.getFirst().run();
|
||||
var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
|
||||
f.dispatcher.accept(hit);assertEquals(8,hit.getDamage(),1.0e-9);f.delayed.getLast().run();
|
||||
var expired=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
|
||||
f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1.0e-9);
|
||||
use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);
|
||||
var exited=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
|
||||
f.dispatcher.accept(exited);assertEquals(10,exited.getDamage(),1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void latestReplacementOwnsEffectivePreventionSupport() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-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);withHex(data,a,true);withHex(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 parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();for(var member:List.of(c,b))assertTrue(parties.accept(member.core.id,parties.invite(a.core.id,party.id(),member.core.id).orElseThrow().id()));
|
||||
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 Location(a.world,8,64,0));
|
||||
for(var caster:List.of(a,c)){doReturn(new Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(enemy);doReturn(enemy).when(caster.player).getTargetEntity(64);use(f,caster);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
var allySource=DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new EntityDamageByEntityEvent(b.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));var enemySource=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,10);f.dispatcher.accept(hit);assertEquals(8,hit.getDamage(),1.0e-9);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.valueOf(5),storage.find(c.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());
|
||||
}}
|
||||
|
||||
@Test void unusedProtectionCannotManufactureSupportShare() 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);withHex(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()));
|
||||
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());
|
||||
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
var allySource=DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(b.player,enemy,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 failedCooldownPersistenceAppliesNoHex() {
|
||||
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 mitigation=new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions,parties,ignored->{});var hex=new games.dmg.fantasystick.ability.Hex(games.dmg.fantasystick.ability.Hex.Settings.defaults(),mitigation);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(hex.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(14));stats.put(Stat.WIS,BigInteger.valueOf(12));
|
||||
var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),java.util.Set.of("hex"),List.of("hex","","","","","",""),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());var world=mock(org.bukkit.World.class);doReturn(new Location(world,0,64,0)).when(player).getLocation();
|
||||
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 Location(world,8,64,0));doReturn(true).when(player).hasLineOfSight(enemy);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("hex"),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,"hex",games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.USE,enemy,0,0,()->true));
|
||||
verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("hex"),any());var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);mitigation.reduce(hit);assertEquals(10,hit.getDamage(),1.0e-9);engine.close();hex.close();mitigation.close();parties.close();
|
||||
}
|
||||
|
||||
@Test void cancelledDamageCreatesNoReductionOrSupportButRetainsAcknowledgedCooldown() 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);withHex(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()));
|
||||
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);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
class Veto implements org.bukkit.event.Listener{@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.LOWEST)public void damage(EntityDamageByEntityEvent event){if(event.getDamager()==enemy)event.setCancelled(true);}}
|
||||
f.dispatcher.register(new Veto(),f.plugin);var source=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertTrue(hit.isCancelled());assertEquals(10,hit.getDamage(),1.0e-9);
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedPenalty() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
|
||||
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);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex");
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=abilityButton(a,"[Unlearn]","hex");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("hex"));
|
||||
var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);f.dispatcher.accept(hit);assertEquals(10,hit.getDamage(),1.0e-9);
|
||||
learnAndAssign(f,a);use(f,a);assertTrue(a.messages.stream().anyMatch(message->message.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(new Location(a.world,0,64,0)).when(b.player).getLocation();doReturn(true).when(b.player).hasLineOfSight(enemy);doReturn(enemy).when(b.player).getTargetEntity(64);use(f,b);assertTrue(b.messages.stream().anyMatch(message->message.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
|
||||
}}
|
||||
|
||||
@Test void configuredDefinitionAdminRoutesRestartAndStartupValidationCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 2\nhex-range-blocks: 16\nhex-duration-seconds: 6\nhex-damage-reduction-percent: 30\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("hex").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(2),d.cooldown());assertEquals(0,new java.math.BigDecimal("16").compareTo(d.parameters().get("range")));assertEquals(0,new java.math.BigDecimal("6").compareTo(d.parameters().get("duration-seconds")));assertEquals(0,new java.math.BigDecimal("30").compareTo(d.parameters().get("damage-reduction-percent")));
|
||||
var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","hex-"}).isEmpty());
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","18"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;
|
||||
assertEquals(List.of("hex-cooldown-seconds","hex-damage-reduction-percent","hex-duration-seconds","hex-range-blocks"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","hex-"}));
|
||||
assertEquals(List.of("12","16"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","hex-range-blocks",""}));
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks"});assertTrue(sender.messages.getLast().contains("=16"));
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","18"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();
|
||||
assertEquals(0,new java.math.BigDecimal("18").compareTo(registry.find("hex").orElseThrow().parameters().get("range")));
|
||||
var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);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);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,17,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);var configured=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);f.dispatcher.accept(configured);assertEquals(7,configured.getDamage(),1.0e-9);
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","18","extra"});assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(0,new java.math.BigDecimal("18").compareTo(reloaded.find("hex").orElseThrow().parameters().get("range")));
|
||||
}
|
||||
for(String invalid:List.of("hex-cooldown-seconds: -1\n","hex-range-blocks: 0\n","hex-duration-seconds: 0\n","hex-damage-reduction-percent: 101\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());}}
|
||||
}
|
||||
|
||||
@Test void partyAndMixedModeAimedPlayersRefuseWithoutCooldown() throws Exception {
|
||||
for(int route=0;route<2;route++){var dir=data.resolve("player-refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;
|
||||
if(route==0){var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);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()));}else{target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}
|
||||
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new Location(a.world,8,64,0)).when(target).getLocation();doReturn(true).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("hex"));}}
|
||||
}
|
||||
|
||||
@Test void firstAimedOutOfRangeOrObstructedTargetRefusesWithoutFallbackOrCooldown() throws Exception {
|
||||
for(int route=0;route<2;route++){var dir=data.resolve("refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);
|
||||
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);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,route==0?13:8,64,0));doReturn(route==0).when(a.player).hasLineOfSight(enemy);doReturn(enemy).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("hex"));}}
|
||||
}
|
||||
|
||||
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,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,abilityButton(a,"[3]","hex"));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 abilityButton(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=abilityButton(page.raw(),label,ability);if(found!=null)return found;}throw new AssertionError("Missing action");}
|
||||
private static ClickEvent.Custom abilityButton(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=abilityButton(child,label,ability);if(found!=null)return found;}return null;}
|
||||
private static void withHex(Path data,FantasyStickLifecycleNativeTest.Actor actor,boolean learned)throws Exception{
|
||||
java.nio.file.Files.createDirectories(data);var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old=repository.load(actor.core.record.id()).orElseThrow();var stats=new EnumMap<>(old.stats());
|
||||
stats.put(Stat.CHR,BigInteger.valueOf(14));stats.put(Stat.WIS,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?java.util.Set.of("hex"):old.learned(),
|
||||
learned?List.of("hex","","","","","",""):old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
|
||||
}
|
||||
@@ -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());}}
|
||||
@@ -0,0 +1,479 @@
|
||||
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.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.AbstractArrow;
|
||||
import org.bukkit.entity.Arrow;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.ProjectileHitEvent;
|
||||
import org.bukkit.event.entity.ProjectileLaunchEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
class LongbowNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionNativeDrawFullShotDamageAttributionAndPersistentRecovery() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 0, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("Longbow")); assertTrue(text.contains("DEX14"));
|
||||
assertTrue(text.contains("Cost:1"), text); assertTrue(text.contains("Draw"), text);
|
||||
assertTrue(text.contains("1s"), text);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "longbow"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "longbow"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var learned = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertTrue(learned.learned().contains("longbow")); assertEquals("longbow", learned.loadout().getFirst());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
|
||||
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var arrow = arrow(a, new Location(a.world, 6, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 6, 65.6, 0)); when(target.getWorld()).thenReturn(a.world);
|
||||
var damages = new ArrayList<EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = call.getArgument(1, DamageSource.class);
|
||||
var event = new EntityDamageByEntityEvent(arrow, target, EntityDamageEvent.DamageCause.PROJECTILE,
|
||||
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
a.core.inventory.setItem(20, new ItemStack(Material.ARROW, 32));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var velocity = ArgumentCaptor.forClass(Vector.class);
|
||||
verify(a.player).launchProjectile(eq(Arrow.class), velocity.capture());
|
||||
assertEquals(3.0, velocity.getValue().length(), 1.0e-9);
|
||||
verify(arrow).setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
|
||||
verify(arrow).setDamage(0);
|
||||
assertEquals(32, a.core.inventory.getItem(20).getAmount(), "Ordinary ammunition must remain untouched");
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
assertEquals(1, damages.size()); assertEquals(7.0, damages.getFirst().getFinalDamage(), 1.0e-9);
|
||||
f.dispatcher.accept(new EntityDeathEvent(target, damages.getFirst().getDamageSource(), new ArrayList<>(), 0));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var saved = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.TEN, saved.experience()); assertNotNull(saved.cooldowns().get("longbow"));
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target)); assertEquals(1, damages.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partialDrawUsesReviewedCurveWhileTooShortDrawRefusesBeforeCooldown() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var arrow = arrow(a, new Location(a.world, 6, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 2);
|
||||
verify(a.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("longbow"));
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
|
||||
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 6, 65.6, 0));
|
||||
var amount = new double[1];
|
||||
doAnswer(call -> {
|
||||
amount[0] = call.getArgument(0); var source = call.getArgument(1, DamageSource.class);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(arrow, target, EntityDamageEvent.DamageCause.PROJECTILE,
|
||||
source, amount[0])); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
drawAndRelease(f, a, 10); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
assertEquals(7.0 * (0.25 + 1.0) / 3.0, amount[0], 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeDamagePartyTargetsAndCancelledCustomDamageProduceNoAcceptedHit() 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);
|
||||
withLongbow(data, a, 14); f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var party = PartyLifecycleNativeTest.parties(f).create(a.core.id).orElseThrow();
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).accept(b.core.id,
|
||||
PartyLifecycleNativeTest.parties(f).invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
|
||||
var arrow = arrow(a, new Location(a.world, 4, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var ordinaryTarget = mock(LivingEntity.class); when(ordinaryTarget.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var nativeDamage = new EntityDamageByEntityEvent(arrow, ordinaryTarget,
|
||||
EntityDamageEvent.DamageCause.PROJECTILE, 7.0);
|
||||
f.dispatcher.accept(nativeDamage); assertTrue(nativeDamage.isCancelled(), "Owned arrows cannot add vanilla damage");
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, b.player));
|
||||
verify(b.player, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, c, 14);
|
||||
f.players.put(c.core.id, c.player); f.activate(c); when(c.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var arrow2 = arrow(c, new Location(c.world, 4, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow2)); return arrow2; })
|
||||
.when(c.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
var hostile = mock(LivingEntity.class); when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(hostile.getLocation()).thenReturn(new Location(c.world, 4, 65.6, 0));
|
||||
doAnswer(call -> {
|
||||
var source = call.getArgument(1, DamageSource.class);
|
||||
var event = new EntityDamageByEntityEvent(arrow2, hostile, EntityDamageEvent.DamageCause.PROJECTILE,
|
||||
source, (Double) call.getArgument(0)); event.setCancelled(true); f.dispatcher.accept(event); return null;
|
||||
}).when(hostile).damage(anyDouble(), any(DamageSource.class));
|
||||
drawAndRelease(f, c, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow2, hostile));
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(BigInteger.ZERO, storage.find(c.core.record.id()).get().orElseThrow().experience());
|
||||
assertNotNull(storage.find(c.core.record.id()).get().orElseThrow().cooldowns().get("longbow"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rangeLifetimeAndSessionEndRemoveOwnedArrowsWithoutLateDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
for (int route = 0; route < 3; route++) {
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var location = new Location(a.world, route == 0 ? 64.01 : 2, 65.6, 0);
|
||||
var arrow = arrow(a, location);
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
if (route == 0) {
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
verify(target, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
} else if (route == 1) {
|
||||
f.delayed.getLast().run();
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
verify(target, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
} else {
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "abilityEngine")).sweep();
|
||||
}
|
||||
verify(arrow, atLeastOnce()).remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainRecoveryAndRemoveTheOwnedArrow() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var arrow = arrow(a, new Location(a.world, 2, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("longbow");
|
||||
assertNotNull(deadline);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var unlearn = abilityButton(a, "[Unlearn]", "longbow");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertFalse(refunded.learned().contains("longbow")); assertEquals(deadline, refunded.cooldowns().get("longbow"));
|
||||
verify(arrow, atLeastOnce()).remove();
|
||||
learnAndAssign(f, a); drawAndRelease(f, a, 20);
|
||||
verify(a.player, times(1)).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.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); drawAndRelease(f, b, 20);
|
||||
verify(b.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedRecoveryPersistenceLaunchesNoArrow() {
|
||||
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 registry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
var longbow = new games.dmg.fantasystick.ability.Longbow(
|
||||
games.dmg.fantasystick.ability.Longbow.Settings.defaults()); registry.register(longbow.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.DEX, BigInteger.valueOf(14));
|
||||
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), base.health(), java.util.Set.of("longbow"),
|
||||
List.of("longbow", "", "", "", "", "", ""), 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(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 Longbow recovery failure"));
|
||||
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("longbow"), any())).thenReturn(failed);
|
||||
var failures = new ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
|
||||
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
|
||||
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
|
||||
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "longbow",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.DRAW_RELEASE, null, 20, 0, () -> true));
|
||||
verify(player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
assertEquals(1, failures.size()); engine.close(); longbow.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void acknowledgedConfigurationChangesOnlyFutureShotSnapshots() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var oldArrow = arrow(a, new Location(a.world, 3, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(oldArrow)); return oldArrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
|
||||
var oldTarget = targetRecordingDamage(f, a, oldArrow); f.dispatcher.accept(new ProjectileHitEvent(oldArrow, oldTarget.entity()));
|
||||
assertEquals(7.0, oldTarget.amount()[0], 1.0e-9);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, b, 14);
|
||||
f.players.put(b.core.id, b.player); f.activate(b); when(b.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var newArrow = arrow(b, new Location(b.world, 3, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(newArrow)); return newArrow; })
|
||||
.when(b.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
var newTarget = targetRecordingDamage(f, b, newArrow);
|
||||
drawAndRelease(f, b, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(newArrow, newTarget.entity()));
|
||||
assertEquals(11.0, newTarget.amount()[0], 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAdminWriteKeepsLongbowDefinitionAndExternalFileUnchanged() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var outside = data.resolveSibling(data.getFileName() + "-longbow-outside");
|
||||
java.nio.file.Files.writeString(outside, "untouched\n");
|
||||
java.nio.file.Files.createSymbolicLink(data.resolve("config.yml"), outside);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("5"), registry.find("longbow").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
assertEquals("untouched\n", java.nio.file.Files.readString(outside));
|
||||
java.nio.file.Files.delete(data.resolve("config.yml")); java.nio.file.Files.delete(outside);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reenableDrainsAcceptedLongbowConfigurationBeforeGameplayRegistration() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var settings = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "longbowSettings");
|
||||
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
|
||||
worker.execute(() -> {
|
||||
entered.countDown();
|
||||
try { release.await(5, java.util.concurrent.TimeUnit.SECONDS); }
|
||||
catch (InterruptedException failure) { Thread.currentThread().interrupt(); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"));
|
||||
release.countDown(); f.tick();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("longbow").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionFormulaValidationAndAdminRoutesCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"longbow-base-damage: 8\nlongbow-recovery-seconds: 2\nlongbow-minimum-draw-ticks: 4\n"
|
||||
+ "longbow-full-draw-ticks: 30\nlongbow-range-blocks: 70\nlongbow-lifetime-seconds: 11\n"
|
||||
+ "longbow-full-projectile-speed-blocks-per-tick: 4\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 definition = registry.find("longbow").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(new java.math.BigDecimal("8"), definition.parameters().get("base-damage"));
|
||||
assertEquals(new java.math.BigDecimal("4"), definition.parameters().get("minimum-draw-ticks"));
|
||||
assertEquals(new java.math.BigDecimal("30"), definition.parameters().get("full-draw-ticks"));
|
||||
assertEquals(0, new java.math.BigDecimal("70").compareTo(definition.parameters().get("range")));
|
||||
assertEquals(0, new java.math.BigDecimal("11").compareTo(definition.parameters().get("lifetime")));
|
||||
assertEquals(0, new java.math.BigDecimal("4").compareTo(definition.parameters().get("full-projectile-speed")));
|
||||
assertEquals(java.math.BigDecimal.ONE, games.dmg.fantasystick.ability.Longbow.power(30, 30));
|
||||
assertEquals(0, games.dmg.fantasystick.ability.Longbow.power(15, 30)
|
||||
.compareTo(new java.math.BigDecimal("0.4166666666666666666666666666666667")));
|
||||
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Longbow.fullDamage(BigInteger.valueOf(14)));
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(5)),
|
||||
games.dmg.fantasystick.ability.Longbow.fullDamage(BigInteger.TEN.pow(500)));
|
||||
var unavailable = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable);
|
||||
unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable);
|
||||
assertTrue(PartyBookNativeTest.text(unavailable).replaceAll("\\s+", "").contains("Unavailable:requirements"));
|
||||
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
|
||||
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(java.util.List.of("config", "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());
|
||||
assertEquals(java.util.List.of("longbow-base-damage"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow-b"}));
|
||||
assertEquals(7, f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow-"}).size());
|
||||
assertEquals(java.util.List.of("5", "8"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "longbow-base-damage", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage"});
|
||||
assertTrue(sender.messages.getLast().contains("longbow-base-damage=8"));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied"), sender.messages.toString()); sender.allowed = true;
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("longbow").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "-1"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), reloaded.find("longbow").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
}
|
||||
|
||||
for (String invalid : List.of("longbow-base-damage: -1\n", "longbow-recovery-seconds: 0.5\n",
|
||||
"longbow-minimum-draw-ticks: 0\n", "longbow-full-draw-ticks: 2\n",
|
||||
"longbow-range-blocks: 0\n", "longbow-lifetime-seconds: 0\n",
|
||||
"longbow-full-projectile-speed-blocks-per-tick: 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 f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
|
||||
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record DamageTarget(LivingEntity entity, double[] amount) { }
|
||||
|
||||
private static DamageTarget targetRecordingDamage(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, Arrow arrow) {
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getLocation()).thenReturn(new Location(actor.world, 3, 65.6, 0));
|
||||
var amount = new double[1];
|
||||
doAnswer(call -> {
|
||||
amount[0] = call.getArgument(0); var source = call.getArgument(1, DamageSource.class);
|
||||
fixture.dispatcher.accept(new EntityDamageByEntityEvent(arrow, target,
|
||||
EntityDamageEvent.DamageCause.PROJECTILE, source, amount[0])); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
return new DamageTarget(target, amount);
|
||||
}
|
||||
|
||||
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, abilityButton(actor, "[Learn]", "longbow"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "longbow"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static void drawAndRelease(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, int ticks) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var eye = new Location(actor.world, 0, 65.6, 0); eye.setDirection(new Vector(1, 0, 0));
|
||||
doReturn(eye).when(actor.player).getEyeLocation();
|
||||
var bow = actor.core.inventory.getItem(2).clone();
|
||||
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
bow, null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
fixture.dispatcher.accept(new io.papermc.paper.event.player.PlayerStopUsingItemEvent(actor.player, bow, ticks));
|
||||
}
|
||||
|
||||
private static Arrow arrow(FantasyStickLifecycleNativeTest.Actor actor, Location location) {
|
||||
var arrow = PartyCombatNativeTest.entity(Arrow.class);
|
||||
when(arrow.getShooter()).thenReturn(actor.player); when(arrow.getLocation()).thenReturn(location);
|
||||
when(arrow.getWorld()).thenReturn(actor.world); return arrow;
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(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 = abilityButton(page.raw(), label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
throw new AssertionError("Missing " + label + " action for " + ability);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(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 = abilityButton(child, label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
int intelligence, int dexterity) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); stats.put(Stat.INT, BigInteger.valueOf(intelligence));
|
||||
stats.put(Stat.DEX, BigInteger.valueOf(dexterity));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(40), old.health(), old.learned(), old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static void withLongbow(Path data, FantasyStickLifecycleNativeTest.Actor actor, int dexterity) throws Exception {
|
||||
withStats(data, actor, 0, dexterity);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
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().subtract(BigInteger.ONE), old.health(),
|
||||
java.util.Set.of("longbow"), List.of("longbow", "", "", "", "", "", ""), old.cooldowns(),
|
||||
old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
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.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuickstepNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseAppliesAndStopsOwnedHorizontalVelocityAtFourBlocks() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, false);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("Quickstep")); assertTrue(text.contains("DEX12"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("4")); assertTrue(text.contains("5s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "quickstep"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "quickstep"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
|
||||
var velocity = prepareMovement(a, new Vector(0, 0.25, 0));
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(new Vector(1, 0.25, 0), velocity.get());
|
||||
verify(a.player, never()).setFallDistance(anyFloat());
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
|
||||
var from = new Location(a.world, 0, 64, 0); var to = new Location(a.world, 4, 64, 0);
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, to));
|
||||
assertEquals(new Vector(0, 0.25, 0), velocity.get(), "Only remaining owned horizontal motion is removed");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void airborneFlyingGlidingOrMountedUseRefusesBeforeCooldownAndMovement() throws Exception {
|
||||
for (int route = 0; route < 4; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve(Integer.toString(route)))) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve(Integer.toString(route)));
|
||||
withStats(data.resolve(Integer.toString(route)), a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var velocity = prepareMovement(a, new Vector(0.3, 0.4, 0.5));
|
||||
doReturn(route != 0).when(a.player).isOnGround();
|
||||
doReturn(route == 1).when(a.player).isFlying();
|
||||
doReturn(route == 2).when(a.player).isGliding();
|
||||
doReturn(route == 3).when(a.player).isInsideVehicle();
|
||||
use(f, a);
|
||||
assertEquals(new Vector(0.3, 0.4, 0.5), velocity.get());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("quickstep"));
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void velocityCancellationObstacleAndUnrelatedReplacementAreAuthoritative() throws Exception {
|
||||
for (int route = 0; route < 3; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("motion-" + route))) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("motion-" + route));
|
||||
withStats(data.resolve("motion-" + route), a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var velocity = prepareMovement(a, new Vector(0, 0.3, 0));
|
||||
if (route == 0) {
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void velocity(org.bukkit.event.player.PlayerVelocityEvent event) {
|
||||
if (event.getPlayer() == a.player) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Veto(), f.plugin);
|
||||
}
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
|
||||
if (route == 0) {
|
||||
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Cancelled velocity applies no movement");
|
||||
} else if (route == 1) {
|
||||
velocity.set(new Vector(0, 0.3, 0));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
|
||||
new Location(a.world, 0.2, 64, 0)));
|
||||
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Native collision stops movement early");
|
||||
} else {
|
||||
velocity.set(new Vector(0, 0.6, 2));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
|
||||
new Location(a.world, 0.2, 64, 0)));
|
||||
assertEquals(new Vector(0, 0.6, 2), velocity.get(), "Replacement movement relinquishes ownership");
|
||||
}
|
||||
verify(a.player, never()).setFallDistance(anyFloat());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedHorizontalMotion() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); var velocity = prepareMovement(a, new Vector(0, 0.4, 0));
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"); assertNotNull(deadline);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var unlearn = abilityButton(a, "[Unlearn]", "quickstep");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("quickstep"));
|
||||
assertEquals(new Vector(0, 0.4, 0), velocity.get());
|
||||
learnAndAssign(f, a); use(f, a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
assertEquals(new Vector(0, 0.4, 0), velocity.get());
|
||||
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); prepareMovement(b, new Vector(0.2, 0.3, 0.4)); use(f, b);
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceAppliesNoVelocity() {
|
||||
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 registry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
var quickstep = new games.dmg.fantasystick.ability.Quickstep(
|
||||
games.dmg.fantasystick.ability.Quickstep.Settings.defaults(), ignored -> {});
|
||||
registry.register(quickstep.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.DEX, BigInteger.valueOf(12));
|
||||
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), base.health(), java.util.Set.of("quickstep"),
|
||||
List.of("quickstep", "", "", "", "", "", ""), 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(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
|
||||
doReturn(true).when(player).isOnGround(); doReturn(false).when(player).isFlying();
|
||||
doReturn(false).when(player).isGliding(); doReturn(false).when(player).isInsideVehicle();
|
||||
var eye = new Location(mock(org.bukkit.World.class), 0, 0, 0); eye.setDirection(new Vector(1, 0, 0));
|
||||
doReturn(eye).when(player).getEyeLocation();
|
||||
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
|
||||
failed.completeExceptionally(new java.io.IOException("simulated Quickstep cooldown failure"));
|
||||
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("quickstep"), any())).thenReturn(failed);
|
||||
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
|
||||
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
|
||||
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
|
||||
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "quickstep",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
|
||||
verify(player, never()).setVelocity(any(Vector.class)); assertEquals(1, failures.size());
|
||||
engine.close(); quickstep.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void acknowledgedUpdatesAffectFutureDashesWhileActiveDashKeepsCapturedBounds() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); var oldVelocity = prepareMovement(a, new Vector(0, 0.2, 0));
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-speed-blocks-per-tick", "2"});
|
||||
for (int i = 0; i < 2; i++) {
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
}
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
|
||||
new Location(a.world, 4, 64, 0)));
|
||||
assertEquals(new Vector(0, 0.2, 0), oldVelocity.get(), "Active dash retains its four-block snapshot");
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, b, 12, true);
|
||||
f.players.put(b.core.id, b.player); f.activate(b); var newVelocity = prepareMovement(b, new Vector(0, 0.3, 0));
|
||||
use(f, b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(new Vector(2, 0.3, 0), newVelocity.get(), "Future dash uses acknowledged speed");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEveryQuickstepSetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"quickstep-cooldown-seconds: 2\nquickstep-distance-blocks: 6\nquickstep-speed-blocks-per-tick: 1.5\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 definition = registry.find("quickstep").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
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", "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());
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quickstep"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("quickstep-cooldown-seconds", "quickstep-distance-blocks", "quickstep-speed-blocks-per-tick"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quickstep-"}));
|
||||
assertEquals(List.of("4", "6"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "quickstep-distance-blocks", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=6"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(0, new java.math.BigDecimal("7").compareTo(registry.find("quickstep").orElseThrow()
|
||||
.parameters().get("distance")));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.command.execute(sender.port, "fantasystick",
|
||||
new String[]{"config", "quickstep-distance-blocks", "7", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(0, new java.math.BigDecimal("7").compareTo(reloaded.find("quickstep").orElseThrow()
|
||||
.parameters().get("distance")));
|
||||
}
|
||||
for (String invalid : List.of("quickstep-cooldown-seconds: -1\n", "quickstep-distance-blocks: 0\n",
|
||||
"quickstep-speed-blocks-per-tick: 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 f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
|
||||
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, abilityButton(actor, "[Learn]", "quickstep"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "quickstep"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static AtomicReference<Vector> prepareMovement(FantasyStickLifecycleNativeTest.Actor actor, Vector initial) {
|
||||
var velocity = new AtomicReference<>(initial.clone());
|
||||
doReturn(true).when(actor.player).isOnGround(); doReturn(false).when(actor.player).isFlying();
|
||||
doReturn(false).when(actor.player).isGliding(); doReturn(false).when(actor.player).isInsideVehicle();
|
||||
doAnswer(ignored -> velocity.get().clone()).when(actor.player).getVelocity();
|
||||
doAnswer(call -> { velocity.set(call.getArgument(0, Vector.class).clone()); return null; }).when(actor.player).setVelocity(any(Vector.class));
|
||||
var eye = new Location(actor.world, 0, 65.6, 0); eye.setDirection(new Vector(1, 0, 0));
|
||||
doReturn(eye).when(actor.player).getEyeLocation();
|
||||
return velocity;
|
||||
}
|
||||
|
||||
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 ClickEvent.Custom abilityButton(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 = abilityButton(page.raw(), label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
throw new AssertionError("Missing " + label + " action for " + ability);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(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 = abilityButton(child, label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
int dexterity, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); stats.put(Stat.DEX, BigInteger.valueOf(dexterity));
|
||||
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 ? java.util.Set.of("quickstep") : old.learned(),
|
||||
learned ? List.of("quickstep", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,9 @@ class ReviveNativeTest {
|
||||
String text = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("Revive")); assertTrue(text.contains("WIS30")); assertTrue(text.contains("CHR24"));
|
||||
assertTrue(text.contains("CON12")); assertTrue(text.contains("Cooldown:86400s"));
|
||||
PartyBookNativeTest.click(fixture, actor, PartyBookNativeTest.button(actor, "[Learn]"));
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "revive"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, PartyBookNativeTest.button(actor, "[3]"));
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "revive"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
@@ -372,6 +372,29 @@ class ReviveNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
|
||||
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 = abilityButton(page.raw(), label, ability); if (found != null) return found;
|
||||
}
|
||||
throw new AssertionError("Missing " + label + " for " + ability);
|
||||
}
|
||||
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
|
||||
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 = abilityButton(child, label, ability); if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void withRevive(Path data, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
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.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
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 SecondWindNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionActualNativeHealPersistsBeforePublicationAndRetainsCooldown() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, false, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("SecondWind")); assertTrue(text.contains("CON12")); assertTrue(text.contains("WIS12"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "second_wind"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "second_wind"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(4, a.player.getHealth());
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var saved = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(0, BigDecimal.valueOf(11).compareTo(saved.health())); assertEquals(11, a.player.getHealth());
|
||||
assertNotNull(saved.cooldowns().get("second_wind"));
|
||||
assertFalse(a.messages.stream().anyMatch(message -> message.contains("support")));
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
assertEquals(11, a.player.getHealth(), "Persisted healing survives exit and resume");
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var unlearn = abilityButton(a, "[Unlearn]", "second_wind");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
assertEquals(saved.cooldowns().get("second_wind"), refunded.cooldowns().get("second_wind"));
|
||||
learnAndAssign(f, a); use(f, a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fullHealthAndCancelledNativeHealingApplyNoHealth() throws Exception {
|
||||
for (boolean full : new boolean[]{true, false}) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve(Boolean.toString(full)))) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve(Boolean.toString(full)));
|
||||
withSecondWind(data.resolve(Boolean.toString(full)), a, true, BigDecimal.valueOf(full ? 22 : 4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
if (!full) {
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == a.player && event.getRegainReason() == EntityRegainHealthEvent.RegainReason.CUSTOM) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Veto(), f.plugin);
|
||||
}
|
||||
double before = a.player.getHealth(); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(before, a.player.getHealth());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var saved = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
if (full) assertFalse(saved.cooldowns().containsKey("second_wind"));
|
||||
else assertNotNull(saved.cooldowns().get("second_wind"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedHealthPersistencePublishesNoNativeHealing() {
|
||||
var characters = mock(CharacterStorage.class);
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
var attribute = mock(org.bukkit.attribute.AttributeInstance.class);
|
||||
when(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(attribute);
|
||||
when(attribute.getValue()).thenReturn(22.0); when(player.getHealth()).thenReturn(4.0);
|
||||
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var record = new CharacterRecord(base.id(), base.name(), BigInteger.ONE, stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), BigDecimal.valueOf(4), java.util.Set.of("second_wind"),
|
||||
List.of("second_wind", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
|
||||
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
|
||||
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
|
||||
java.util.UUID.randomUUID(), record.id());
|
||||
when(input.player()).thenReturn(player); when(input.session()).thenReturn(active);
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
var effect = new games.dmg.fantasystick.ability.SecondWind(
|
||||
games.dmg.fantasystick.ability.SecondWind.Settings.defaults(), characters, sessions, Runnable::run,
|
||||
ignored -> {}, () -> BigDecimal.valueOf(1024));
|
||||
when(execution.stillCurrent()).thenReturn(true); when(execution.input()).thenReturn(input);
|
||||
when(execution.character()).thenReturn(record); when(execution.definition()).thenReturn(effect.definition());
|
||||
var failure = new java.util.concurrent.CompletableFuture<java.util.Optional<CharacterRecord>>();
|
||||
when(characters.saveHealth(eq(record.id()), eq(record.revision()), any(), any())).thenReturn(failure);
|
||||
effect.activate(execution); verify(player, never()).setHealth(anyDouble());
|
||||
failure.completeExceptionally(new java.io.IOException("simulated health save failure"));
|
||||
verify(player, never()).setHealth(anyDouble()); verify(execution).finish();
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionFormulaAndAdminRoutesCoverBothSettings() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"second-wind-base-healing: 8\nsecond-wind-cooldown-seconds: 20\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 definition = registry.find("second_wind").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(20), definition.cooldown());
|
||||
assertEquals(new BigDecimal("8"), definition.parameters().get("base-healing"));
|
||||
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.valueOf(12)));
|
||||
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", "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());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("second-wind-base-healing", "second-wind-cooldown-seconds"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second-wind-"}));
|
||||
assertEquals(List.of("6", "8"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "second-wind-base-healing", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing"});
|
||||
assertTrue(sender.messages.getLast().contains("=8"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(new BigDecimal("9"), registry.find("second_wind").orElseThrow().parameters().get("base-healing"));
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(14, a.player.getHealth(), "Future cast uses acknowledged base healing");
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "-1"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new BigDecimal("9"), reloaded.find("second_wind").orElseThrow().parameters().get("base-healing"));
|
||||
}
|
||||
for (String invalid : List.of("second-wind-base-healing: -1\n", "second-wind-cooldown-seconds: -1\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 f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
|
||||
assertTrue(f.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, abilityButton(actor, "[Learn]", "second_wind"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "second_wind"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
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 ClickEvent.Custom abilityButton(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 = abilityButton(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " action for " + ability);
|
||||
}
|
||||
private static ClickEvent.Custom abilityButton(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 = abilityButton(child, label, ability); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void withSecondWind(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
boolean learned, BigDecimal health) throws Exception {
|
||||
java.nio.file.Files.createDirectories(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, 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), health,
|
||||
learned ? java.util.Set.of("second_wind") : old.learned(),
|
||||
learned ? List.of("second_wind", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
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.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ShieldBashNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionAndEquippedAttackReplaceVanillaDamageThenKnockBack() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withShieldBash(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("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
|
||||
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
assertEquals(org.bukkit.Material.SHIELD, a.core.inventory.getItem(2).getType());
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var block = new org.bukkit.event.player.PlayerInteractEvent(a.player,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
|
||||
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(block); assertTrue(block.isCancelled(), "Contained shield cannot start ordinary blocking");
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
|
||||
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var origin = new Location(a.world, 0, 64, 0); var destination = new Location(a.world, 2, 64, 0);
|
||||
doReturn(origin).when(a.player).getLocation(); when(target.getLocation()).thenReturn(destination);
|
||||
doReturn(true).when(a.player).hasLineOfSight(target); when(target.getVelocity()).thenReturn(new Vector(0.1, 0.2, 0));
|
||||
var applied = new java.util.concurrent.atomic.AtomicReference<Vector>();
|
||||
doAnswer(call -> { applied.set(call.getArgument(0)); return null; }).when(target).setVelocity(any(Vector.class));
|
||||
var damages = new java.util.ArrayList<EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertTrue(attack.isCancelled(), "Contained shield cannot continue into vanilla damage/blocking");
|
||||
assertEquals(1, damages.size()); assertEquals(5, damages.getFirst().getDamage(), 1.0e-9);
|
||||
assertEquals(new Vector(0.9, 0.35, 0), applied.get());
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
|
||||
new java.util.ArrayList<>(), 0)); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(BigInteger.TEN, storage.find(a.core.record.id()).get().orElseThrow().experience());
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); assertEquals(1, damages.size());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void outOfRangeOrObstructedTargetRefusesBeforeCooldownWhileStillContainingShield() throws Exception {
|
||||
for (int route = 0; route < 2; route++) {
|
||||
var directory = data.resolve("refused-" + route);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(directory);
|
||||
withShieldBash(directory, a, true); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, route == 0 ? 4 : 2, 64, 0));
|
||||
doReturn(route == 0).when(a.player).hasLineOfSight(target);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack); assertTrue(attack.isCancelled());
|
||||
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partyAndMixedModePlayerTargetsAreRefusedWithoutFreeVanillaHits() 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);
|
||||
withShieldBash(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()));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var friendly=new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,b.player,true);f.dispatcher.accept(friendly);
|
||||
assertTrue(friendly.isCancelled());verify(b.player,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
|
||||
var ordinary=mock(org.bukkit.entity.Player.class);when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var mixed=new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,ordinary,true);f.dispatcher.accept(mixed);
|
||||
assertTrue(mixed.isCancelled());verify(ordinary,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledDamageAppliesNoKnockbackAndNeverRefundsAcknowledgedCooldown() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(target);
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0)); event.setCancelled(true); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); verify(target, never()).setVelocity(any(Vector.class));
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledPlayerVelocityPreservesMovementAfterAcceptedDamage() 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); withShieldBash(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);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
doReturn(new Location(a.world, 2, 64, 0)).when(b.player).getLocation();
|
||||
doReturn(true).when(a.player).hasLineOfSight(b.player); doReturn(new Vector(0.1, 0.2, 0)).when(b.player).getVelocity();
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void velocity(org.bukkit.event.player.PlayerVelocityEvent event) {
|
||||
if (event.getPlayer() == b.player) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Veto(), f.plugin);
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, b.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0))); return null;
|
||||
}).when(b.player).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); verify(b.player, never()).setVelocity(any(Vector.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndCharacterTransferRetainCooldown() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "shield-bash-cooldown-seconds: 60\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(target);
|
||||
when(target.getVelocity()).thenReturn(new Vector()); var hits = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> { hits.incrementAndGet(); var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0))); return null; }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); assertEquals(1, hits.get());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash");
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var unlearn = abilityButton(a, "[Unlearn]", "shield_bash");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("shield_bash"));
|
||||
learnAndAssign(f, a); a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); assertEquals(1, hits.get());
|
||||
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); b.core.inventory.getInventory().setSelectedSlot(2);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(b.player).getLocation(); doReturn(true).when(b.player).hasLineOfSight(target);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(b.player, target, true));
|
||||
PartyBookNativeTest.drain(f); assertEquals(1, hits.get()); assertEquals(deadline,
|
||||
storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactDamageFormulaSupportsFiniteUncappedStats() {
|
||||
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.valueOf(3), games.dmg.fantasystick.ability.ShieldBash.damage(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.ShieldBash.damage(huge));
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceDealsNoDamageOrKnockback() {
|
||||
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 registry=new games.dmg.fantasystick.ability.AbilityRegistry();var bash=new games.dmg.fantasystick.ability.ShieldBash(
|
||||
games.dmg.fantasystick.ability.ShieldBash.Settings.defaults(),ignored->{});registry.register(bash.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.STR,BigInteger.valueOf(12));stats.put(Stat.CON,BigInteger.valueOf(12));
|
||||
var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),
|
||||
java.util.Set.of("shield_bash"),List.of("shield_bash","","","","","",""),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());
|
||||
var world=mock(org.bukkit.World.class);doReturn(new Location(world,0,64,0)).when(player).getLocation();
|
||||
var target=mock(org.bukkit.entity.LivingEntity.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(target.getLocation()).thenReturn(new Location(world,2,64,0));
|
||||
doReturn(true).when(player).hasLineOfSight(target);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 cooldown failure"));
|
||||
when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("shield_bash"),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,"shield_bash",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK,
|
||||
target,0,1,()->true));
|
||||
verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("shield_bash"),any());
|
||||
verify(target,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));verify(target,never()).setVelocity(any(Vector.class));
|
||||
engine.close();bash.close();parties.close();
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRoutesRestartAndFutureStrikeCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"shield-bash-base-damage: 8\nshield-bash-cooldown-seconds: 0\nshield-bash-range-blocks: 5\n" +
|
||||
"shield-bash-horizontal-knockback: 1.2\nshield-bash-minimum-vertical-knockback: 0.5\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 definition = registry.find("shield_bash").orElseThrow(); assertEquals(java.time.Duration.ZERO, definition.cooldown());
|
||||
assertEquals(0, new java.math.BigDecimal("8").compareTo(definition.parameters().get("base-damage")));
|
||||
assertEquals(0, new java.math.BigDecimal("5").compareTo(definition.parameters().get("range")));
|
||||
assertEquals(0, new java.math.BigDecimal("1.2").compareTo(definition.parameters().get("horizontal-knockback")));
|
||||
assertEquals(0, new java.math.BigDecimal("0.5").compareTo(definition.parameters().get("minimum-vertical-knockback")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed=false;
|
||||
assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","shield-bash"}).isEmpty());
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed=true;
|
||||
assertEquals(List.of("shield-bash-base-damage","shield-bash-cooldown-seconds","shield-bash-horizontal-knockback",
|
||||
"shield-bash-minimum-vertical-knockback","shield-bash-range-blocks"),
|
||||
f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","shield-bash-"}));
|
||||
assertEquals(List.of("4","8"),f.command.tabComplete(sender.port,"fantasystick",
|
||||
new String[]{"config","shield-bash-base-damage",""}));
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage"});
|
||||
assertTrue(sender.messages.getLast().contains("=8"));
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9"});
|
||||
var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();
|
||||
assertEquals(0,new java.math.BigDecimal("9").compareTo(registry.find("shield_bash").orElseThrow().parameters().get("base-damage")));
|
||||
var a=new FantasyStickLifecycleNativeTest.Actor(data);withShieldBash(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
|
||||
var target=mock(org.bukkit.entity.LivingEntity.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(target.getLocation()).thenReturn(new Location(a.world,4,64,0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(target);when(target.getVelocity()).thenReturn(new Vector());
|
||||
var amount=new java.util.concurrent.atomic.AtomicReference<Double>();var movement=new java.util.concurrent.atomic.AtomicReference<Vector>();
|
||||
doAnswer(call->{amount.set(call.getArgument(0));var source=(org.bukkit.damage.DamageSource)call.getArgument(1);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player,target,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,(Double)call.getArgument(0)));return null;
|
||||
}).when(target).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
|
||||
doAnswer(call->{movement.set(call.getArgument(0));return null;}).when(target).setVelocity(any(Vector.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,target,true));
|
||||
PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,amount.get(),1.0e-9);assertEquals(new Vector(1.2,0.5,0),movement.get());
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","-1"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9","extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");
|
||||
assertEquals(0,new java.math.BigDecimal("9").compareTo(reloaded.find("shield_bash").orElseThrow().parameters().get("base-damage")));
|
||||
}
|
||||
for(String invalid:List.of("shield-bash-base-damage: -1\n","shield-bash-cooldown-seconds: -1\n",
|
||||
"shield-bash-range-blocks: 0\n","shield-bash-horizontal-knockback: 9\n",
|
||||
"shield-bash-minimum-vertical-knockback: 9\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 f=new FantasyStickLifecycleNativeTest.Fixture(directory)){
|
||||
assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, abilityButton(actor, "[Learn]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static ClickEvent.Custom abilityButton(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 = abilityButton(page.raw(), label, ability); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + ability);
|
||||
}
|
||||
private static ClickEvent.Custom abilityButton(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 = abilityButton(child, label, ability); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
private static void withShieldBash(Path data, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.STR, BigInteger.valueOf(12)); 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 ? java.util.Set.of("shield_bash") : old.learned(),
|
||||
learned ? List.of("shield_bash", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import net.minecraft.network.chat.ClickEvent;
|
||||
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.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class StoneSkinNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseReducesNativeEntityDamageForFourSeconds() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(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 text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("StoneSkin")); assertTrue(text.contains("CON14"));
|
||||
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("25%")); assertTrue(text.contains("4s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
|
||||
var attacker = mock(org.bukkit.entity.Zombie.class);
|
||||
var hit = new EntityDamageByEntityEvent(attacker, a.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(hit); assertEquals(6, hit.getDamage(), 1.0e-9);
|
||||
assertEquals(80L, f.delayedTicks.getLast());
|
||||
f.delayed.getLast().run();
|
||||
var expired = new EntityDamageByEntityEvent(attacker, a.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(expired); assertEquals(8, expired.getDamage(), 1.0e-9);
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin");
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
var unlearn = abilityButton(a, "[Unlearn]", "stone_skin");
|
||||
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("stone_skin"));
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); use(f, a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void environmentalAndCancelledDamageRemainUntouchedWhileProjectileCombatIsReduced() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var fall = new EntityDamageEvent(a.player, EntityDamageEvent.DamageCause.FALL,
|
||||
mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(fall); assertEquals(8, fall.getDamage(), 1.0e-9);
|
||||
var arrow = mock(org.bukkit.entity.Arrow.class);
|
||||
var projectile = new EntityDamageByEntityEvent(arrow, a.player,
|
||||
EntityDamageEvent.DamageCause.PROJECTILE, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(projectile); assertEquals(6, projectile.getDamage(), 1.0e-9);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
|
||||
public void veto(EntityDamageByEntityEvent event) { event.setCancelled(true); }
|
||||
}
|
||||
f.dispatcher.register(new Veto(), f.plugin);
|
||||
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), a.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled()); assertEquals(8, cancelled.getDamage(), 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void replacementExpirySessionExitAndTransferRemoveOnlyOwnedReductionAndRetainDeadline() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"stone-skin-cooldown-seconds: 0\nstone-skin-duration-seconds: 4\nstone-skin-damage-reduction-percent: 25\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(2, f.delayed.size());
|
||||
f.delayed.getFirst().run();
|
||||
assertEquals(6, hit(f, a, 8).getDamage(), 1.0e-9, "Old expiry cannot remove replacement protection");
|
||||
f.delayed.getLast().run();
|
||||
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin");
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9, "Exit clears owned reduction");
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b);
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedConfigurationAffectsFutureCastsWhileActiveProtectionKeepsSnapshot() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"stone-skin-cooldown-seconds: 0\nstone-skin-duration-seconds: 4\nstone-skin-damage-reduction-percent: 25\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "6"});
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-damage-reduction-percent", "50"});
|
||||
for (int i = 0; i < 2; i++) { var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run(); }
|
||||
assertEquals(6, hit(f, a, 8).getDamage(), 1.0e-9, "Active cast retains captured 25 percent");
|
||||
assertEquals(80L, f.delayedTicks.getFirst());
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, b, true);
|
||||
f.players.put(b.core.id, b.player); f.activate(b); use(f, b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(4, hit(f, b, 8).getDamage(), 1.0e-9); assertEquals(120L, f.delayedTicks.getLast());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acceptedDamageReportsExactPreventionWhileCancelledDamageReportsNothing() {
|
||||
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
var stone = new games.dmg.fantasystick.ability.StoneSkin(
|
||||
games.dmg.fantasystick.ability.StoneSkin.Settings.defaults(), mitigation);
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
|
||||
var player = mock(org.bukkit.entity.Player.class); var id = java.util.UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(id); when(input.player()).thenReturn(player);
|
||||
when(execution.input()).thenReturn(input); when(execution.definition()).thenReturn(stone.definition());
|
||||
when(execution.stillCurrent()).thenReturn(true);
|
||||
stone.activate(execution);
|
||||
var weaker = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
when(weaker.stillCurrent()).thenReturn(true);
|
||||
var unrelated = mitigation.grant(weaker, player, new java.math.BigDecimal("20"));
|
||||
var accepted = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
mitigation.reduce(accepted); mitigation.accepted(accepted);
|
||||
verify(execution).report(games.dmg.fantasystick.ability.AbilityReport.Type.PREVENTION,
|
||||
player, new java.math.BigDecimal("2.0"));
|
||||
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
mitigation.reduce(cancelled); cancelled.setCancelled(true); mitigation.accepted(cancelled);
|
||||
verify(execution, times(1)).report(any(), any(), any());
|
||||
verify(weaker, never()).report(any(), any(), any());
|
||||
try { unrelated.close(); } catch (Exception failure) { fail(failure); }
|
||||
stone.close(); mitigation.close();
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceCreatesNoReduction() {
|
||||
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 registry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
var stone = new games.dmg.fantasystick.ability.StoneSkin(
|
||||
games.dmg.fantasystick.ability.StoneSkin.Settings.defaults(), mitigation); registry.register(stone.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.CON, BigInteger.valueOf(14));
|
||||
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
base.unspentPoints(), base.health(), java.util.Set.of("stone_skin"),
|
||||
List.of("stone_skin", "", "", "", "", "", ""), 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(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 cooldown save failure"));
|
||||
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("stone_skin"), any())).thenReturn(failed);
|
||||
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
|
||||
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
|
||||
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
|
||||
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "stone_skin",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
|
||||
var hit = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9);
|
||||
verify(storage).commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("stone_skin"), any());
|
||||
engine.close(); stone.close(); mitigation.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"stone-skin-cooldown-seconds: 2\nstone-skin-duration-seconds: 6\nstone-skin-damage-reduction-percent: 40\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 definition = registry.find("stone_skin").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
|
||||
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("duration-seconds")));
|
||||
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("damage-reduction-percent")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "stone-skin"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("stone-skin-cooldown-seconds", "stone-skin-damage-reduction-percent", "stone-skin-duration-seconds"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "stone-skin-"}));
|
||||
assertEquals(List.of("4", "6"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "stone-skin-duration-seconds", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds"});
|
||||
assertTrue(sender.messages.getLast().contains("=6"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(0, new java.math.BigDecimal("7").compareTo(
|
||||
registry.find("stone_skin").orElseThrow().parameters().get("duration-seconds")));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "-1"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(0, new java.math.BigDecimal("7").compareTo(
|
||||
reloaded.find("stone_skin").orElseThrow().parameters().get("duration-seconds")));
|
||||
}
|
||||
for (String invalid : List.of("stone-skin-cooldown-seconds: -1\n",
|
||||
"stone-skin-duration-seconds: 0\n", "stone-skin-damage-reduction-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 f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true)); assertTrue(f.order.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor target, double amount) {
|
||||
var event = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), target.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), amount);
|
||||
fixture.dispatcher.accept(event); return event;
|
||||
}
|
||||
|
||||
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 ClickEvent.Custom abilityButton(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 = abilityButton(page.raw(), label, ability);
|
||||
if (found != null) return found;
|
||||
}
|
||||
fail("Missing " + label + " for " + ability); return null;
|
||||
}
|
||||
private static ClickEvent.Custom abilityButton(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 = abilityButton(child, label, ability); if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void withStoneSkin(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(data);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); stats.put(Stat.CON, BigInteger.valueOf(14));
|
||||
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 ? java.util.Set.of("stone_skin") : old.learned(),
|
||||
learned ? List.of("stone_skin", "", "", "", "", "", "") : old.loadout(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,37 @@ class DistributionTest {
|
||||
"frost-shard-slowness-amplifier: 0", "frost-shard-slowness-duration-seconds: 2")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Frost Shard default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("longbow-base-damage: 5", "longbow-recovery-seconds: 1",
|
||||
"longbow-minimum-draw-ticks: 3", "longbow-full-draw-ticks: 20",
|
||||
"longbow-range-blocks: 64", "longbow-lifetime-seconds: 10",
|
||||
"longbow-full-projectile-speed-blocks-per-tick: 3")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Longbow default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("quickstep-cooldown-seconds: 5",
|
||||
"quickstep-distance-blocks: 4", "quickstep-speed-blocks-per-tick: 1")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Quickstep default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("second-wind-base-healing: 6",
|
||||
"second-wind-cooldown-seconds: 15")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Second Wind default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("stone-skin-cooldown-seconds: 14",
|
||||
"stone-skin-duration-seconds: 4", "stone-skin-damage-reduction-percent: 25")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Stone Skin default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("shield-bash-base-damage: 4", "shield-bash-cooldown-seconds: 6",
|
||||
"shield-bash-range-blocks: 3", "shield-bash-horizontal-knockback: 0.8",
|
||||
"shield-bash-minimum-vertical-knockback: 0.35")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Shield Bash default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("hex-cooldown-seconds: 10", "hex-range-blocks: 12",
|
||||
"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 HexSettingsTest{@TempDir Path data;
|
||||
@Test void serializesPreservesAndPublishesAcknowledgedSnapshots()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: retained\n");try(var s=new HexSettings(data,defaults())){
|
||||
var r=s.set(HexSettings.RANGE,"16").toCompletableFuture();var p=s.set(HexSettings.REDUCTION,"30").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(0,new java.math.BigDecimal("30").compareTo(s.settings().reductionPercent()));
|
||||
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(HexSettings.RANGE)));}}
|
||||
@Test void invalidAndFailedWritesRetainState()throws Exception{try(var s=new HexSettings(data,defaults())){for(var e:Map.of(HexSettings.COOLDOWN,"-1",HexSettings.RANGE,"0",HexSettings.DURATION,"0",HexSettings.REDUCTION,"101").entrySet())assertThrows(IllegalArgumentException.class,()->s.set(e.getKey(),e.getValue()));assertThrows(IllegalArgumentException.class,()->s.set(HexSettings.COOLDOWN,"0.0000000001"));}
|
||||
var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var s=new HexSettings(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(HexSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("12",s.value(HexSettings.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 HexSettings(data,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(HexSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("safe\n",Files.readString(out));}}
|
||||
var s=new HexSettings(data.resolve("closed"),defaults());s.close();assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(HexSettings.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:HexSettings.keys())m.put(k,HexSettings.defaultValue(k));return m;}}
|
||||
@@ -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;}}
|
||||
@@ -0,0 +1,101 @@
|
||||
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 LongbowSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesConcurrentAcceptedKeysWithoutLosingUnrelatedConfiguration() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
|
||||
try (var settings = new LongbowSettings(data, defaults())) {
|
||||
var damage = settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture();
|
||||
var range = settings.set(LongbowSettings.RANGE, "40.5").toCompletableFuture();
|
||||
var savedDamage = damage.get(5, TimeUnit.SECONDS);
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("9", savedDamage.value()); assertEquals("40.5", savedRange.value());
|
||||
assertEquals(new java.math.BigInteger("5"), settings.settings().baseDamage(),
|
||||
"Durability acknowledgment alone must not publish runtime settings");
|
||||
settings.publish(savedRange); settings.publish(savedDamage); // stale callback cannot roll back the newer snapshot.
|
||||
assertEquals(new java.math.BigInteger("9"), settings.settings().baseDamage());
|
||||
assertEquals(40.5, settings.settings().range());
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals(Boolean.TRUE, saved.get("adventure-mode"));
|
||||
assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("9", String.valueOf(saved.get(LongbowSettings.BASE_DAMAGE)));
|
||||
assertEquals("40.5", String.valueOf(saved.get(LongbowSettings.RANGE)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsMalformedAndOutOfRangeValuesBeforeQueueingOrChangingEffectiveState() {
|
||||
try (var settings = new LongbowSettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
LongbowSettings.BASE_DAMAGE, "-1",
|
||||
LongbowSettings.RECOVERY, "0.999",
|
||||
LongbowSettings.MINIMUM_DRAW, "0",
|
||||
LongbowSettings.FULL_DRAW, "2",
|
||||
LongbowSettings.RANGE, "0",
|
||||
LongbowSettings.LIFETIME, "0",
|
||||
LongbowSettings.FULL_SPEED, "Infinity").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.BASE_DAMAGE, "1.5"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.BASE_DAMAGE, "1" + "0".repeat(309)));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.RECOVERY, "1.0000000001"));
|
||||
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
|
||||
assertFalse(Files.exists(data.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAtomicReplacementRetainsEffectiveAndFileStateAndAllowsOneExplicitRetry() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "longbow-base-damage: 5\n");
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new LongbowSettings(data, defaults(), (from, to) -> {
|
||||
if (fail.getAndSet(false)) throw new java.io.IOException("simulated replacement failure");
|
||||
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
|
||||
assertTrue(Files.readString(data.resolve("config.yml")).contains("5"));
|
||||
var retry = settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("9", retry.value());
|
||||
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
|
||||
settings.publish(retry);
|
||||
assertEquals("9", settings.value(LongbowSettings.BASE_DAMAGE));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsUnsafeConfigurationAndLockFilesAndPostCloseWrites() throws Exception {
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var external = data.resolveSibling(data.getFileName() + "-outside");
|
||||
Files.writeString(external, "safe\n");
|
||||
Files.createSymbolicLink(data.resolve("config.yml"), external);
|
||||
try (var settings = new LongbowSettings(data, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(external));
|
||||
}
|
||||
Files.delete(data.resolve("config.yml")); Files.deleteIfExists(external);
|
||||
}
|
||||
var settings = new LongbowSettings(data, defaults()); settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var result = new LinkedHashMap<String, String>();
|
||||
for (String key : LongbowSettings.keys()) result.put(key, LongbowSettings.defaultValue(key));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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 QuickstepSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesOnlyAcknowledgedSnapshots() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
|
||||
try (var settings = new QuickstepSettings(data, defaults())) {
|
||||
var distance = settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture();
|
||||
var speed = settings.set(QuickstepSettings.SPEED, "1.5").toCompletableFuture();
|
||||
var savedDistance = distance.get(5, TimeUnit.SECONDS); var savedSpeed = speed.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(4, settings.settings().distance(), 0);
|
||||
settings.publish(savedSpeed); settings.publish(savedDistance);
|
||||
assertEquals(6, settings.settings().distance(), 0); assertEquals(1.5, settings.settings().speedPerTick(), 0);
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals(Boolean.TRUE, saved.get("adventure-mode")); assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("6", String.valueOf(saved.get(QuickstepSettings.DISTANCE)));
|
||||
assertEquals("1.5", String.valueOf(saved.get(QuickstepSettings.SPEED)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsMalformedOrOutOfRangeValuesBeforeQueueing() {
|
||||
try (var settings = new QuickstepSettings(data, defaults())) {
|
||||
for (var entry : Map.of(QuickstepSettings.COOLDOWN, "-1", QuickstepSettings.DISTANCE, "0",
|
||||
QuickstepSettings.SPEED, "Infinity").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(QuickstepSettings.COOLDOWN, "0.0000000001"));
|
||||
assertEquals("4", settings.value(QuickstepSettings.DISTANCE));
|
||||
assertFalse(Files.exists(data.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReplacementAndUnsafeFilesRetainEffectiveStateAndAllowRetry() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "quickstep-distance-blocks: 4\n");
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new QuickstepSettings(data, defaults(), (from, to) -> {
|
||||
if (fail.getAndSet(false)) throw new java.io.IOException("simulated failure");
|
||||
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("4", settings.value(QuickstepSettings.DISTANCE));
|
||||
var retry = settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals("4", settings.value(QuickstepSettings.DISTANCE)); settings.publish(retry);
|
||||
assertEquals("6", settings.value(QuickstepSettings.DISTANCE));
|
||||
}
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
|
||||
Files.delete(data.resolve("config.yml")); Files.createSymbolicLink(data.resolve("config.yml"), external);
|
||||
try (var settings = new QuickstepSettings(data, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(QuickstepSettings.DISTANCE, "7").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(external));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void closeRejectsNewWritesAndCompletesDrain() throws Exception {
|
||||
var settings = new QuickstepSettings(data, defaults()); settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var result = new LinkedHashMap<String, String>();
|
||||
for (String key : QuickstepSettings.keys()) result.put(key, QuickstepSettings.defaultValue(key));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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 SecondWindSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesConcurrentWritesPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new SecondWindSettings(data, defaults())) {
|
||||
var healing = settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture();
|
||||
var cooldown = settings.set(SecondWindSettings.COOLDOWN, "20").toCompletableFuture();
|
||||
var h = healing.get(5, TimeUnit.SECONDS); var c = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals("6", settings.value(SecondWindSettings.BASE_HEALING)); settings.publish(c); settings.publish(h);
|
||||
assertEquals("9", settings.value(SecondWindSettings.BASE_HEALING));
|
||||
assertEquals(java.time.Duration.ofSeconds(20), settings.settings().cooldown());
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", saved.get("unrelated")); assertEquals("9", String.valueOf(saved.get(SecondWindSettings.BASE_HEALING)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
|
||||
try (var settings = new SecondWindSettings(data, defaults())) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.BASE_HEALING, "-1"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.BASE_HEALING, "1.5"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.COOLDOWN, "-1"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.COOLDOWN, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new SecondWindSettings(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(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("6", settings.value(SecondWindSettings.BASE_HEALING));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsUnsafeFileAndPostCloseWrites() throws Exception {
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
|
||||
Files.createSymbolicLink(data.resolve("config.yml"), external);
|
||||
try (var settings = new SecondWindSettings(data, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(external));
|
||||
}
|
||||
}
|
||||
var other = data.resolve("closed"); var settings = new SecondWindSettings(other, defaults()); settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String, String> defaults() {
|
||||
var result = new LinkedHashMap<String, String>();
|
||||
for (String key : SecondWindSettings.keys()) result.put(key, SecondWindSettings.defaultValue(key));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 ShieldBashSettingsTest {
|
||||
@TempDir Path data;
|
||||
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new ShieldBashSettings(data, defaults())) {
|
||||
var range = settings.set(ShieldBashSettings.RANGE, "5").toCompletableFuture();
|
||||
var horizontal = settings.set(ShieldBashSettings.HORIZONTAL, "1.2").toCompletableFuture();
|
||||
var r = range.get(5, TimeUnit.SECONDS); var h = horizontal.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(3, settings.settings().range(), 0); settings.publish(h); settings.publish(r);
|
||||
assertEquals(5, settings.settings().range(), 0); assertEquals(1.2, settings.settings().horizontalKnockback(), 0);
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", saved.get("unrelated")); assertEquals("5", String.valueOf(saved.get(ShieldBashSettings.RANGE)));
|
||||
assertEquals("1.2", String.valueOf(saved.get(ShieldBashSettings.HORIZONTAL)));
|
||||
}
|
||||
}
|
||||
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
|
||||
try (var settings = new ShieldBashSettings(data, defaults())) {
|
||||
for (var entry : Map.of(ShieldBashSettings.BASE_DAMAGE, "-1", ShieldBashSettings.COOLDOWN, "-1",
|
||||
ShieldBashSettings.RANGE, "0", ShieldBashSettings.HORIZONTAL, "9",
|
||||
ShieldBashSettings.VERTICAL, "Infinity").entrySet())
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(ShieldBashSettings.COOLDOWN, "0.0000000001"));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(ShieldBashSettings.BASE_DAMAGE, "1.5"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new ShieldBashSettings(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(ShieldBashSettings.RANGE, "5").toCompletableFuture().get(5,TimeUnit.SECONDS));
|
||||
assertEquals("3", settings.value(ShieldBashSettings.RANGE));
|
||||
}
|
||||
}
|
||||
@Test void unsafeFileAndClosedStoreFailSafely() throws Exception {
|
||||
if (!System.getProperty("os.name","").toLowerCase().contains("win")) {
|
||||
var external=data.resolveSibling(data.getFileName()+"-outside"); Files.writeString(external,"safe\n");
|
||||
Files.createSymbolicLink(data.resolve("config.yml"),external);
|
||||
try(var settings=new ShieldBashSettings(data,defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(ShieldBashSettings.RANGE,"5").toCompletableFuture().get(5,TimeUnit.SECONDS));
|
||||
assertEquals("safe\n",Files.readString(external));
|
||||
}
|
||||
}
|
||||
var settings=new ShieldBashSettings(data.resolve("closed"),defaults()); settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(ShieldBashSettings.RANGE,"5").toCompletableFuture().get(5,TimeUnit.SECONDS));
|
||||
settings.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
}
|
||||
private static Map<String,String> defaults(){var result=new LinkedHashMap<String,String>();for(String key:ShieldBashSettings.keys())result.put(key,ShieldBashSettings.defaultValue(key));return result;}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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 StoneSkinSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new StoneSkinSettings(data, defaults())) {
|
||||
var duration = settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture();
|
||||
var reduction = settings.set(StoneSkinSettings.REDUCTION, "40").toCompletableFuture();
|
||||
var d = duration.get(5, TimeUnit.SECONDS); var r = reduction.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(java.time.Duration.ofSeconds(4), settings.settings().duration());
|
||||
settings.publish(r); settings.publish(d);
|
||||
assertEquals(java.time.Duration.ofSeconds(6), settings.settings().duration());
|
||||
assertEquals(0, new java.math.BigDecimal("40").compareTo(settings.settings().reductionPercent()));
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("6", String.valueOf(saved.get(StoneSkinSettings.DURATION)));
|
||||
assertEquals("40", String.valueOf(saved.get(StoneSkinSettings.REDUCTION)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
|
||||
try (var settings = new StoneSkinSettings(data, defaults())) {
|
||||
for (var entry : Map.of(StoneSkinSettings.COOLDOWN, "-1", StoneSkinSettings.DURATION, "0",
|
||||
StoneSkinSettings.REDUCTION, "101").entrySet())
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(StoneSkinSettings.DURATION, "0.0000000001"));
|
||||
}
|
||||
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var settings = new StoneSkinSettings(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(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("4", settings.value(StoneSkinSettings.DURATION));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unsafeFileAndClosedStoreFailSafely() throws Exception {
|
||||
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
||||
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
|
||||
Files.createSymbolicLink(data.resolve("config.yml"), external);
|
||||
try (var settings = new StoneSkinSettings(data, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(external));
|
||||
}
|
||||
}
|
||||
var directory = data.resolve("closed"); var settings = new StoneSkinSettings(directory, defaults()); settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () ->
|
||||
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static Map<String,String> defaults() {
|
||||
var result = new LinkedHashMap<String,String>();
|
||||
for (String key : StoneSkinSettings.keys()) result.put(key, StoneSkinSettings.defaultValue(key));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,23 @@ class CombatContributionsTest {
|
||||
assertEquals(Set.of(), ledger.consume(target, start.plusSeconds(301)), "One target is awarded once");
|
||||
}
|
||||
|
||||
@Test void targetSpecificSupportRequiresDirectAllyAndTenSecondWindowWithoutRecursion() {
|
||||
var now = Instant.parse("2026-09-19T20:00:00Z");
|
||||
var target = UUID.randomUUID(); var otherTarget = UUID.randomUUID();
|
||||
var ally = UUID.randomUUID(); var supporter = UUID.randomUUID(); var supportOnly = UUID.randomUUID();
|
||||
var ledger = new CombatContributions(CombatRewardRules.defaults());
|
||||
ledger.accept(target, ally, now);
|
||||
ledger.support(target, ally, supporter, now);
|
||||
ledger.support(target, supporter, supportOnly, now);
|
||||
ledger.support(otherTarget, ally, supportOnly, now);
|
||||
assertEquals(Set.of(ally, supporter), ledger.consume(target, now.plusSeconds(10)));
|
||||
ledger.accept(target, ally, now); ledger.accept(target, supporter, now);
|
||||
ledger.support(target, ally, supporter, now);
|
||||
assertEquals(Set.of(ally, supporter), ledger.consume(target, now.plusSeconds(10)), "Direct and support roles deduplicate");
|
||||
ledger.accept(target, ally, now); ledger.support(target, ally, supporter, now);
|
||||
assertEquals(Set.of(ally), ledger.consume(target, now.plusSeconds(10).plusNanos(1)));
|
||||
}
|
||||
|
||||
@Test void invalidationAndCloseBoundMemoryAndRefuseLaterDamage() {
|
||||
var ledger = new CombatContributions(CombatRewardRules.defaults());
|
||||
var target = UUID.randomUUID(); var character = UUID.randomUUID();
|
||||
|
||||
Reference in new issue
Block a user