Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0536d4c717 | ||
|
|
079555900c | ||
|
|
1f3a1fe396 | ||
|
|
139cc4519b | ||
|
|
184d5520b2 |
No files matched your search
@@ -105,6 +105,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.session.OutOfCombatRecovery outOfCombatRecovery;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
|
||||
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
|
||||
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
|
||||
private games.dmg.fantasystick.passive.SpeedI speedI;
|
||||
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
@@ -140,6 +146,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var quickerRegenerationInitial = games.dmg.fantasystick.persistence.QuickerRegenerationSettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY,
|
||||
games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT));
|
||||
var quickerRegenerationIIInitial = games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY,
|
||||
games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT));
|
||||
var healthStealInitial = games.dmg.fantasystick.persistence.HealthStealSettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.HealthStealSettings.KEY,
|
||||
games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT));
|
||||
var sheetPackDefaults = games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults();
|
||||
sheetResourcePackSettings = new games.dmg.fantasystick.persistence.SheetResourcePackSettings(getDataFolder().toPath(),
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY),
|
||||
@@ -346,13 +358,33 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
quickerRegenerationSettings = new games.dmg.fantasystick.persistence.QuickerRegenerationSettings(
|
||||
getDataFolder().toPath(), quickerRegenerationInitial.bonus().toString());
|
||||
quickerRegeneration = new games.dmg.fantasystick.passive.QuickerRegenerationI(quickerRegenerationInitial);
|
||||
quickerRegenerationIISettings = new games.dmg.fantasystick.persistence.QuickerRegenerationIISettings(
|
||||
getDataFolder().toPath(), quickerRegenerationIIInitial.bonus().toString());
|
||||
quickerRegenerationII = new games.dmg.fantasystick.passive.QuickerRegenerationII(quickerRegenerationIIInitial);
|
||||
healthStealSettings = new games.dmg.fantasystick.persistence.HealthStealSettings(
|
||||
getDataFolder().toPath(), healthStealInitial.healing().toString());
|
||||
healthSteal = new games.dmg.fantasystick.passive.HealthSteal(healthStealInitial, characters, sessions, nextTick,
|
||||
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()));
|
||||
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
|
||||
lowLightVision = new games.dmg.fantasystick.passive.LowLightVision(sessions);
|
||||
passiveRegistry = new games.dmg.fantasystick.passive.PassiveRegistry();
|
||||
passiveRegistry.register(quickerRegeneration.definition());
|
||||
passiveRegistry.register(quickerRegenerationII.definition());
|
||||
passiveRegistry.register(healthSteal.definition());
|
||||
passiveRegistry.register(speedI.definition());
|
||||
passiveRegistry.register(lowLightVision.definition());
|
||||
passiveEngine = new games.dmg.fantasystick.passive.PassiveEngine(passiveRegistry, characters, sessions, nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Passive change failed for character " + problem.character()
|
||||
+ "; passive " + problem.passive(), problem.cause()));
|
||||
outOfCombatRecovery.attachBonusHealing(record -> record.passives().contains(
|
||||
games.dmg.fantasystick.passive.QuickerRegenerationI.ID) ? quickerRegeneration.bonus() : java.math.BigInteger.ZERO);
|
||||
outOfCombatRecovery.attachBonusHealing(record -> {
|
||||
if (!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)) {
|
||||
return java.math.BigInteger.ZERO;
|
||||
}
|
||||
var bonus = quickerRegeneration.bonus();
|
||||
return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)
|
||||
? bonus.add(quickerRegenerationII.bonus()) : bonus;
|
||||
});
|
||||
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);
|
||||
@@ -409,6 +441,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
|
||||
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
|
||||
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
|
||||
admin.attachQuickerRegenerationII(quickerRegenerationIISettings, quickerRegenerationII, passiveRegistry);
|
||||
admin.attachHealthSteal(healthStealSettings, healthSteal, passiveRegistry);
|
||||
admin.attachCharacterIssuer(new games.dmg.fantasystick.session.AdminCharacterIssuer(characters,
|
||||
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
|
||||
"Admin character issue " + problem.operation() + " for player " + problem.player()
|
||||
@@ -424,6 +458,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(healthSteal, this);
|
||||
getServer().getPluginManager().registerEvents(combatExperience, this);
|
||||
combatExperience.install(this);
|
||||
getServer().getPluginManager().registerEvents(fireball, this);
|
||||
@@ -449,6 +484,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
|
||||
getServer().getPluginManager().registerEvents(outOfCombatRecovery, this);
|
||||
outOfCombatRecovery.install(this);
|
||||
speedI.install(this);
|
||||
lowLightVision.install(this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
|
||||
@@ -550,8 +587,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingOutOfCombatRecovery = outOfCombatRecovery; outOfCombatRecovery = null;
|
||||
var closingOutOfCombatRecoverySettings = outOfCombatRecoverySettings; outOfCombatRecoverySettings = null;
|
||||
var closingQuickerRegenerationSettings = quickerRegenerationSettings; quickerRegenerationSettings = null;
|
||||
var closingQuickerRegenerationIISettings = quickerRegenerationIISettings; quickerRegenerationIISettings = null;
|
||||
var closingHealthStealSettings = healthStealSettings; healthStealSettings = null;
|
||||
var closingHealthSteal = healthSteal; healthSteal = null;
|
||||
var closingSpeedI = speedI; speedI = null;
|
||||
var closingLowLightVision = lowLightVision; lowLightVision = null;
|
||||
var closingPassiveEngine = passiveEngine; passiveEngine = null;
|
||||
quickerRegeneration = null; passiveRegistry = null;
|
||||
quickerRegeneration = null; quickerRegenerationII = null; passiveRegistry = null;
|
||||
// Retire client resources independently of successful ordinary-equipment restoration.
|
||||
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
|
||||
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
|
||||
@@ -590,6 +632,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
|
||||
if (closingOutOfCombatRecoverySettings != null) cleanup("out-of-combat recovery configuration", closingOutOfCombatRecoverySettings::close);
|
||||
if (closingQuickerRegenerationSettings != null) cleanup("Quicker Regeneration I configuration", closingQuickerRegenerationSettings::close);
|
||||
if (closingQuickerRegenerationIISettings != null) cleanup("Quicker Regeneration II configuration", closingQuickerRegenerationIISettings::close);
|
||||
if (closingHealthStealSettings != null) cleanup("Health Steal configuration", closingHealthStealSettings::close);
|
||||
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
@@ -640,7 +684,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingOutOfCombatRecoverySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingOutOfCombatRecoverySettings.drained().toCompletableFuture(),
|
||||
closingQuickerRegenerationSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingQuickerRegenerationSettings.drained().toCompletableFuture());
|
||||
: closingQuickerRegenerationSettings.drained().toCompletableFuture(),
|
||||
closingQuickerRegenerationIISettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingQuickerRegenerationIISettings.drained().toCompletableFuture(),
|
||||
closingHealthStealSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingHealthStealSettings.drained().toCompletableFuture());
|
||||
if (closingBooks != null) cleanup("character book", closingBooks::close);
|
||||
if (closingAdminRevival != null) cleanup("admin revival", closingAdminRevival::close);
|
||||
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
|
||||
@@ -662,6 +710,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
|
||||
if (closingOutOfCombatRecovery != null) cleanup("out-of-combat recovery", closingOutOfCombatRecovery::close);
|
||||
if (closingPassiveEngine != null) cleanup("passive engine", closingPassiveEngine::close);
|
||||
if (closingHealthSteal != null) cleanup("Health Steal", closingHealthSteal::close);
|
||||
if (closingSpeedI != null) cleanup("Speed I", closingSpeedI::close);
|
||||
if (closingLowLightVision != null) cleanup("Low-Light Vision", closingLowLightVision::close);
|
||||
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
@@ -674,6 +725,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingHealthSteal);
|
||||
unregister(closingFireball);
|
||||
unregister(closingFrostShard);
|
||||
unregister(closingLongbow);
|
||||
|
||||
@@ -158,8 +158,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
|
||||
if (!passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
|
||||
var details = definitions.stream().map(definition -> abilityPages(record, definition, abilityView)).toList();
|
||||
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::id,
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::name));
|
||||
var passiveDetails = passiveDefinitions.stream()
|
||||
.map(definition -> passiveDetailPages(record, definition, passiveView)).toList();
|
||||
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView)).toList();
|
||||
var placeholderTargets = definitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.ability.AbilityDefinition::id, ignored -> 1));
|
||||
var passivePlaceholderTargets = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
@@ -293,11 +296,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
private static java.util.List<Component> passiveDetailPages(CharacterRecord record,
|
||||
games.dmg.fantasystick.passive.PassiveDefinition definition, PassiveView view) {
|
||||
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view) {
|
||||
String requirements = definition.requirements().entrySet().stream().sorted(Map.Entry.comparingByKey())
|
||||
.map(entry -> entry.getKey() + " " + entry.getValue()).collect(java.util.stream.Collectors.joining(", "));
|
||||
if (!definition.prerequisites().isEmpty()) {
|
||||
String prerequisiteNames = definition.prerequisites().stream().sorted().collect(java.util.stream.Collectors.joining(", "));
|
||||
String prerequisiteNames = definition.prerequisites().stream().map(id -> names.getOrDefault(id, id)).sorted()
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
requirements = requirements.isEmpty() ? prerequisiteNames : prerequisiteNames + ", " + requirements;
|
||||
}
|
||||
boolean learned = record.passives().contains(definition.id());
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.bukkit.persistence.PersistentDataType;
|
||||
* The caller must enforce session custody and suppress vanilla item uses; this class registers no events.
|
||||
*/
|
||||
public final class SessionControls {
|
||||
public static final NamespacedKey COOLDOWN_MODEL = new NamespacedKey("fantasystick", "cooldown_control");
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
|
||||
private final Clock clock;
|
||||
private static final NamespacedKey VERSION = key("control_version");
|
||||
@@ -92,6 +93,7 @@ public final class SessionControls {
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(icon.name()));
|
||||
meta.setMaxStackSize(1);
|
||||
if (cooling) meta.setItemModel(COOLDOWN_MODEL);
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(OWNER, PersistentDataType.STRING, owner.toString());
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.party.CombatSources;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
import org.bukkit.event.entity.EntityRemoveEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
|
||||
/** Consume-once final-blow attribution and persisted native healing for Health Steal. */
|
||||
public final class HealthSteal implements Listener, AutoCloseable {
|
||||
public static final String ID = "health_steal";
|
||||
private static final int MAX_PENDING_PER_CHARACTER = 64;
|
||||
private static final int MAX_CONSUMED_EVENTS = 1_024;
|
||||
public record Settings(BigInteger healing) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(healing);
|
||||
if (healing.signum() < 0 || healing.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Health Steal healing");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.ONE); }
|
||||
}
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
private record Candidate(LivingEntity target, CombatSources.Origin origin) { }
|
||||
private record Heal(EquipmentSessions.Active active, double amount) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents;
|
||||
private final Supplier<BigDecimal> nativeLimit;
|
||||
private final Consumer<Failure> failures;
|
||||
private final CombatSources sources;
|
||||
private final Map<UUID, Candidate> lethal = new java.util.HashMap<>();
|
||||
private final Set<PlayerDeathEvent> fantasyDefeats = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final Set<EntityDeathEvent> consumed = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final ArrayDeque<EntityDeathEvent> consumedOrder = new ArrayDeque<>();
|
||||
private final Map<UUID, ArrayDeque<Heal>> pending = new java.util.HashMap<>();
|
||||
private volatile Settings settings;
|
||||
private boolean closed;
|
||||
|
||||
public HealthSteal(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
|
||||
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures) {
|
||||
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);
|
||||
this.failures = Objects.requireNonNull(failures); sources = new CombatSources(sessions);
|
||||
}
|
||||
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger healing() { return settings.healing(); }
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Health Steal",
|
||||
Map.of(Stat.STR, BigInteger.valueOf(14), Stat.CON, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
|
||||
List.of("Immediately restore " + healing() + " health point" + (healing().equals(BigInteger.ONE) ? "" : "s")
|
||||
+ " when this active character's accepted damage lands a qualifying final blow.",
|
||||
"Passive mobs qualify; assists, ordinary players and repeated defeat callbacks do not."));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || !(event.getEntity() instanceof LivingEntity target)) return;
|
||||
UUID targetId = target.getUniqueId();
|
||||
if (event.isCancelled() || event.getFinalDamage() <= 0) { lethal.remove(targetId); return; }
|
||||
var origin = sources.source(event);
|
||||
double remaining = target.getHealth() + target.getAbsorptionAmount();
|
||||
if (origin == null || origin.session() == null || !sources.current(origin)
|
||||
|| targetId.equals(origin.player()) || !Double.isFinite(remaining)
|
||||
|| event.getFinalDamage() < remaining) {
|
||||
lethal.remove(targetId); return;
|
||||
}
|
||||
lethal.put(targetId, new Candidate(target, origin));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void beforePlayerDeath(PlayerDeathEvent event) {
|
||||
if (!closed && sessions.active(event.getEntity().getUniqueId()).isPresent()) fantasyDefeats.add(event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void playerDeath(PlayerDeathEvent event) {
|
||||
boolean fantasy = fantasyDefeats.remove(event);
|
||||
if (!closed && fantasy && event.isCancelled()) defeat(event, event.getEntity());
|
||||
else lethal.remove(event.getEntity().getUniqueId());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
if (!closed && !(event instanceof PlayerDeathEvent)) defeat(event, event.getEntity());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void removed(EntityRemoveEvent event) {
|
||||
if (event.getCause() != EntityRemoveEvent.Cause.DEATH) lethal.remove(event.getEntity().getUniqueId());
|
||||
}
|
||||
|
||||
private void defeat(EntityDeathEvent event, LivingEntity target) {
|
||||
if (!consumed.add(event)) return;
|
||||
consumedOrder.addLast(event);
|
||||
while (consumedOrder.size() > MAX_CONSUMED_EVENTS) consumed.remove(consumedOrder.removeFirst());
|
||||
var candidate = lethal.remove(target.getUniqueId());
|
||||
if (candidate == null || candidate.target() != target || !sources.current(candidate.origin())) return;
|
||||
var origin = candidate.origin();
|
||||
var active = sessions.active(origin.player()).orElse(null);
|
||||
if (active == null || !active.session().equals(origin.session()) || !active.character().equals(origin.character())
|
||||
|| !sessions.current(active)) return;
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
var player = sessions.currentPlayer(active).orElse(null);
|
||||
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) return;
|
||||
BigInteger configured = settings.healing();
|
||||
if (configured.signum() == 0) return;
|
||||
var maximumAttribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (maximumAttribute == null) return;
|
||||
double current = player.getHealth();
|
||||
double maximum = Math.min(maximumAttribute.getValue(), record.maximumHealth().doubleValue());
|
||||
double reserved = pending.getOrDefault(active.character(), new ArrayDeque<>()).stream()
|
||||
.mapToDouble(Heal::amount).sum();
|
||||
double amount = configured.bitLength() > 1023 ? maximum - current - reserved : configured.doubleValue();
|
||||
double proposed = Math.min(maximum - current - reserved, amount);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || current < 0 || maximum <= 0
|
||||
|| !Double.isFinite(proposed) || proposed <= 0) return;
|
||||
var healing = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(healing);
|
||||
double accepted = Math.min(proposed, Math.min(maximum - current - reserved, healing.getAmount()));
|
||||
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
|
||||
var queue = pending.computeIfAbsent(active.character(), ignored -> new ArrayDeque<>());
|
||||
if (queue.size() >= MAX_PENDING_PER_CHARACTER) return;
|
||||
queue.addLast(new Heal(active, accepted));
|
||||
if (queue.size() == 1) attempt(active.character());
|
||||
}
|
||||
|
||||
private void attempt(UUID character) {
|
||||
var queue = pending.get(character); var operation = queue == null ? null : queue.peekFirst();
|
||||
if (closed || operation == null) return;
|
||||
if (!sessions.current(operation.active())) { finish(character); return; }
|
||||
var record = sessions.currentRecord(operation.active()).orElse(null);
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) { finish(character); return; }
|
||||
double nativeBefore = player.getHealth();
|
||||
BigDecimal target = record.health().add(BigDecimal.valueOf(operation.amount()));
|
||||
BigDecimal limit;
|
||||
try { limit = nativeLimit.get(); }
|
||||
catch (RuntimeException failure) { report(character, failure); finish(character); return; }
|
||||
BigInteger expected = record.revision();
|
||||
characters.saveHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try { nextTick.accept(() -> complete(character, operation, expected, nativeBefore, saved, failure)); }
|
||||
catch (RuntimeException rejected) { report(character, rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void complete(UUID character, Heal operation, BigInteger expected, double nativeBefore,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (closed || failure != null || saved == null || saved.isEmpty()) {
|
||||
if (failure != null) report(character, failure); return;
|
||||
}
|
||||
var record = saved.orElseThrow();
|
||||
if (!sessions.current(operation.active())
|
||||
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) return;
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) return;
|
||||
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
|
||||
double publish = Math.min(maximum, record.health().doubleValue());
|
||||
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) player.setHealth(publish);
|
||||
} finally { finish(character); }
|
||||
}
|
||||
|
||||
private void finish(UUID character) {
|
||||
var queue = pending.get(character);
|
||||
if (queue == null) return;
|
||||
queue.pollFirst();
|
||||
if (queue.isEmpty()) pending.remove(character); else attempt(character);
|
||||
}
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
@Override public void close() {
|
||||
closed = true; lethal.clear(); fantasyDefeats.clear(); consumed.clear(); consumedOrder.clear(); pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Current-session native Night Vision I layer for the learned character passive. */
|
||||
public final class LowLightVision implements AutoCloseable {
|
||||
public static final String ID = "low_light_vision";
|
||||
private final PersistentPotionEffect effect;
|
||||
|
||||
public LowLightVision(EquipmentSessions sessions) {
|
||||
effect = new PersistentPotionEffect(ID, PotionEffectType.NIGHT_VISION, 0, false, sessions);
|
||||
}
|
||||
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Low-Light Vision", Map.of(Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
|
||||
List.of("Continuously provides native Night Vision I while this character is active.",
|
||||
"Preserves unrelated Night Vision and does not change light, darkness or mob behavior."));
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
@Override public void close() { effect.close(); }
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Bounded current-character ownership for one persistent native potion layer. */
|
||||
final class PersistentPotionEffect implements AutoCloseable {
|
||||
private static final int MAX_ACTIVE_CHECKS = 256;
|
||||
|
||||
private final class State {
|
||||
final EquipmentSessions.Active active;
|
||||
final Player player;
|
||||
PotionEffect owned;
|
||||
PotionEffect displaced;
|
||||
long displacedUntil;
|
||||
long refreshedAt;
|
||||
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
|
||||
|
||||
void refresh() {
|
||||
refreshedAt = sweepGeneration;
|
||||
if (displaced != null && !displaced.isInfinite() && sweepGeneration >= displacedUntil) displaced = null;
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (owned != null && !same(current, owned)) { owned = null; displaced = null; }
|
||||
if (!eligible()) { remove(); return; }
|
||||
if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return;
|
||||
var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true);
|
||||
try {
|
||||
if (player.addPotionEffect(effect, true)) {
|
||||
owned = effect; displaced = current;
|
||||
displacedUntil = current == null ? 0 : current.isInfinite()
|
||||
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
|
||||
}
|
||||
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
|
||||
}
|
||||
|
||||
boolean eligible() {
|
||||
if (closed || !sessions.current(active)) return false;
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
return record != null && !record.dead() && record.passives().contains(passive);
|
||||
}
|
||||
|
||||
void remove() {
|
||||
if (owned != null) {
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (same(current, owned)) {
|
||||
try {
|
||||
player.removePotionEffect(type);
|
||||
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
|
||||
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
|
||||
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
|
||||
player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
|
||||
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
|
||||
}
|
||||
} catch (RuntimeException unavailable) { return; }
|
||||
}
|
||||
owned = null; displaced = null;
|
||||
}
|
||||
states.remove(active.player(), this);
|
||||
}
|
||||
}
|
||||
|
||||
private final String passive;
|
||||
private final PotionEffectType type;
|
||||
private final int amplifier;
|
||||
private final boolean replaceWeaker;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Map<UUID, State> states = new java.util.HashMap<>();
|
||||
private int activeCursor;
|
||||
private int stateCursor;
|
||||
private long sweepGeneration;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
PersistentPotionEffect(String passive, PotionEffectType type, int amplifier, boolean replaceWeaker,
|
||||
EquipmentSessions sessions) {
|
||||
this.passive = Objects.requireNonNull(passive); this.type = Objects.requireNonNull(type);
|
||||
if (passive.isBlank() || amplifier < 0) throw new IllegalArgumentException("Invalid persistent potion effect");
|
||||
this.amplifier = amplifier; this.replaceWeaker = replaceWeaker; this.sessions = Objects.requireNonNull(sessions);
|
||||
}
|
||||
|
||||
void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Passive potion sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
|
||||
void sweep() {
|
||||
if (closed) return;
|
||||
sweepGeneration++;
|
||||
var active = sessions.activeCharacters();
|
||||
int activeChecks = Math.min(MAX_ACTIVE_CHECKS, active.size());
|
||||
for (int index = 0; index < activeChecks; index++) {
|
||||
var snapshot = active.get((activeCursor + index) % active.size());
|
||||
reconcile(snapshot);
|
||||
}
|
||||
activeCursor = active.isEmpty() ? 0 : (activeCursor + activeChecks) % active.size();
|
||||
|
||||
var known = List.copyOf(states.values());
|
||||
int stateChecks = Math.min(MAX_ACTIVE_CHECKS, known.size());
|
||||
for (int index = 0; index < stateChecks; index++) {
|
||||
var state = known.get((stateCursor + index) % known.size());
|
||||
if (state.refreshedAt != sweepGeneration) state.refresh();
|
||||
}
|
||||
stateCursor = known.isEmpty() ? 0 : (stateCursor + stateChecks) % known.size();
|
||||
}
|
||||
|
||||
private void reconcile(EquipmentSessions.ActiveCharacter snapshot) {
|
||||
var active = snapshot.session();
|
||||
var state = states.get(active.player());
|
||||
boolean learned = !snapshot.record().dead() && snapshot.record().passives().contains(passive);
|
||||
if (!learned || !sessions.current(active)) { if (state != null) state.refresh(); return; }
|
||||
if (state != null && !state.active.equals(active)) { state.remove(); state = states.get(active.player()); }
|
||||
if (state == null) {
|
||||
var player = sessions.currentPlayer(active).orElse(null);
|
||||
if (player == null) return;
|
||||
state = new State(active, player); states.put(active.player(), state);
|
||||
}
|
||||
state.refresh();
|
||||
}
|
||||
|
||||
private static boolean same(PotionEffect first, PotionEffect second) {
|
||||
return first != null && second != null && first.getType().equals(second.getType())
|
||||
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite()
|
||||
&& first.isAmbient() == second.isAmbient() && first.hasParticles() == second.hasParticles()
|
||||
&& first.hasIcon() == second.hasIcon();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(states.values())) state.remove();
|
||||
states.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Quicker Regeneration II metadata and live configured pulse bonus. */
|
||||
public final class QuickerRegenerationII {
|
||||
public static final String ID = "quicker_regeneration_ii";
|
||||
public record Settings(BigInteger bonus) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(bonus);
|
||||
if (bonus.signum() < 0 || bonus.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Quicker Regeneration II bonus");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.TWO); }
|
||||
}
|
||||
private volatile Settings settings;
|
||||
public QuickerRegenerationII(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger bonus() { return settings.bonus(); }
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Quicker Regeneration II",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.valueOf(14)),
|
||||
Set.of(QuickerRegenerationI.ID), BigInteger.ONE,
|
||||
List.of("Add " + bonus() + " health point" + (bonus().equals(BigInteger.ONE) ? "" : "s")
|
||||
+ " beyond Quicker Regeneration I to each eligible out-of-combat recovery pulse.",
|
||||
"Both bonuses use the same capped persisted custom-healing transaction as base recovery."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Current-session native Speed I layer for the learned character passive. */
|
||||
public final class SpeedI implements AutoCloseable {
|
||||
public static final String ID = "speed_i";
|
||||
private final PersistentPotionEffect effect;
|
||||
|
||||
public SpeedI(EquipmentSessions sessions) {
|
||||
effect = new PersistentPotionEffect(ID, PotionEffectType.SPEED, 0, true, sessions);
|
||||
}
|
||||
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Speed I", Map.of(Stat.DEX, BigInteger.valueOf(14)), Set.of(), BigInteger.ONE,
|
||||
List.of("Continuously provides the standard native Speed I effect while this character is active.",
|
||||
"Does not stack with equal Speed I and preserves stronger unrelated Speed effects."));
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
@Override public void close() { effect.close(); }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.passive.HealthSteal;
|
||||
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.util.LinkedHashMap;
|
||||
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;
|
||||
|
||||
/** Durable save-before-publication Health Steal balance. */
|
||||
public final class HealthStealSettings implements AutoCloseable {
|
||||
public static final String KEY = "health-steal-healing-per-kill";
|
||||
public static final String DEFAULT = "1";
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String value, HealthSteal.Settings settings, long sequence) { }
|
||||
private final Path directory; private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-health-steal-configuration"));
|
||||
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
|
||||
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public HealthStealSettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
HealthStealSettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
public static HealthSteal.Settings parse(String raw) {
|
||||
try { return new HealthSteal.Settings(new java.math.BigInteger(raw)); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
|
||||
}
|
||||
private static String canonical(String raw) { var parsed = parse(raw); return parsed.healing().toString(); }
|
||||
public String value() { return effective; }
|
||||
public HealthSteal.Settings settings() { return parse(effective); }
|
||||
public CompletionStage<Update> set(String raw) {
|
||||
String accepted = canonical(raw);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed")); pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try { save(accepted); persisted = accepted; return new Update(accepted, parse(accepted), ++persistedSequence); }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Health Steal configuration save failed; effective value unchanged", failure));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
|
||||
}
|
||||
}
|
||||
public synchronized boolean publish(Update update) {
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
|
||||
}
|
||||
private void save(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 key)) throw new IOException("Invalid configuration key");
|
||||
document.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
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", 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,113 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.passive.QuickerRegenerationII;
|
||||
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.util.LinkedHashMap;
|
||||
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;
|
||||
|
||||
/** Durable save-before-publication Quicker Regeneration II balance. */
|
||||
public final class QuickerRegenerationIISettings implements AutoCloseable {
|
||||
public static final String KEY = "quicker-regeneration-ii-bonus-healing";
|
||||
public static final String DEFAULT = "2";
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String value, QuickerRegenerationII.Settings settings, long sequence) { }
|
||||
private final Path directory; private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-quicker-regeneration-ii-configuration"));
|
||||
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
|
||||
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public QuickerRegenerationIISettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
QuickerRegenerationIISettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
public static QuickerRegenerationII.Settings parse(String raw) {
|
||||
try { return new QuickerRegenerationII.Settings(new java.math.BigInteger(raw)); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
|
||||
}
|
||||
private static String canonical(String raw) { var parsed = parse(raw); return parsed.bonus().toString(); }
|
||||
public String value() { return effective; }
|
||||
public QuickerRegenerationII.Settings settings() { return parse(effective); }
|
||||
public CompletionStage<Update> set(String raw) {
|
||||
String accepted = canonical(raw);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed")); pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try { save(accepted); persisted = accepted; return new Update(accepted, parse(accepted), ++persistedSequence); }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Quicker Regeneration II configuration save failed; effective value unchanged", failure));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
|
||||
}
|
||||
}
|
||||
public synchronized boolean publish(Update update) {
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
|
||||
}
|
||||
private void save(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 key)) throw new IOException("Invalid configuration key");
|
||||
document.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
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", 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); }
|
||||
}
|
||||
@@ -70,6 +70,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private OutOfCombatRecovery outOfCombatRecovery;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
|
||||
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
|
||||
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
@@ -313,6 +317,22 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
passiveRegistry = Objects.requireNonNull(registry);
|
||||
}
|
||||
|
||||
public void attachQuickerRegenerationII(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings settings,
|
||||
games.dmg.fantasystick.passive.QuickerRegenerationII passive,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry registry) {
|
||||
if (closed || quickerRegenerationIISettings != null) throw new IllegalStateException("Quicker Regeneration II already attached or command closed");
|
||||
quickerRegenerationIISettings = Objects.requireNonNull(settings); quickerRegenerationII = Objects.requireNonNull(passive);
|
||||
if (passiveRegistry != Objects.requireNonNull(registry)) throw new IllegalArgumentException("Passive registry mismatch");
|
||||
}
|
||||
|
||||
public void attachHealthSteal(games.dmg.fantasystick.persistence.HealthStealSettings settings,
|
||||
games.dmg.fantasystick.passive.HealthSteal passive,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry registry) {
|
||||
if (closed || healthStealSettings != null) throw new IllegalStateException("Health Steal already attached or command closed");
|
||||
healthStealSettings = Objects.requireNonNull(settings); healthSteal = Objects.requireNonNull(passive);
|
||||
if (passiveRegistry != Objects.requireNonNull(registry)) throw new IllegalArgumentException("Passive registry mismatch");
|
||||
}
|
||||
|
||||
public void attachExperienceGrant(AdminExperienceGrant grant) {
|
||||
if (closed || experienceGrant != null) throw new IllegalStateException("Experience grant already attached or command closed");
|
||||
experienceGrant = Objects.requireNonNull(grant);
|
||||
@@ -498,6 +518,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
&& quickerRegenerationSettings != null) {
|
||||
quickerRegenerationConfiguration(sender, args); return;
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY)
|
||||
&& quickerRegenerationIISettings != null) {
|
||||
quickerRegenerationIIConfiguration(sender, args); return;
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.HealthStealSettings.KEY) && healthStealSettings != null) {
|
||||
healthStealConfiguration(sender, args); return;
|
||||
}
|
||||
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
|
||||
longbowConfiguration(sender, args); return;
|
||||
}
|
||||
@@ -642,6 +669,64 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private void quickerRegenerationIIConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY + "="
|
||||
+ quickerRegenerationIISettings.value() + " (applies to future recovery pulses only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.Update> operation;
|
||||
try { operation = quickerRegenerationIISettings.set(args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Quicker Regeneration II setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-quicker-regeneration-ii", failure); if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return; boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = quickerRegenerationIISettings.publish(saved);
|
||||
if (applied) { quickerRegenerationII.configure(saved.settings()); passiveRegistry.replace(quickerRegenerationII.definition()); }
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Quicker Regeneration II configuration save failed; effective value is unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective "
|
||||
+ games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY + "=" + quickerRegenerationIISettings.value() + ".");
|
||||
else sender.sendMessage("Saved " + games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY
|
||||
+ "=" + saved.value() + " for future recovery pulses.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void healthStealConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.HealthStealSettings.KEY + "="
|
||||
+ healthStealSettings.value() + " (applies to later qualifying final blows only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HealthStealSettings.Update> operation;
|
||||
try { operation = healthStealSettings.set(args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Health Steal setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-health-steal", failure); if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return; boolean applied = false;
|
||||
if (failure == null) {
|
||||
applied = healthStealSettings.publish(saved);
|
||||
if (applied) { healthSteal.configure(saved.settings()); passiveRegistry.replace(healthSteal.definition()); }
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Health Steal configuration save failed; effective value is unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request was superseded; effective "
|
||||
+ games.dmg.fantasystick.persistence.HealthStealSettings.KEY + "=" + healthStealSettings.value() + ".");
|
||||
else sender.sendMessage("Saved " + games.dmg.fantasystick.persistence.HealthStealSettings.KEY
|
||||
+ "=" + saved.value() + " for later qualifying final blows.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void longbowConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
@@ -1071,6 +1156,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys());
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY);
|
||||
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
|
||||
keys.add(games.dmg.fantasystick.persistence.HealthStealSettings.KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
|
||||
@@ -1109,6 +1196,17 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return matching(current.equals(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT)
|
||||
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT), args[2]);
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY)
|
||||
&& quickerRegenerationIISettings != null) {
|
||||
String current = quickerRegenerationIISettings.value();
|
||||
return matching(current.equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT)
|
||||
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT), args[2]);
|
||||
}
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.HealthStealSettings.KEY) && healthStealSettings != null) {
|
||||
String current = healthStealSettings.value();
|
||||
return matching(current.equals(games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT)
|
||||
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT), args[2]);
|
||||
}
|
||||
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
String current = frostSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
|
||||
|
||||
@@ -16,6 +16,8 @@ out-of-combat-healing-delay-seconds: 30
|
||||
out-of-combat-healing-interval-seconds: 10
|
||||
out-of-combat-healing-amount: 1
|
||||
quicker-regeneration-i-bonus-healing: 1
|
||||
quicker-regeneration-ii-bonus-healing: 2
|
||||
health-steal-healing-per-kill: 1
|
||||
|
||||
# New-character and transferable fantasy progression defaults.
|
||||
starting-points: 40
|
||||
|
||||
@@ -172,6 +172,8 @@ class AbilityEngineNativeTest {
|
||||
assertTrue(cooled.cooldowns().get("test_focus").isAfter(java.time.Instant.now()));
|
||||
var cooling = a.core.inventory.getItem(2);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, cooling.getType());
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
cooling.getItemMeta().getItemModel());
|
||||
assertEquals(net.kyori.adventure.text.Component.text("Test Focus — Cooling Down"), cooling.getItemMeta().displayName());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(cooling).orElseThrow());
|
||||
|
||||
@@ -189,6 +191,8 @@ class AbilityEngineNativeTest {
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, a.core.inventory.getItem(2).getType());
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry)
|
||||
.ability(a.core.inventory.getItem(2)).orElseThrow());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
|
||||
@@ -1078,13 +1078,22 @@ class EquipmentSessionsNativeTest {
|
||||
assertTrue(f.sessions.acknowledgeProgress(active, learned.revision(), cooled));
|
||||
var pane = actor.inventory.getItem(2).clone();
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, pane.getType());
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL, pane.getItemMeta().getItemModel(),
|
||||
"Only the acknowledged persisted cooldown control selects the cosmetic model");
|
||||
assertFalse(f.sessions.acknowledgeProgress(active, current.revision(), learned),
|
||||
"A stale pre-cooldown acknowledgment cannot replace the current presentation");
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL,
|
||||
actor.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
|
||||
f.sessions.exit(actor.player); f.state(actor); f.activate(actor);
|
||||
assertEquals(Material.GRAY_STAINED_GLASS_PANE, actor.inventory.getItem(2).getType(),
|
||||
"Resume reconstructs the future persisted deadline");
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL, actor.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
clock.advance(Duration.ofSeconds(30));
|
||||
f.sessions.refreshCooldownControls(1);
|
||||
assertEquals(Material.SNOWBALL, actor.inventory.getItem(2).getType());
|
||||
assertNull(actor.inventory.getItem(2).getItemMeta().getItemModel(),
|
||||
"Exact-deadline restoration replaces the whole control without stale cosmetic metadata");
|
||||
assertNotEquals(pane, actor.inventory.getItem(2), "The retired pane is no longer installed");
|
||||
assertEquals(deadline, f.sessions.currentRecord(f.sessions.active(actor.id).orElseThrow()).orElseThrow()
|
||||
.cooldowns().get("fireball"), "Visual restoration does not rewrite the deadline");
|
||||
|
||||
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(9, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery and session particles");
|
||||
assertEquals(11, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
|
||||
|
||||
@@ -15,7 +15,7 @@ class FireballNativeTest {
|
||||
@Test void bookAcquisitionEquippedUseAndDirectHitDealOneAttributedScaledDamageWithCooldown() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withInt(data, a, 12);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withIntAndHealthSteal(data, a, 12);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
@@ -54,6 +54,8 @@ class FireballNativeTest {
|
||||
var target = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
|
||||
org.mockito.Mockito.when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
org.mockito.Mockito.when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
org.mockito.Mockito.when(target.getHealth()).thenReturn(1d);
|
||||
org.mockito.Mockito.when(target.getAbsorptionAmount()).thenReturn(0d);
|
||||
org.mockito.Mockito.when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
|
||||
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
@@ -70,8 +72,11 @@ class FireballNativeTest {
|
||||
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(),
|
||||
var rewarded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.TEN, rewarded.experience(),
|
||||
"Native projectile attribution reaches the originating character's combat XP");
|
||||
assertEquals(5, a.player.getHealth(), "An actual Fireball final blow activates learned Health Steal");
|
||||
assertEquals(0, java.math.BigDecimal.valueOf(5).compareTo(rewarded.health()));
|
||||
org.mockito.Mockito.verify(projectile, org.mockito.Mockito.atLeastOnce()).remove();
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
|
||||
assertEquals(1, damages.size(), "A projectile can deal custom damage only once");
|
||||
@@ -83,11 +88,15 @@ class FireballNativeTest {
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball");
|
||||
assertTrue(deadline.isAfter(java.time.Instant.now()));
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
|
||||
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);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(a.core.inventory.getItem(2)).orElseThrow());
|
||||
@@ -140,6 +149,8 @@ class FireballNativeTest {
|
||||
assertTrue(new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(a.core.inventory.getItem(2)).isEmpty());
|
||||
assertNull(a.core.inventory.getItem(2).getItemMeta().getItemModel(),
|
||||
"Unlearning replaces the cooling control and leaves no stale invisible model");
|
||||
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 WIS]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
@@ -149,6 +160,9 @@ class FireballNativeTest {
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
a.core.inventory.getItem(2).getItemMeta().getItemModel(),
|
||||
"Relearning reconstructs the retained future cooldown model");
|
||||
cast(f, a); assertEquals(1, launches.get());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
|
||||
@@ -159,6 +173,8 @@ class FireballNativeTest {
|
||||
b.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals(org.bukkit.Material.GRAY_STAINED_GLASS_PANE, b.core.inventory.getItem(2).getType(),
|
||||
"The transferred character reconstructs its retained cooldown pane");
|
||||
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
|
||||
b.core.inventory.getItem(2).getItemMeta().getItemModel());
|
||||
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
|
||||
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
|
||||
.ability(b.core.inventory.getItem(2)).orElseThrow());
|
||||
@@ -360,6 +376,17 @@ class FireballNativeTest {
|
||||
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
|
||||
private static void withIntAndHealthSteal(Path data, FantasyStickLifecycleNativeTest.Actor actor, int intelligence) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(intelligence));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.valueOf(4), old.learned(), old.loadout(),
|
||||
java.util.List.of("health_steal", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(saved, old.revision());
|
||||
}
|
||||
|
||||
private static void withInt(Path data, FantasyStickLifecycleNativeTest.Actor actor, int intelligence) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
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.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class HealthStealNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void connectedLearnBasicProjectileFantasyDefeatDuplicateAssistAndTransferPathPersistsOnlyFinalBlows() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"character-swap-cooldown-seconds: 0\nhealth-steal-healing-per-kill: 1\n"
|
||||
+ "out-of-combat-healing-delay-seconds: 31536000\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); assertTrue(f.order.contains("HealthSteal"));
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); makeEligible(data, a, BigDecimal.valueOf(4));
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); makeEligibleAtLevelCap(data, b, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("HealthStealSTR14,CON12[Learn]"));
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "health_steal")); settle(f);
|
||||
var learned = record(f, a); assertEquals("health_steal", learned.passives().getFirst());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints()); var activeLoadout = learned.loadout();
|
||||
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var basicTarget = mob(EntityType.COW, new Location(world, 1, 64, 0), 1);
|
||||
doAnswer(call -> { hit(f, a.player, basicTarget, (Double) call.getArgument(0), false); return null; })
|
||||
.when(basicTarget).damage(anyDouble(), same(a.player));
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, basicTarget, true));
|
||||
die(f, basicTarget); settle(f); settle(f);
|
||||
assertEquals(5, a.player.getHealth()); assertEquals(0, BigDecimal.valueOf(5).compareTo(record(f, a).health()));
|
||||
die(f, basicTarget); settle(f); assertEquals(5, a.player.getHealth(), "A repeated death callback is consumed");
|
||||
|
||||
var projectileTarget = mob(EntityType.ZOMBIE, new Location(world, 2, 64, 0), 1);
|
||||
var arrow = PartyCombatNativeTest.projectile(f, a.player);
|
||||
hit(f, arrow, projectileTarget, 4, false); die(f, projectileTarget); settle(f); settle(f);
|
||||
assertEquals(6, a.player.getHealth(), "Originating-character projectile attribution heals its final blow");
|
||||
var spacingBlocked = mob(EntityType.ZOMBIE, new Location(world, 2, 64, 0), 1);
|
||||
hit(f, a.player, spacingBlocked, 4, false); die(f, spacingBlocked); settle(f); settle(f);
|
||||
assertEquals(7, a.player.getHealth(), "Restricted-XP spacing does not suppress final-blow healing");
|
||||
|
||||
var assisted = mob(EntityType.ZOMBIE, new Location(world, 3, 64, 0), 10);
|
||||
hit(f, a.player, assisted, 1, false); when(assisted.getHealth()).thenReturn(1d);
|
||||
hit(f, b.player, assisted, 4, false); die(f, assisted); settle(f); settle(f);
|
||||
assertEquals(7, a.player.getHealth(), "Contribution without the final blow does not heal");
|
||||
var capped = record(f, b); assertEquals(5, b.player.getHealth()); assertEquals(30, capped.level());
|
||||
assertEquals(BigInteger.valueOf(2_900), capped.experience(),
|
||||
"The final blower heals with multiple contributors even when XP is already capped");
|
||||
|
||||
b.player.setHealth(1); hit(f, a.player, b.player, 4, false);
|
||||
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, mock(org.bukkit.damage.DamageSource.class),
|
||||
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(defeat); settle(f); settle(f);
|
||||
assertTrue(defeat.isCancelled()); assertEquals(8, a.player.getHealth(), "Enemy fantasy-character final blows qualify");
|
||||
|
||||
var saved = record(f, a); assertEquals(activeLoadout, saved.loadout());
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
var transferred = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
transferred.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(saved));
|
||||
f.players.put(transferred.core.id, transferred.player); f.activate(transferred);
|
||||
assertEquals(8, transferred.player.getHealth());
|
||||
assertEquals(saved.passives(), storage(f).find(saved.id()).get().orElseThrow().passives());
|
||||
PartyBookNativeTest.open(f, transferred);
|
||||
PartyBookNativeTest.click(f, transferred, action(transferred, "[Unlearn]", "health_steal")); settle(f);
|
||||
var unlearned = storage(f).find(saved.id()).get().orElseThrow();
|
||||
assertFalse(unlearned.passives().contains("health_steal"));
|
||||
assertEquals(saved.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints());
|
||||
var afterUnlearn = mob(EntityType.COW, new Location(world, 5, 64, 0), 1);
|
||||
hit(f, transferred.player, afterUnlearn, 4, false); die(f, afterUnlearn); settle(f);
|
||||
assertEquals(8, transferred.player.getHealth(), "Unlearning removes only the passive's future healing");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configuredZeroAndOrdinaryPlayerTargetPerformNoHealthWriteOrPublication() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"health-steal-healing-per-kill: 0\nout-of-combat-healing-delay-seconds: 31536000\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, a, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var before = record(f, a); var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var target = mob(EntityType.WARDEN, new Location(world, 0, 64, 0), 1);
|
||||
hit(f, a.player, target, 4, false); die(f, target); settle(f);
|
||||
assertEquals(0, before.health().compareTo(record(f, a).health())); assertEquals(4, a.player.getHealth());
|
||||
|
||||
var ordinary = mock(org.bukkit.entity.Player.class); when(ordinary.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(ordinary.getHealth()).thenReturn(1d); when(ordinary.getAbsorptionAmount()).thenReturn(0d);
|
||||
hit(f, a.player, ordinary, 4, false);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.PlayerDeathEvent(ordinary, mock(org.bukkit.damage.DamageSource.class),
|
||||
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("death"), false));
|
||||
settle(f); assertEquals(4, a.player.getHealth(), "Ordinary player targets never qualify");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void changedValueCapsAtFullHealthAndCancellationFailureOrStaleSourceNeverPublishes() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"character-swap-cooldown-seconds: 0\nhealth-steal-healing-per-kill: 3\n"
|
||||
+ "out-of-combat-healing-delay-seconds: 31536000\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
class Gate implements org.bukkit.event.Listener {
|
||||
final java.util.ArrayList<Double> amounts = new java.util.ArrayList<>(); boolean veto;
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void heal(org.bukkit.event.entity.EntityRegainHealthEvent event) {
|
||||
amounts.add(event.getAmount()); if (veto) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
var gate = new Gate(); f.plugin.getServer().getPluginManager().registerEvents(gate, f.plugin);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
|
||||
var changed = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, changed, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
|
||||
f.players.put(changed.core.id, changed.player); f.activate(changed);
|
||||
var first = mob(EntityType.BEE, new Location(world, 0, 64, 0), 1);
|
||||
hit(f, changed.player, first, 4, false); die(f, first); settle(f); settle(f);
|
||||
assertEquals(7, changed.player.getHealth()); assertEquals(List.of(3d), gate.amounts);
|
||||
|
||||
gate.veto = true; var cancelled = mob(EntityType.COW, new Location(world, 1, 64, 0), 1);
|
||||
hit(f, changed.player, cancelled, 4, false); die(f, cancelled); settle(f);
|
||||
assertEquals(7, changed.player.getHealth()); assertEquals(List.of(3d, 3d), gate.amounts);
|
||||
gate.veto = false;
|
||||
int beforeInvalidDamage = gate.amounts.size();
|
||||
var cancelledDamage = mob(EntityType.COW, new Location(world, 1.25, 64, 0), 1);
|
||||
hit(f, changed.player, cancelledDamage, 4, true); die(f, cancelledDamage); settle(f);
|
||||
var zeroDamage = mob(EntityType.COW, new Location(world, 1.5, 64, 0), 1);
|
||||
hit(f, changed.player, zeroDamage, 0, false); die(f, zeroDamage); settle(f);
|
||||
assertEquals(beforeInvalidDamage, gate.amounts.size(), "Cancelled and zero damage cannot establish a final blow");
|
||||
|
||||
var full = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, full, BigDecimal.valueOf(22), List.of("health_steal", "", ""));
|
||||
f.players.put(full.core.id, full.player); f.activate(full); int beforeFull = gate.amounts.size();
|
||||
var fullTarget = mob(EntityType.WARDEN, new Location(world, 2, 64, 0), 1);
|
||||
hit(f, full.player, fullTarget, 4, false); die(f, fullTarget); settle(f);
|
||||
assertEquals(beforeFull, gate.amounts.size()); assertEquals(22, full.player.getHealth());
|
||||
|
||||
var stale = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, stale, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
|
||||
f.players.put(stale.core.id, stale.player); f.activate(stale);
|
||||
var staleTarget = mob(EntityType.ZOMBIE, new Location(world, 3, 64, 0), 1);
|
||||
hit(f, stale.player, staleTarget, 4, false);
|
||||
stale.core.inventory.setItemInOffHand(null); f.changed(stale); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(stale);
|
||||
int beforeStale = gate.amounts.size(); die(f, staleTarget); settle(f);
|
||||
assertEquals(beforeStale, gate.amounts.size(), "A transferred source cannot receive redirected healing");
|
||||
|
||||
var failed = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, failed, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
|
||||
f.players.put(failed.core.id, failed.player); f.activate(failed);
|
||||
var failedTarget = mob(EntityType.COW, new Location(world, 4, 64, 0), 1);
|
||||
hit(f, failed.player, failedTarget, 4, false);
|
||||
Runnable callback;
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); die(f, failedTarget);
|
||||
callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback);
|
||||
}
|
||||
callback.run(); assertEquals(4, failed.player.getHealth());
|
||||
assertEquals(0, BigDecimal.valueOf(4).compareTo(record(f, failed).health()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configurationIsDurablePermissionAwareAndPrefixFiltered() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("health-steal-healing-per-kill"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "health-steal"}));
|
||||
assertEquals(List.of("1"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "health-steal-healing-per-kill", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "health-steal-healing-per-kill"});
|
||||
assertTrue(sender.messages.getLast().contains("=1")); sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "health-steal"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "health-steal-healing-per-kill", "3"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "health-steal-healing-per-kill", "3"});
|
||||
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var settings = (games.dmg.fantasystick.persistence.HealthStealSettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthStealSettings");
|
||||
assertEquals("3", settings.value());
|
||||
}
|
||||
}
|
||||
|
||||
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
|
||||
makeEligible(data, actor, health, List.of("", "", ""));
|
||||
}
|
||||
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
|
||||
List<String> passives) 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.STR, BigInteger.valueOf(14)); 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(40), health, old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static void makeEligibleAtLevelCap(Path data, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
BigDecimal health) throws Exception {
|
||||
makeEligible(data, actor, health, List.of("health_steal", "", ""));
|
||||
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(),
|
||||
30, BigInteger.valueOf(2_900), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
|
||||
org.bukkit.entity.Entity target, double amount, boolean cancelled) {
|
||||
var damage = mock(org.bukkit.damage.DamageSource.class); when(damage.getCausingEntity()).thenReturn(source);
|
||||
when(damage.getDirectEntity()).thenReturn(source);
|
||||
var event = new EntityDamageByEntityEvent(source, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, damage, amount);
|
||||
event.setCancelled(cancelled); f.dispatcher.accept(event); return event;
|
||||
}
|
||||
private static void die(FantasyStickLifecycleNativeTest.Fixture f, LivingEntity target) {
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target,
|
||||
mock(org.bukkit.damage.DamageSource.class), new ArrayList<>(), 0));
|
||||
}
|
||||
private static LivingEntity mob(EntityType type, Location location, double health) {
|
||||
var entity = mock(LivingEntity.class); when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(entity.getType()).thenReturn(type); when(entity.getHealth()).thenReturn(health); when(entity.getAbsorptionAmount()).thenReturn(0d);
|
||||
when(entity.getLocation()).thenAnswer(ignored -> location.clone()); when(entity.getWorld()).thenReturn(location.getWorld());
|
||||
when(entity.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
return entity;
|
||||
}
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
return storage(f).find(actor.core.record.id()).get().orElseThrow();
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
|
||||
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + passive);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
net.minecraft.network.chat.Component component, String label, String passive) {
|
||||
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(":" + passive)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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.passive.LowLightVision;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class LowLightVisionNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void connectedLearnExitTransferUnlearnFailureAndDefeatOwnOnlyCharacterNightVision() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); withWis(data, a, List.of("", "", ""));
|
||||
var aEffects = effects(a.player); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var vision = (LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision");
|
||||
PartyBookNativeTest.open(f, a);
|
||||
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("Low-LightVisionWIS12[Learn]"));
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "low_light_vision")); settle(f); vision.sweep();
|
||||
var learned = record(f, a); assertEquals(List.of("low_light_vision", "", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
|
||||
assertEquals(0, aEffects.get(PotionEffectType.NIGHT_VISION).getAmplifier());
|
||||
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone(); a.core.inventory.setItemInOffHand(null);
|
||||
f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a); vision.sweep();
|
||||
assertFalse(aEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); var bEffects = effects(b.player);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b); vision.sweep();
|
||||
assertTrue(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
PartyBookNativeTest.open(f, b); PartyBookNativeTest.click(f, b, action(b, "[Unlearn]", "low_light_vision")); settle(f); vision.sweep();
|
||||
var unlearned = storage(f).find(learned.id()).get().orElseThrow(); assertFalse(unlearned.passives().contains("low_light_vision"));
|
||||
assertEquals(learned.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints());
|
||||
assertFalse(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
|
||||
PartyBookNativeTest.open(f, b); Runnable failed;
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); PartyBookNativeTest.click(f, b, action(b, "[Learn]", "low_light_vision"));
|
||||
failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed);
|
||||
}
|
||||
failed.run(); vision.sweep(); assertFalse(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
assertFalse(storage(f).find(learned.id()).get().orElseThrow().passives().contains("low_light_vision"));
|
||||
|
||||
b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
storage(f).find(learned.id()).get(); f.receipts().find(b.core.id).toCompletableFuture().get();
|
||||
withWis(data, a, List.of("low_light_vision", "", ""));
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data); var cEffects = effects(c.player);
|
||||
c.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(
|
||||
new YamlCharacterRepository(data).load(a.core.record.id()).orElseThrow()));
|
||||
f.players.put(c.core.id, c.player); f.activate(c); vision.sweep(); assertTrue(cEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
c.player.setHealth(1);
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(c.player, mock(org.bukkit.damage.DamageSource.class),
|
||||
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(death); settle(f); vision.sweep(); assertFalse(cEffects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void milkAndUnrelatedNightVisionReconcileWithoutTouchingBlindnessOrDarkness() {
|
||||
var sessions = mock(EquipmentSessions.class); var player = mock(Player.class); var effects = effects(player);
|
||||
UUID id = UUID.randomUUID(); var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
|
||||
var record = mock(CharacterRecord.class); when(record.passives()).thenReturn(List.of("low_light_vision", "", ""));
|
||||
when(player.getUniqueId()).thenReturn(id); when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, record)));
|
||||
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
|
||||
when(sessions.currentPlayer(active)).thenReturn(java.util.Optional.of(player));
|
||||
var blindness = new PotionEffect(PotionEffectType.BLINDNESS, 100, 0);
|
||||
var darkness = new PotionEffect(PotionEffectType.DARKNESS, 100, 0);
|
||||
effects.put(PotionEffectType.BLINDNESS, blindness); effects.put(PotionEffectType.DARKNESS, darkness);
|
||||
var unrelated = new PotionEffect(PotionEffectType.NIGHT_VISION, 40, 0); effects.put(PotionEffectType.NIGHT_VISION, unrelated);
|
||||
var vision = new LowLightVision(sessions); assertEquals(Map.of(Stat.WIS, BigInteger.valueOf(12)), vision.definition().requirements());
|
||||
vision.sweep(); assertSame(unrelated, effects.get(PotionEffectType.NIGHT_VISION));
|
||||
effects.remove(PotionEffectType.NIGHT_VISION); vision.sweep(); assertTrue(effects.get(PotionEffectType.NIGHT_VISION).isInfinite());
|
||||
assertSame(blindness, effects.get(PotionEffectType.BLINDNESS)); assertSame(darkness, effects.get(PotionEffectType.DARKNESS));
|
||||
effects.remove(PotionEffectType.NIGHT_VISION); vision.sweep();
|
||||
verify(player, times(2)).addPotionEffect(argThat(effect -> effect.getType().equals(PotionEffectType.NIGHT_VISION)), eq(true));
|
||||
when(record.passives()).thenReturn(List.of("", "", "")); vision.sweep();
|
||||
assertFalse(effects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
assertSame(blindness, effects.get(PotionEffectType.BLINDNESS)); assertSame(darkness, effects.get(PotionEffectType.DARKNESS));
|
||||
vision.close();
|
||||
}
|
||||
|
||||
private static Map<PotionEffectType, PotionEffect> effects(Player player) {
|
||||
var result = new HashMap<PotionEffectType, PotionEffect>();
|
||||
doAnswer(call -> result.get(call.getArgument(0))).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> { PotionEffect effect = call.getArgument(0); result.put(effect.getType(), effect); return true; })
|
||||
.when(player).addPotionEffect(any(), eq(true));
|
||||
doAnswer(call -> { result.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return result;
|
||||
}
|
||||
private static void withWis(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
|
||||
var repository = new YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); 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(),
|
||||
old.unspentPoints(), old.health(), old.learned(), old.loadout(), passives, old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
return storage(f).find(actor.core.record.id()).get().orElseThrow();
|
||||
}
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
|
||||
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + passive);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
net.minecraft.network.chat.Component component, String label, String passive) {
|
||||
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(":" + passive)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,14 @@ class OutOfCombatRecoveryNativeTest {
|
||||
assertTrue(full.events.isEmpty()); assertEquals(fullRevision, full.record.get().revision());
|
||||
}
|
||||
|
||||
@Test void stackedRegenerationBonusProducesOneMaximumCappedNativeAndPersistedPulse() {
|
||||
var f = new Direct(); f.health.set(9d); f.record.set(f.saved(BigDecimal.valueOf(9)));
|
||||
f.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3));
|
||||
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(30)); f.recovery.sweep();
|
||||
assertEquals(1, f.events.size()); assertEquals(1, f.events.getFirst().getAmount());
|
||||
f.runNext(); assertEquals(10, f.health.get()); assertEquals(0, BigDecimal.TEN.compareTo(f.record.get().health()));
|
||||
}
|
||||
|
||||
@Test void zeroAmountDisablesPulsesAndReconfigurationRetainsTheActivityTimestamp() {
|
||||
var f = new Direct(new OutOfCombatRecovery.Settings(Duration.ofSeconds(30), Duration.ofSeconds(10), BigInteger.ZERO));
|
||||
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(40)); f.recovery.sweep();
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.OutOfCombatRecovery;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuickerRegenerationIINativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void prerequisiteLearnStackTransferTailReviveAndDependencySafeRefundUseTwoPassiveSlots() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"character-swap-cooldown-seconds: 0\nout-of-combat-healing-delay-seconds: 0\n"
|
||||
+ "out-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n"
|
||||
+ "quicker-regeneration-i-bonus-healing: 1\nquicker-regeneration-ii-bonus-healing: 2\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, a, BigDecimal.valueOf(4)); f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
|
||||
String initial = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(initial.contains("QuickerRegenerationIIQuickerRegenerationI,CON16,WIS14"), initial);
|
||||
assertFalse(hasAction(a, "[Learn]", "quicker_regeneration_ii"));
|
||||
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "quicker_regeneration_i")); drain(f);
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "quicker_regeneration_ii")); drain(f);
|
||||
var learned = storage(f).find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(List.of("quicker_regeneration_i", "quicker_regeneration_ii", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(38), learned.unspentPoints());
|
||||
assertEquals(a.core.record.loadout(), learned.loadout());
|
||||
|
||||
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
|
||||
recovery.sweep(); PartyBookNativeTest.drain(f);
|
||||
assertEquals(8, a.player.getHealth(), "The single pulse is base 1 + Regeneration I 1 + Regeneration II 2");
|
||||
learned = storage(f).find(learned.id()).get().orElseThrow(); assertEquals(0, new BigDecimal("8").compareTo(learned.health()));
|
||||
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Unlearn]", "quicker_regeneration_i")); drain(f);
|
||||
var refused = storage(f).find(learned.id()).get().orElseThrow();
|
||||
assertEquals(learned.passives(), refused.passives()); assertEquals(learned.unspentPoints(), refused.unspentPoints());
|
||||
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(refused));
|
||||
f.players.put(b.core.id, b.player); f.activate(b); PartyBookNativeTest.open(f, b);
|
||||
assertTrue(PartyBookNativeTest.text(b).replaceAll("\\s+", "").contains("2:QuickerRegenerationII"));
|
||||
|
||||
b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(b);
|
||||
var exited = storage(f).find(refused.id()).get().orElseThrow();
|
||||
var dead = storage(f).recordDeath(exited.id(), exited.revision()).get().orElseThrow();
|
||||
assertEquals(refused.passives(), dead.passives());
|
||||
var revived = storage(f).restoreDeathRecordAdministratively(dead.id(), dead.deathId(), new BigDecimal("1024")).get().orElseThrow();
|
||||
assertEquals(refused.passives(), revived.passives());
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
c.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(revived));
|
||||
f.players.put(c.core.id, c.player); f.activate(c); PartyBookNativeTest.open(f, c);
|
||||
PartyBookNativeTest.click(f, c, action(c, "[Unlearn]", "quicker_regeneration_ii")); drain(f);
|
||||
PartyBookNativeTest.click(f, c, action(c, "[Unlearn]", "quicker_regeneration_i")); drain(f);
|
||||
var refunded = storage(f).find(revived.id()).get().orElseThrow();
|
||||
assertEquals(List.of("", "", ""), refunded.passives()); assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertEquals(refunded.passives(), storage(f).find(refunded.id()).get().orElseThrow().passives());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void orphanedSavedSecondRankRemainsVisibleButCannotApplyWithoutItsPrerequisite() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\n"
|
||||
+ "out-of-combat-healing-amount: 1\nquicker-regeneration-i-bonus-healing: 1\n"
|
||||
+ "quicker-regeneration-ii-bonus-healing: 2\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
makeEligible(data, a, BigDecimal.valueOf(4), List.of("quicker_regeneration_ii", "", ""));
|
||||
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
|
||||
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("1:QuickerRegenerationII"));
|
||||
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
|
||||
recovery.sweep(); PartyBookNativeTest.drain(f);
|
||||
assertEquals(5, a.player.getHealth(), "An orphaned second rank cannot execute without Regeneration I");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configurationIsPermissionAwareDurableAndPrefixFiltered() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("quicker-regeneration-ii-bonus-healing"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii"}));
|
||||
assertEquals(List.of("2"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "quicker-regeneration-ii-bonus-healing", ""}));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii-bonus-healing"});
|
||||
assertTrue(sender.messages.getLast().contains("=2")); sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii-bonus-healing", "5"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii-bonus-healing", "5"});
|
||||
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var settings = (games.dmg.fantasystick.persistence.QuickerRegenerationIISettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "quickerRegenerationIISettings");
|
||||
assertEquals("5", settings.value());
|
||||
}
|
||||
}
|
||||
|
||||
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
|
||||
makeEligible(data, actor, health, List.of("", "", ""));
|
||||
}
|
||||
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
|
||||
List<String> passives) 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.CON, BigInteger.valueOf(16)); stats.put(Stat.WIS, BigInteger.valueOf(14));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), BigInteger.valueOf(40), health, old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static boolean hasAction(FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
|
||||
try { action(actor, label, passive); return true; } catch (AssertionError missing) { return false; }
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + passive);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
net.minecraft.network.chat.Component component, String label, String passive) {
|
||||
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(":" + passive)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -35,13 +35,13 @@ class QuickerRegenerationNativeTest {
|
||||
assertTrue(active > 0 && active < passive && passive < activeIndex && activeIndex < passiveIndex && passiveIndex < party);
|
||||
assertTrue(pages.get(passive).getString().contains("1: No Passive"));
|
||||
assertTrue(pages.get(passiveIndex).getString().replace("\n", "").contains("Quicker Regeneration I"));
|
||||
var jump = firstChangePage(pages.get(passiveIndex)); assertNotNull(jump);
|
||||
var jump = changePageTo(pages.get(passiveIndex), pages, "Quicker Regeneration I"); assertNotNull(jump);
|
||||
String detail = pages.get(jump.page() - 1).getString();
|
||||
assertTrue(detail.replaceAll("\\s+", "").startsWith("QuickerRegenerationICON12,WIS12[Learn]"), detail);
|
||||
assertTrue(detail.replaceAll("\\s+", "").contains("out-of-combatrecoverypulse"));
|
||||
|
||||
var beforeLoadout = storage(f).find(a.core.record.id()).get().orElseThrow().loadout();
|
||||
PartyBookNativeTest.click(f, a, custom(nativePages(a), "fantasystick:passive_unlock"));
|
||||
PartyBookNativeTest.click(f, a, custom(List.of(pages.get(jump.page() - 1)), "fantasystick:passive_unlock"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var learned = storage(f).find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(List.of("quicker_regeneration_i", "", ""), learned.passives());
|
||||
@@ -92,7 +92,7 @@ class QuickerRegenerationNativeTest {
|
||||
@Test void liveBonusConfigurationIsPermissionAwareDurableAndFuturePulseOnly() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("quicker-regeneration-i-bonus-healing"),
|
||||
assertEquals(List.of("quicker-regeneration-i-bonus-healing", "quicker-regeneration-ii-bonus-healing"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker"}));
|
||||
assertEquals(List.of("1"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "quicker-regeneration-i-bonus-healing", ""}));
|
||||
@@ -133,9 +133,15 @@ class QuickerRegenerationNativeTest {
|
||||
for (int i = 0; i < pages.size(); i++) if (pages.get(i).getString().startsWith(text)) return i;
|
||||
return -1;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.ChangePage firstChangePage(net.minecraft.network.chat.Component component) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page) return page;
|
||||
for (var child : component.getSiblings()) { var found = firstChangePage(child); if (found != null) return found; }
|
||||
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageTo(
|
||||
net.minecraft.network.chat.Component component, List<net.minecraft.network.chat.Component> pages, String heading) {
|
||||
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page
|
||||
&& page.page() > 0 && page.page() <= pages.size()
|
||||
&& pages.get(page.page() - 1).getString().replaceAll("\\s+", "")
|
||||
.startsWith(heading.replaceAll("\\s+", ""))) return page;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = changePageTo(child, pages, heading); if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom custom(List<net.minecraft.network.chat.Component> pages, String id) {
|
||||
|
||||
@@ -48,6 +48,7 @@ class SessionControlsNativeTest {
|
||||
item.getItemMeta().displayName());
|
||||
assertEquals(1, item.getAmount());
|
||||
assertEquals(1, item.getMaxStackSize());
|
||||
assertNull(item.getItemMeta().getItemModel(), "Book, basic attack and No Ability remain unaffected");
|
||||
var expected = new SessionControls.Identity(owner, session, slot);
|
||||
assertEquals(expected, controls.identify(item).orElseThrow());
|
||||
assertTrue(controls.owned(item, owner, session));
|
||||
@@ -82,6 +83,7 @@ class SessionControlsNativeTest {
|
||||
var item = roundTrip(items[abilitySlot + 2]);
|
||||
assertEquals(materials.get(index), item.getType(), ids.get(index));
|
||||
assertEquals(Component.text(names.get(index)), item.getItemMeta().displayName());
|
||||
assertNull(item.getItemMeta().getItemModel(), "Ready ability icons remain unaffected");
|
||||
assertEquals(ids.get(index), item.getItemMeta().getPersistentDataContainer()
|
||||
.get(key("control_ability_id"), PersistentDataType.STRING));
|
||||
assertEquals(1, item.getAmount());
|
||||
@@ -113,9 +115,24 @@ class SessionControlsNativeTest {
|
||||
assertEquals("cooldown", pane.getPersistentDataContainer().get(key("control_kind"), PersistentDataType.STRING));
|
||||
assertEquals(java.util.Optional.of("fireball"), timed.ability(pane));
|
||||
assertEquals(new SessionControls.Identity(owner, session, slot), timed.identify(pane).orElseThrow());
|
||||
assertEquals(SessionControls.COOLDOWN_MODEL, pane.getItemMeta().getItemModel());
|
||||
}
|
||||
assertEquals(Material.ECHO_SHARD, items[4].getType(), "A deadline equal to now is ready");
|
||||
assertNull(items[4].getItemMeta().getItemModel(), "An exact-deadline ready icon retains no stale model");
|
||||
assertEquals(Material.PAPER, items[5].getType(), "Unknown saved IDs remain unavailable, not cooling panes");
|
||||
assertNull(items[5].getItemMeta().getItemModel(), "Unknown controls remain visible and unaffected");
|
||||
|
||||
var genuineWithoutModel = items[2].clone();
|
||||
var genuineMeta = genuineWithoutModel.getItemMeta();
|
||||
genuineMeta.setItemModel(null);
|
||||
genuineWithoutModel.setItemMeta(genuineMeta);
|
||||
assertEquals(java.util.Optional.of("fireball"), timed.ability(genuineWithoutModel),
|
||||
"Cosmetic model metadata is not casting or session authority");
|
||||
var ordinaryPane = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
|
||||
var ordinaryMeta = ordinaryPane.getItemMeta();
|
||||
ordinaryMeta.setItemModel(SessionControls.COOLDOWN_MODEL);
|
||||
ordinaryPane.setItemMeta(ordinaryMeta);
|
||||
assertRejected(ordinaryPane);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityExecution;
|
||||
import games.dmg.fantasystick.ability.OutgoingDamageBonus;
|
||||
import games.dmg.fantasystick.ability.RallyingCry;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.passive.SpeedI;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SpeedINativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void connectedLearnExitTransferUnlearnAndDefeatOwnOnlyTheCharacterSpeedLayer() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); withDex(data, a, List.of("", "", ""));
|
||||
var aEffects = effects(a.player); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var speed = (SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI");
|
||||
PartyBookNativeTest.open(f, a); assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("SpeedIDEX14[Learn]"));
|
||||
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "speed_i")); settle(f); speed.sweep();
|
||||
var learned = record(f, a); assertEquals(List.of("speed_i", "", ""), learned.passives());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints()); assertEquals(0, aEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone(); a.core.inventory.setItemInOffHand(null);
|
||||
f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a); speed.sweep();
|
||||
assertFalse(aEffects.containsKey(PotionEffectType.SPEED));
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); var bEffects = effects(b.player);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b); speed.sweep();
|
||||
assertEquals(0, bEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
PartyBookNativeTest.open(f, b); PartyBookNativeTest.click(f, b, action(b, "[Unlearn]", "speed_i")); settle(f); speed.sweep();
|
||||
var unlearned = storage(f).find(learned.id()).get().orElseThrow(); assertFalse(unlearned.passives().contains("speed_i"));
|
||||
assertEquals(learned.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints()); assertFalse(bEffects.containsKey(PotionEffectType.SPEED));
|
||||
PartyBookNativeTest.open(f, b); Runnable failed;
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); PartyBookNativeTest.click(f, b, action(b, "[Learn]", "speed_i"));
|
||||
failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed);
|
||||
}
|
||||
failed.run(); speed.sweep(); assertFalse(bEffects.containsKey(PotionEffectType.SPEED));
|
||||
assertFalse(storage(f).find(learned.id()).get().orElseThrow().passives().contains("speed_i"));
|
||||
b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
storage(f).find(learned.id()).get(); f.receipts().find(b.core.id).toCompletableFuture().get();
|
||||
|
||||
withDex(data, a, List.of("speed_i", "", ""));
|
||||
var c = new FantasyStickLifecycleNativeTest.Actor(data); var cEffects = effects(c.player);
|
||||
c.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(
|
||||
new YamlCharacterRepository(data).load(a.core.record.id()).orElseThrow()));
|
||||
f.players.put(c.core.id, c.player); f.activate(c); speed.sweep(); assertTrue(cEffects.containsKey(PotionEffectType.SPEED));
|
||||
c.player.setHealth(1);
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(c.player, mock(org.bukkit.damage.DamageSource.class),
|
||||
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(death); settle(f); speed.sweep(); assertFalse(cEffects.containsKey(PotionEffectType.SPEED));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void milkAndExpiredEffectsReapplyWhileStrongerAndCleanupPathsPreserveUnrelatedSpeed() {
|
||||
var sessions = mock(EquipmentSessions.class); var player = mock(Player.class); var effects = effects(player);
|
||||
UUID playerId = UUID.randomUUID(); var active = new EquipmentSessions.Active(playerId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var learned = mock(CharacterRecord.class); when(learned.passives()).thenReturn(List.of("speed_i", "", "")); when(learned.dead()).thenReturn(false);
|
||||
when(player.getUniqueId()).thenReturn(playerId); when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, learned)));
|
||||
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(learned));
|
||||
when(sessions.currentPlayer(active)).thenReturn(java.util.Optional.of(player));
|
||||
var speed = new SpeedI(sessions); assertEquals(Map.of(Stat.DEX, BigInteger.valueOf(14)), speed.definition().requirements());
|
||||
speed.sweep(); assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
speed.sweep(); verify(player, times(1)).addPotionEffect(any(), eq(true));
|
||||
effects.clear(); speed.sweep(); verify(player, times(2)).addPotionEffect(any(), eq(true));
|
||||
|
||||
var stronger = new PotionEffect(PotionEffectType.SPEED, 200, 1); effects.put(PotionEffectType.SPEED, stronger); speed.sweep();
|
||||
assertSame(stronger, effects.get(PotionEffectType.SPEED));
|
||||
when(learned.passives()).thenReturn(List.of("", "", "")); speed.sweep();
|
||||
assertSame(stronger, effects.get(PotionEffectType.SPEED), "Cleanup cannot remove a stronger unrelated effect");
|
||||
when(learned.passives()).thenReturn(List.of("speed_i", "", "")); effects.clear(); speed.sweep();
|
||||
assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier(), "The passive resumes after stronger Speed expires");
|
||||
speed.close(); assertFalse(effects.containsKey(PotionEffectType.SPEED));
|
||||
|
||||
var equalSessions = mock(EquipmentSessions.class); var equalPlayer = mock(Player.class); var equalEffects = effects(equalPlayer);
|
||||
UUID equalId = UUID.randomUUID(); var equalActive = new EquipmentSessions.Active(equalId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var equalRecord = mock(CharacterRecord.class); when(equalRecord.passives()).thenReturn(List.of("speed_i", "", ""));
|
||||
when(equalPlayer.getUniqueId()).thenReturn(equalId); when(equalSessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(equalActive, equalRecord)));
|
||||
when(equalSessions.current(equalActive)).thenReturn(true); when(equalSessions.currentRecord(equalActive)).thenReturn(java.util.Optional.of(equalRecord));
|
||||
when(equalSessions.currentPlayer(equalActive)).thenReturn(java.util.Optional.of(equalPlayer));
|
||||
var equal = new PotionEffect(PotionEffectType.SPEED, 40, 0); equalEffects.put(PotionEffectType.SPEED, equal);
|
||||
var equalSpeed = new SpeedI(equalSessions); equalSpeed.sweep(); assertSame(equal, equalEffects.get(PotionEffectType.SPEED));
|
||||
equalEffects.clear(); equalSpeed.sweep(); assertTrue(equalEffects.get(PotionEffectType.SPEED).isInfinite()); equalSpeed.close();
|
||||
|
||||
var weaker = new PotionEffect(PotionEffectType.SPEED, 4, -1); equalEffects.put(PotionEffectType.SPEED, weaker);
|
||||
var weakerSpeed = new SpeedI(equalSessions); weakerSpeed.sweep();
|
||||
assertEquals(0, equalEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
when(equalRecord.passives()).thenReturn(List.of("", "", "")); weakerSpeed.sweep();
|
||||
assertEquals(-1, equalEffects.get(PotionEffectType.SPEED).getAmplifier(), "An unexpired weaker native layer is preserved");
|
||||
weakerSpeed.close();
|
||||
|
||||
when(equalRecord.passives()).thenReturn(List.of("speed_i", "", ""));
|
||||
equalEffects.put(PotionEffectType.SPEED, new PotionEffect(PotionEffectType.SPEED, 1, -1));
|
||||
var expiredWeak = new SpeedI(equalSessions); expiredWeak.sweep(); expiredWeak.sweep();
|
||||
when(equalRecord.passives()).thenReturn(List.of("", "", "")); expiredWeak.sweep();
|
||||
assertFalse(equalEffects.containsKey(PotionEffectType.SPEED), "An unrelated weaker layer that expired is not restored");
|
||||
expiredWeak.close();
|
||||
}
|
||||
|
||||
@Test void rallyOverlapNeverStacksAndEitherOwnerLeavesTheOtherApplicableLayer() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var player = mock(Player.class);
|
||||
var effects = effects(player); UUID id = UUID.randomUUID(); var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
|
||||
var record = mock(CharacterRecord.class); when(record.passives()).thenReturn(List.of("speed_i", "", ""));
|
||||
when(player.getUniqueId()).thenReturn(id); when(player.getLocation()).thenReturn(new Location(mock(org.bukkit.World.class), 0, 64, 0));
|
||||
when(player.getNearbyEntities(anyDouble(), anyDouble(), anyDouble())).thenReturn(List.of());
|
||||
when(sessions.active(id)).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
|
||||
when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, record)));
|
||||
when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record)); when(sessions.currentPlayer(active)).thenReturn(java.util.Optional.of(player));
|
||||
var speed = new SpeedI(sessions); speed.sweep();
|
||||
var rally = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties,
|
||||
new OutgoingDamageBonus(sessions, parties, ignored -> { }));
|
||||
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
|
||||
when(input.player()).thenReturn(player); when(input.session()).thenReturn(active); when(execution.input()).thenReturn(input);
|
||||
when(execution.stillCurrent()).thenReturn(true); when(execution.definition()).thenReturn(rally.definition());
|
||||
rally.activate(execution); assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
rally.close(); assertTrue(effects.containsKey(PotionEffectType.SPEED), "Rally cleanup leaves the passive-owned Speed I");
|
||||
speed.close(); assertFalse(effects.containsKey(PotionEffectType.SPEED));
|
||||
|
||||
var rallyFirst = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties,
|
||||
new OutgoingDamageBonus(sessions, parties, ignored -> { }));
|
||||
when(execution.definition()).thenReturn(rallyFirst.definition()); rallyFirst.activate(execution);
|
||||
assertTrue(effects.containsKey(PotionEffectType.SPEED));
|
||||
var passiveSecond = new SpeedI(sessions); passiveSecond.sweep(); rallyFirst.close();
|
||||
assertFalse(effects.containsKey(PotionEffectType.SPEED)); passiveSecond.sweep();
|
||||
assertTrue(effects.containsKey(PotionEffectType.SPEED), "The learned passive resumes after Rally-owned Speed I ends");
|
||||
passiveSecond.close();
|
||||
}
|
||||
|
||||
private static Map<PotionEffectType, PotionEffect> effects(Player player) {
|
||||
var result = new HashMap<PotionEffectType, PotionEffect>();
|
||||
doAnswer(call -> result.get(call.getArgument(0))).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> { PotionEffect effect = call.getArgument(0); result.put(effect.getType(), effect); return true; })
|
||||
.when(player).addPotionEffect(any(), eq(true));
|
||||
doAnswer(call -> { result.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return result;
|
||||
}
|
||||
private static void withDex(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
|
||||
var repository = new YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new EnumMap<>(old.stats()); stats.put(Stat.DEX, BigInteger.valueOf(14));
|
||||
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(),
|
||||
old.unspentPoints(), old.health(), old.learned(), old.loadout(), passives, old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
return storage(f).find(actor.core.record.id()).get().orElseThrow();
|
||||
}
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
|
||||
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
|
||||
throw new AssertionError("Missing " + label + " for " + passive);
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom action(
|
||||
net.minecraft.network.chat.Component component, String label, String passive) {
|
||||
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(":" + passive)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:select",
|
||||
"property": "minecraft:display_context",
|
||||
"cases": [
|
||||
{
|
||||
"when": [
|
||||
"firstperson_lefthand",
|
||||
"firstperson_righthand"
|
||||
],
|
||||
"model": {
|
||||
"type": "minecraft:empty"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fallback": {
|
||||
"type": "minecraft:model",
|
||||
"model": "minecraft:item/gray_stained_glass_pane"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pack": {
|
||||
"description": "Fantasy Stick ${version} sheet presentation",
|
||||
"description": "Fantasy Stick ${version} first-person presentation",
|
||||
"min_format": [88, 0],
|
||||
"max_format": [88, 0]
|
||||
}
|
||||
|
||||
@@ -88,7 +88,8 @@ class DistributionTest {
|
||||
assertTrue(defaults.contains("sheet-resource-pack-sha1: ''"));
|
||||
for (String setting : java.util.List.of("out-of-combat-healing-delay-seconds: 30",
|
||||
"out-of-combat-healing-interval-seconds: 10", "out-of-combat-healing-amount: 1",
|
||||
"quicker-regeneration-i-bonus-healing: 1")) {
|
||||
"quicker-regeneration-i-bonus-healing: 1", "quicker-regeneration-ii-bonus-healing: 2",
|
||||
"health-steal-healing-per-kill: 1")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged recovery default: " + setting);
|
||||
}
|
||||
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
|
||||
@@ -105,7 +106,7 @@ class DistributionTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagesVersionedContextSensitiveSheetResourcePackAndMatchingRuntimeMetadata() throws Exception {
|
||||
@Test void packagesVersionedContextSensitiveResourcePackAndMatchingRuntimeMetadata() throws Exception {
|
||||
Path artifact = Path.of(System.getProperty("distribution.jar"));
|
||||
Path pack = Path.of(System.getProperty("resource.pack.zip"));
|
||||
String version = System.getProperty("distribution.version");
|
||||
@@ -128,6 +129,19 @@ class DistributionTest {
|
||||
assertTrue(json.contains("firstperson_righthand"));
|
||||
assertTrue(json.contains("minecraft:empty"));
|
||||
assertTrue(json.contains("minecraft:item/paper"), "Inventory and other contexts retain Paper presentation");
|
||||
var cooldownModel = zip.getEntry("assets/fantasystick/items/cooldown_control.json");
|
||||
assertNotNull(cooldownModel, "The single pack includes the dedicated cooldown-control model");
|
||||
String cooldownJson;
|
||||
try (var input = zip.getInputStream(cooldownModel)) {
|
||||
cooldownJson = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
assertTrue(cooldownJson.contains("minecraft:select"));
|
||||
assertTrue(cooldownJson.contains("minecraft:display_context"));
|
||||
assertTrue(cooldownJson.contains("firstperson_lefthand"));
|
||||
assertTrue(cooldownJson.contains("firstperson_righthand"));
|
||||
assertTrue(cooldownJson.contains("minecraft:empty"));
|
||||
assertTrue(cooldownJson.contains("minecraft:item/gray_stained_glass_pane"),
|
||||
"Hotbar, inventory and all other contexts retain gray-pane presentation");
|
||||
}
|
||||
try (var jar = new JarFile(artifact.toFile())) {
|
||||
var metadata = jar.getJarEntry("sheet-resource-pack.properties"); assertNotNull(metadata);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class HealthStealSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void validatesReviewedBoundsAndPersistsBeforePublication() throws Exception {
|
||||
assertEquals(java.math.BigInteger.ONE, HealthStealSettings.parse("1").healing());
|
||||
assertEquals(java.math.BigInteger.ZERO, HealthStealSettings.parse("0").healing());
|
||||
assertEquals(java.math.BigInteger.valueOf(1024), HealthStealSettings.parse("1024").healing());
|
||||
for (String invalid : java.util.List.of("-1", "1025", "1.5", "x")) {
|
||||
assertThrows(IllegalArgumentException.class, () -> HealthStealSettings.parse(invalid));
|
||||
}
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new HealthStealSettings(data, "1")) {
|
||||
var update = settings.set("4").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals("1", settings.value()); assertTrue(settings.publish(update)); assertEquals("4", settings.value());
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", saved.get("unrelated")); assertEquals("4", String.valueOf(saved.get(HealthStealSettings.KEY)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedWriteAndCloseRetainTheEffectiveValue() throws Exception {
|
||||
try (var settings = new HealthStealSettings(data, "1", (from, to) -> { throw new java.io.IOException("simulated"); })) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set("4").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("1", settings.value());
|
||||
}
|
||||
var closed = new HealthStealSettings(data.resolve("closed"), "1"); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set("4").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.passive.QuickerRegenerationI;
|
||||
import games.dmg.fantasystick.passive.QuickerRegenerationII;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuickerRegenerationIIPassiveTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void secondRankRequiresFirstRankAndRefusesAFullPassiveLoadoutBeforeSpending() throws Exception {
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(base.stats()); stats.put(Stat.CON, BigInteger.valueOf(16)); stats.put(Stat.WIS, BigInteger.valueOf(14));
|
||||
var noFirst = record(base, stats, List.of("", "", ""));
|
||||
var repository = new YamlCharacterRepository(data); repository.create(noFirst);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var requirements = Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.valueOf(14));
|
||||
assertEquals(CharacterStorage.PassiveMutationStatus.REQUIREMENTS,
|
||||
storage.unlockPassive(noFirst.id(), noFirst.revision(), QuickerRegenerationII.ID, requirements,
|
||||
Set.of(QuickerRegenerationI.ID), BigInteger.ONE).get().status());
|
||||
var withFirst = new CharacterRecord(noFirst.id(), noFirst.name(), noFirst.revision().add(BigInteger.ONE), stats,
|
||||
noFirst.level(), noFirst.experience(), noFirst.unspentPoints(), noFirst.health(), noFirst.learned(), noFirst.loadout(),
|
||||
List.of(QuickerRegenerationI.ID, "unknown_a", "unknown_b"), noFirst.cooldowns(), noFirst.restrictedKills(),
|
||||
false, null, null);
|
||||
repository.replace(withFirst, noFirst.revision());
|
||||
var full = storage.unlockPassive(withFirst.id(), withFirst.revision(), QuickerRegenerationII.ID, requirements,
|
||||
Set.of(QuickerRegenerationI.ID), BigInteger.ONE).get();
|
||||
assertEquals(CharacterStorage.PassiveMutationStatus.PASSIVE_SLOTS_FULL, full.status());
|
||||
assertEquals(withFirst, repository.load(withFirst.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
private static CharacterRecord record(CharacterRecord base, EnumMap<Stat, BigInteger> stats, List<String> passives) {
|
||||
return new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
|
||||
BigInteger.valueOf(40), BigDecimal.valueOf(10), base.learned(), base.loadout(), passives, base.cooldowns(),
|
||||
base.restrictedKills(), false, null, null);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class QuickerRegenerationIISettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void validatesBoundsAndPersistsBeforePublicationWithoutReplacingOtherKeys() throws Exception {
|
||||
assertEquals(java.math.BigInteger.TWO, QuickerRegenerationIISettings.parse("2").bonus());
|
||||
assertEquals(java.math.BigInteger.ZERO, QuickerRegenerationIISettings.parse("0").bonus());
|
||||
assertEquals(java.math.BigInteger.valueOf(1024), QuickerRegenerationIISettings.parse("1024").bonus());
|
||||
for (String invalid : java.util.List.of("-1", "1025", "2.5", "x")) {
|
||||
assertThrows(IllegalArgumentException.class, () -> QuickerRegenerationIISettings.parse(invalid));
|
||||
}
|
||||
Files.writeString(data.resolve("config.yml"), "quicker-regeneration-i-bonus-healing: '7'\nunrelated: retained\n");
|
||||
try (var settings = new QuickerRegenerationIISettings(data, "2")) {
|
||||
var update = settings.set("5").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals("2", settings.value()); assertTrue(settings.publish(update)); assertEquals("5", settings.value());
|
||||
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("7", String.valueOf(saved.get("quicker-regeneration-i-bonus-healing")));
|
||||
assertEquals("retained", saved.get("unrelated"));
|
||||
assertEquals("5", String.valueOf(saved.get(QuickerRegenerationIISettings.KEY)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedWriteAndCloseRetainThePriorEffectiveValue() throws Exception {
|
||||
try (var settings = new QuickerRegenerationIISettings(data, "2", (from, to) -> {
|
||||
throw new java.io.IOException("simulated");
|
||||
})) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set("5").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("2", settings.value());
|
||||
}
|
||||
var closed = new QuickerRegenerationIISettings(data.resolve("closed"), "2"); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set("5").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user