Compare commits

...
11 Commits
Author SHA1 Message Date
dmg 7a85eea769 feat(book): add passive return navigation
Release / release (push) Successful in 9m24s
2026-09-21 17:19:47 -04:00
dmg 6d0d695052 feat(book): categorize active abilities
Release / release (push) Successful in 9m53s
2026-09-21 17:09:03 -04:00
dmg 710a29a43c feat(shield-bash): shorten strike cooldown
Release / release (push) Successful in 10m4s
2026-09-21 16:52:02 -04:00
dmg d3f58f4223 feat(great-sword): shorten attack recovery
Release / release (push) Successful in 10m13s
2026-09-21 16:43:22 -04:00
dmg 87024bfd24 feat(recovery): shorten initial healing delay
Release / release (push) Successful in 9m51s
2026-09-21 16:31:18 -04:00
dmg d8bbe78a08 feat(frost-shard): use fast arrow flight
Release / release (push) Successful in 9m56s
2026-09-21 16:23:46 -04:00
dmg 37be6b13fa feat(fireball): use fast arrow flight
Release / release (push) Successful in 9m54s
2026-09-21 16:13:18 -04:00
dmg 1dcc7cfae8 feat(training): add durable damage dummies
Release / release (push) Successful in 10m2s
2026-09-21 15:48:52 -04:00
dmg e16570cb35 feat(feedback): report accepted combat effects
Release / release (push) Successful in 10m7s
2026-09-21 14:48:24 -04:00
dmg be8870502f fix(session): prioritize native door interaction
Release / release (push) Successful in 9m57s
2026-09-21 11:57:22 -04:00
dmg 8b5461753c fix(book): shorten gear summary labels
Release / release (push) Successful in 9m45s
2026-09-21 11:49:45 -04:00
82 changed files with 2784 additions and 220 deletions

No files matched your search

@@ -39,6 +39,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.party.CharacterPartyDirectory characterParties;
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
private games.dmg.fantasystick.party.PartyManagement partyManagement;
private games.dmg.fantasystick.party.PartyRegistrarMenus partyRegistrarMenus;
private games.dmg.fantasystick.party.PartyEvents partyEvents;
@@ -51,6 +53,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
private org.bukkit.scheduler.BukkitTask armorMenuPulse;
private games.dmg.fantasystick.party.PartyCombat partyCombat;
private EquipmentAdminCommand admin;
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
private games.dmg.fantasystick.presentation.CombatFeedback combatFeedback;
private volatile Object lifetime;
private CharacterPresentation presentation;
private ProtocolPresentation protocolPresentation;
@@ -64,6 +69,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.ability.AbilityRegistry abilityRegistry;
private games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
private games.dmg.fantasystick.ability.FrostShard frostShard;
private games.dmg.fantasystick.persistence.FrostShardSettings frostShardSettings;
private games.dmg.fantasystick.ability.Longbow longbow;
@@ -181,7 +187,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY), sheetPackDefaults);
var rules = progressionRules();
var rewardRules = combatRewardRules();
var fireballSettings = fireballSettings();
var fireballConfiguration = fireballConfiguration();
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
var greatSwordSettings = greatSwordSettings();
var frostShardConfiguration = frostShardConfiguration();
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
@@ -238,6 +245,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
problem -> getLogger().log(Level.WARNING, "Character craft " + problem.operation() + " for player "
+ problem.player() + "; character records " + problem.characters(), problem.cause()));
java.util.function.Consumer<Runnable> nextTick = task -> getServer().getScheduler().runTask(this, task);
try {
feedbackPreferences = new games.dmg.fantasystick.persistence.PlayerFeedbackPreferences(getDataFolder().toPath());
} catch (java.io.IOException invalidPreferences) {
throw new java.io.UncheckedIOException("Player feedback preferences could not be loaded", invalidPreferences);
}
feedbackCommand = new games.dmg.fantasystick.session.FantasyFeedbackCommand(feedbackPreferences, nextTick,
failure -> getLogger().log(Level.WARNING, "Player feedback preference failed", failure));
java.util.function.Consumer<Throwable> presentationFailures = problem ->
getLogger().log(Level.WARNING, "Character presentation failed", problem);
// This supplier belongs to one enable, never the mutable plugin field after re-enable.
@@ -274,9 +288,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
new games.dmg.fantasystick.persistence.YamlPartyRepository(getDataFolder().toPath()), partySettings::maximum);
partyRegistrars = new games.dmg.fantasystick.party.PartyRegistrarDirectory(
new games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository(getDataFolder().toPath()));
trainingDummies = new games.dmg.fantasystick.training.TrainingDummyDirectory(
new games.dmg.fantasystick.persistence.YamlTrainingDummyRepository(getDataFolder().toPath()));
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
} catch (java.io.IOException invalidPartyState) {
throw new java.io.UncheckedIOException("Persistent party state could not be loaded", invalidPartyState);
} catch (java.io.IOException invalidManagedState) {
throw new java.io.UncheckedIOException("Persistent party, shop or training state could not be loaded", invalidManagedState);
}
characterParties.suspend();
sheetResourcePack = new games.dmg.fantasystick.presentation.SheetResourcePack(sheetResourcePackSettings,
@@ -285,6 +301,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
sessions.attachSwitchDelay(switchDelaySettings::delay);
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
combatFeedback = new games.dmg.fantasystick.presentation.CombatFeedback(feedbackPreferences, sessions);
trainingDummyController = new games.dmg.fantasystick.training.TrainingDummyController(this, trainingDummies);
parties = new games.dmg.fantasystick.party.PartyRoster(characterParties, sessions, partySettings::maximum);
characterParties.attachEnded(former -> nextTick.accept(() -> {
String message = "Party ended: " + former.displayName();
@@ -313,7 +331,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
sessions.attachPvpDefeat(combatExperience::consumePvpDefeat);
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
fireballSettings = new games.dmg.fantasystick.persistence.FireballSettings(
getDataFolder().toPath(), fireballConfiguration);
fireball = new games.dmg.fantasystick.ability.Fireball(fireballInitial);
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
getDataFolder().toPath(), frostShardConfiguration);
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
@@ -417,7 +437,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING,
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()));
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()), combatFeedback);
quickerRegenerationSettings = new games.dmg.fantasystick.persistence.QuickerRegenerationSettings(
getDataFolder().toPath(), quickerRegenerationInitial.bonus().toString());
quickerRegeneration = new games.dmg.fantasystick.passive.QuickerRegenerationI(quickerRegenerationInitial);
@@ -428,7 +448,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
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()));
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()),
combatFeedback);
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
lowLightVision = new games.dmg.fantasystick.passive.LowLightVision(sessions);
passiveRegistry = new games.dmg.fantasystick.passive.PassiveRegistry();
@@ -458,7 +479,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityScheduler::later, (size, title) -> getServer().createInventory(null, size, title),
problem -> getLogger().log(Level.WARNING, "Admin revival failed", problem));
abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties,
Clock.systemUTC(), nextTick, abilityScheduler, report -> { },
Clock.systemUTC(), nextTick, abilityScheduler, report -> { }, combatFeedback, trainingDummyController,
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
+ "; ability " + problem.ability(), problem.cause()));
abilityEngine.install(this);
@@ -469,7 +490,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var player = getServer().getPlayer(id);
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
}, abilityRegistry, abilityEngine, passiveRegistry, passiveEngine);
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(
sessions, Clock.systemUTC(), combatFeedback, trainingDummyController);
basicAttack = localBasicAttack;
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
books.accept(input);
@@ -502,6 +524,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
inspireSettings, inspire, abilityRegistry);
admin.attachFireball(fireballSettings, fireball);
admin.attachMendingLight(mendingLightSettings, mendingLight);
admin.attachPurify(purifySettings, purify);
admin.attachGuardianBond(guardianBondSettings, guardianBond);
@@ -521,6 +544,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachPartySettings(partySettings);
admin.attachPartyEconomySettings(partyEconomySettings);
admin.attachPartyRegistrars(partyRegistrars, partyRegistrarController);
admin.attachTrainingDummies(trainingDummies, trainingDummyController);
admin.attachGearSettings(gearSettings);
admin.attachArmorers(armorers,armorerController);
admin.attachArmorMarket(armorMarket);
@@ -537,6 +561,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
var playerFeedbackCommand = java.util.Objects.requireNonNull(getCommand("fantasyfeedback"),
"Missing packaged fantasyfeedback command");
playerFeedbackCommand.setExecutor(feedbackCommand);
playerFeedbackCommand.setTabCompleter(feedbackCommand);
getServer().getPluginManager().registerEvents(crafting, this);
getServer().getPluginManager().registerEvents(guard, this);
getServer().getPluginManager().registerEvents(anvilNames, this);
@@ -545,10 +573,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(partyEvents, this);
getServer().getPluginManager().registerEvents(partyCombat, this);
getServer().getPluginManager().registerEvents(partyRegistrarController, this);
getServer().getPluginManager().registerEvents(trainingDummyController, this);
getServer().getPluginManager().registerEvents(partyRegistrarMenus, this);
getServer().getPluginManager().registerEvents(armorerController,this);
getServer().getPluginManager().registerEvents(armorShopMenus,this);
partyRegistrarController.install();
trainingDummyController.install();
armorerController.install();
var localArmorMarket=armorMarket;
armorMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;
@@ -612,6 +642,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingProtocol = protocolPresentation; protocolPresentation = null;
var closingChat = chatNames; chatNames = null;
var closingAdmin = admin; admin = null;
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
@@ -620,6 +653,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingCharacterParties = characterParties; characterParties = null;
var closingPartyRegistrars = partyRegistrars; partyRegistrars = null;
var closingPartyRegistrarController = partyRegistrarController; partyRegistrarController = null;
var closingTrainingDummies = trainingDummies; trainingDummies = null;
var closingTrainingDummyController = trainingDummyController; trainingDummyController = null;
var closingPartyRegistrarMenus = partyRegistrarMenus; partyRegistrarMenus = null;
var closingPartyManagement = partyManagement; partyManagement = null;
var closingArmorers=armorers;armorers=null;var closingArmorerController=armorerController;armorerController=null;
@@ -641,6 +676,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingCombatExperience = combatExperience; combatExperience = null;
var closingAbilityEngine = abilityEngine; abilityEngine = null;
var closingFireball = fireball; fireball = null;
var closingFireballSettings = fireballSettings; fireballSettings = null;
var closingFrostShard = frostShard; frostShard = null;
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
var closingLongbow = longbow; longbow = null;
@@ -708,7 +744,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
if (closingFeedbackCommand != null) cleanup("feedback command", closingFeedbackCommand::close);
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
@@ -748,10 +787,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
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(
closingFeedbackPreferences == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFeedbackPreferences.drained().toCompletableFuture(),
closingCharacterParties == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingCharacterParties.drained().toCompletableFuture(),
closingPartyRegistrars == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartyRegistrars.drained().toCompletableFuture(),
closingTrainingDummies == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingTrainingDummies.drained().toCompletableFuture(),
closingPartyManagement == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartyManagement.drained().toCompletableFuture(),
closingArmorers==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorers.drained().toCompletableFuture(),
@@ -761,6 +804,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
: closingPartyEconomySettings.drained().toCompletableFuture(),
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFireballSettings.drained().toCompletableFuture(),
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFrostSettings.drained().toCompletableFuture(),
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -844,6 +889,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingPartyManagement != null) cleanup("party management", closingPartyManagement::close);
if (closingPartyRegistrarController != null) cleanup("party registrar entities", closingPartyRegistrarController::close);
if (closingPartyRegistrars != null) cleanup("party registrars", closingPartyRegistrars::close);
if (closingTrainingDummyController != null) cleanup("training dummy entities", closingTrainingDummyController::close);
if (closingTrainingDummies != null) cleanup("training dummies", closingTrainingDummies::close);
if(closingArmorMarketPulse!=null)cleanup("armor market schedule",closingArmorMarketPulse::cancel);
if(closingArmorMenuPulse!=null)cleanup("armor menu schedule",closingArmorMenuPulse::cancel);
if(closingArmorShopMenus!=null)cleanup("armor shop menus",closingArmorShopMenus::close);
@@ -860,6 +907,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingPartyRegistrarController);
unregister(closingTrainingDummyController);
unregister(closingPartyRegistrarMenus);
unregister(closingArmorerController);unregister(closingArmorShopMenus);
unregister(closingCombatExperience);
@@ -933,18 +981,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
}
private games.dmg.fantasystick.ability.Fireball.Settings fireballSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("fireball-base-damage", "6"));
var cooldown = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-cooldown-seconds", "4")).longValueExact());
double range = new java.math.BigDecimal(getConfig().getString("fireball-range-blocks", "32")).doubleValue();
var lifetime = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-lifetime-seconds", "5")).longValueExact());
return new games.dmg.fantasystick.ability.Fireball.Settings(base, cooldown, range, lifetime);
} catch (ArithmeticException | NumberFormatException invalid) {
throw new IllegalArgumentException("Fireball settings must use supported nonnegative numeric values", invalid);
private java.util.Map<String, String> fireballConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.FireballSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.FireballSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.FireballSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> outOfCombatRecoveryConfiguration() {
@@ -1127,7 +1171,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "1.2"));
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "0.75"));
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
} catch (ArithmeticException | NumberFormatException invalid) {
@@ -13,9 +13,10 @@ import org.bukkit.Material;
/** Immutable internal ability metadata and focused effect boundary; not an external add-on API. */
public record AbilityDefinition(String id, String name, Material icon, List<String> lore, String instructions,
Map<Stat, BigInteger> requirements, BigInteger pointCost, Activation activation,
Map<Stat, BigInteger> requirements, BigInteger pointCost, Category category, Activation activation,
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
TargetPolicy targetPolicy, AbilityEffect effect) {
public enum Category { ATK, BUF, HEAL }
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT,
EXCEPTIONAL_NONCOMBAT_SELECTION }
@@ -25,7 +26,7 @@ public record AbilityDefinition(String id, String name, Material icon, List<Stri
public AbilityDefinition {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(icon);
Objects.requireNonNull(lore); Objects.requireNonNull(instructions); Objects.requireNonNull(requirements);
Objects.requireNonNull(pointCost); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
Objects.requireNonNull(pointCost); Objects.requireNonNull(category); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
Objects.requireNonNull(cooldown); Objects.requireNonNull(parameters); Objects.requireNonNull(targetPolicy);
Objects.requireNonNull(effect);
lore = List.copyOf(lore); requirements = Map.copyOf(requirements); parameters = Map.copyOf(parameters);
@@ -39,6 +39,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final Consumer<Runnable> nextTick;
private final AbilityScheduler scheduler;
private final Consumer<AbilityReport> reports;
private final AbilityFeedback feedback;
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Consumer<Failure> failures;
private final Map<Key, Object> pending = new HashMap<>();
private final Map<Key, Long> generations = new HashMap<>();
@@ -49,10 +51,27 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
this(registry, characters, sessions, parties, clock, nextTick, scheduler, reports,
(definition, input, type, target, amount) -> { },
games.dmg.fantasystick.training.TrainingDamage.passthrough(), failures);
}
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, AbilityFeedback feedback, Consumer<Failure> failures) {
this(registry, characters, sessions, parties, clock, nextTick, scheduler, reports, feedback,
games.dmg.fantasystick.training.TrainingDamage.passthrough(), failures);
}
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, AbilityFeedback feedback,
games.dmg.fantasystick.training.TrainingDamage trainingDamage, Consumer<Failure> failures) {
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.scheduler = Objects.requireNonNull(scheduler); this.reports = Objects.requireNonNull(reports);
this.feedback = Objects.requireNonNull(feedback); this.trainingDamage = Objects.requireNonNull(trainingDamage);
this.failures = Objects.requireNonNull(failures);
}
@@ -326,11 +345,21 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
}
owned.add(resource);
}
@Override public boolean trainingDummy(Entity target) { return trainingDamage.target(Objects.requireNonNull(target)); }
@Override public void damage(org.bukkit.entity.LivingEntity target, double amount,
org.bukkit.damage.DamageSource source) {
Objects.requireNonNull(target); Objects.requireNonNull(source);
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(amount, source));
measured.ifPresent(value -> report(AbilityReport.Type.DAMAGE, target, value));
}
@Override public void report(AbilityReport.Type type, Entity target, java.math.BigDecimal amount) {
if (!stillCurrent() || definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT
|| definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION
|| target == null || !AbilityEngine.this.eligible(definition.targetPolicy(), input, target)) return;
reports.accept(new AbilityReport(type, key.character(), input.session().session(), target.getUniqueId(), amount, clock.instant()));
var report = new AbilityReport(type, key.character(), input.session().session(), target.getUniqueId(), amount, clock.instant());
reports.accept(report);
try { feedback.report(definition, input, type, target, report.amount()); }
catch (RuntimeException failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
}
@Override public void finish() { close(); }
@Override public void close() {
@@ -5,7 +5,9 @@ import java.math.BigDecimal;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
/** Bounded owned runtime supplied by the shared engine after durable cast authorization. */
public interface AbilityExecution extends AutoCloseable {
@@ -16,6 +18,8 @@ public interface AbilityExecution extends AutoCloseable {
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
AbilityScheduler.Task later(Duration delay, Runnable action);
void own(AutoCloseable resource);
boolean trainingDummy(Entity target);
void damage(LivingEntity target, double amount, DamageSource source);
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
void finish();
@Override void close();
@@ -0,0 +1,11 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import org.bukkit.entity.Entity;
/** Presentation sink for an authoritative applied ability outcome. */
@FunctionalInterface
public interface AbilityFeedback {
void report(AbilityDefinition definition, ControlInput input, AbilityReport.Type type, Entity target, BigDecimal amount);
}
@@ -72,7 +72,7 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
"Each jump deals " + decimal(current.jumpMultiplier().movePointRight(2))
+ "% of the previous defined damage within " + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
"Use while aiming at the first visible hostile within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks.",
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
@@ -117,7 +117,7 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
.withDirectEntity(caster).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
try { target.damage(amount, source); }
try { execution.damage(target, amount, source); }
finally { if (damaging == attempt) damaging = null; }
}
@@ -15,47 +15,50 @@ import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.util.Vector;
/** Fireball's owned snowball projectile, direct custom damage and bounded cleanup adapter. */
/** Fireball's owned arrow flight, direct custom damage and bounded cleanup adapter. */
public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
private static final double SPEED_PER_TICK = 1.0;
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime) {
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime, double speedPerTick) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 1024
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0) {
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 64) {
throw new IllegalArgumentException("Invalid Fireball settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(4), 32, Duration.ofSeconds(5));
return new Settings(BigInteger.valueOf(6), Duration.ofMillis(1200), 32, Duration.ofSeconds(5), 3);
}
}
private final Settings settings;
private volatile Settings settings;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
private final class State implements AutoCloseable {
final Snowball projectile;
final Arrow projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
final Settings settings;
LivingEntity target;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence) {
this.projectile = projectile; this.execution = execution; this.origin = origin; this.intelligence = intelligence;
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
this.intelligence = intelligence; this.settings = settings;
}
@Override public void close() {
if (finished) return;
@@ -66,16 +69,19 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
public Fireball(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("fireball", "Fireball", Material.SNOWBALL,
List.of("Launch a bounded fiery snowball.", "Deals " + settings.baseDamage() + " + INT modifier damage."),
List.of("Launch a bounded fiery arrow flight.", "Deals " + current.baseDamage() + " + INT modifier damage."),
"Use the equipped snowball; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, settings.cooldown(),
Map.of("base-damage", new BigDecimal(settings.baseDamage()),
"range", BigDecimal.valueOf(settings.range()).stripTrailingZeros(),
"lifetime", BigDecimal.valueOf(settings.lifetime().toMillis(), 3).stripTrailingZeros()),
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"lifetime", seconds(current.lifetime()),
"projectile-speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
@@ -95,21 +101,24 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = settings;
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Fireball direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(SPEED_PER_TICK));
var projectile = player.launchProjectile(Arrow.class, direction.normalize().multiply(current.speedPerTick()));
projectile.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
projectile.setDamage(0); projectile.setKnockbackStrength(0); projectile.setPierceLevel(0); projectile.setCritical(false);
projectile.setVisualFire(true);
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT));
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT), current);
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Fireball projectile identity");
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(settings.range() / SPEED_PER_TICK));
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(settings.lifetime(), execution::finish);
execution.later(current.lifetime(), execution::finish);
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@@ -121,13 +130,13 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
BigInteger amount = damage(settings.baseDamage(), state.intelligence);
BigInteger amount = damage(state.settings.baseDamage(), state.intelligence);
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target;
damaging = state;
try { target.damage(nativeDamage, source); }
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.execution.finish();
@@ -137,7 +146,14 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
&& current.distanceSquared(state.origin) <= settings.range() * settings.range();
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
}
@EventHandler(priority = EventPriority.HIGHEST)
public void containNativeDamage(EntityDamageByEntityEvent event) {
if (!(event.getDamager() instanceof Arrow arrow)) return;
var state = projectiles.get(arrow.getUniqueId());
if (state != null && damaging != state) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -153,6 +169,10 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
private static BigDecimal seconds(Duration duration) {
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros();
}
@Override public void close() {
if (closed) return;
closed = true;
@@ -102,7 +102,7 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
+ current.baseDamage() + " + INT modifier damage.",
"Applies Slowness " + level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + " seconds."),
"Use to affect exposed enemies; caster, party and ordinary players are excluded.",
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
@@ -135,7 +135,7 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
.withDirectEntity(source).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
try { target.damage(amount, sourceDamage); }
try { execution.damage(target, amount, sourceDamage); }
finally { if (damaging == attempt) damaging = null; }
affected++;
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
@@ -15,11 +15,13 @@ import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.potion.PotionEffect;
@@ -45,12 +47,12 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(5), 32, Duration.ofSeconds(5),
1, 0, Duration.ofSeconds(2));
3, 0, Duration.ofSeconds(2));
}
}
private final class State implements AutoCloseable {
final Snowball projectile;
final Arrow projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
@@ -58,7 +60,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
LivingEntity target;
boolean accepted;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
this.intelligence = intelligence; this.settings = settings;
}
@@ -112,7 +114,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
+ level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + "s."),
"Use the equipped shard; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
@@ -144,7 +146,9 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Frost Shard direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(current.speedPerTick()));
var projectile = player.launchProjectile(Arrow.class, direction.normalize().multiply(current.speedPerTick()));
projectile.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
projectile.setDamage(0); projectile.setKnockbackStrength(0); projectile.setPierceLevel(0); projectile.setCritical(false);
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT), current);
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Frost Shard projectile identity");
@@ -160,6 +164,13 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
player.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void containNativeDamage(EntityDamageByEntityEvent event) {
if (!(event.getDamager() instanceof Arrow arrow)) return;
var state = projectiles.get(arrow.getUniqueId());
if (state != null && damaging != state) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = projectiles.remove(event.getEntity().getUniqueId());
@@ -175,7 +186,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
var source = DamageSource.builder(DamageType.FREEZE).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target; damaging = state;
try { target.damage(nativeDamage, source); }
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
if (state.accepted && state.execution.stillCurrent()) {
int ticks = Math.toIntExact(Math.max(1,
@@ -27,7 +27,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
throw new IllegalArgumentException("Invalid Great Sword settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(1200)); }
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(750)); }
}
private static final class Attempt {
@@ -50,7 +50,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
List.of("Charge a contained melee strike.",
"Fully charged: " + settings.baseDamage() + " + STR modifier damage."),
"Attack with the equipped sword; partial charge follows the native attack curve.",
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.ATTACK, settings.recovery(),
Map.of("base-damage", new BigDecimal(settings.baseDamage())),
AbilityDefinition.TargetPolicy.HOSTILE, this);
@@ -93,7 +93,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
.withDirectEntity(player).build();
var attempt = new Attempt(execution, target);
damaging = attempt;
try { target.damage(nativeDamage, source); }
try { execution.damage(target, nativeDamage, source); }
finally { if (damaging == attempt) damaging = null; }
}
@@ -130,7 +130,7 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
List.of("Redirect " + decimal(current.redirectPercent()) + "% of an aimed party ally's combat damage.",
"Range " + decimal(BigDecimal.valueOf(current.range())) + " blocks for " + seconds(current.duration()) + " seconds."),
"Use on an aimed active party ally; one guardian and one bond per ally.",
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
@@ -30,13 +30,13 @@ public final class Hex implements AbilityEffect,AutoCloseable{
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("hex","Hex",Material.SPIDER_EYE,
List.of("Reduce an aimed hostile's combat damage by "+current.reductionPercent().stripTrailingZeros().toPlainString()+"%.",
"Range "+BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString()+" blocks; lasts "+seconds(current.duration())+" seconds."),
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("range",BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
AbilityDefinition.TargetPolicy.HOSTILE,this);}
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())){execution.finish();return;}
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())||execution.trainingDummy(target)){execution.finish();return;}
var state=new State(execution,target,mitigation.grant(execution,target,current.reductionPercent()));var previous=hexes.put(target.getUniqueId(),state);
if(previous!=null)previous.execution.finish();execution.own(state);execution.later(current.duration(),execution::finish);}
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
@@ -5,7 +5,7 @@ public final class Inspire implements AbilityEffect,AutoCloseable{
public record Settings(BigInteger baseTemporaryHealth,Duration cooldown,double range,Duration duration){public Settings{Objects.requireNonNull(baseTemporaryHealth);Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);if(baseTemporaryHealth.signum()<0||baseTemporaryHealth.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger())>0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0)throw new IllegalArgumentException("Invalid Inspire settings");}public static Settings defaults(){return new Settings(BigInteger.valueOf(4),Duration.ofSeconds(15),12,Duration.ofSeconds(6));}}
private volatile Settings settings;private final TemporaryHealth temporaryHealth;private boolean closed;
public Inspire(Settings settings,TemporaryHealth temporaryHealth){this.settings=Objects.requireNonNull(settings);this.temporaryHealth=Objects.requireNonNull(temporaryHealth);}public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Category.BUF,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
@@ -83,7 +83,7 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
List.of("Draw and release an ammunition-free owned arrow.",
"A full draw deals " + current.baseDamage() + " + DEX modifier damage."),
"Draw by holding use; release after at least " + current.minimumDrawTicks() + " ticks.",
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE,
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.DRAW_RELEASE, current.recovery(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"minimum-draw-ticks", BigDecimal.valueOf(current.minimumDrawTicks()),
@@ -171,7 +171,7 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
state.target = target; damaging = state;
try { target.damage(nativeDamage, source); }
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.close(); state.execution.finish();
@@ -70,7 +70,7 @@ public final class MendingLight implements AbilityEffect {
List.of("Restore a party ally's missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier within " + decimal(current.range()) + " blocks."),
"Use while aiming at an injured active party ally; self and full-health targets are refused.",
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
@@ -142,8 +142,7 @@ public final class MendingLight implements AbilityEffect {
target.player().setHealth(Math.min(maximum, current + restored));
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
execution.input().player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
target.player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
execution.report(AbilityReport.Type.HEALING, target.player(), amount);
} finally { execution.finish(); }
}
@@ -46,7 +46,7 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{target.damage(amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{execution.damage(target,amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);ground.origin().getWorld().spawnParticle(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();world.spawnParticle(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);world.spawnParticle(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
@@ -59,7 +59,7 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("meteor","Meteor",Material.FIRE_CHARGE,
List.of("Mark visible ground within "+decimal(current.targetRange())+" blocks for a "+seconds(current.warning())+"-second warning.","Impact exposed enemies in a "+decimal(current.radius())+"-block area for "+current.baseDamage()+" + INT modifier damage."),
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("base-damage",new BigDecimal(current.baseDamage()),"target-range",BigDecimal.valueOf(current.targetRange()).stripTrailingZeros(),"warning-seconds",nanos(current.warning()),"radius",BigDecimal.valueOf(current.radius()).stripTrailingZeros(),"vertical-range",BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros()),AbilityDefinition.TargetPolicy.AREA,this);}
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@@ -58,7 +58,7 @@ public final class Purify implements AbilityEffect {
List.of("Remove Poison, Wither, Slowness and Weakness.",
"Aimed party-ally range: " + decimal(current.range()) + " blocks; beneficial effects remain."),
"Sneak+Use cleanses self; normal Use requires an aimed active party ally.",
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PARTY, this);
@@ -81,12 +81,11 @@ public final class Purify implements AbilityEffect {
if (target.player().getPotionEffect(type) == null) removed++;
}
if (removed == 0) return;
execution.input().player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
var amount = BigDecimal.valueOf(removed);
execution.report(AbilityReport.Type.CLEANSING, target.player(), amount);
if (target.player() != execution.input().player()
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
var amount = BigDecimal.valueOf(removed);
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
target.player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
}
}
@@ -80,7 +80,7 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
List.of("Dash forward with native horizontal movement.",
"Travel up to " + decimal(current.distance()) + " blocks while obstacles remain authoritative."),
"Use while grounded, on foot, and not flying or gliding.",
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
@@ -175,7 +175,7 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
+ " and " + decimal(current.damageBonusPercent()) + "% combat damage.",
"A " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere lasting " + seconds(current.duration()) + " seconds."),
"Use to rally yourself and nearby active party members; copies do not stack.",
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
@@ -85,7 +85,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
List.of("Let another player continue one fallen character.", "A valid cast spends the daily opportunity."),
"Use the equipped shard while aiming at another player within 12 blocks.",
Map.of(Stat.WIS, BigInteger.valueOf(30), Stat.CHR, BigInteger.valueOf(24), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
BigInteger.ONE, AbilityDefinition.Category.HEAL, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
Map.of("range", BigDecimal.valueOf(12), "selection-time", BigDecimal.valueOf(300),
"death-age", BigDecimal.valueOf(86400)),
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION, this);
@@ -174,7 +174,7 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
"Party members inside heal " + decimal(current.healingPerPulse()) + " each second and take "
+ decimal(current.damageReductionPercent()) + "% less combat damage."),
"Use to hold exposed ground; healing starts after one second and the area does not follow you.",
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
@@ -199,6 +199,8 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
target.player().setHealth(Math.min(maximum, current + restored));
state.execution.report(AbilityReport.Type.HEALING, target.player(),
BigDecimal.valueOf(restored).stripTrailingZeros());
if (target.player() != state.source) supports.accept(new MendingLight.Support(
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
} finally { completePending(state); }
@@ -51,7 +51,7 @@ public final class SecondWind implements AbilityEffect {
List.of("Restore missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier, capped at maximum health."),
"Use while injured; full health refuses without spending cooldown.",
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing())),
AbilityDefinition.TargetPolicy.SELF, this);
@@ -105,8 +105,12 @@ public final class SecondWind implements AbilityEffect {
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
}
double publish = Math.min(attribute.getValue(), Math.max(player.getHealth(), persisted.health().doubleValue()));
double nativeBefore = player.getHealth();
double publish = Math.min(attribute.getValue(), Math.max(nativeBefore, persisted.health().doubleValue()));
player.setHealth(publish);
double restored = publish - nativeBefore;
if (Double.isFinite(restored) && restored > 0) execution.report(AbilityReport.Type.HEALING,
player, BigDecimal.valueOf(restored).stripTrailingZeros());
} finally { execution.finish(); }
});
} catch (RuntimeException rejected) { execution.finish(); }
@@ -35,7 +35,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|| minimumVerticalKnockback > 8) throw new IllegalArgumentException("Invalid Shield Bash settings");
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(6), 3, 0.8, 0.35);
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(3), 3, 0.8, 0.35);
}
}
private static final class Attempt {
@@ -61,7 +61,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
List.of("Strike within " + decimal(current.range()) + " blocks for " + current.baseDamage() + " + STR modifier damage.",
"Accepted damage adds knockback; this shield never enables ordinary blocking."),
"Attack a visible hostile at short range.", Map.of(Stat.STR, BigInteger.valueOf(12), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
BigInteger.ONE, AbilityDefinition.Category.ATK, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()), "range", decimalValue(current.range()),
"horizontal-knockback", decimalValue(current.horizontalKnockback()),
"minimum-vertical-knockback", decimalValue(current.minimumVerticalKnockback())),
@@ -91,7 +91,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
var attempt = new Attempt(execution, target, current); damaging = attempt;
try { target.damage(amount, source); }
try { execution.damage(target, amount, source); }
finally { if (damaging == attempt) damaging = null; execution.finish(); }
}
@@ -53,7 +53,7 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
return new AbilityDefinition("stone_skin", "Stone Skin", Material.BRICK,
List.of("Reduce entity and projectile combat damage by " + decimal(current.reductionPercent()) + "%.",
"Lasts " + decimal(BigDecimal.valueOf(current.duration().toMillis()).movePointLeft(3)) + " seconds without stacking."),
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE,
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
"damage-reduction-percent", current.reductionPercent()), AbilityDefinition.TargetPolicy.SELF, this);
@@ -20,9 +20,9 @@ public final class TemporaryHealth implements Listener,AutoCloseable{
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();long ownerSequence=owner.sequence;UUID source=owner.execution.input().session().character();
try{nextTick.accept(()->consume(target,before,owned,ownerSequence,source,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
private void consume(Player target,double before,double ownedBefore,long ownerSequence,UUID source,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,source,aided,BigDecimal.valueOf(consumed).stripTrailingZeros()));}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();
try{nextTick.accept(()->consume(target,before,owned,owner,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
private void consume(Player target,double before,double ownedBefore,Grant owner,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));}
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
@@ -181,7 +181,7 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
"Party members in its " + decimal(current.radius()) + "-block area gain " + decimal(current.damageBonusPercent())
+ "% combat damage and " + decimal(current.knockbackResistancePercent()) + "% knockback resistance."),
"Use while looking at the solid top surface where the fixed banner should stand; copies do not stack.",
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("placement-range", BigDecimal.valueOf(current.placementRange()).stripTrailingZeros(),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
@@ -142,7 +142,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::category)
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::id))
.toList();
var passiveDefinitions = passives.definitions().stream()
@@ -158,35 +159,51 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.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, passiveNames, passiveView)).toList();
var placeholderPassiveDetails = passiveDefinitions.stream()
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView, 1)).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(
games.dmg.fantasystick.passive.PassiveDefinition::id, ignored -> 1));
var placeholderDetails = definitions.stream()
.map(definition -> abilityPages(record, definition, abilityView, 1)).toList();
int activeCount = activePages(record, definitions, placeholderTargets).size();
int passiveCount = passiveSlotPages(record, passiveDefinitions, passivePlaceholderTargets).size();
int indexCount = indexPages(definitions, placeholderTargets).size();
int passiveIndexCount = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets).size();
var placeholderIndex = indexPages(definitions, placeholderTargets);
int indexCount = placeholderIndex.pages().size();
var placeholderPassiveIndex = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets);
int passiveIndexCount = placeholderPassiveIndex.pages().size();
int indexStart = pages.size() + activeCount + passiveCount;
var indexTargets = new java.util.HashMap<String, Integer>();
placeholderIndex.relativePageByAbility().forEach((id, page) -> indexTargets.put(id, indexStart + page + 1));
int passiveIndexStart = indexStart + indexCount;
var passiveIndexTargets = new java.util.HashMap<String, Integer>();
placeholderPassiveIndex.relativePageByPassive().forEach((id, page) ->
passiveIndexTargets.put(id, passiveIndexStart + page + 1));
var targets = new java.util.HashMap<String, Integer>();
int target = pages.size() + activeCount + passiveCount + indexCount + passiveIndexCount + 1;
for (int index = 0; index < definitions.size(); index++) {
targets.put(definitions.get(index).id(), target);
target += details.get(index).size();
target += placeholderDetails.get(index).size();
}
var passiveTargets = new java.util.HashMap<String, Integer>();
for (int index = 0; index < passiveDefinitions.size(); index++) {
passiveTargets.put(passiveDefinitions.get(index).id(), target);
target += passiveDetails.get(index).size();
target += placeholderPassiveDetails.get(index).size();
}
var index = indexPages(definitions, targets);
var details = definitions.stream()
.map(definition -> abilityPages(record, definition, abilityView, indexTargets.get(definition.id()))).toList();
var passiveIndex = passiveIndexPages(passiveDefinitions, passiveTargets);
var passiveDetails = passiveDefinitions.stream().map(definition -> passiveDetailPages(record, definition,
passiveNames, passiveView, passiveIndexTargets.get(definition.id()))).toList();
pages.addAll(activePages(record, definitions, targets));
pages.addAll(passiveSlotPages(record, passiveDefinitions, passiveTargets));
pages.addAll(indexPages(definitions, targets));
pages.addAll(passiveIndexPages(passiveDefinitions, passiveTargets));
pages.addAll(index.pages());
pages.addAll(passiveIndex.pages());
details.forEach(pages::addAll);
passiveDetails.forEach(pages::addAll);
}
@@ -205,7 +222,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return item;
}
private Component gearPage(CharacterRecord record){return Component.text("Current gear\n\nHead: Cosmetic\nonly\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
@@ -274,34 +291,61 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return builder.pages();
}
private static java.util.List<Component> indexPages(
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
builder.link("", definition.name(), targets.get(definition.id()));
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
private record AbilityIndex(java.util.List<Component> pages, Map<String, Integer> relativePageByAbility) {
private AbilityIndex { pages = java.util.List.copyOf(pages); relativePageByAbility = Map.copyOf(relativePageByAbility); }
}
private static java.util.List<Component> passiveIndexPages(
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
private static AbilityIndex indexPages(
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var relativePages = new java.util.HashMap<String, Integer>();
for (var category : games.dmg.fantasystick.ability.AbilityDefinition.Category.values()) {
var grouped = definitions.stream().filter(definition -> definition.category() == category).toList();
int cursor = 0;
while (cursor < grouped.size()) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.text(category.name());
int usedLines = 0;
while (cursor < grouped.size()) {
var definition = grouped.get(cursor);
int linkLines = BookPagination.lines(definition.name()).size();
if (usedLines > 0 && usedLines + linkLines > 11) break;
relativePages.put(definition.id(), pages.size());
builder.link("", definition.name(), targets.get(definition.id()));
usedLines += linkLines; cursor++;
}
pages.addAll(builder.pages());
}
}
return new AbilityIndex(pages, relativePages);
}
private record PassiveIndex(java.util.List<Component> pages, Map<String, Integer> relativePageByPassive) {
private PassiveIndex { pages = java.util.List.copyOf(pages); relativePageByPassive = Map.copyOf(relativePageByPassive); }
}
private static PassiveIndex passiveIndexPages(
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>(); var relativePages = new java.util.HashMap<String, Integer>();
int cursor = 0;
while (cursor < definitions.size()) {
var builder = new PageBuilder(); builder.text("Passive index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
int usedLines = 0;
while (cursor < definitions.size()) {
var definition = definitions.get(cursor);
int linkLines = BookPagination.lines(definition.name()).size();
if (usedLines > 0 && usedLines + linkLines > 11) break;
relativePages.put(definition.id(), pages.size());
builder.link("", definition.name(), targets.get(definition.id()));
usedLines += linkLines; cursor++;
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
return new PassiveIndex(pages, relativePages);
}
private static java.util.List<Component> passiveDetailPages(CharacterRecord record,
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view) {
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view,
int indexPage) {
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()) {
@@ -322,11 +366,13 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
else if (prerequisites && stats && points && slot) builder.component(passiveButton("Learn", PASSIVE_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank(); definition.description().forEach(builder::text); builder.blank(); builder.text(state);
builder.component(Component.text("[Back to index]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(indexPage)));
return builder.pages();
}
private static java.util.List<Component> abilityPages(CharacterRecord record,
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view, int indexPage) {
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " " + entry.getValue())
.collect(java.util.stream.Collectors.joining(", "));
@@ -339,6 +385,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
: !points ? "Unavailable: points" : !slot ? "Unavailable: slots" : "Available";
var builder = new PageBuilder();
builder.text(definition.name());
builder.text(definition.category().name());
builder.text(requirements);
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
@@ -351,6 +398,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(java.util.stream.Collectors.joining(", ")));
builder.text(state);
builder.component(Component.text("[Back to index]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(indexPage)));
return builder.pages();
}
@@ -1,5 +1,7 @@
package games.dmg.fantasystick.combat;
import games.dmg.fantasystick.ability.AbilityReport;
import games.dmg.fantasystick.presentation.CombatFeedback;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
@@ -23,6 +25,8 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
private static final Duration CADENCE = Duration.ofSeconds(1);
private final EquipmentSessions sessions;
private final Clock clock;
private final CombatFeedback feedback;
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Map<UUID, Instant> readyAt = new HashMap<>();
private Pending pending;
private boolean closed;
@@ -38,12 +42,23 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
}
public BasicAttack(EquipmentSessions sessions) {
this(sessions, Clock.systemUTC());
this(sessions, Clock.systemUTC(), null);
}
public BasicAttack(EquipmentSessions sessions, Clock clock) {
this(sessions, clock, null);
}
public BasicAttack(EquipmentSessions sessions, Clock clock, CombatFeedback feedback) {
this(sessions, clock, feedback, games.dmg.fantasystick.training.TrainingDamage.passthrough());
}
public BasicAttack(EquipmentSessions sessions, Clock clock, CombatFeedback feedback,
games.dmg.fantasystick.training.TrainingDamage trainingDamage) {
this.sessions = Objects.requireNonNull(sessions);
this.clock = Objects.requireNonNull(clock);
this.feedback = feedback;
this.trainingDamage = Objects.requireNonNull(trainingDamage);
}
@Override public void accept(ControlInput input) {
@@ -60,7 +75,11 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
var attempt = new Pending(input, target, now);
pending = attempt;
try {
target.damage(nativeDamage, input.player());
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
measured.ifPresent(amount -> {
attempt.accepted = true;
if (feedback != null) feedback.report("Basic Attack", input.player(), AbilityReport.Type.DAMAGE, target, amount);
});
} finally {
if (pending == attempt) pending = null;
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
@@ -78,9 +97,11 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
attempt.accepted = true;
if (feedback != null) feedback.report("Basic Attack", attempt.input.player(), AbilityReport.Type.DAMAGE,
event.getEntity(), java.math.BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
}
@Override public void close() {
@@ -41,7 +41,7 @@ public final class TailBooks {
.append("\nCoins: ").append(character.coins());
for (Stat stat : Stat.values()) summary.append('\n').append(stat).append(": ").append(character.stats().get(stat));
pages.addAll(BookPagination.pages(summary.toString()));
pages.addAll(BookPagination.pages("Current gear\nHead: Cosmetic only\nChestplate: "+tier(character.gear().chestplate())+"\nLeggings: "+tier(character.gear().leggings())+"\nBoots: "+tier(character.gear().boots())));
pages.addAll(BookPagination.pages("Gear\nHead: Cosmetic\nChestplate: "+tier(character.gear().chestplate())+"\nLeggings: "+tier(character.gear().leggings())+"\nBoots: "+tier(character.gear().boots())));
if (character.learned().isEmpty()) pages.addAll(BookPagination.pages("Abilities: None"));
character.learned().stream().sorted().forEach(ability -> {
var slots = new ArrayList<String>();
@@ -1,6 +1,8 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.ability.AbilityReport;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.presentation.CombatFeedback;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
@@ -53,6 +55,7 @@ public final class HealthSteal implements Listener, AutoCloseable {
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private final Consumer<Failure> failures;
private final CombatFeedback feedback;
private final CombatSources sources;
private final Map<UUID, Candidate> lethal = new java.util.HashMap<>();
private final Set<PlayerDeathEvent> fantasyDefeats = Collections.newSetFromMap(new IdentityHashMap<>());
@@ -65,10 +68,16 @@ public final class HealthSteal implements Listener, AutoCloseable {
public HealthSteal(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures) {
this(settings, characters, sessions, nextTick, healingEvents, nativeLimit, failures, null);
}
public HealthSteal(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures, CombatFeedback feedback) {
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);
this.failures = Objects.requireNonNull(failures); this.feedback = feedback; sources = new CombatSources(sessions);
}
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
@@ -187,7 +196,11 @@ public final class HealthSteal implements Listener, AutoCloseable {
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);
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) {
player.setHealth(publish);
if (feedback != null) feedback.report("Health Steal", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
}
} finally { finish(character); }
}
@@ -0,0 +1,171 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Fireball;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Fireball settings; publication remains a server-thread caller decision. */
public final class FireballSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "fireball-base-damage";
public static final String COOLDOWN = "fireball-cooldown-seconds";
public static final String RANGE = "fireball-range-blocks";
public static final String LIFETIME = "fireball-lifetime-seconds";
public static final String SPEED = "fireball-projectile-speed-blocks-per-tick";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, LIFETIME, SPEED, RANGE);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "6", COOLDOWN, "1.2", RANGE, "32",
LIFETIME, "5", SPEED, "3");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Fireball.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-fireball-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public FireballSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
FireballSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Fireball.Settings settings() { return parse(effectiveValues); }
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence(); return true;
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Fireball configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Fireball.Settings parse(Map<String, String> values) {
try {
return new Fireball.Settings(new java.math.BigInteger(values.get(BASE_DAMAGE)), seconds(values.get(COOLDOWN)),
finite(values.get(RANGE)), seconds(values.get(LIFETIME)), finite(values.get(SPEED)));
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Fireball settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
return Duration.ofNanos(new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact());
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -36,7 +36,7 @@ public final class FrostShardSettings implements AutoCloseable {
public static final String SLOWNESS = "frost-shard-slowness-duration-seconds";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, LIFETIME, SPEED, RANGE, AMPLIFIER, SLOWNESS);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "5", RANGE, "32",
LIFETIME, "5", SPEED, "1", AMPLIFIER, "0", SLOWNESS, "2");
LIFETIME, "5", SPEED, "3", AMPLIFIER, "0", SLOWNESS, "2");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, FrostShard.Settings settings,
@@ -30,7 +30,7 @@ public final class OutOfCombatRecoverySettings implements AutoCloseable {
public static final String INTERVAL = "out-of-combat-healing-interval-seconds";
public static final String AMOUNT = "out-of-combat-healing-amount";
private static final List<String> KEYS = List.of(DELAY, INTERVAL, AMOUNT);
private static final Map<String, String> DEFAULTS = Map.of(DELAY, "30", INTERVAL, "10", AMOUNT, "1");
private static final Map<String, String> DEFAULTS = Map.of(DELAY, "10", INTERVAL, "10", AMOUNT, "1");
private static final int MAX_BYTES = 1_048_576;
private static final long MAX_SECONDS = 31_536_000L;
@@ -0,0 +1,186 @@
package games.dmg.fantasystick.persistence;
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.UUID;
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 account-owned routine combat/effect feedback preference. */
public final class PlayerFeedbackPreferences implements AutoCloseable {
private static final String FILE = "player-preferences.yml";
private static final String PLAYERS = "players";
private static final String KEY = "combat-feedback";
private static final int MAX_BYTES = 1_048_576;
public record Update(UUID account, boolean enabled, long sequence) {
public Update { Objects.requireNonNull(account); }
}
private final Path directory;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-player-feedback-preferences"));
private final Map<UUID, Boolean> effective = new java.util.HashMap<>();
private final Map<UUID, Long> published = new java.util.HashMap<>();
private long persistedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public PlayerFeedbackPreferences(Path directory) throws IOException {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
effective.putAll(loadEnabled());
}
public synchronized boolean enabled(UUID account) {
Objects.requireNonNull(account);
return effective.getOrDefault(account, true);
}
public CompletionStage<Update> set(UUID account, boolean enabled) {
Objects.requireNonNull(account);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(account, enabled);
return new Update(account, enabled, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Combat feedback preference save refused or failed; effective setting unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
}
}
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (closed) return false;
long previous = published.getOrDefault(update.account(), 0L);
if (update.sequence() <= previous) return false;
effective.put(update.account(), update.enabled());
published.put(update.account(), update.sequence());
return true;
}
private Map<UUID, Boolean> loadEnabled() throws IOException {
Map<String, Object> root = readRoot();
Object rawPlayers = root.get(PLAYERS);
if (rawPlayers == null) return Map.of();
if (!(rawPlayers instanceof Map<?, ?> players)) throw new IOException("Invalid player preferences");
var result = new LinkedHashMap<UUID, Boolean>();
for (var entry : players.entrySet()) {
if (!(entry.getKey() instanceof String rawId)) throw new IOException("Invalid player preference identity");
UUID account;
try { account = UUID.fromString(rawId); }
catch (IllegalArgumentException invalid) { throw new IOException("Invalid player preference identity", invalid); }
if (!(entry.getValue() instanceof Map<?, ?> values)) throw new IOException("Invalid player preference entry");
Object raw = values.get(KEY);
if (raw == null) continue;
if (!(raw instanceof Boolean value)) throw new IOException("Invalid combat feedback preference");
result.put(account, value);
}
return result;
}
private void save(UUID account, boolean enabled) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
Path lockFile = directory.resolve(".player-preferences.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe preference lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS);
var ignored = channel.tryLock()) {
if (ignored == null) throw new IOException("Player preferences busy");
Map<String, Object> root = readRoot();
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
values.put(KEY, enabled);
players.put(account.toString(), values);
root.put(PLAYERS, players);
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".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);
}
Files.move(temporary, directory.resolve(FILE), StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) {
throw new IOException("Player preferences busy", busy);
}
}
private Map<String, Object> readRoot() throws IOException {
Path file = directory.resolve(FILE);
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return new LinkedHashMap<>();
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe player preferences");
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("Player preferences too large");
Object loaded = yaml().load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
return stringMap(loaded, "player preference root");
}
private static Map<String, Object> stringMap(Object raw, String label) throws IOException {
var result = new LinkedHashMap<String, Object>();
if (raw == null) return result;
if (!(raw instanceof Map<?, ?> map)) throw new IOException("Invalid " + label);
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid " + label + " key");
result.put(key, entry.getValue());
}
return result;
}
private static Yaml yaml() {
var options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20);
options.setCodePointLimit(MAX_BYTES);
return new Yaml(new SafeConstructor(options));
}
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);
}
}
@@ -33,7 +33,7 @@ public final class ShieldBashSettings implements AutoCloseable {
public static final String HORIZONTAL = "shield-bash-horizontal-knockback";
public static final String VERTICAL = "shield-bash-minimum-vertical-knockback";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, RANGE, HORIZONTAL, VERTICAL);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "6", RANGE, "3",
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "3", RANGE, "3",
HORIZONTAL, "0.8", VERTICAL, "0.35");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
@@ -0,0 +1,89 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.training.TrainingDummy;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Atomic desired-state adapter for administrator-managed training dummies. */
public final class YamlTrainingDummyRepository {
private final Path file;
public YamlTrainingDummyRepository(Path data) {
file = data.toAbsolutePath().resolve("training-dummies.yml");
}
public List<TrainingDummy> load() throws IOException {
if (Files.notExists(file)) return List.of();
try {
Object decoded = yaml().load(Files.readString(file, StandardCharsets.UTF_8));
if (!(decoded instanceof Map<?, ?> root) || number(root.get("schema-version")) != 1) {
throw new IllegalArgumentException();
}
var result = new ArrayList<TrainingDummy>();
for (Object value : list(root.get("dummies"))) {
var row = map(value);
result.add(new TrainingDummy(UUID.fromString(text(row.get("dummy-id"))), text(row.get("name")),
UUID.fromString(text(row.get("world"))), decimal(row.get("x")), decimal(row.get("y")),
decimal(row.get("z")), (float) decimal(row.get("yaw")), (float) decimal(row.get("pitch"))));
}
validate(result); return List.copyOf(result);
} catch (RuntimeException invalid) {
throw new IOException("Invalid training dummy file", invalid);
}
}
public void save(List<TrainingDummy> dummies) throws IOException {
validate(dummies);
Map<String, Object> root = new LinkedHashMap<>(); root.put("schema-version", 1);
root.put("dummies", dummies.stream().sorted(Comparator.comparing(TrainingDummy::id)).map(value -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("dummy-id", value.id().toString()); row.put("name", value.name());
row.put("world", value.world().toString()); row.put("x", Double.toString(value.x()));
row.put("y", Double.toString(value.y())); row.put("z", Double.toString(value.z()));
row.put("yaw", Float.toString(value.yaw())); row.put("pitch", Float.toString(value.pitch())); return row;
}).toList());
Files.createDirectories(file.getParent());
Path temporary = Files.createTempFile(file.getParent(), "training-dummies-", ".tmp");
try {
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
try (var output = java.nio.channels.FileChannel.open(temporary, StandardOpenOption.WRITE)) {
var buffer = java.nio.ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
catch (AtomicMoveNotSupportedException unsupported) {
throw new IOException("Atomic training dummy persistence is unavailable", unsupported);
}
DurableFiles.forceDirectory(file.getParent());
} finally { Files.deleteIfExists(temporary); }
}
private static void validate(List<TrainingDummy> values) {
var ids = new HashSet<UUID>();
if (values.stream().anyMatch(value -> !ids.add(value.id()))) {
throw new IllegalArgumentException("Duplicate training dummy identity");
}
}
private static Yaml yaml() { var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(0); options.setCodePointLimit(1_048_576);
return new Yaml(new SafeConstructor(options)); }
private static Map<?, ?> map(Object value) { if (value instanceof Map<?, ?> result) return result; throw new IllegalArgumentException(); }
private static List<?> list(Object value) { if (value instanceof List<?> result) return result; throw new IllegalArgumentException(); }
private static String text(Object value) { if (value instanceof String result) return result; throw new IllegalArgumentException(); }
private static int number(Object value) { return Integer.parseInt(value.toString()); }
private static double decimal(Object value) { return Double.parseDouble(text(value)); }
}
@@ -0,0 +1,84 @@
package games.dmg.fantasystick.presentation;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.ability.AbilityFeedback;
import games.dmg.fantasystick.ability.AbilityReport;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Locale;
import java.util.Objects;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
/** Account-filtered chat presentation for authoritative Fantasy Stick effect outcomes. */
public final class CombatFeedback implements AbilityFeedback {
private final PlayerFeedbackPreferences preferences;
private final EquipmentSessions sessions;
public CombatFeedback(PlayerFeedbackPreferences preferences, EquipmentSessions sessions) {
this.preferences = Objects.requireNonNull(preferences);
this.sessions = Objects.requireNonNull(sessions);
}
@Override public void report(AbilityDefinition definition, ControlInput input, AbilityReport.Type type,
Entity target, BigDecimal amount) {
Objects.requireNonNull(definition); Objects.requireNonNull(input);
report(definition.name(), input.player(), type, target, amount);
}
public void report(String effect, Player source, AbilityReport.Type type, Entity target, BigDecimal amount) {
Objects.requireNonNull(effect); Objects.requireNonNull(source); Objects.requireNonNull(type);
Objects.requireNonNull(target); Objects.requireNonNull(amount);
if (amount.signum() <= 0 || !Double.isFinite(amount.doubleValue())) return;
String message = message(effect, type, name(target), amount);
if (preferences.enabled(source.getUniqueId())) send(source, message);
if (target instanceof Player recipient && recipient != source && recipientEffect(type)
&& preferences.enabled(recipient.getUniqueId())) send(recipient, message);
}
private String name(Entity entity) {
if (entity instanceof Player player) {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
if (active != null) {
var record = sessions.currentRecord(active).orElse(null);
if (record != null && !record.name().isBlank()) return record.name();
}
}
String nativeName = entity.getName();
if (nativeName != null && !nativeName.isBlank()) return nativeName;
var entityType = entity.getType();
if (entityType == null) return "Target";
String value = entityType.name().toLowerCase(Locale.ROOT).replace('_', ' ');
var words = value.split(" ");
var result = new StringBuilder();
for (String word : words) {
if (!result.isEmpty()) result.append(' ');
if (!word.isEmpty()) result.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return result.toString();
}
private static String message(String effect, AbilityReport.Type type, String target, BigDecimal amount) {
String value = amount.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
return switch (type) {
case DAMAGE -> effect + " did " + value + " damage to " + target + ".";
case HEALING -> effect + " healed " + target + " for " + value + " health.";
case CLEANSING -> effect + " removed " + value + " harmful "
+ (amount.compareTo(BigDecimal.ONE) == 0 ? "effect" : "effects") + " from " + target + ".";
case PREVENTION -> effect + " prevented " + value + " damage to " + target + ".";
case BOOST_USED -> effect + " added " + value + " damage against " + target + ".";
};
}
private static void send(Player player, String message) {
try { player.sendMessage(message); } catch (RuntimeException ignored) { }
}
private static boolean recipientEffect(AbilityReport.Type type) {
return type == AbilityReport.Type.HEALING || type == AbilityReport.Type.CLEANSING
|| type == AbilityReport.Type.PREVENTION;
}
}
@@ -22,6 +22,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private final Consumer<Failure> diagnostics;
private volatile boolean closed;
private games.dmg.fantasystick.persistence.AdventureSettings settings;
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
private games.dmg.fantasystick.ability.FrostShard frostShard;
private games.dmg.fantasystick.ability.AbilityRegistry abilities;
@@ -80,6 +82,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
@@ -365,6 +369,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
partyRegistrars=Objects.requireNonNull(directory); partyRegistrarController=Objects.requireNonNull(controller);
}
public void attachFireball(games.dmg.fantasystick.persistence.FireballSettings settings,
games.dmg.fantasystick.ability.Fireball effect) {
if (closed || fireballSettings != null) throw new IllegalStateException("Fireball already attached or command closed");
fireballSettings = Objects.requireNonNull(settings); fireball = Objects.requireNonNull(effect);
}
public void attachTrainingDummies(games.dmg.fantasystick.training.TrainingDummyDirectory directory,
games.dmg.fantasystick.training.TrainingDummyController controller) {
if (closed || trainingDummies != null) throw new IllegalStateException("Training dummies already attached or command closed");
trainingDummies = Objects.requireNonNull(directory); trainingDummyController = Objects.requireNonNull(controller);
}
public void attachArmorers(games.dmg.fantasystick.shop.FantasyArmorerDirectory directory,games.dmg.fantasystick.shop.FantasyArmorerController controller){if(closed||armorers!=null)throw new IllegalStateException("Armorers already attached or command closed");armorers=Objects.requireNonNull(directory);armorerController=Objects.requireNonNull(controller);}
public void attachArmorMarket(games.dmg.fantasystick.shop.ArmorMarket market){if(closed||armorMarket!=null)throw new IllegalStateException("Armor market already attached or command closed");armorMarket=Objects.requireNonNull(market);}
@@ -375,6 +391,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
if(args.length>0&&args[0].equals("shop")){shop(sender,args);return true;}
if (args.length > 0 && args[0].equals("training-dummy")) {
trainingDummy(sender, args); return true;
}
if (args.length > 0 && args[0].equals("party-registrar")) {
partyRegistrar(sender,args); return true;
}
@@ -519,6 +538,116 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
private void trainingDummy(CommandSender sender, String[] args) {
if (trainingDummies == null || trainingDummyController == null || args.length < 2) {
trainingDummyUsage(sender); return;
}
if (args[1].equals("list") && args.length == 2) {
var values = trainingDummies.all();
if (values.isEmpty()) sender.sendMessage("No training dummies are registered.");
else for (var value : values) sender.sendMessage(value.id() + " " + value.displayName() + " @ "
+ value.world() + " " + value.x() + " " + value.y() + " " + value.z());
return;
}
if (args[1].equals("place") && sender instanceof org.bukkit.entity.Player player) {
String name = args.length < 3 ? "" : String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
var location = player.getLocation().clone();
try {
trainingDummies.place(name, location.getWorld().getUID(), location.getX(), location.getY(),
location.getZ(), location.getYaw(), location.getPitch())
.whenComplete((saved, failure) -> placeDummyResponse(player, location, saved, failure));
} catch (IllegalArgumentException invalid) { sender.sendMessage("Training dummy name is invalid."); }
return;
}
if (args[1].equals("remove") && args.length == 3) {
UUID id;
try { id = UUID.fromString(args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("Training dummy UUID is invalid."); return; }
trainingDummies.remove(id).whenComplete((removed, failure) -> removeDummyResponse(sender, id, removed, failure));
return;
}
trainingDummyUsage(sender);
}
private static void trainingDummyUsage(CommandSender sender) {
sender.sendMessage("Usage: /fantasystick training-dummy <place [name...]|remove <dummy-uuid>|list>");
}
private void placeDummyResponse(org.bukkit.entity.Player player, org.bukkit.Location location,
games.dmg.fantasystick.training.TrainingDummy saved, Throwable failure) {
if (failure != null) report(player.getUniqueId(), "training-dummy-place", failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure != null) {
if (player.isOnline()) player.sendMessage("Training dummy placement failed; no record or entity changed.");
return;
}
if (!player.isOnline() || !player.hasPermission(PERMISSION)) {
trainingDummies.remove(saved.id()).whenComplete((ignored, rollbackFailure) -> {
if (rollbackFailure != null) report(player.getUniqueId(), "training-dummy-permission-rollback", rollbackFailure);
if (!closed) nextTick.accept(() -> {
trainingDummyController.removeLoaded(saved.id());
if (player.isOnline()) player.sendMessage("Training dummy placement was cancelled because permission changed.");
});
});
return;
}
try {
int chunkX = location.getBlockX() >> 4, chunkZ = location.getBlockZ() >> 4;
if (location.getWorld().isChunkLoaded(chunkX, chunkZ)) {
trainingDummyController.reconcile(location.getWorld().getChunkAt(chunkX, chunkZ));
}
player.sendMessage("Placed training dummy " + saved.id() + ".");
} catch (RuntimeException entityFailure) {
report(player.getUniqueId(), "training-dummy-entity", entityFailure);
trainingDummies.remove(saved.id()).whenComplete((ignored, rollbackFailure) -> {
if (rollbackFailure != null) report(player.getUniqueId(), "training-dummy-rollback", rollbackFailure);
if (!closed) nextTick.accept(() -> {
trainingDummyController.removeLoaded(saved.id());
if (player.isOnline()) player.sendMessage("Training dummy placement failed; no record or entity changed.");
});
});
}
}); } catch (RuntimeException rejected) { report(player.getUniqueId(), "training-dummy-response", rejected); }
}
private void removeDummyResponse(CommandSender sender, UUID id,
java.util.Optional<games.dmg.fantasystick.training.TrainingDummy> removed, Throwable failure) {
if (failure != null) report(null, "training-dummy-remove", failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure != null) { sender.sendMessage("Training dummy removal failed; record and entity are unchanged."); return; }
if (removed.isEmpty()) { sender.sendMessage("Training dummy not found."); return; }
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) {
restoreDummy(sender, removed.orElseThrow(), "Training dummy removal was cancelled because permission changed.");
return;
}
try { trainingDummyController.removeLoaded(id); sender.sendMessage("Removed training dummy " + id + "."); }
catch (RuntimeException entityFailure) {
report(null, "training-dummy-entity", entityFailure);
restoreDummy(sender, removed.orElseThrow(), "Training dummy removal failed; record and entity are unchanged.");
}
}); } catch (RuntimeException rejected) { report(null, "training-dummy-response", rejected); }
}
private void restoreDummy(CommandSender sender, games.dmg.fantasystick.training.TrainingDummy removed, String message) {
trainingDummies.restore(removed).whenComplete((restored, rollbackFailure) -> {
if (rollbackFailure != null) report(null, "training-dummy-rollback", rollbackFailure);
if (closed) return;
nextTick.accept(() -> {
if (rollbackFailure == null) {
var world = server.getWorld(restored.world());
int chunkX = ((int) Math.floor(restored.x())) >> 4, chunkZ = ((int) Math.floor(restored.z())) >> 4;
if (world != null && world.isChunkLoaded(chunkX, chunkZ)) trainingDummyController.reconcile(world.getChunkAt(chunkX, chunkZ));
}
sender.sendMessage(message);
});
});
}
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{partyRegistrarController.removeLoaded(id);sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
private void partyRegistrar(CommandSender sender,String[] args){
@@ -627,6 +756,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (meteorSettings != null && games.dmg.fantasystick.persistence.MeteorSettings.keys().contains(args[1])) {
meteorConfiguration(sender, args); return;
}
if (fireballSettings != null && games.dmg.fantasystick.persistence.FireballSettings.keys().contains(args[1])) {
fireballConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -661,6 +793,33 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void fireballConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + fireballSettings.value(key) + " (applies to future Fireball casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FireballSettings.Update> operation;
try { operation = fireballSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Fireball setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null && fireballSettings.publish(saved)) {
fireball.configure(fireballSettings.settings()); abilities.replace(fireball.definition());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Fireball casts; active projectiles and deadlines are unchanged."
: "Fireball configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void outOfCombatRecoveryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
@@ -1204,9 +1363,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "revive","shop") : List.of("config", "experience", "give", "inspect", "party-registrar", "recover","shop"), args[0]);
? List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "revive", "shop", "training-dummy")
: List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"), args[0]);
if(args.length==2&&args[0].equals("shop"))return matching(sender instanceof org.bukkit.entity.Player?List.of("list","place","remove","restock"):List.of("list","remove","restock"),args[1]);
if(args.length==3&&args[0].equals("shop")&&args[1].equals("remove")&&armorers!=null)return matching(armorers.all().stream().map(value->value.id().toString()).toList(),args[2]);
if (args.length == 2 && args[0].equals("training-dummy")) return matching(
sender instanceof org.bukkit.entity.Player ? List.of("list", "place", "remove") : List.of("list", "remove"), args[1]);
if (args.length == 3 && args[0].equals("training-dummy") && args[1].equals("remove") && trainingDummies != null)
return matching(trainingDummies.all().stream().map(value -> value.id().toString()).toList(), args[2]);
if (args.length == 2 && args[0].equals("party-registrar")) return matching(
sender instanceof org.bukkit.entity.Player ? List.of("list","place","remove") : List.of("list","remove"),args[1]);
if (args.length == 3 && args[0].equals("party-registrar") && args[1].equals("remove") && partyRegistrars!=null)
@@ -1227,6 +1391,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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.FireballSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
@@ -1279,6 +1444,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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 (fireballSettings != null && games.dmg.fantasystick.persistence.FireballSettings.keys().contains(args[1])) {
String current = fireballSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.FireballSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), 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]);
@@ -353,6 +353,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
if (allowed && event.getHand() == EquipmentSlot.HAND && (event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK)) {
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
if (selected != null && !selected.ability().isEmpty() && doorInteraction(event)) return;
if (selected != null && useGesture(selected)) {
org.bukkit.entity.Entity target = selected.ability().equals("hex")
|| selected.ability().equals("mending_light")
@@ -367,6 +368,12 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
}
}
private boolean doorInteraction(org.bukkit.event.player.PlayerInteractEvent event) {
var block = event.getClickedBlock();
return event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK && block != null
&& block.getBlockData() instanceof org.bukkit.block.data.type.Door;
}
private void beginDraw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) {
UUID owner = player.getUniqueId();
if (closed || draws.containsKey(owner) || player.hasActiveItem()) return;
@@ -0,0 +1,68 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Consumer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
import org.bukkit.entity.Player;
/** Ordinary-player command for one durable account-owned routine effect-feedback switch. */
public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable {
private final PlayerFeedbackPreferences preferences;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private boolean closed;
public FantasyFeedbackCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
Consumer<Throwable> failures) {
this.preferences = Objects.requireNonNull(preferences);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Fantasy feedback is a player-account preference.");
return true;
}
if (args.length == 0) {
player.sendMessage("Fantasy feedback is " + state(preferences.enabled(player.getUniqueId())) + ".");
return true;
}
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) return false;
boolean enabled = args[0].equals("on");
var account = player.getUniqueId();
preferences.set(account, enabled).whenComplete((saved, failure) -> {
if (failure != null) report(failure);
try {
nextTick.accept(() -> {
if (closed) return;
if (failure != null) {
if (player.isOnline()) player.sendMessage(
"Fantasy feedback preference could not be saved; it remains "
+ state(preferences.enabled(account)) + ".");
return;
}
boolean published = preferences.publish(saved);
if (player.isOnline()) player.sendMessage("Fantasy feedback is "
+ (published ? "now " : "already ") + state(preferences.enabled(account)) + ".");
});
} catch (RuntimeException rejected) { report(rejected); }
});
return true;
}
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (!(sender instanceof Player) || args.length != 1) return List.of();
String prefix = args[0].toLowerCase(Locale.ROOT);
return List.of("off", "on").stream().filter(value -> value.startsWith(prefix)).toList();
}
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
@Override public void close() { closed = true; }
}
@@ -1,6 +1,8 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.ability.AbilityReport;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.presentation.CombatFeedback;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigDecimal;
@@ -38,7 +40,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(30), Duration.ofSeconds(10), BigInteger.ONE);
return new Settings(Duration.ofSeconds(10), Duration.ofSeconds(10), BigInteger.ONE);
}
}
@@ -61,6 +63,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private final Consumer<Failure> failures;
private final CombatFeedback feedback;
private final CombatSources sources;
private java.util.function.Function<CharacterRecord, BigInteger> bonusHealing = ignored -> BigInteger.ZERO;
private final Map<UUID, State> states = new HashMap<>();
@@ -73,11 +76,17 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
public OutOfCombatRecovery(Settings settings, CharacterStorage characters, EquipmentSessions sessions, Clock clock,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures) {
this(settings, characters, sessions, clock, nextTick, healingEvents, nativeLimit, failures, null);
}
public OutOfCombatRecovery(Settings settings, CharacterStorage characters, EquipmentSessions sessions, Clock clock,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures, CombatFeedback feedback) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.clock = Objects.requireNonNull(clock);
this.nextTick = Objects.requireNonNull(nextTick); this.healingEvents = Objects.requireNonNull(healingEvents);
this.nativeLimit = Objects.requireNonNull(nativeLimit); this.failures = Objects.requireNonNull(failures);
sources = new CombatSources(sessions);
this.feedback = feedback; sources = new CombatSources(sessions);
}
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
@@ -225,6 +234,8 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
double publish = Math.min(maximum, persisted.health().doubleValue());
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) return;
player.setHealth(publish);
if (feedback != null) feedback.report("Out-of-Combat Recovery", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
}
private void remove(UUID owner) {
@@ -0,0 +1,18 @@
package games.dmg.fantasystick.training;
import java.math.BigDecimal;
import java.util.Optional;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
/** Applies native damage while allowing a managed dummy to return its intercepted final amount. */
@FunctionalInterface
public interface TrainingDamage {
Optional<BigDecimal> apply(Player source, LivingEntity target, Runnable nativeDamage);
default boolean target(org.bukkit.entity.Entity entity) { return false; }
static TrainingDamage passthrough() {
return (source, target, nativeDamage) -> { nativeDamage.run(); return Optional.empty(); };
}
}
@@ -0,0 +1,21 @@
package games.dmg.fantasystick.training;
import java.util.Objects;
import java.util.UUID;
/** Durable desired location for one administrator-managed training dummy. */
public record TrainingDummy(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch) {
public TrainingDummy {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(world);
name = name.strip();
if (name.codePointCount(0, name.length()) > 64 || name.codePoints().anyMatch(codePoint -> {
int type = Character.getType(codePoint);
return type == Character.CONTROL || type == Character.FORMAT || type == Character.SURROGATE;
}) || !Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
throw new IllegalArgumentException("Invalid training dummy");
}
}
public String displayName() { return name.isBlank() ? "Training Dummy" : name; }
}
@@ -0,0 +1,190 @@
package games.dmg.fantasystick.training;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Zombie;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
/** Loaded-chunk reconciliation, containment and final-damage interception for training dummies. */
public final class TrainingDummyController implements Listener, TrainingDamage, AutoCloseable {
private static final String TYPE = "training_dummy";
private final Plugin plugin;
private final TrainingDummyDirectory directory;
private final NamespacedKey idKey;
private final NamespacedKey typeKey;
private final java.util.Set<Entity> relocating = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private Attempt pending;
private boolean closed;
private static final class Attempt {
final LivingEntity target; BigDecimal amount;
Attempt(LivingEntity target) { this.target = target; }
}
public TrainingDummyController(Plugin plugin, TrainingDummyDirectory directory) {
this.plugin = Objects.requireNonNull(plugin); this.directory = Objects.requireNonNull(directory);
idKey = new NamespacedKey(plugin, "training_dummy_id");
typeKey = new NamespacedKey(plugin, "managed_npc_type");
}
public void install() {
for (var world : plugin.getServer().getWorlds()) {
var chunks = world.getLoadedChunks(); if (chunks != null) for (var chunk : chunks) reconcile(chunk);
}
}
public boolean tagged(Entity entity) {
var data = entity.getPersistentDataContainer();
return data != null && TYPE.equals(data.get(typeKey, PersistentDataType.STRING));
}
public void reconcile(Chunk chunk) {
if (closed) return;
var desired = new HashMap<UUID, TrainingDummy>();
for (var value : directory.all()) {
if (value.world().equals(chunk.getWorld().getUID()) && chunk(value.x()) == chunk.getX()
&& chunk(value.z()) == chunk.getZ()) desired.put(value.id(), value);
}
var retained = new HashSet<UUID>();
for (var entity : chunk.getEntities()) {
if (!tagged(entity)) continue;
UUID id = id(entity); var target = id == null ? null : desired.get(id);
if (!(entity instanceof Zombie zombie) || target == null || !retained.add(id)) { entity.remove(); continue; }
configure(zombie, target);
var current = zombie.getLocation(); var expected = location(target);
if (current.distanceSquared(expected) > 0.000001 || Math.abs(current.getYaw() - expected.getYaw()) > 0.001
|| Math.abs(current.getPitch() - expected.getPitch()) > 0.001) {
relocating.add(zombie); boolean moved;
try { moved = zombie.teleport(expected); } finally { relocating.remove(zombie); }
if (!moved) { zombie.remove(); spawn(target); }
}
}
for (var value : desired.values()) if (!retained.contains(value.id())) spawn(value);
}
public void removeLoaded(UUID id) {
for (var world : plugin.getServer().getWorlds()) {
var entities = world.getEntities(); if (entities != null) for (var entity : entities) {
if (tagged(entity) && id.equals(id(entity))) entity.remove();
}
}
}
@Override public Optional<BigDecimal> apply(Player source, LivingEntity target, Runnable nativeDamage) {
Objects.requireNonNull(source); Objects.requireNonNull(target); Objects.requireNonNull(nativeDamage);
if (closed || !managed(target)) { nativeDamage.run(); return Optional.empty(); }
if (pending != null) throw new IllegalStateException("Nested training damage is unavailable");
var attempt = new Attempt(target); pending = attempt;
try { nativeDamage.run(); }
finally { if (pending == attempt) pending = null; }
return Optional.ofNullable(attempt.amount);
}
@Override public boolean target(Entity entity) { return managed(entity); }
private boolean managed(Entity entity) {
if (!(entity instanceof Zombie) || !tagged(entity)) return false;
UUID id = id(entity); return id != null && directory.find(id).isPresent();
}
private void spawn(TrainingDummy value) {
var world = plugin.getServer().getWorld(value.world()); if (world == null) return;
int x = chunk(value.x()), z = chunk(value.z()); if (!world.isChunkLoaded(x, z)) return;
world.spawn(location(value), Zombie.class, zombie -> configure(zombie, value));
}
private void configure(Zombie zombie, TrainingDummy value) {
zombie.getPersistentDataContainer().set(typeKey, PersistentDataType.STRING, TYPE);
zombie.getPersistentDataContainer().set(idKey, PersistentDataType.STRING, value.id().toString());
zombie.setAI(false); zombie.setAware(false); zombie.setGravity(false); zombie.setSilent(true);
zombie.setInvulnerable(false); zombie.setCollidable(false); zombie.setCanPickupItems(false);
zombie.setLeashHolder(null); zombie.setRemoveWhenFarAway(false); zombie.setPersistent(true);
zombie.setShouldBurnInDay(false); zombie.setBaby(false); zombie.setCanBreakDoors(false);
zombie.setFireTicks(0); zombie.setFreezeTicks(0); zombie.setVelocity(new org.bukkit.util.Vector());
for (var effect : java.util.List.copyOf(zombie.getActivePotionEffects())) zombie.removePotionEffect(effect.getType());
zombie.customName(net.kyori.adventure.text.Component.text(value.displayName()));
zombie.setCustomNameVisible(true);
}
private Location location(TrainingDummy value) {
return new Location(plugin.getServer().getWorld(value.world()), value.x(), value.y(), value.z(), value.yaw(), value.pitch());
}
private UUID id(Entity entity) {
String value = entity.getPersistentDataContainer().get(idKey, PersistentDataType.STRING);
try { return value == null ? null : UUID.fromString(value); }
catch (IllegalArgumentException invalid) { return null; }
}
private static int chunk(double coordinate) { return ((int) Math.floor(coordinate)) >> 4; }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (!tagged(event.getEntity())) return;
var attempt = pending;
if (attempt != null && attempt.target == event.getEntity() && managed(event.getEntity())
&& Double.isFinite(event.getFinalDamage()) && event.getFinalDamage() > 0) {
attempt.amount = BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros();
}
event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void combust(org.bukkit.event.entity.EntityCombustEvent event) {
if (tagged(event.getEntity())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void transform(org.bukkit.event.entity.EntityTransformEvent event) {
if (tagged(event.getEntity())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void breed(org.bukkit.event.entity.EntityBreedEvent event) {
if (tagged(event.getMother()) || tagged(event.getFather())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event) {
if (tagged(event.getEntered())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void leash(org.bukkit.event.entity.PlayerLeashEntityEvent event) {
if (tagged(event.getEntity())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void teleport(org.bukkit.event.entity.EntityTeleportEvent event) {
if (tagged(event.getEntity()) && !relocating.contains(event.getEntity())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void potion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
if (tagged(event.getEntity())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void splash(org.bukkit.event.entity.PotionSplashEvent event) {
for (var entity : java.util.List.copyOf(event.getAffectedEntities())) if (tagged(entity)) event.setIntensity(entity, 0);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void cloud(org.bukkit.event.entity.AreaEffectCloudApplyEvent event) {
event.getAffectedEntities().removeIf(this::tagged);
}
@EventHandler(priority = EventPriority.MONITOR)
public void chunk(org.bukkit.event.world.ChunkLoadEvent event) { reconcile(event.getChunk()); }
@Override public void close() {
closed = true; pending = null;
for (var world : plugin.getServer().getWorlds()) {
var entities = world.getEntities(); if (entities != null) for (var entity : entities) if (tagged(entity)) entity.remove();
}
}
}
@@ -0,0 +1,73 @@
package games.dmg.fantasystick.training;
import games.dmg.fantasystick.persistence.YamlTrainingDummyRepository;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
/** Serialized durable desired state for training dummy entities. */
public final class TrainingDummyDirectory implements AutoCloseable {
private final YamlTrainingDummyRepository repository;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-training-dummy-persistence"));
private volatile List<TrainingDummy> snapshot;
private volatile boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public TrainingDummyDirectory(YamlTrainingDummyRepository repository) throws IOException {
this.repository = Objects.requireNonNull(repository); snapshot = repository.load();
}
public List<TrainingDummy> all() { return snapshot; }
public Optional<TrainingDummy> find(UUID id) {
return snapshot.stream().filter(value -> value.id().equals(id)).findFirst();
}
public CompletableFuture<TrainingDummy> place(String name, UUID world, double x, double y, double z,
float yaw, float pitch) {
var value = new TrainingDummy(UUID.randomUUID(), name, world, x, y, z, yaw, pitch);
return submit(() -> {
var next = new ArrayList<>(snapshot); next.add(value); repository.save(next);
snapshot = List.copyOf(next); return value;
});
}
public CompletableFuture<Optional<TrainingDummy>> remove(UUID id) {
return submit(() -> {
var found = find(id); if (found.isEmpty()) return Optional.empty();
var next = snapshot.stream().filter(value -> !value.id().equals(id)).toList();
repository.save(next); snapshot = next; return found;
});
}
public CompletableFuture<TrainingDummy> restore(TrainingDummy dummy) {
return submit(() -> {
var found = find(dummy.id());
if (found.isPresent()) {
if (found.orElseThrow().equals(dummy)) return dummy;
throw new IOException("Training dummy identity collision");
}
var next = new ArrayList<>(snapshot); next.add(dummy);
next.sort(Comparator.comparing(value -> value.id().toString()));
repository.save(next); snapshot = List.copyOf(next); return dummy;
});
}
private <T> CompletableFuture<T> submit(Callable<T> operation) {
var future = new CompletableFuture<T>();
synchronized (this) {
if (closed) { future.completeExceptionally(new IOException("Training dummy directory closed")); return future; }
pending++;
}
worker.execute(() -> { try { future.complete(operation.call()); }
catch (Throwable failure) { future.completeExceptionally(failure); }
finally { finished(); } });
return future;
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public java.util.concurrent.CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
+7 -5
View File
@@ -33,7 +33,7 @@ sheet-resource-pack-sha1: ''
# Active-session recovery after actual accepted damage has stopped.
# Admin command: /fantasystick config <key> [value]
out-of-combat-healing-delay-seconds: 30
out-of-combat-healing-delay-seconds: 10
out-of-combat-healing-interval-seconds: 10
out-of-combat-healing-amount: 1
quicker-regeneration-i-bonus-healing: 1
@@ -60,14 +60,16 @@ boss-xp: 100
restricted-kill-spacing-blocks: 3
# Fireball defaults from the reviewed starter catalogue.
# Admin command: /fantasystick config <key> [value]
fireball-base-damage: 6
fireball-cooldown-seconds: 4
fireball-cooldown-seconds: 1.2
fireball-range-blocks: 32
fireball-lifetime-seconds: 5
fireball-projectile-speed-blocks-per-tick: 3
# Great Sword defaults from the reviewed starter catalogue.
great-sword-base-damage: 7
great-sword-recovery-seconds: 1.2
great-sword-recovery-seconds: 0.75
# Frost Shard defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
@@ -75,7 +77,7 @@ frost-shard-base-damage: 4
frost-shard-cooldown-seconds: 5
frost-shard-range-blocks: 32
frost-shard-lifetime-seconds: 5
frost-shard-projectile-speed-blocks-per-tick: 1
frost-shard-projectile-speed-blocks-per-tick: 3
frost-shard-slowness-amplifier: 0
frost-shard-slowness-duration-seconds: 2
@@ -109,7 +111,7 @@ stone-skin-damage-reduction-percent: 25
# Shield Bash defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
shield-bash-base-damage: 4
shield-bash-cooldown-seconds: 6
shield-bash-cooldown-seconds: 3
shield-bash-range-blocks: 3
shield-bash-horizontal-knockback: 0.8
shield-bash-minimum-vertical-knockback: 0.35
+4 -1
View File
@@ -8,8 +8,11 @@ author: dmg.games
commands:
fantasystick:
description: Issue or revive sheets, grant fantasy experience, recover sessions or configure Fantasy Stick.
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value] | shop <place [name...]|remove <shop-uuid>|list|restock> | party-registrar <place [name...]|remove <registrar-uuid>|list>
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value] | shop <place [name...]|remove <shop-uuid>|list|restock> | party-registrar <place [name...]|remove <registrar-uuid>|list> | training-dummy <place [name...]|remove <dummy-uuid>|list>
permission: fantasystick.admin
fantasyfeedback:
description: Read or change routine Fantasy Stick combat and ability feedback for your player account.
usage: /fantasyfeedback [on|off]
permissions:
fantasystick.admin:
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
@@ -135,7 +135,7 @@ class AbilityEngineNativeTest {
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var effects = new AtomicInteger();
registry.register(new AbilityDefinition("test_focus", "Test Focus", Material.AMETHYST_SHARD,
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE,
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
@@ -206,7 +206,7 @@ class AbilityEngineNativeTest {
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
AbilityDefinition.TargetPolicy targetPolicy, games.dmg.fantasystick.ability.AbilityEffect effect) {
return new AbilityDefinition(id, id, Material.AMETHYST_SHARD, List.of("Test definition"), "Use test input",
Map.of(), BigInteger.ONE, activation, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), BigInteger.ONE, AbilityDefinition.Category.ATK, activation, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), targetPolicy, effect);
}
@@ -36,16 +36,19 @@ class AbilityRegistryNativeTest {
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "UPPER", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("x", " ", Material.STONE,
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE,
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {})),
() -> assertThrows(NullPointerException.class, () -> new AbilityDefinition("category", "Category", Material.STONE,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, null, AbilityDefinition.Activation.IMMEDIATE,
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {})),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "low", Map.of(Stat.INT, BigInteger.valueOf(9)), Map.of(), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "negative", valid.requirements(), Map.of("range", BigDecimal.valueOf(-1)), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "infinite", valid.requirements(), Map.of("range", new BigDecimal("1E1000000")), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "cooldown", valid.requirements(), Map.of(), Duration.ofSeconds(-1))),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "long_cooldown", valid.requirements(), Map.of(), Duration.ofDays(366))),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("air", "Air", Material.AIR,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Activation.IMMEDIATE,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Category.ATK, AbilityDefinition.Activation.IMMEDIATE,
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {}))
);
}
@@ -68,7 +71,7 @@ class AbilityRegistryNativeTest {
for (var policy : AbilityDefinition.TargetPolicy.values()) {
var base = definition("x");
assertDoesNotThrow(() -> new AbilityDefinition("a_" + activation.name().toLowerCase() + "_" + policy.name().toLowerCase(),
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), activation,
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), base.category(), activation,
base.gesture(), base.cooldown(), base.parameters(), policy, base.effect()));
}
}
@@ -76,7 +79,7 @@ class AbilityRegistryNativeTest {
static AbilityDefinition definition(String id) {
return new AbilityDefinition(id, "Test Bolt", Material.AMETHYST_SHARD, List.of("A focused test effect"),
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12), "duration", BigDecimal.valueOf(5)),
AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {});
@@ -85,6 +88,6 @@ class AbilityRegistryNativeTest {
private static AbilityDefinition copy(AbilityDefinition value, String id, Map<Stat, BigInteger> requirements,
Map<String, BigDecimal> parameters, Duration cooldown) {
return new AbilityDefinition(id, value.name(), value.icon(), value.lore(), value.instructions(), requirements,
value.pointCost(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
value.pointCost(), value.category(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
}
}
@@ -80,6 +80,9 @@ class BasicAttackNativeTest {
assertTrue(parties.leave(a.core.id, party.id()));
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(2, events.size()); assertFalse(events.getLast().isCancelled());
String feedback = "Basic Attack did 1 damage to " + b.core.record.name() + ".";
assertEquals(1, a.messages.stream().filter(feedback::equals).count());
assertEquals(0, b.messages.stream().filter(feedback::equals).count(), "Targets receive no incoming-damage report");
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(2, events.size(), "The accepted second hit starts cadence; the cancelled first hit did not");
}
@@ -40,6 +40,7 @@ class ChainLightningNativeTest {
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "chain_lightning")); drain(fixture, 2);
var first = mock(LivingEntity.class); var second = mock(LivingEntity.class);
locate(first, caster.world, UUID.randomUUID(), 10); locate(second, caster.world, UUID.randomUUID(), 14);
when(first.getName()).thenReturn("Zombie"); when(second.getName()).thenReturn("Skeleton");
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
doReturn(new Location(caster.world, 0, 65, 0)).when(caster.player).getEyeLocation();
doReturn(true).when(caster.player).hasLineOfSight(first);
@@ -51,6 +52,8 @@ class ChainLightningNativeTest {
use(fixture, caster); drain(fixture, 2);
assertFalse(damage.isEmpty(), caster.messages.toString());
assertEquals(13, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(9.75, damage.get(second.getUniqueId()), 1.0e-10);
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 13 damage to Zombie."::equals).count());
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 9.75 damage to Skeleton."::equals).count());
var stored = ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
.find(caster.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning"));
@@ -58,6 +61,49 @@ class ChainLightningNativeTest {
}
}
@SuppressWarnings("unchecked")
@Test void chainedDamageMeasuresEachTrainingDummyIndependentlyWithoutContribution() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, true);
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
UUID worldId = UUID.randomUUID(); when(caster.world.getUID()).thenReturn(worldId);
var directory = (games.dmg.fantasystick.training.TrainingDummyDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "trainingDummies");
var firstRecord = directory.place("First Target", worldId, 10, 64, 0, 0, 0).get();
var secondRecord = directory.place("Second Target", worldId, 14, 64, 0, 0, 0).get();
int chunkX = 0, chunkZ = 0; var chunk = mock(org.bukkit.Chunk.class); when(chunk.getWorld()).thenReturn(caster.world);
when(chunk.getX()).thenReturn(chunkX); when(chunk.getZ()).thenReturn(chunkZ); when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);
when(caster.world.isChunkLoaded(chunkX, chunkZ)).thenReturn(true);
var first = TrainingDummyNativeTest.zombie(new Location(caster.world, firstRecord.x(), firstRecord.y(), firstRecord.z()));
var second = TrainingDummyNativeTest.zombie(new Location(caster.world, secondRecord.x(), secondRecord.y(), secondRecord.z()));
when(first.getName()).thenReturn("First Target"); when(second.getName()).thenReturn("Second Target");
var spawning = new ArrayDeque<>(List.of(first, second));
doAnswer(call -> { var dummy = spawning.removeFirst(); ((java.util.function.Consumer<org.bukkit.entity.Zombie>) call.getArgument(2)).accept(dummy); return dummy; })
.when(caster.world).spawn(any(Location.class), eq(org.bukkit.entity.Zombie.class), any(java.util.function.Consumer.class));
var controller = (games.dmg.fantasystick.training.TrainingDummyController)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "trainingDummyController");
controller.reconcile(chunk); assertTrue(controller.tagged(first)); assertTrue(controller.tagged(second));
assertTrue(controller.target(first)); assertTrue(controller.target(second));
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
doReturn(new Location(caster.world, 0, 65, 0)).when(caster.player).getEyeLocation();
doReturn(true).when(caster.player).hasLineOfSight(first); doReturn(true).when(first).hasLineOfSight(second);
doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(10, 64, 0), first))
.when(caster.world).rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0),
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
doReturn(List.of(second)).when(first).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
var events = new ArrayList<EntityDamageEvent>();
for (var target : List.of(first, second)) doAnswer(call -> {
var source = (DamageSource) call.getArgument(1);
var event = new EntityDamageEvent(target, EntityDamageEvent.DamageCause.LIGHTNING, source, (Double) call.getArgument(0));
events.add(event); fixture.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class));
use(fixture, caster); drain(fixture, 2);
assertEquals(2, events.size()); assertTrue(events.stream().allMatch(EntityDamageEvent::isCancelled));
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 13 damage to First Target."::equals).count(), caster.messages::toString);
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 9.75 damage to Second Target."::equals).count());
}
}
@Test void exactChainUsesNearestUuidTiesFourTargetLimitDecayAndCancellationContinuation() {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
var world = mock(org.bukkit.World.class); UUID casterId = UUID.randomUUID(); when(caster.getUniqueId()).thenReturn(casterId);
@@ -226,6 +272,8 @@ class ChainLightningNativeTest {
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active); when(input.target()).thenReturn(target);
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
doAnswer(call -> { ((LivingEntity) call.getArgument(0)).damage((Double) call.getArgument(1), (org.bukkit.damage.DamageSource) call.getArgument(2)); return null; })
.when(execution).damage(any(LivingEntity.class), anyDouble(), any(org.bukkit.damage.DamageSource.class));
var record = new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(record.stats()); stats.put(Stat.INT, BigInteger.valueOf(20));
when(execution.character()).thenReturn(new CharacterRecord(active.character(), record.name(), record.revision(), stats,
@@ -327,32 +327,67 @@ class CharacterBookNativeTest {
assertTrue(pages.size() <= 100, "Keep each native linked-book view bounded");
assertTrue(pages.get(0).getString().contains("STR: 0 [+1 STR]"));
assertTrue(pages.get(0).getString().contains("INT: 12 [+1 INT]"));
assertTrue(pages.get(1).getString().startsWith("Current gear"));
assertTrue(pages.get(1).getString().contains("Head: Cosmetic"));
assertTrue(pages.get(1).getString().startsWith("Gear\n\nHead: Cosmetic\nChestplate:"));
assertFalse(pages.get(1).getString().contains("Current gear"));
assertFalse(pages.get(1).getString().contains("Cosmetic\nonly"));
assertTrue(pages.get(2).getString().startsWith("Active abilities"));
assertTrue(pages.get(2).getString().contains("3: No Ability"));
int index = firstPageStarting(pages, "Ability index");
assertTrue(index > 2);
var indexedNames = new java.util.ArrayList<String>();
var categorized = new java.util.LinkedHashMap<String, java.util.List<String>>();
var indexPageByAbility = new java.util.HashMap<String, Integer>();
int partyPage = firstPageStarting(pages, "Party");
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Ability index")) {
indexedNames.add(line);
var jump = changePage(pages.get(page), line);
assertNotNull(jump, line);
var lines = pages.get(page).getString().split("\\n");
assertTrue(lines.length >= 2); String category = lines[1];
assertTrue(java.util.List.of("ATK", "BUF", "HEAL").contains(category));
var names = categorized.computeIfAbsent(category, ignored -> new java.util.ArrayList<>());
for (int line = 2; line < lines.length; line++) if (!lines[line].isBlank()) {
names.add(lines[line]); indexPageByAbility.put(lines[line], page);
var jump = changePage(pages.get(page), lines[line]);
assertNotNull(jump, lines[line]);
assertTrue(jump.page() - 1 < partyPage, "Ability details must precede party controls");
}
}
var sortedNames = indexedNames.stream().sorted().toList();
assertEquals(sortedNames, indexedNames);
assertEquals(20, indexedNames.size());
assertEquals(java.util.List.of("ATK", "BUF", "HEAL"), new java.util.ArrayList<>(categorized.keySet()));
assertEquals(java.util.List.of("Chain Lightning", "Fireball", "Frost Nova", "Frost Shard", "Great Sword",
"Hex", "Longbow", "Meteor", "Shield Bash"), categorized.get("ATK"));
assertEquals(java.util.List.of("Guardian Bond", "Inspire", "Purify", "Quickstep", "Rallying Cry",
"Stone Skin", "War Banner"), categorized.get("BUF"));
assertEquals(java.util.List.of("Mending Light", "Revive", "Sanctuary", "Second Wind"), categorized.get("HEAL"));
assertEquals(20, categorized.values().stream().mapToInt(java.util.List::size).sum());
var detailStartByAbility = new java.util.HashMap<String, Integer>();
for (var entry : indexPageByAbility.entrySet()) {
var jump = changePage(pages.get(entry.getValue()), entry.getKey()); assertNotNull(jump);
detailStartByAbility.put(entry.getKey(), jump.page() - 1);
}
int passiveIndex = firstPageStarting(pages, "Passive index");
int firstPassiveDetail = Integer.MAX_VALUE;
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) {
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Passive index")) {
var jump = changePage(pages.get(page), line); if (jump != null) firstPassiveDetail = Math.min(firstPassiveDetail, jump.page() - 1);
}
}
for (var entry : indexPageByAbility.entrySet()) {
int detailPage = detailStartByAbility.get(entry.getKey());
assertTrue(pages.get(detailPage).getString().startsWith(entry.getKey() + "\n"));
assertTrue(pages.get(detailPage).getString().split("\\n")[1].matches("ATK|BUF|HEAL"));
int end = firstPassiveDetail;
for (int other : detailStartByAbility.values()) if (other > detailPage) end = Math.min(end, other);
var backPages = new java.util.ArrayList<Integer>();
for (int candidate = detailPage; candidate < end; candidate++)
if (changePage(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
var back = changePage(pages.get(end - 1), "[Back to index]"); assertNotNull(back);
assertEquals(entry.getValue() + 1, back.page(), entry.getKey());
}
var fireballJump = changePage(pages.get(index), "Fireball");
assertNotNull(fireballJump);
var detail = pages.get(fireballJump.page() - 1);
assertTrue(detail.getString().startsWith("Fireball\nINT 12\n[Learn]"), detail.getString());
assertTrue(detail.getString().startsWith("Fireball\nATK\nINT 12\n[Learn]"), detail.getString());
assertFalse(text(a.books.getLast()).contains("Cost:"));
assertFalse(text(a.books.getLast()).contains("Requires:"));
assertTrue(text(a.books.getLast()).contains("Shield Bash\nSTR 12, CON 12"));
assertTrue(text(a.books.getLast()).contains("Shield Bash\nATK\nSTR 12, CON 12"));
assertFalse(detail.getString().contains("Icon:"));
assertFalse(text(a.books.getLast()).contains("Assign ability"));
assertFalse(text(a.books.getLast()).contains("[3]"));
@@ -365,7 +400,29 @@ class CharacterBookNativeTest {
assertTrue(refreshed.get(2).getString().contains("3: Fireball"));
var activeJump = changePage(refreshed.get(2), "Fireball");
assertNotNull(activeJump);
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nINT 12\n[Unlearn]"));
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nATK\nINT 12\n[Unlearn]"));
}
}
@Test void mixedCategorySlotsAndUnknownIdsRemainUnrestrictedAndUnchanged() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var mixed = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(),
java.util.Set.of("fireball", "quickstep", "second_wind", "unknown_saved"),
java.util.List.of("fireball", "quickstep", "second_wind", "unknown_saved", "", "", ""),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(mixed, old.revision()); f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
var pages = nativePages(a); var active = pages.get(firstPageStarting(pages, "Active abilities"));
assertTrue(active.getString().contains("3: Fireball")); assertTrue(active.getString().contains("4: Quickstep"));
assertTrue(active.getString().contains("5: Second Wind")); assertTrue(active.getString().contains("6: unknown_saved"));
for (String ability : java.util.List.of("Fireball", "Quickstep", "Second Wind")) {
var jump = changePage(active, ability); assertNotNull(jump, ability);
assertTrue(pages.get(jump.page() - 1).getString().startsWith(ability + "\n"));
}
assertEquals(mixed, characters(f).find(mixed.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@@ -53,7 +53,7 @@ class EquipmentAdminCommandNativeTest {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
var sender=new Sender();
assertEquals(List.of("config","experience","give","inspect","party-registrar","recover","shop"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("config","experience","give","inspect","party-registrar","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
@@ -94,7 +94,7 @@ class EquipmentAdminCommandNativeTest {
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
var sender = new Sender();
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
@@ -1283,6 +1283,40 @@ class EquipmentSessionEventsNativeTest {
}
}
@Test void nativeDoorInteractionTakesPrecedenceOverAbilityUseAndLongbowDraw() throws Exception {
try (var f = new Fixture()) {
f.loadout("quickstep", "longbow");
var a = f.actor; f.core.activate(a);
var door = org.mockito.Mockito.mock(org.bukkit.block.Block.class);
org.mockito.Mockito.doReturn(org.mockito.Mockito.mock(org.bukkit.block.data.type.Door.class)).when(door).getBlockData();
for (int slot : new int[]{2, 3}) {
a.inventory.getInventory().setSelectedSlot(slot);
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK, a.inventory.getItem(slot), door,
org.bukkit.block.BlockFace.UP, org.bukkit.inventory.EquipmentSlot.HAND);
event.setUseInteractedBlock(org.bukkit.event.Event.Result.ALLOW);
int beforeInputs = f.inputs.size(); int beforeDraws = a.useStarts;
f.dispatcher.accept(event);
assertEquals(org.bukkit.event.Event.Result.ALLOW, event.useInteractedBlock());
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
assertEquals(beforeInputs, f.inputs.size(), "A door click is not an ability input");
assertEquals(beforeDraws, a.useStarts, "A door click does not begin Longbow drawing");
}
var stone = org.mockito.Mockito.mock(org.bukkit.block.Block.class);
org.mockito.Mockito.doReturn(org.mockito.Mockito.mock(org.bukkit.block.data.BlockData.class)).when(stone).getBlockData();
a.inventory.getInventory().setSelectedSlot(2);
var ordinaryBlock = new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK, a.inventory.getItem(2), stone,
org.bukkit.block.BlockFace.UP, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(ordinaryBlock); assertEquals(1, f.inputs.size(), "Non-door ability use remains active");
a.inventory.getInventory().setSelectedSlot(3);
var air = new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.inventory.getItem(3), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(air); assertEquals(1, a.useStarts, "Air use still begins Longbow drawing");
}
}
@Test void selectedAbilityUseReachesItsHandlerWithoutVanillaThrowDrinkEquipBlockOrPlacement() throws Exception {
try (var f = new Fixture()) {
String[] abilities = {"chain_lightning", "purify", "guardian_bond", "shield_bash", "rallying_cry", "frost_nova", "revive"};
@@ -0,0 +1,123 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FantasyFeedbackCommandNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredPlayerCommandDefaultsOnDurablyTurnsOffAndReloadsWithoutAdminPermission() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
assertNotNull(fixture.feedbackCommand);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
assertTrue(fixture.feedbackCommand.execute(player.player, "fantasyfeedback", new String[]{}));
assertEquals("Fantasy feedback is on.", player.messages.getLast());
assertEquals(List.of("off", "on"), fixture.feedbackCommand.tabComplete(
player.player, "fantasyfeedback", new String[]{""}));
assertEquals(List.of("off"), fixture.feedbackCommand.tabComplete(
player.player, "fantasyfeedback", new String[]{"of"}));
int before = player.messages.size();
assertTrue(fixture.feedbackCommand.execute(player.player, "fantasyfeedback", new String[]{"off"}));
assertEquals(before, player.messages.size(), "queued persistence is not an acknowledged preference change");
fixture.tick();
assertEquals("Fantasy feedback is now off.", player.messages.getLast());
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
assertTrue(fixture.feedbackCommand.execute(player.player, "fantasyfeedback", new String[]{}));
assertEquals("Fantasy feedback is off.", player.messages.getLast());
}
}
@Test void consoleMalformedAndExtraArgumentsCannotChangeAnAccountPreference() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var console = new EquipmentAdminCommandNativeTest.Sender();
assertTrue(fixture.feedbackCommand.execute(console.port, "fantasyfeedback", new String[]{"off"}));
assertTrue(console.messages.getLast().contains("player-account"));
assertTrue(fixture.feedbackCommand.tabComplete(console.port, "fantasyfeedback", new String[]{""}).isEmpty());
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
assertFalse(fixture.feedbackCommand.getExecutor().onCommand(player.player, fixture.feedbackCommand,
"fantasyfeedback", new String[]{"OFF"}));
assertFalse(fixture.feedbackCommand.getExecutor().onCommand(player.player, fixture.feedbackCommand,
"fantasyfeedback", new String[]{"off", "extra"}));
assertTrue(((games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences"))
.enabled(player.core.id));
}
}
@Test void sourceAndAffectedAccountPreferencesIndependentlyFilterPerTickMessages() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = new FantasyStickLifecycleNativeTest.Actor(data);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(source.core.id, source.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(source); fixture.activate(target);
var feedback = (games.dmg.fantasystick.presentation.CombatFeedback)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "combatFeedback");
feedback.report("Mending Light", source.player,
games.dmg.fantasystick.ability.AbilityReport.Type.HEALING, target.player,
new java.math.BigDecimal("1.234"));
String healing = "Mending Light healed " + target.core.record.name() + " for 1.23 health.";
assertEquals(1, source.messages.stream().filter(healing::equals).count());
assertEquals(1, target.messages.stream().filter(healing::equals).count());
fixture.feedbackCommand.execute(source.player, "fantasyfeedback", new String[]{"off"}); fixture.tick();
feedback.report("Sanctuary", source.player,
games.dmg.fantasystick.ability.AbilityReport.Type.HEALING, target.player,
new java.math.BigDecimal("2"));
String pulse = "Sanctuary healed " + target.core.record.name() + " for 2 health.";
assertEquals(0, source.messages.stream().filter(pulse::equals).count());
assertEquals(1, target.messages.stream().filter(pulse::equals).count());
fixture.feedbackCommand.execute(target.player, "fantasyfeedback", new String[]{"off"}); fixture.tick();
feedback.report("Sanctuary", source.player,
games.dmg.fantasystick.ability.AbilityReport.Type.HEALING, target.player,
new java.math.BigDecimal("2"));
assertEquals(0, source.messages.stream().filter(pulse::equals).count());
assertEquals(1, target.messages.stream().filter(pulse::equals).count());
fixture.feedbackCommand.execute(source.player, "fantasyfeedback", new String[]{"on"}); fixture.tick();
var zombie = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(zombie.getName()).thenReturn("Zombie");
org.mockito.Mockito.when(zombie.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
for (int tick = 0; tick < 2; tick++) feedback.report("Burning Ground", source.player,
games.dmg.fantasystick.ability.AbilityReport.Type.DAMAGE, zombie,
new java.math.BigDecimal("2.345"));
assertEquals(2, source.messages.stream()
.filter("Burning Ground did 2.35 damage to Zombie."::equals).count());
feedback.report("Purify", source.player,
games.dmg.fantasystick.ability.AbilityReport.Type.CLEANSING, target.player,
new java.math.BigDecimal("2"));
feedback.report("Stone Skin", source.player,
games.dmg.fantasystick.ability.AbilityReport.Type.PREVENTION, target.player,
new java.math.BigDecimal("1.005"));
feedback.report("Rallying Cry", source.player,
games.dmg.fantasystick.ability.AbilityReport.Type.BOOST_USED, zombie,
new java.math.BigDecimal("0.5"));
assertTrue(source.messages.contains("Purify removed 2 harmful effects from "
+ target.core.record.name() + "."));
assertTrue(source.messages.contains("Stone Skin prevented 1.01 damage to "
+ target.core.record.name() + "."));
assertTrue(source.messages.contains("Rallying Cry added 0.5 damage against Zombie."));
int messages = source.messages.size();
feedback.report("No-op", source.player,
games.dmg.fantasystick.ability.AbilityReport.Type.DAMAGE, zombie, java.math.BigDecimal.ZERO);
assertEquals(messages, source.messages.size());
}
}
}
@@ -887,7 +887,9 @@ class FantasyStickLifecycleNativeTest {
int scenario = 0;
for (String row : List.of("fireball-base-damage: -1", "fireball-cooldown-seconds: -1",
"fireball-range-blocks: 0", "fireball-range-blocks: 1e1000000",
"fireball-lifetime-seconds: 0", "fireball-lifetime-seconds: 61")) {
"fireball-lifetime-seconds: 0", "fireball-lifetime-seconds: 61",
"fireball-projectile-speed-blocks-per-tick: 0",
"fireball-projectile-speed-blocks-per-tick: 65")) {
var directory = data.resolve("invalid-fireball-" + scenario++); java.nio.file.Files.createDirectories(directory);
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
try (var f = new Fixture(directory)) {
@@ -1117,6 +1119,7 @@ class FantasyStickLifecycleNativeTest {
final Thread main = Thread.currentThread();
final Map<java.util.UUID, org.bukkit.entity.Player> players = new HashMap<>();
org.bukkit.command.PluginCommand command;
org.bukkit.command.PluginCommand feedbackCommand;
int playerLookups;
String failRegistrationAt;
boolean failRemoval;
@@ -1199,7 +1202,7 @@ class FantasyStickLifecycleNativeTest {
case "getWorld" -> players.values().stream().map(org.bukkit.entity.Player::getWorld)
.filter(world -> java.util.Objects.equals(world.getUID(), args[0])).findFirst().orElse(null);
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
case "getPluginCommand" -> command;
case "getPluginCommand" -> "fantasyfeedback".equals(args[0]) ? feedbackCommand : command;
case "createInventory" -> args[1] instanceof Integer size
? new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], size, (net.kyori.adventure.text.Component) args[2])
: new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], (org.bukkit.event.inventory.InventoryType) args[1], (net.kyori.adventure.text.Component) args[2]);
@@ -1220,17 +1223,24 @@ class FantasyStickLifecycleNativeTest {
loader = new Loader(server, data);
plugin = (JavaPlugin) loader.loadClass("games.dmg.fantasystick.FantasyStickPlugin").getConstructor().newInstance();
plugin.getLogger().addHandler(logHandler);
var constructor = org.bukkit.command.PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class);
constructor.setAccessible(true);
var definition = plugin.getDescription().getCommands().get("fantasystick");
if (definition != null) {
// Bukkit normally constructs this from the packaged descriptor during plugin loading.
var constructor = org.bukkit.command.PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class);
constructor.setAccessible(true);
command = constructor.newInstance("fantasystick", plugin);
command.setPermission((String) definition.get("permission"));
// Explicit server-message port: native permission dispatch otherwise reads global Bukkit.server.
command.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
command.setUsage((String) definition.get("usage"));
}
var feedbackDefinition = plugin.getDescription().getCommands().get("fantasyfeedback");
if (feedbackDefinition != null) {
feedbackCommand = constructor.newInstance("fantasyfeedback", plugin);
feedbackCommand.setPermission((String) feedbackDefinition.get("permission"));
feedbackCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
feedbackCommand.setUsage((String) feedbackDefinition.get("usage"));
}
}
<T> List<T> packets(org.bukkit.entity.Player viewer, Class<T> type) {
@@ -1278,7 +1288,7 @@ class FantasyStickLifecycleNativeTest {
assertTrue(actor.core.marker().endsWith("|PACKED"));
}
private void captureWorkers(JavaPlugin instance) {
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals")) {
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies")) {
var store = field(instance, name);
if (store != null) {
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -21,6 +22,8 @@ class FireballNativeTest {
PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).contains("Fireball"));
assertTrue(PartyBookNativeTest.text(a).contains("INT 12"));
assertTrue(PartyBookNativeTest.text(a).contains("Cooldown: 1.2s"));
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("projectile-speed=3"));
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
@@ -41,7 +44,7 @@ class FireballNativeTest {
launches.incrementAndGet(); launchedVelocity.set(call.getArgument(1));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(a.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
}).when(a.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Arrow.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
@@ -49,14 +52,22 @@ class FireballNativeTest {
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand()); assertEquals(1, launches.get());
org.mockito.Mockito.verify(projectile).setVisualFire(true);
assertEquals(new org.bukkit.util.Vector(1, 0, 0), launchedVelocity.get());
org.mockito.Mockito.verify(projectile).setPickupStatus(org.bukkit.entity.AbstractArrow.PickupStatus.DISALLOWED);
org.mockito.Mockito.verify(projectile).setDamage(0); org.mockito.Mockito.verify(projectile).setCritical(false);
org.mockito.Mockito.verify(projectile).setPierceLevel(0); org.mockito.Mockito.verify(projectile).setKnockbackStrength(0);
assertEquals(new org.bukkit.util.Vector(3, 0, 0), launchedVelocity.get());
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.getName()).thenReturn("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 vanillaSource = org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class);
var vanilla = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, vanillaSource, 9);
f.dispatcher.accept(vanilla); assertTrue(vanilla.isCancelled(), "Owned arrow native damage is contained");
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
org.mockito.Mockito.doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
@@ -69,6 +80,7 @@ class FireballNativeTest {
assertFalse(damages.getFirst().isCancelled());
assertSame(projectile, damages.getFirst().getDamager());
assertEquals(org.bukkit.damage.DamageType.FIREBALL, damages.getFirst().getDamageSource().getDamageType());
assertEquals(1, a.messages.stream().filter("Fireball did 7 damage to Zombie."::equals).count());
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -76,6 +88,8 @@ class FireballNativeTest {
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(1, a.messages.stream().filter(("Health Steal healed " + a.core.record.name()
+ " for 1 health.")::equals).count());
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));
@@ -84,7 +98,7 @@ class FireballNativeTest {
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertEquals(1, launches.get(), "The persisted four-second deadline refuses immediate reuse");
assertEquals(1, launches.get(), "The persisted 1.2-second deadline refuses immediate reuse");
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()));
@@ -108,6 +122,58 @@ class FireballNativeTest {
}
}
@SuppressWarnings("unchecked")
@Test void projectileMeasuresFinalFractionalDamageAgainstTrainingDummyWithoutHealthLossOrRewards() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLearnedFireball(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
java.util.UUID worldId = java.util.UUID.randomUUID(); org.mockito.Mockito.when(a.world.getUID()).thenReturn(worldId);
var directory = (games.dmg.fantasystick.training.TrainingDummyDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
var saved = directory.place("Arcane Target", worldId, 10, 65.6, 0, 0, 0).get();
int chunkX = ((int) Math.floor(saved.x())) >> 4, chunkZ = ((int) Math.floor(saved.z())) >> 4;
var chunk = org.mockito.Mockito.mock(org.bukkit.Chunk.class); org.mockito.Mockito.when(chunk.getWorld()).thenReturn(a.world);
org.mockito.Mockito.when(chunk.getX()).thenReturn(chunkX); org.mockito.Mockito.when(chunk.getZ()).thenReturn(chunkZ);
org.mockito.Mockito.when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);
org.mockito.Mockito.when(a.world.isChunkLoaded(chunkX, chunkZ)).thenReturn(true);
var dummy = TrainingDummyNativeTest.zombie(new org.bukkit.Location(a.world, 10, 65.6, 0));
org.mockito.Mockito.when(dummy.getName()).thenReturn("Arcane Target");
org.mockito.Mockito.doAnswer(call -> { ((java.util.function.Consumer<org.bukkit.entity.Zombie>) call.getArgument(2)).accept(dummy); return dummy; })
.when(a.world).spawn(org.mockito.ArgumentMatchers.any(org.bukkit.Location.class),
org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Zombie.class), org.mockito.ArgumentMatchers.any(java.util.function.Consumer.class));
var controller = (games.dmg.fantasystick.training.TrainingDummyController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummyController");
controller.reconcile(chunk); assertTrue(controller.tagged(dummy)); assertTrue(controller.target(dummy));
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
org.mockito.Mockito.doAnswer(call -> {
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile)); return projectile;
}).when(a.player).launchProjectile(
org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Arrow.class), org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
var events = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
org.mockito.Mockito.doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, dummy,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0));
event.setDamage(6.5); events.add(event); f.dispatcher.accept(event); return null;
}).when(dummy).damage(org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, dummy));
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
assertEquals(1, a.messages.stream().filter("Fireball did 6.5 damage to Arcane Target."::equals).count(), a.messages::toString);
org.mockito.Mockito.verify(dummy, org.mockito.Mockito.never()).setHealth(org.mockito.ArgumentMatchers.anyDouble());
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var before = storage.find(a.core.record.id()).get().orElseThrow();
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(dummy, events.getFirst().getDamageSource(), new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var after = storage.find(before.id()).get().orElseThrow();
assertEquals(before.experience(), after.experience()); assertEquals(before.coins(), after.coins());
}
}
@Test void bookUnlearnRefundsOnceClearsOwnedFireballAndRetainsDeadlineThroughReuse() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -124,7 +190,7 @@ class FireballNativeTest {
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
org.mockito.Mockito.doAnswer(call -> { launches.incrementAndGet(); return projectile; }).when(a.player)
.launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
.launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Arrow.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
cast(f, a);
var cast = storage.find(a.core.record.id()).get().orElseThrow();
@@ -257,7 +323,8 @@ class FireballNativeTest {
@Test void validatedConfiguredBalanceReachesTheRegisteredDefinition() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-base-damage: 9\n"
+ "fireball-cooldown-seconds: 2\nfireball-range-blocks: 40\nfireball-lifetime-seconds: 7\n");
+ "fireball-cooldown-seconds: 2\nfireball-range-blocks: 40\nfireball-lifetime-seconds: 7\n"
+ "fireball-projectile-speed-blocks-per-tick: 4\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
@@ -267,7 +334,111 @@ class FireballNativeTest {
assertEquals(new java.math.BigDecimal("9"), definition.parameters().get("base-damage"));
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("range")));
assertEquals(new java.math.BigDecimal("7"), definition.parameters().get("lifetime"));
assertEquals(new java.math.BigDecimal("4"), definition.parameters().get("projectile-speed"));
assertTrue(String.join(" ", definition.lore()).contains("Deals 9"));
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
assertEquals(java.util.List.of("fireball-projectile-speed-blocks-per-tick"), f.command.tabComplete(admin.port,
"fantasystick", new String[]{"config", "fireball-projectile-s"}));
assertEquals(java.util.List.of("3", "4"), f.command.tabComplete(admin.port, "fantasystick",
new String[]{"config", "fireball-projectile-speed-blocks-per-tick", ""}));
f.command.execute(admin.port, "fantasystick",
new String[]{"config", "fireball-projectile-speed-blocks-per-tick", "4.5"});
Runnable response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
var updated = registry.find("fireball").orElseThrow();
assertEquals(new java.math.BigDecimal("4.5"), updated.parameters().get("projectile-speed"));
assertTrue(java.nio.file.Files.readString(data.resolve("config.yml"))
.contains("fireball-projectile-speed-blocks-per-tick: '4.5'"));
}
}
@Test void liveSpeedUpdateAppliesOnlyToFutureCastsAndRetainsActiveArrow() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-cooldown-seconds: 0\n"
+ "fireball-projectile-speed-blocks-per-tick: 2\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); withFireball(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
var first = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
var second = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
var arrows = new java.util.ArrayDeque<>(java.util.List.of(first, second));
var velocities = new java.util.ArrayList<org.bukkit.util.Vector>();
org.mockito.Mockito.doAnswer(call -> {
velocities.add(call.getArgument(1)); return arrows.removeFirst();
}).when(a.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Arrow.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
cast(f, a); assertEquals(new org.bukkit.util.Vector(2, 0, 0), velocities.getFirst());
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fantasystick",
new String[]{"config", "fireball-projectile-speed-blocks-per-tick", "4"});
Runnable response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
org.mockito.Mockito.verify(first, org.mockito.Mockito.never()).remove();
cast(f, a); assertEquals(java.util.List.of(new org.bukkit.util.Vector(2, 0, 0),
new org.bukkit.util.Vector(4, 0, 0)), velocities);
}
}
@Test void fractionalCooldownRefusesImmediatelyAndOneNanosecondBeforeButAcceptsExactBoundary() throws Exception {
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2026-09-21T20:00:00Z"));
java.time.Clock clock = new java.time.Clock() {
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
@Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; }
@Override public java.time.Instant instant() { return now.get(); }
};
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var created = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(created.stats()); stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
var initial = new games.dmg.fantasystick.persistence.CharacterRecord(created.id(), created.name(), created.revision(), stats,
created.level(), created.experience(), created.unspentPoints(), created.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""), created.cooldowns(), created.restrictedKills(), false, null, null);
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(repository, clock)) {
storage.create(initial).get(5, java.util.concurrent.TimeUnit.SECONDS);
var current = new java.util.concurrent.atomic.AtomicReference<>(initial);
var sessions = org.mockito.Mockito.mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), initial.id());
org.mockito.Mockito.when(sessions.current(active)).thenReturn(true);
org.mockito.Mockito.when(sessions.currentRecord(active)).thenAnswer(ignored -> java.util.Optional.of(current.get()));
org.mockito.Mockito.when(sessions.acknowledgeProgress(org.mockito.ArgumentMatchers.eq(active),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenAnswer(call -> {
current.set(call.getArgument(2)); return true;
});
var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class); var world = org.mockito.Mockito.mock(org.bukkit.World.class);
var eye = new org.bukkit.Location(world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.when(player.getEyeLocation()).thenReturn(eye); org.mockito.Mockito.when(player.getWorld()).thenReturn(world);
var launches = new java.util.concurrent.atomic.AtomicInteger();
org.mockito.Mockito.when(player.launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Arrow.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class))).thenAnswer(ignored -> {
launches.incrementAndGet(); var arrow = org.mockito.Mockito.mock(org.bukkit.entity.Arrow.class);
org.mockito.Mockito.when(arrow.getUniqueId()).thenReturn(UUID.randomUUID()); return arrow;
});
var fireball = new games.dmg.fantasystick.ability.Fireball(games.dmg.fantasystick.ability.Fireball.Settings.defaults());
var registry = new games.dmg.fantasystick.ability.AbilityRegistry(); registry.register(fireball.definition());
var parties = new games.dmg.fantasystick.party.PartyRoster(clock, ignored -> true);
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties, clock,
Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failure -> fail(failure.toString()));
java.util.function.Consumer<Void> cast = ignored -> engine.accept(new games.dmg.fantasystick.session.ControlInput(player,
active, 2, "fireball", games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
cast.accept(null); await(launches, 1);
var deadline = now.get().plusMillis(1200); assertEquals(deadline, current.get().cooldowns().get("fireball"));
cast.accept(null); assertEquals(1, launches.get());
now.set(deadline.minusNanos(1)); cast.accept(null); assertEquals(1, launches.get());
now.set(deadline); cast.accept(null); await(launches, 2);
engine.close(); fireball.close(); parties.close();
}
}
@Test void retainedCooldownIsHonoredAndAbsentSpeedDefaultsWithoutRewritingConfiguration() throws Exception {
String retained = "fireball-cooldown-seconds: 4\n";
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var definition = ((games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry")).find("fireball").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(4), definition.cooldown());
assertEquals(new java.math.BigDecimal("3"), definition.parameters().get("projectile-speed"));
assertEquals(retained, java.nio.file.Files.readString(data.resolve("config.yml")));
}
}
@@ -302,7 +473,7 @@ class FireballNativeTest {
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).launchProjectile(
org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class), org.mockito.ArgumentMatchers.any());
org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Arrow.class), org.mockito.ArgumentMatchers.any());
org.mockito.Mockito.verify(sessions, org.mockito.Mockito.never()).acknowledgeProgress(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
assertEquals(1, failures.size());
@@ -327,11 +498,12 @@ class FireballNativeTest {
assertEquals(java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)),
fireball.requirements());
assertEquals(BigInteger.ONE, fireball.pointCost());
assertEquals(java.time.Duration.ofSeconds(4), fireball.cooldown());
assertEquals(java.time.Duration.ofMillis(1200), fireball.cooldown());
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, fireball.gesture());
assertEquals(games.dmg.fantasystick.ability.AbilityDefinition.Activation.PROJECTILE, fireball.activation());
assertEquals(new java.math.BigDecimal("32"), fireball.parameters().get("range"));
assertEquals(new java.math.BigDecimal("5"), fireball.parameters().get("lifetime"));
assertEquals(new java.math.BigDecimal("3"), fireball.parameters().get("projectile-speed"));
var unavailable = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable);
unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable);
@@ -341,6 +513,11 @@ class FireballNativeTest {
}
}
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
assertEquals(expected, value.get());
}
private static void withFireball(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();
@@ -354,7 +531,7 @@ class FireballNativeTest {
private static void prepareLaunches(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.entity.Snowball... projectiles) {
org.bukkit.entity.Arrow... projectiles) {
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(actor.player).getEyeLocation();
@@ -363,7 +540,7 @@ class FireballNativeTest {
var projectile = queue.removeFirst();
fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(actor.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
}).when(actor.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Arrow.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
}
@@ -387,6 +564,15 @@ class FireballNativeTest {
repository.replace(saved, old.revision());
}
private static void withLearnedFireball(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));
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
stats, old.level(), old.experience(), BigInteger.valueOf(39), old.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null), 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();
@@ -398,9 +584,9 @@ class FireballNativeTest {
repository.replace(saved, old.revision());
}
private static org.bukkit.entity.Snowball projectile(FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.Location location) {
var projectile = org.mockito.Mockito.mock(org.bukkit.entity.Snowball.class);
private static org.bukkit.entity.Arrow projectile(FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.Location location) {
var projectile = org.mockito.Mockito.mock(org.bukkit.entity.Arrow.class);
org.mockito.Mockito.when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(projectile.getShooter()).thenReturn(actor.player);
org.mockito.Mockito.when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
@@ -55,6 +55,7 @@ class FrostNovaNativeTest {
fixture.activate(caster); fixture.activate(ally); fixture.activate(opponent); party(fixture, caster, ally);
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getLocation()).thenReturn(new Location(caster.world, 3, 64, 0)); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(enemy.getName()).thenReturn("Zombie");
var vetoed = mock(LivingEntity.class); when(vetoed.getUniqueId()).thenReturn(UUID.randomUUID());
when(vetoed.getLocation()).thenReturn(new Location(caster.world, 2.5, 64, 0));
doReturn(true).when(caster.player).hasLineOfSight(enemy); doReturn(true).when(caster.player).hasLineOfSight(vetoed);
@@ -79,6 +80,9 @@ class FrostNovaNativeTest {
use(fixture, caster); drain(fixture, 2);
assertEquals(8, damage.get(enemy.getUniqueId()), 1.0e-9); assertEquals(8, damage.get(opponent.core.id), 1.0e-9);
assertEquals(2, damage.size(), "Duplicate, cancelled, party and ordinary targets cannot add accepted hits");
assertEquals(1, caster.messages.stream().filter("Frost Nova did 8 damage to Zombie."::equals).count());
assertEquals(1, caster.messages.stream().filter(("Frost Nova did 8 damage to "
+ opponent.core.record.name() + ".")::equals).count());
assertFalse(vetoedEffects.containsKey(PotionEffectType.SLOWNESS));
assertEquals(1, enemyEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
assertEquals(1, opponentEffects.get(PotionEffectType.SLOWNESS).getAmplifier());
@@ -89,6 +93,42 @@ class FrostNovaNativeTest {
}
}
@SuppressWarnings("unchecked")
@Test void areaDamageMeasuresTrainingDummyWithoutApplyingOwnedSlowness() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, true);
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
UUID worldId = UUID.randomUUID(); when(caster.world.getUID()).thenReturn(worldId);
var directory = (games.dmg.fantasystick.training.TrainingDummyDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "trainingDummies");
var saved = directory.place("Nova Target", worldId, 3, 64, 0, 0, 0).get();
int chunkX = ((int) Math.floor(saved.x())) >> 4, chunkZ = ((int) Math.floor(saved.z())) >> 4;
var chunk = mock(org.bukkit.Chunk.class); when(chunk.getWorld()).thenReturn(caster.world);
when(chunk.getX()).thenReturn(chunkX); when(chunk.getZ()).thenReturn(chunkZ);
when(chunk.getEntities()).thenReturn(new Entity[0]); when(caster.world.isChunkLoaded(chunkX, chunkZ)).thenReturn(true);
var dummy = TrainingDummyNativeTest.zombie(new Location(caster.world, 3, 64, 0)); when(dummy.getName()).thenReturn("Nova Target");
doAnswer(call -> { ((java.util.function.Consumer<org.bukkit.entity.Zombie>) call.getArgument(2)).accept(dummy); return dummy; })
.when(caster.world).spawn(any(Location.class), eq(org.bukkit.entity.Zombie.class), any(java.util.function.Consumer.class));
var controller = (games.dmg.fantasystick.training.TrainingDummyController)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "trainingDummyController");
controller.reconcile(chunk); assertTrue(controller.tagged(dummy)); assertTrue(controller.target(dummy));
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
doReturn(new Location(caster.world, 0, 65, 0)).when(caster.player).getEyeLocation();
doReturn(List.of(dummy)).when(caster.player).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
doReturn(true).when(caster.player).hasLineOfSight(dummy);
var events = new ArrayList<EntityDamageEvent>();
doAnswer(call -> { var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new EntityDamageEvent(dummy, EntityDamageEvent.DamageCause.FREEZE, source, (Double) call.getArgument(0));
events.add(event); fixture.dispatcher.accept(event); return null; })
.when(dummy).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
use(fixture, caster); drain(fixture, 2);
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
assertEquals(1, caster.messages.stream().filter("Frost Nova did 8 damage to Nova Target."::equals).count(), caster.messages::toString);
verify(dummy, never()).addPotionEffect(any(PotionEffect.class));
}
}
@Test void spherePathOrderingCandidateAndTargetBoundsAreDeterministic() {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var source = mock(Player.class);
var world = mock(org.bukkit.World.class); when(source.getUniqueId()).thenReturn(UUID.randomUUID());
@@ -258,6 +298,8 @@ class FrostNovaNativeTest {
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active);
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
doAnswer(call -> { ((LivingEntity) call.getArgument(0)).damage((Double) call.getArgument(1), (org.bukkit.damage.DamageSource) call.getArgument(2)); return null; })
.when(execution).damage(any(LivingEntity.class), anyDouble(), any(org.bukkit.damage.DamageSource.class));
var record = new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(record.stats()); stats.put(Stat.INT, BigInteger.valueOf(16));
when(execution.character()).thenReturn(new CharacterRecord(active.character(), record.name(), record.revision(), stats,
@@ -25,6 +25,7 @@ class FrostShardNativeTest {
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(book.contains("FrostShard"), book); assertTrue(book.contains("INT12"), book);
assertTrue(book.contains("WIS10"), book); assertTrue(book.contains("SlownessI"), book);
assertTrue(book.contains("projectile-speed=3"), book);
var learn = abilityButton(a, "[Learn]", "frost_shard");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -41,17 +42,23 @@ class FrostShardNativeTest {
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
var velocity = new java.util.concurrent.atomic.AtomicReference<org.bukkit.util.Vector>();
doAnswer(call -> {
velocity.set(call.getArgument(1));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
}).when(a.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand());
verify(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
verify(a.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
assertEquals(new org.bukkit.util.Vector(3, 0, 0), velocity.get());
verify(projectile).setPickupStatus(org.bukkit.entity.AbstractArrow.PickupStatus.DISALLOWED);
verify(projectile).setDamage(0); verify(projectile).setCritical(false);
verify(projectile).setPierceLevel(0); verify(projectile).setKnockbackStrength(0);
var frost = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "frostShard");
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(frost, "projectiles")).size());
@@ -60,6 +67,9 @@ class FrostShardNativeTest {
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
when(target.addPotionEffect(any())).thenReturn(true);
var vanilla = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, mock(org.bukkit.damage.DamageSource.class), 9);
f.dispatcher.accept(vanilla); assertTrue(vanilla.isCancelled(), "Owned arrow native damage is contained");
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
@@ -184,13 +194,13 @@ class FrostShardNativeTest {
verify(projectile, atLeastOnce()).remove();
learnAndAssign(f, a); cast(f, a);
verify(a.player, times(1)).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
verify(a.player, times(1)).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); cast(f, b);
verify(b.player, never()).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
verify(b.player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard"));
}
@@ -225,10 +235,22 @@ class FrostShardNativeTest {
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "frost_shard",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
verify(player, never()).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
verify(player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
assertEquals(1, failures.size()); engine.close(); frost.close(); parties.close();
}
@Test void retainedProjectileSpeedIsHonoredWithoutRewritingConfiguration() throws Exception {
String retained = "frost-shard-projectile-speed-blocks-per-tick: 1\n";
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var definition = ((games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry")).find("frost_shard").orElseThrow();
assertEquals(0, new java.math.BigDecimal("1").compareTo(definition.parameters().get("projectile-speed")));
assertEquals(retained, java.nio.file.Files.readString(data.resolve("config.yml")));
}
}
@Test void configuredDefinitionAndFormulaPreserveReviewedFiniteUncappedScaling() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"frost-shard-base-damage: 9\nfrost-shard-cooldown-seconds: 2.5\n"
@@ -286,7 +308,7 @@ class FrostShardNativeTest {
doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile)); return projectile; })
.when(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
.when(a.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
@@ -458,14 +480,14 @@ class FrostShardNativeTest {
private static void prepareLaunches(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.entity.Snowball... projectiles) {
org.bukkit.entity.Arrow... projectiles) {
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0)); doReturn(eye).when(actor.player).getEyeLocation();
var queue = new java.util.ArrayDeque<>(java.util.List.of(projectiles));
doAnswer(call -> {
var projectile = queue.removeFirst(); fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(actor.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
}).when(actor.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
@@ -518,9 +540,9 @@ class FrostShardNativeTest {
old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static org.bukkit.entity.Snowball projectile(FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.Location location) {
var projectile = mock(org.bukkit.entity.Snowball.class);
private static org.bukkit.entity.Arrow projectile(FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.Location location) {
var projectile = mock(org.bukkit.entity.Arrow.class);
when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(projectile.getShooter()).thenReturn(actor.player);
when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
@@ -26,7 +26,7 @@ class GreatSwordNativeTest {
String book = PartyBookNativeTest.text(a);
assertTrue(book.contains("Great Sword"));
assertTrue(book.contains("STR 12"));
assertTrue(book.contains("1.2"));
assertTrue(book.contains("0.75"));
var learn = abilityButton(a, "[Learn]", "great_sword");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -68,8 +68,8 @@ class GreatSwordNativeTest {
var saved = storage.find(a.core.record.id()).get().orElseThrow();
var deadline = saved.cooldowns().get("great_sword");
assertNotNull(deadline);
assertFalse(deadline.isBefore(before.plusMillis(1200)));
assertFalse(deadline.isAfter(java.time.Instant.now().plusMillis(1200)));
assertFalse(deadline.isBefore(before.plusMillis(750)));
assertFalse(deadline.isAfter(java.time.Instant.now().plusMillis(750)));
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -82,6 +82,42 @@ class GreatSwordNativeTest {
}
}
@SuppressWarnings("unchecked")
@Test void directAbilityMeasuresTrainingDummyAndPersistsNormalRecovery() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a); when(a.player.getAttackCooldown()).thenReturn(1.0f);
var worldId = java.util.UUID.randomUUID(); when(a.world.getUID()).thenReturn(worldId);
var directory = (games.dmg.fantasystick.training.TrainingDummyDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
var saved = directory.place("Sword Target", worldId, 3, 64, 0, 0, 0).get();
int chunkX = ((int) Math.floor(saved.x())) >> 4, chunkZ = ((int) Math.floor(saved.z())) >> 4;
var chunk = mock(org.bukkit.Chunk.class); when(chunk.getWorld()).thenReturn(a.world); when(chunk.getX()).thenReturn(chunkX);
when(chunk.getZ()).thenReturn(chunkZ); when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);
when(a.world.isChunkLoaded(chunkX, chunkZ)).thenReturn(true);
var dummy = TrainingDummyNativeTest.zombie(new org.bukkit.Location(a.world, 3, 64, 0)); when(dummy.getName()).thenReturn("Sword Target");
doAnswer(call -> { ((java.util.function.Consumer<org.bukkit.entity.Zombie>) call.getArgument(2)).accept(dummy); return dummy; })
.when(a.world).spawn(any(org.bukkit.Location.class), eq(org.bukkit.entity.Zombie.class), any(java.util.function.Consumer.class));
var controller = (games.dmg.fantasystick.training.TrainingDummyController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummyController");
controller.reconcile(chunk);
var events = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> { var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, dummy,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
event.setDamage(8.25); events.add(event); f.dispatcher.accept(event); return null; })
.when(dummy).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, dummy, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
assertEquals(1, a.messages.stream().filter("Great Sword did 8.25 damage to Sword Target."::equals).count());
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
}
}
@Test void partialChargeUsesTheApprovedNativeCurveWithoutVanillaContinuation() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "great-sword-recovery-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
@@ -223,6 +259,54 @@ class GreatSwordNativeTest {
}
}
@Test void fractionalRecoveryRefusesImmediatelyAndOneNanosecondBeforeButAcceptsExactBoundary() throws Exception {
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2026-09-21T20:00:00Z"));
java.time.Clock clock = new java.time.Clock() {
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
@Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; }
@Override public java.time.Instant instant() { return now.get(); }
};
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var created = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(created.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(12));
var initial = new games.dmg.fantasystick.persistence.CharacterRecord(created.id(), created.name(), created.revision(), stats,
created.level(), created.experience(), created.unspentPoints(), created.health(), java.util.Set.of("great_sword"),
java.util.List.of("great_sword", "", "", "", "", "", ""), created.cooldowns(), created.restrictedKills(), false, null, null);
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(repository, clock)) {
storage.create(initial).get(5, java.util.concurrent.TimeUnit.SECONDS);
var current = new java.util.concurrent.atomic.AtomicReference<>(initial);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), initial.id());
when(sessions.current(active)).thenReturn(true);
when(sessions.currentRecord(active)).thenAnswer(ignored -> java.util.Optional.of(current.get()));
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenAnswer(call -> {
current.set(call.getArgument(2)); return true;
});
var player = mock(org.bukkit.entity.Player.class); var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var hits = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> { hits.incrementAndGet(); return null; }).when(target)
.damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var sword = new games.dmg.fantasystick.ability.GreatSword(
games.dmg.fantasystick.ability.GreatSword.Settings.defaults());
var registry = new games.dmg.fantasystick.ability.AbilityRegistry(); registry.register(sword.definition());
var parties = new games.dmg.fantasystick.party.PartyRoster(clock, ignored -> true);
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties, clock,
Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failure -> fail(failure.toString()));
Runnable cast = () -> engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "great_sword",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> true));
cast.run(); await(hits, 1);
var deadline = now.get().plusMillis(750); assertEquals(deadline, current.get().cooldowns().get("great_sword"));
cast.run(); assertEquals(1, hits.get());
now.set(deadline.minusNanos(1)); cast.run(); assertEquals(1, hits.get());
now.set(deadline); cast.run(); await(hits, 2);
engine.close(); sword.close(); parties.close();
}
}
@Test void failedCooldownPersistenceDealsNoDamageOrFreeEffect() {
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
@@ -276,6 +360,18 @@ class GreatSwordNativeTest {
}
}
@Test void retainedRecoveryIsHonoredWithoutRewritingConfiguration() throws Exception {
String retained = "great-sword-recovery-seconds: 1.2\n";
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var definition = ((games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry")).find("great_sword").orElseThrow();
assertEquals(java.time.Duration.ofMillis(1200), definition.cooldown());
assertEquals(retained, java.nio.file.Files.readString(data.resolve("config.yml")));
}
}
@Test void configuredDefinitionAndChargeCurvePreserveExactUncappedScaling() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"great-sword-base-damage: 9\ngreat-sword-recovery-seconds: 2.5\n");
@@ -353,6 +449,11 @@ class GreatSwordNativeTest {
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
assertEquals(expected, value.get());
}
private static void withStrength(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
@@ -67,6 +67,21 @@ class HexNativeTest {
}
}
@Test void trainingDummyConsumesNoOwnedHexEffect() {
var mitigation = mock(games.dmg.fantasystick.ability.OutgoingDamageMitigation.class);
var hex = new games.dmg.fantasystick.ability.Hex(games.dmg.fantasystick.ability.Hex.Settings.defaults(), mitigation);
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
var input = mock(games.dmg.fantasystick.session.ControlInput.class); var source = mock(org.bukkit.entity.Player.class);
var target = mock(org.bukkit.entity.Zombie.class); var world = mock(org.bukkit.World.class);
when(input.player()).thenReturn(source); when(input.target()).thenReturn(target);
when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0));
when(target.getLocation()).thenReturn(new Location(world, 2, 64, 0)); when(source.hasLineOfSight(target)).thenReturn(true);
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
when(execution.definition()).thenReturn(hex.definition()); when(execution.trainingDummy(target)).thenReturn(true);
hex.activate(execution);
verify(execution).finish(); verifyNoInteractions(mitigation);
}
@Test void replacementOldExpiryAndSourceExitPreserveOnlyCurrentOwnedPenalty() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 0\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
@@ -6,7 +6,7 @@ class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()thr
double maximum=a.maximumHealth.getBaseValue();var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);assertEquals(maximum,a.maximumHealth.getBaseValue(),1e-9);assertEquals(120L,f.delayedTicks.getLast());f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3));absorption.set(2.0);PartyBookNativeTest.drain(f);var sourceSheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3));absorption.set(2.0);PartyBookNativeTest.drain(f);String feedback="Inspire prevented 3 damage to "+b.core.record.name()+".";assertEquals(1,a.messages.stream().filter(feedback::equals).count());assertEquals(1,b.messages.stream().filter(feedback::equals).count());var sourceSheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}
@Test void normalSelfOrdinaryOutOfRangeAndObstructedTargetsRefuseBeforeCooldown()throws Exception{for(int route=0;route<4;route++){var dir=data.resolve("refuse-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;if(route==0)target=a.player;else if(route==1){target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}else{var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);}doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,route==2?13:8,64,0)).when(target).getLocation();doReturn(route!=3).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("inspire"));}}}
@Test void nonPartyTargetIsAllowedAndStrongestRemainingCopiesPreserveUnrelatedAbsorption()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var c=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);withInspire(data,c,true);f.players.put(a.core.id,a.player);f.players.put(c.core.id,c.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(c);f.activate(b);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());for(var caster:List.of(a,c)){doReturn(false).when(caster.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(b.player);doReturn(b.player).when(caster.player).getTargetEntity(64);}doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","6"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();use(f,c);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(9,absorption.get(),1e-9);
@@ -346,7 +346,7 @@ class LongbowNativeTest {
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"),
assertEquals(java.util.List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
@@ -68,6 +68,9 @@ class MendingLightNativeTest {
var healed = storage.find(ally.core.record.id()).get().orElseThrow();
assertEquals(0, BigDecimal.TEN.compareTo(healed.health()), "6 + WIS modifier caps at ally maximum");
assertEquals(10, ally.player.getHealth());
String feedback = "Mending Light healed " + ally.core.record.name() + " for 6 health.";
assertEquals(1, caster.messages.stream().filter(feedback::equals).count());
assertEquals(1, ally.messages.stream().filter(feedback::equals).count());
var deadline = storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light");
assertNotNull(deadline);
@@ -49,7 +49,7 @@ class MeteorNativeTest {
f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("18"),reloaded.find("meteor").orElseThrow().parameters().get("base-damage"));}}
private record Direct(Meteor meteor,AbilityExecution execution,Player caster,World world,EquipmentSessions sessions,PartyRoster parties,ArrayDeque<Runnable> scheduled,java.util.concurrent.atomic.AtomicReference<Material> blockType){}
private static Direct direct(){var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var caster=mock(Player.class);var world=mock(World.class);UUID id=UUID.randomUUID();when(caster.getUniqueId()).thenReturn(id);when(caster.getWorld()).thenReturn(world);when(caster.getLocation()).thenReturn(new Location(world,0,65,0));when(caster.getBoundingBox()).thenReturn(new BoundingBox(0,65,0,1,67,1));var active=new EquipmentSessions.Active(id,UUID.randomUUID(),UUID.randomUUID());when(sessions.active(id)).thenReturn(Optional.of(active));when(sessions.current(active)).thenReturn(true);var type=new java.util.concurrent.atomic.AtomicReference<>(Material.STONE);var block=mock(Block.class);when(block.getType()).thenAnswer(call->type.get());when(block.getWorld()).thenReturn(world);when(block.getLocation()).thenReturn(new Location(world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(BlockFace.UP);when(caster.getEyeLocation()).thenReturn(new Location(world,0,65.62,0,0,45));doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(24d),eq(FluidCollisionMode.NEVER),eq(true));var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(caster);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);var character=mock(CharacterRecord.class);var stats=new EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,BigInteger.ZERO);stats.put(Stat.INT,BigInteger.valueOf(26));when(character.stats()).thenReturn(stats);when(execution.character()).thenReturn(character);var scheduled=new ArrayDeque<Runnable>();doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());var meteor=new Meteor(Meteor.Settings.defaults(),sessions,parties);when(execution.definition()).thenReturn(meteor.definition());return new Direct(meteor,execution,caster,world,sessions,parties,scheduled,type);}
private static Direct direct(){var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var caster=mock(Player.class);var world=mock(World.class);UUID id=UUID.randomUUID();when(caster.getUniqueId()).thenReturn(id);when(caster.getWorld()).thenReturn(world);when(caster.getLocation()).thenReturn(new Location(world,0,65,0));when(caster.getBoundingBox()).thenReturn(new BoundingBox(0,65,0,1,67,1));var active=new EquipmentSessions.Active(id,UUID.randomUUID(),UUID.randomUUID());when(sessions.active(id)).thenReturn(Optional.of(active));when(sessions.current(active)).thenReturn(true);var type=new java.util.concurrent.atomic.AtomicReference<>(Material.STONE);var block=mock(Block.class);when(block.getType()).thenAnswer(call->type.get());when(block.getWorld()).thenReturn(world);when(block.getLocation()).thenReturn(new Location(world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(BlockFace.UP);when(caster.getEyeLocation()).thenReturn(new Location(world,0,65.62,0,0,45));doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(24d),eq(FluidCollisionMode.NEVER),eq(true));var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(caster);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);doAnswer(call->{((LivingEntity)call.getArgument(0)).damage((Double)call.getArgument(1),(org.bukkit.damage.DamageSource)call.getArgument(2));return null;}).when(execution).damage(any(LivingEntity.class),anyDouble(),any(org.bukkit.damage.DamageSource.class));var character=mock(CharacterRecord.class);var stats=new EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,BigInteger.ZERO);stats.put(Stat.INT,BigInteger.valueOf(26));when(character.stats()).thenReturn(stats);when(execution.character()).thenReturn(character);var scheduled=new ArrayDeque<Runnable>();doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());var meteor=new Meteor(Meteor.Settings.defaults(),sessions,parties);when(execution.definition()).thenReturn(meteor.definition());return new Direct(meteor,execution,caster,world,sessions,parties,scheduled,type);}
private static Block placement(FantasyStickLifecycleNativeTest.Actor actor,double range,BlockFace face){var block=mock(Block.class);when(block.getType()).thenReturn(Material.STONE);when(block.getWorld()).thenReturn(actor.world);when(block.getLocation()).thenReturn(new Location(actor.world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(face);doReturn(new Location(actor.world,0,65.62,0,0,45)).when(actor.player).getEyeLocation();doReturn(ray).when(actor.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(range),eq(FluidCollisionMode.NEVER),eq(true));return block;}
private static LivingEntity target(World world,UUID id,double x,double y){var target=mock(LivingEntity.class);locate(target,world,id,x,y,0);return target;}
private static void locate(LivingEntity target,World world,UUID id,double x,double y,double z){when(target.getUniqueId()).thenReturn(id);when(target.getLocation()).thenReturn(new Location(world,x,y,z));when(target.getBoundingBox()).thenReturn(new BoundingBox(x-.3,y,z-.3,x+.3,y+1.8,z+.3));when(target.getHeight()).thenReturn(1.8);}
@@ -40,7 +40,7 @@ class OutOfCombatRecoveryNativeTest {
@Test void exactDelayRepeatedIntervalsAndLargeClockJumpsPersistOnePulseBeforeNativePublication() {
var f = new Direct();
f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(29)); f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep();
assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep();
@@ -49,6 +49,9 @@ class OutOfCombatRecoveryNativeTest {
assertEquals(4, f.health.get());
f.runNext();
assertEquals(5, f.health.get());
verify(f.feedback).report(eq("Out-of-Combat Recovery"), eq(f.player),
eq(games.dmg.fantasystick.ability.AbilityReport.Type.HEALING), eq(f.player),
argThat(amount -> amount.compareTo(BigDecimal.ONE) == 0));
assertEquals(0, BigDecimal.valueOf(5).compareTo(f.record.get().health()));
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep();
@@ -69,12 +72,12 @@ class OutOfCombatRecoveryNativeTest {
@Test void acceptedEnvironmentalAndDealtDamageResetWhileCancelledAndZeroAttemptsDoNot() {
var f = new Direct(); f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(29));
f.clock.advance(Duration.ofSeconds(9));
f.recovery.acceptedDamage(damage(f.player, 2));
f.clock.advance(Duration.ofSeconds(29)); f.recovery.sweep(); assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep(); assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext(); assertEquals(5, f.health.get());
var noReset = new Direct(); noReset.recovery.sweep(); noReset.clock.advance(Duration.ofSeconds(29));
var noReset = new Direct(); noReset.recovery.sweep(); noReset.clock.advance(Duration.ofSeconds(9));
var cancelled = damage(noReset.player, 2); cancelled.setCancelled(true); noReset.recovery.acceptedDamage(cancelled);
noReset.recovery.acceptedDamage(damage(noReset.player, 0));
noReset.clock.advance(Duration.ofSeconds(1)); noReset.recovery.sweep(); noReset.runNext();
@@ -82,58 +85,58 @@ class OutOfCombatRecoveryNativeTest {
var dealt = new Direct();
var target = mock(Player.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
dealt.recovery.sweep(); dealt.clock.advance(Duration.ofSeconds(29));
dealt.recovery.sweep(); dealt.clock.advance(Duration.ofSeconds(9));
var source = DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
.withCausingEntity(dealt.player).withDirectEntity(dealt.player).build();
dealt.recovery.acceptedDamage(new EntityDamageByEntityEvent(dealt.player, target,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 2));
dealt.clock.advance(Duration.ofSeconds(29)); dealt.recovery.sweep(); assertTrue(dealt.events.isEmpty());
dealt.clock.advance(Duration.ofSeconds(9)); dealt.recovery.sweep(); assertTrue(dealt.events.isEmpty());
dealt.clock.advance(Duration.ofSeconds(1)); dealt.recovery.sweep(); dealt.runNext();
assertEquals(5, dealt.health.get(), "Accepted outgoing damage starts a fresh delay for its source character");
}
@Test void cancellationFailureAndDamageDuringPersistenceNeverPublishOrCatchUp() {
var cancelled = new Direct(); cancelled.veto = true; cancelled.recovery.sweep();
cancelled.clock.advance(Duration.ofSeconds(30)); cancelled.recovery.sweep();
cancelled.clock.advance(Duration.ofSeconds(10)); cancelled.recovery.sweep();
assertEquals(1, cancelled.events.size()); assertTrue(cancelled.callbacks.isEmpty()); assertEquals(4, cancelled.health.get());
cancelled.clock.advance(Duration.ofSeconds(9)); cancelled.recovery.sweep(); assertEquals(1, cancelled.events.size());
var failed = new Direct(); failed.automatic = false; failed.recovery.sweep(); failed.clock.advance(Duration.ofSeconds(30));
var failed = new Direct(); failed.automatic = false; failed.recovery.sweep(); failed.clock.advance(Duration.ofSeconds(10));
failed.recovery.sweep(); assertNotNull(failed.pending);
failed.pending.completeExceptionally(new java.io.IOException("simulated")); failed.runNext();
assertEquals(4, failed.health.get());
failed.clock.advance(Duration.ofSeconds(9)); failed.recovery.sweep();
assertEquals(1, failed.events.size(), "Failed persistence does not immediately replay a missed pulse");
var stale = new Direct(); stale.automatic = false; stale.recovery.sweep(); stale.clock.advance(Duration.ofSeconds(30));
var stale = new Direct(); stale.automatic = false; stale.recovery.sweep(); stale.clock.advance(Duration.ofSeconds(10));
stale.recovery.sweep();
stale.recovery.acceptedDamage(damage(stale.player, 1));
stale.pending.complete(Optional.of(stale.saved(BigDecimal.valueOf(5)))); stale.runNext();
assertEquals(4, stale.health.get(), "Damage during the save invalidates native publication");
stale.clock.advance(Duration.ofSeconds(29)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
stale.clock.advance(Duration.ofSeconds(9)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
}
@Test void passiveBonusZeroCancellationAndFullHealthStayInTheBasePulseTransaction() {
var zero = new Direct(); zero.recovery.attachBonusHealing(ignored -> BigInteger.ZERO);
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(30)); zero.recovery.sweep(); zero.runNext();
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(10)); zero.recovery.sweep(); zero.runNext();
assertEquals(5, zero.health.get(), "A zero passive bonus leaves the base pulse unchanged");
var cancelled = new Direct(); cancelled.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); cancelled.veto = true;
var cancelledRevision = cancelled.record.get().revision();
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(30)); cancelled.recovery.sweep();
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(10)); cancelled.recovery.sweep();
assertEquals(4, cancelled.events.getFirst().getAmount());
assertEquals(4, cancelled.health.get()); assertEquals(cancelledRevision, cancelled.record.get().revision());
var full = new Direct(); full.health.set(10d); full.record.set(full.saved(BigDecimal.TEN));
full.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); var fullRevision = full.record.get().revision();
full.recovery.sweep(); full.clock.advance(Duration.ofSeconds(30)); full.recovery.sweep();
full.recovery.sweep(); full.clock.advance(Duration.ofSeconds(10)); full.recovery.sweep();
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();
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10)); 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()));
}
@@ -148,6 +151,21 @@ class OutOfCombatRecoveryNativeTest {
assertEquals(5, f.health.get(), "The new delay evaluates from the retained activity timestamp");
}
@Test void retainedThirtySecondDelayRemainsEffectiveWithoutRewritingConfiguration() throws Exception {
String retained = "out-of-combat-healing-delay-seconds: 30\n";
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var settings = (games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecoverySettings");
assertEquals("30", settings.value(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.DELAY));
assertEquals(retained, java.nio.file.Files.readString(data.resolve("config.yml")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("10", "30"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "out-of-combat-healing-delay-seconds", ""}));
}
}
@Test void packagedPluginConnectsZeroDelayRecoveryConfigurationAndRestart() 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\nout-of-combat-healing-amount: 1\n");
@@ -201,6 +219,8 @@ class OutOfCombatRecoveryNativeTest {
private static final class Direct {
final CharacterStorage characters = mock(CharacterStorage.class);
final EquipmentSessions sessions = mock(EquipmentSessions.class);
final games.dmg.fantasystick.presentation.CombatFeedback feedback =
mock(games.dmg.fantasystick.presentation.CombatFeedback.class);
final Player player = mock(Player.class);
final AttributeInstance maximum = mock(AttributeInstance.class);
final MutableClock clock = new MutableClock();
@@ -242,7 +262,7 @@ class OutOfCombatRecoveryNativeTest {
return pending;
});
recovery = new OutOfCombatRecovery(settings, characters, sessions, clock, callbacks::add,
event -> { events.add(event); if (veto) event.setCancelled(true); }, () -> BigDecimal.valueOf(1024), ignored -> {});
event -> { events.add(event); if (veto) event.setCancelled(true); }, () -> BigDecimal.valueOf(1024), ignored -> {}, feedback);
}
CharacterRecord saved(BigDecimal target) {
@@ -62,6 +62,8 @@ class PurifyNativeTest {
harmful.forEach(type -> assertFalse(effects.containsKey(type), type + " and its hidden chain must be gone"));
assertTrue(effects.containsKey(PotionEffectType.REGENERATION));
assertEquals(1, actor.messages.stream().filter(("Purify removed 4 harmful effects from "
+ actor.core.record.name() + ".")::equals).count());
var storage = storage(fixture); var saved = storage.find(actor.core.record.id()).get().orElseThrow();
var deadline = saved.cooldowns().get("purify"); assertNotNull(deadline);
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
@@ -35,6 +35,28 @@ 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 indexPageByPassive = new java.util.HashMap<String, Integer>();
var detailStartByPassive = new java.util.HashMap<String, Integer>();
var passiveRegistry = (games.dmg.fantasystick.passive.PassiveRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
for (var definition : passiveRegistry.definitions()) {
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) {
var target = changePageTo(pages.get(page), pages, definition.name());
if (target != null) { indexPageByPassive.put(definition.name(), page);
detailStartByPassive.put(definition.name(), target.page() - 1); break; }
}
}
assertEquals(passiveRegistry.definitions().size(), detailStartByPassive.size());
for (var entry : detailStartByPassive.entrySet()) {
int end = party;
for (int other : detailStartByPassive.values()) if (other > entry.getValue()) end = Math.min(end, other);
var backPages = new java.util.ArrayList<Integer>();
for (int candidate = entry.getValue(); candidate < end; candidate++)
if (changePageNamed(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
assertEquals(indexPageByPassive.get(entry.getKey()) + 1,
changePageNamed(pages.get(end - 1), "[Back to index]").page(), entry.getKey());
}
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);
@@ -74,6 +96,50 @@ class QuickerRegenerationNativeTest {
}
}
@Test void multiPagePassiveIndexAndWrappedDetailsReturnToTheirExactContainingPage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.passive.PassiveRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passiveRegistry");
for (int index = 0; index < 12; index++) {
String suffix = String.format(java.util.Locale.ROOT, "%02d", index);
registry.register(new games.dmg.fantasystick.passive.PassiveDefinition("synthetic_" + suffix,
"Synthetic Passive " + suffix, java.util.Map.of(), java.util.Set.of(), BigInteger.ONE,
List.of(index == 0 ? "Wrapped detail ".repeat(80) : "Synthetic passive detail.")));
}
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
var before = storage(f).find(a.core.record.id()).get().orElseThrow(); PartyBookNativeTest.open(f, a);
var pages = nativePages(a); assertTrue(pages.size() <= 100);
int passiveIndex = first(pages, "Passive index"), party = first(pages, "Party");
var indexPageByPassive = new java.util.HashMap<String, Integer>();
var detailStartByPassive = new java.util.HashMap<String, Integer>();
int indexPages = 0;
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) indexPages++;
for (var definition : registry.definitions()) {
for (int page = passiveIndex; page < passiveIndex + indexPages; page++) {
var target = changePageTo(pages.get(page), pages, definition.name());
if (target != null) { indexPageByPassive.put(definition.name(), page);
detailStartByPassive.put(definition.name(), target.page() - 1); break; }
}
}
assertTrue(indexPages >= 2); assertEquals(registry.definitions().size(), detailStartByPassive.size());
boolean wrapped = false;
for (var entry : detailStartByPassive.entrySet()) {
int end = party;
for (int other : detailStartByPassive.values()) if (other > entry.getValue()) end = Math.min(end, other);
wrapped |= end - entry.getValue() > 1;
var backPages = new java.util.ArrayList<Integer>();
for (int candidate = entry.getValue(); candidate < end; candidate++)
if (changePageNamed(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
assertEquals(indexPageByPassive.get(entry.getKey()) + 1,
changePageNamed(pages.get(end - 1), "[Back to index]").page(), entry.getKey());
}
assertTrue(wrapped, "At least one passive detail must span native pages");
assertEquals(before, storage(f).find(before.id()).get().orElseThrow());
}
}
@Test void unknownFullPassiveSlotsRemainVisibleAndRefuseBeforeSpending() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -144,6 +210,15 @@ class QuickerRegenerationNativeTest {
}
return null;
}
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageNamed(
net.minecraft.network.chat.Component component, String label) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page) return page;
for (var child : component.getSiblings()) {
var found = changePageNamed(child, label); 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) {
for (var page : pages) { var found = custom(page, id); if (found != null) return found; }
throw new AssertionError("Missing custom action " + id);
@@ -200,7 +200,7 @@ class QuickstepNativeTest {
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"),
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
@@ -63,12 +63,13 @@ class RallyingCryNativeTest {
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry"));
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE); when(enemy.getName()).thenReturn("Zombie");
when(enemy.getLocation()).thenReturn(new Location(caster.world, 4, 64, 0)); when(caster.world.getUID()).thenReturn(UUID.randomUUID());
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
var hit = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.dispatcher.accept(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
assertEquals(1, caster.messages.stream().filter("Rallying Cry added 2 damage against Zombie."::equals).count());
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
@@ -65,6 +65,8 @@ class SanctuaryNativeTest {
run(fixture.scheduled, 1); assertEquals(12, fixture.allyHealth.get(), 0); assertEquals(1, fixture.supports.size());
run(fixture.scheduled, 50); assertEquals(20, fixture.allyHealth.get(), 0, "Six two-point pulses cap at maximum health");
assertEquals(5, fixture.supports.size(), "The sixth overhealing pulse creates no support evidence");
verify(fixture.execution, times(5)).report(eq(AbilityReport.Type.HEALING), eq(fixture.ally),
argThat(amount -> amount.compareTo(BigDecimal.valueOf(2)) == 0));
verify(fixture.execution, atLeastOnce()).finish();
}
@@ -40,6 +40,8 @@ class SecondWindNativeTest {
var saved = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(0, BigDecimal.valueOf(11).compareTo(saved.health())); assertEquals(11, a.player.getHealth());
assertNotNull(saved.cooldowns().get("second_wind"));
assertEquals(1, a.messages.stream().filter(("Second Wind healed " + a.core.record.name()
+ " for 7 health.")::equals).count());
assertFalse(a.messages.stream().anyMatch(message -> message.contains("support")));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
@@ -128,7 +130,7 @@ class SecondWindNativeTest {
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop"),
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
@@ -32,7 +32,7 @@ class ShieldBashNativeTest {
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(book.contains("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
assertFalse(book.contains("Cost:")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
assertFalse(book.contains("Cost:")); assertTrue(book.contains("3")); assertTrue(book.contains("3s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "shield_bash"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
@@ -197,6 +197,71 @@ class ShieldBashNativeTest {
}
}
@Test void packagedCooldownRefusesImmediatelyAndOneNanosecondBeforeButAcceptsExactBoundary() throws Exception {
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2026-09-21T20:00:00Z"));
java.time.Clock clock = new java.time.Clock() {
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
@Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; }
@Override public java.time.Instant instant() { return now.get(); }
};
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var created = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(created.stats()); stats.put(Stat.STR, BigInteger.valueOf(12)); stats.put(Stat.CON, BigInteger.valueOf(12));
var initial = new CharacterRecord(created.id(), created.name(), created.revision(), stats, created.level(), created.experience(),
created.unspentPoints(), created.health(), java.util.Set.of("shield_bash"),
List.of("shield_bash", "", "", "", "", "", ""), created.cooldowns(), created.restrictedKills(), false, null, null);
try (var storage = new CharacterStorage(repository, clock)) {
storage.create(initial).get(5, java.util.concurrent.TimeUnit.SECONDS);
var current = new java.util.concurrent.atomic.AtomicReference<>(initial);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), initial.id());
when(sessions.current(active)).thenReturn(true);
when(sessions.currentRecord(active)).thenAnswer(ignored -> java.util.Optional.of(current.get()));
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenAnswer(call -> {
current.set(call.getArgument(2)); return true;
});
var world = mock(org.bukkit.World.class); var player = mock(org.bukkit.entity.Player.class);
when(player.getUniqueId()).thenReturn(active.player()); doReturn(new Location(world, 0, 64, 0)).when(player).getLocation();
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new Location(world, 2, 64, 0)); doReturn(true).when(player).hasLineOfSight(target);
var hits = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> { hits.incrementAndGet(); return null; }).when(target)
.damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var bash = new games.dmg.fantasystick.ability.ShieldBash(
games.dmg.fantasystick.ability.ShieldBash.Settings.defaults(), ignored -> {});
var registry = new games.dmg.fantasystick.ability.AbilityRegistry(); registry.register(bash.definition());
var parties = new games.dmg.fantasystick.party.PartyRoster(clock, ignored -> true);
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties, clock,
Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failure -> fail(failure.toString()));
Runnable cast = () -> engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "shield_bash",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> true));
cast.run(); await(hits, 1);
var deadline = now.get().plusSeconds(3); assertEquals(deadline, current.get().cooldowns().get("shield_bash"));
cast.run(); assertEquals(1, hits.get());
now.set(deadline.minusNanos(1)); cast.run(); assertEquals(1, hits.get());
now.set(deadline); cast.run(); await(hits, 2);
engine.close(); bash.close(); parties.close();
}
}
@Test void retainedSixSecondCooldownIsHonoredWithoutRewritingConfiguration() throws Exception {
String retained = "shield-bash-cooldown-seconds: 6\n";
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(java.time.Duration.ofSeconds(6), registry.find("shield_bash").orElseThrow().cooldown());
assertEquals(retained, java.nio.file.Files.readString(data.resolve("config.yml")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("3", "6"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "shield-bash-cooldown-seconds", ""}));
}
}
@Test void exactDamageFormulaSupportsFiniteUncappedStats() {
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.valueOf(3), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(9)));
@@ -291,6 +356,11 @@ class ShieldBashNativeTest {
}
}
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
assertEquals(expected, value.get());
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
@@ -100,8 +100,9 @@ class TailBooksNativeTest {
for (String expected : List.of("Tail of Ari", "Level: 2", "XP: 100", "Unspent points: 30", "Coins: 123",
"STR: 0", "DEX: 0", "CON: 10", "INT: 0", "WIS: 0", "CHR: 0")) assertTrue(summary.contains(expected), expected);
String complete = String.join("", pages).replace("\n", "");
for (String expected : List.of("Current gear", "Head: Cosmetic only", "Chestplate: Diamond", "Leggings: Gold", "Boots: Leather"))
for (String expected : List.of("GearHead: Cosmetic", "Chestplate: Diamond", "Leggings: Gold", "Boots: Leather"))
assertTrue(complete.contains(expected), expected);
assertFalse(complete.contains("Current gear")); assertFalse(complete.contains("Cosmetic only"));
String wind = pages.stream().map(p -> p.replace("\n", "")).filter(p -> p.contains("Ability: second_wind")).findFirst().orElseThrow();
assertTrue(wind.contains("Slots: 1, 3")); assertTrue(wind.contains(expiry.toString()));
assertFalse(wind.contains("unknown:雪"), "Each learned ability starts its own page/section");
@@ -0,0 +1,238 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.training.TrainingDummyController;
import games.dmg.fantasystick.training.TrainingDummyDirectory;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Zombie;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class TrainingDummyNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@SuppressWarnings("unchecked")
@Test void registeredAdminLifecycleMaterializesDistinctDurableInertZombie() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id, admin.player);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
UUID worldId = UUID.randomUUID(); when(admin.world.getUID()).thenReturn(worldId);
int x = admin.core.location.getBlockX() >> 4, z = admin.core.location.getBlockZ() >> 4;
var chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(admin.world);
when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z); when(chunk.getEntities()).thenReturn(new Entity[0]);
when(admin.world.getChunkAt(x, z)).thenReturn(chunk); when(admin.world.isChunkLoaded(x, z)).thenReturn(true);
when(admin.world.getEntities()).thenReturn(List.of());
var zombie = zombie(admin.core.location);
doAnswer(call -> { ((Consumer<Zombie>) call.getArgument(2)).accept(zombie); return zombie; })
.when(admin.world).spawn(any(Location.class), eq(Zombie.class), any(Consumer.class));
assertEquals(List.of("training-dummy"), f.command.tabComplete(admin.player, "fantasystick", new String[]{"training-d"}));
assertEquals(List.of("list", "place", "remove"),
f.command.tabComplete(admin.player, "fantasystick", new String[]{"training-dummy", ""}));
var denied = new EquipmentAdminCommandNativeTest.Sender(); denied.allowed = false;
assertTrue(f.command.tabComplete(denied.port, "fantasystick", new String[]{"training-d"}).isEmpty());
f.command.execute(admin.player, "fantasystick", new String[]{"training-dummy", "place", "Trial", "Target"});
Runnable callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
var saved = directory.all().getFirst(); assertEquals("Trial Target", saved.name());
verify(zombie).setAI(false); verify(zombie).setGravity(false); verify(zombie).setCollidable(false);
verify(zombie).setCanPickupItems(false); verify(zombie).setRemoveWhenFarAway(false); verify(zombie).setPersistent(true);
verify(zombie).setShouldBurnInDay(false); verify(zombie).setBaby(false);
verify(zombie).customName(Component.text("Trial Target")); verify(zombie).setCustomNameVisible(true);
var controller = (TrainingDummyController) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummyController");
assertTrue(controller.tagged(zombie));
assertFalse(((games.dmg.fantasystick.party.PartyRegistrarController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partyRegistrarController")).tagged(zombie));
assertFalse(((games.dmg.fantasystick.shop.FantasyArmorerController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorerController")).tagged(zombie));
var ordinary = zombie(admin.core.location.clone());
var malformed = zombie(admin.core.location.clone());
malformed.getPersistentDataContainer().set(new NamespacedKey(f.plugin, "managed_npc_type"),
PersistentDataType.STRING, "training_dummy");
malformed.getPersistentDataContainer().set(new NamespacedKey(f.plugin, "training_dummy_id"),
PersistentDataType.STRING, "not-a-uuid");
when(chunk.getEntities()).thenReturn(new Entity[]{ordinary, malformed, zombie}); controller.reconcile(chunk);
verify(ordinary, never()).remove(); verify(malformed).remove();
assertEquals(List.of(saved.id().toString()), f.command.tabComplete(admin.player, "fantasystick",
new String[]{"training-dummy", "remove", saved.id().toString().substring(0, 8)}));
f.command.execute(admin.player, "fantasystick", new String[]{"training-dummy", "list"});
assertTrue(admin.messages.stream().anyMatch(message -> message.contains(saved.id().toString()) && message.contains("Trial Target")));
f.command.execute(admin.player, "fantasystick", new String[]{"training-dummy", "place", "Lost Permission"});
doReturn(false).when(admin.player).hasPermission("fantasystick.admin");
while (admin.messages.stream().noneMatch(message -> message.contains("permission changed"))) {
callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertEquals(List.of(saved), directory.all()); doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
when(admin.world.getEntities()).thenReturn(List.of(zombie));
when(admin.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk}); when(chunk.getEntities()).thenReturn(new Entity[0]);
f.plugin.setEnabled(false); verify(zombie).remove();
f.plugin.setEnabled(true); verify(zombie, atLeast(3)).setAI(false);
directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
assertEquals(saved, directory.find(saved.id()).orElseThrow());
doThrow(new IllegalStateException("remove failed")).doNothing().when(zombie).remove();
f.command.execute(admin.player, "fantasystick", new String[]{"training-dummy", "remove", saved.id().toString()});
while (admin.messages.stream().noneMatch(message -> message.equals(
"Training dummy removal failed; record and entity are unchanged."))) {
callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertEquals(saved, directory.find(saved.id()).orElseThrow());
f.command.execute(admin.player, "fantasystick", new String[]{"training-dummy", "remove", saved.id().toString()});
while (!directory.all().isEmpty()) {
callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
while (admin.messages.stream().noneMatch(message -> message.equals("Removed training dummy " + saved.id() + "."))) {
callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
verify(zombie, atLeast(2)).remove();
}
}
@SuppressWarnings("unchecked")
@Test void failedPlacementSaveCreatesNeitherRecordNorEntity() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id, admin.player); doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
when(admin.world.getUID()).thenReturn(UUID.randomUUID());
var directory = (TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummies");
directory.close();
f.command.execute(admin.player, "fantasystick", new String[]{"training-dummy", "place", "Unwritten"});
Runnable callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
assertTrue(directory.all().isEmpty());
verify(admin.world, never()).spawn(any(Location.class), eq(Zombie.class), any(Consumer.class));
assertTrue(admin.messages.stream().anyMatch(message -> message.contains("placement failed")));
}
}
@SuppressWarnings("unchecked")
@Test void basicAttackMeasuresFinalDamageSpendsCadenceAndHonorsAccountFeedbackWithoutRewards() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var repository = new games.dmg.fantasystick.persistence.YamlTrainingDummyRepository(data);
games.dmg.fantasystick.training.TrainingDummy saved;
try (var directory = new TrainingDummyDirectory(repository)) {
saved = directory.place("", UUID.randomUUID(), 2, 64, 0, 0, 0).get();
}
var source = new FantasyStickLifecycleNativeTest.Actor(data);
when(source.world.getUID()).thenReturn(saved.world());
int x = ((int) Math.floor(saved.x())) >> 4, z = ((int) Math.floor(saved.z())) >> 4;
var chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(source.world);
when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z);
var dummy = zombie(new Location(source.world, saved.x(), saved.y(), saved.z()));
when(chunk.getEntities()).thenReturn(new Entity[0]);
when(source.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
when(source.world.isChunkLoaded(x, z)).thenReturn(true); when(source.world.getChunkAt(x, z)).thenReturn(chunk);
doAnswer(call -> { ((Consumer<Zombie>) call.getArgument(2)).accept(dummy); return dummy; })
.when(source.world).spawn(any(Location.class), eq(Zombie.class), any(Consumer.class));
f.plugin.setEnabled(true); f.players.put(source.core.id, source.player); f.activate(source);
var controller = (TrainingDummyController) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummyController");
controller.reconcile(chunk); assertTrue(controller.tagged(dummy)); clearInvocations(dummy);
var environmental = new EntityDamageEvent(dummy, EntityDamageEvent.DamageCause.LAVA, 20);
f.dispatcher.accept(environmental); assertTrue(environmental.isCancelled());
assertFalse(source.messages.stream().anyMatch(message -> message.contains("damage to Training Dummy")));
var events = new ArrayList<EntityDamageByEntityEvent>();
doAnswer(call -> {
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getCausingEntity()).thenReturn(source.player);
when(damageSource.getDirectEntity()).thenReturn(source.player);
double attempted = call.getArgument(0);
var event = new EntityDamageByEntityEvent(source.player, dummy, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
damageSource, attempted);
event.setDamage(attempted == Double.MAX_VALUE ? Double.MAX_VALUE : 2.75); events.add(event); f.dispatcher.accept(event); return null;
}).when(dummy).damage(anyDouble(), same(source.player));
source.core.inventory.getInventory().setSelectedSlot(1);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(source.player, dummy, true));
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
assertEquals(1, source.messages.stream().filter("Basic Attack did 2.75 damage to Training Dummy."::equals).count());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(source.player, dummy, true));
assertEquals(1, events.size(), "A measured hit spends the normal Basic Attack cadence");
for (int repeat = 0; repeat < 2; repeat++) {
var extreme = controller.apply(source.player, dummy, () -> dummy.damage(Double.MAX_VALUE, source.player));
assertTrue(extreme.isPresent()); assertEquals(0, java.math.BigDecimal.valueOf(Double.MAX_VALUE).compareTo(extreme.orElseThrow()));
}
verify(dummy, never()).setHealth(anyDouble()); verify(dummy, never()).setVelocity(any(org.bukkit.util.Vector.class));
var muted = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(muted.core.id, muted.player); f.activate(muted);
f.feedbackCommand.execute(muted.player, "fantasyfeedback", new String[]{"off"});
while (muted.messages.stream().noneMatch("Fantasy feedback is now off."::equals)) {
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
}
var mutedEvents = new ArrayList<EntityDamageByEntityEvent>();
doAnswer(call -> {
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getCausingEntity()).thenReturn(muted.player);
when(damageSource.getDirectEntity()).thenReturn(muted.player);
var event = new EntityDamageByEntityEvent(muted.player, dummy, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
damageSource, (Double) call.getArgument(0));
mutedEvents.add(event); f.dispatcher.accept(event); return null;
}).when(dummy).damage(anyDouble(), same(muted.player));
muted.core.inventory.getInventory().setSelectedSlot(1);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(muted.player, dummy, true));
assertEquals(1, mutedEvents.size()); assertTrue(mutedEvents.getFirst().isCancelled());
assertFalse(muted.messages.stream().anyMatch(message -> message.startsWith("Basic Attack did")));
var cancelled = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(cancelled.core.id, cancelled.player); f.activate(cancelled);
var cancelledEvents = new ArrayList<EntityDamageByEntityEvent>();
doAnswer(call -> {
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getCausingEntity()).thenReturn(cancelled.player);
when(damageSource.getDirectEntity()).thenReturn(cancelled.player);
var event = new EntityDamageByEntityEvent(cancelled.player, dummy, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
damageSource, (Double) call.getArgument(0)); event.setCancelled(true);
cancelledEvents.add(event); f.dispatcher.accept(event); return null;
}).when(dummy).damage(anyDouble(), same(cancelled.player));
cancelled.core.inventory.getInventory().setSelectedSlot(1);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(cancelled.player, dummy, true));
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(cancelled.player, dummy, true));
assertEquals(2, cancelledEvents.size(), "Externally cancelled hits spend no cadence");
assertFalse(cancelled.messages.stream().anyMatch(message -> message.startsWith("Basic Attack did")));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var before = storage.find(source.core.record.id()).get().orElseThrow();
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(dummy, events.getFirst().getDamageSource(), new ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(before.experience(), storage.find(before.id()).get().orElseThrow().experience());
assertEquals(before.coins(), storage.find(before.id()).get().orElseThrow().coins());
}
}
static Zombie zombie(Location location) {
var zombie = mock(Zombie.class); when(zombie.getUniqueId()).thenReturn(UUID.randomUUID());
when(zombie.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE); when(zombie.getName()).thenReturn("Training Dummy");
when(zombie.getLocation()).thenReturn(location.clone()); when(zombie.getHealth()).thenReturn(20d);
when(zombie.getAbsorptionAmount()).thenReturn(0d);
Map<String, String> values = new HashMap<>(); var pdc = mock(PersistentDataContainer.class);
when(zombie.getPersistentDataContainer()).thenReturn(pdc);
doAnswer(call -> { values.put(((NamespacedKey) call.getArgument(0)).getKey(), call.getArgument(2)); return null; })
.when(pdc).set(any(NamespacedKey.class), eq(PersistentDataType.STRING), anyString());
when(pdc.get(any(NamespacedKey.class), eq(PersistentDataType.STRING)))
.thenAnswer(call -> values.get(((NamespacedKey) call.getArgument(0)).getKey()));
return zombie;
}
}
@@ -29,10 +29,13 @@ class DistributionTest {
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract");
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
assertEquals(java.util.Set.of("fantasystick"), metadata.getCommands().keySet());
assertEquals(java.util.Set.of("fantasystick", "fantasyfeedback"), metadata.getCommands().keySet());
assertEquals("fantasystick.admin", metadata.getCommands().get("fantasystick").get("permission"));
assertNull(metadata.getCommands().get("fantasyfeedback").get("permission"));
assertEquals("/fantasyfeedback [on|off]", metadata.getCommands().get("fantasyfeedback").get("usage"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("experience give"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("shop <place [name...]|remove <shop-uuid>|list|restock>"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("training-dummy <place [name...]|remove <dummy-uuid>|list>"));
assertEquals(1, metadata.getPermissions().size());
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
@@ -46,10 +49,10 @@ class DistributionTest {
"The delivered configuration must expose the reviewed 15-minute default");
assertTrue(defaults.contains("adventure-mode: true"));
assertTrue(defaults.contains("great-sword-base-damage: 7"));
assertTrue(defaults.contains("great-sword-recovery-seconds: 1.2"));
assertTrue(defaults.contains("great-sword-recovery-seconds: 0.75"));
for (String setting : java.util.List.of("frost-shard-base-damage: 4",
"frost-shard-cooldown-seconds: 5", "frost-shard-range-blocks: 32",
"frost-shard-lifetime-seconds: 5", "frost-shard-projectile-speed-blocks-per-tick: 1",
"frost-shard-lifetime-seconds: 5", "frost-shard-projectile-speed-blocks-per-tick: 3",
"frost-shard-slowness-amplifier: 0", "frost-shard-slowness-duration-seconds: 2")) {
assertTrue(defaults.contains(setting), "Missing packaged Frost Shard default: " + setting);
}
@@ -71,7 +74,7 @@ class DistributionTest {
"stone-skin-duration-seconds: 4", "stone-skin-damage-reduction-percent: 25")) {
assertTrue(defaults.contains(setting), "Missing packaged Stone Skin default: " + setting);
}
for (String setting : java.util.List.of("shield-bash-base-damage: 4", "shield-bash-cooldown-seconds: 6",
for (String setting : java.util.List.of("shield-bash-base-damage: 4", "shield-bash-cooldown-seconds: 3",
"shield-bash-range-blocks: 3", "shield-bash-horizontal-knockback: 0.8",
"shield-bash-minimum-vertical-knockback: 0.35")) {
assertTrue(defaults.contains(setting), "Missing packaged Shield Bash default: " + setting);
@@ -101,7 +104,7 @@ class DistributionTest {
}
assertTrue(defaults.contains("sheet-resource-pack-url: ''"));
assertTrue(defaults.contains("sheet-resource-pack-sha1: ''"));
for (String setting : java.util.List.of("out-of-combat-healing-delay-seconds: 30",
for (String setting : java.util.List.of("out-of-combat-healing-delay-seconds: 10",
"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")) {
@@ -0,0 +1,72 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FireballSettingsTest {
@TempDir Path data;
@Test void serializesAcceptedKeysWithoutLosingUnrelatedConfiguration() throws Exception {
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
try (var settings = new FireballSettings(data, defaults())) {
var damage = settings.set(FireballSettings.BASE_DAMAGE, "9").toCompletableFuture();
var speed = settings.set(FireballSettings.SPEED, "4.5").toCompletableFuture();
var savedDamage = damage.get(5, TimeUnit.SECONDS); var savedSpeed = speed.get(5, TimeUnit.SECONDS);
assertEquals(new java.math.BigInteger("6"), settings.settings().baseDamage());
settings.publish(savedSpeed); settings.publish(savedDamage);
assertEquals(new java.math.BigInteger("9"), settings.settings().baseDamage());
assertEquals(4.5, settings.settings().speedPerTick());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals(Boolean.TRUE, saved.get("adventure-mode")); assertEquals("retained", saved.get("unrelated"));
assertEquals("9", String.valueOf(saved.get(FireballSettings.BASE_DAMAGE)));
assertEquals("4.5", String.valueOf(saved.get(FireballSettings.SPEED)));
}
}
@Test void defaultsAndValidationAcceptFractionalCooldownAndRejectUnsafeValues() {
assertEquals("1.2", FireballSettings.defaultValue(FireballSettings.COOLDOWN));
assertEquals("3", FireballSettings.defaultValue(FireballSettings.SPEED));
try (var settings = new FireballSettings(data, defaults())) {
assertEquals(java.time.Duration.ofMillis(1200), settings.settings().cooldown());
for (var entry : Map.of(FireballSettings.BASE_DAMAGE, "-1", FireballSettings.COOLDOWN, "31536001",
FireballSettings.RANGE, "0", FireballSettings.LIFETIME, "0", FireballSettings.SPEED, "65").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(FireballSettings.BASE_DAMAGE, "1.5"));
assertThrows(IllegalArgumentException.class, () -> settings.set(FireballSettings.COOLDOWN, "0.0000000001"));
assertFalse(Files.exists(data.resolve("config.yml")));
}
}
@Test void failedReplacementRetainsEffectiveAndFileStateAndCloseRejectsWrites() throws Exception {
Files.writeString(data.resolve("config.yml"), "fireball-base-damage: 6\n");
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
var settings = new FireballSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated replacement failure");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
});
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(FireballSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("6", settings.value(FireballSettings.BASE_DAMAGE));
var retry = settings.set(FireballSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS);
settings.publish(retry); assertEquals("9", settings.value(FireballSettings.BASE_DAMAGE));
settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(FireballSettings.BASE_DAMAGE, "10").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var values = new LinkedHashMap<String, String>();
for (String key : FireballSettings.keys()) values.put(key, FireballSettings.defaultValue(key));
return values;
}
}
@@ -36,7 +36,9 @@ class FrostShardSettingsTest {
}
@Test void rejectsMalformedAndOutOfRangeValuesBeforeQueueingOrChangingEffectiveState() {
assertEquals("3", FrostShardSettings.defaultValue(FrostShardSettings.SPEED));
try (var settings = new FrostShardSettings(data, defaults())) {
assertEquals(3, settings.settings().speedPerTick());
for (var entry : Map.of(
FrostShardSettings.BASE_DAMAGE, "-1",
FrostShardSettings.COOLDOWN, "31536001",
@@ -18,7 +18,7 @@ class OutOfCombatRecoverySettingsTest {
@Test void parsesReviewedWholeNumberBoundaries() {
var defaults = defaults();
var parsed = OutOfCombatRecoverySettings.parse(defaults);
assertEquals(Duration.ofSeconds(30), parsed.delay());
assertEquals(Duration.ofSeconds(10), parsed.delay());
assertEquals(Duration.ofSeconds(10), parsed.interval());
assertEquals(java.math.BigInteger.ONE, parsed.amount());
@@ -52,7 +52,7 @@ class OutOfCombatRecoverySettingsTest {
var delay = settings.set(OutOfCombatRecoverySettings.DELAY, "45").toCompletableFuture();
var amount = settings.set(OutOfCombatRecoverySettings.AMOUNT, "3").toCompletableFuture();
var d = delay.get(5, TimeUnit.SECONDS); var a = amount.get(5, TimeUnit.SECONDS);
assertEquals("30", settings.value(OutOfCombatRecoverySettings.DELAY));
assertEquals("10", settings.value(OutOfCombatRecoverySettings.DELAY));
settings.publish(a); settings.publish(d);
assertEquals("45", settings.value(OutOfCombatRecoverySettings.DELAY));
assertEquals("3", settings.value(OutOfCombatRecoverySettings.AMOUNT));
@@ -0,0 +1,59 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PlayerFeedbackPreferencesTest {
@TempDir Path data;
@Test void defaultsOnAndPublishesOnlyAfterDurableAccountSaveAcrossRestart() throws Exception {
UUID account = UUID.randomUUID();
PlayerFeedbackPreferences.Update disabled;
try (var preferences = new PlayerFeedbackPreferences(data)) {
assertTrue(preferences.enabled(account));
disabled = preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(preferences.enabled(account), "queued/durable write is not published state");
assertTrue(preferences.publish(disabled));
assertFalse(preferences.enabled(account));
}
try (var reopened = new PlayerFeedbackPreferences(data)) {
assertFalse(reopened.enabled(account));
var enabled = reopened.set(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(reopened.publish(enabled));
assertTrue(reopened.enabled(account));
}
}
@Test void concurrentAcknowledgementsCannotPublishStaleStateAndUnrelatedYamlSurvives() throws Exception {
UUID account = UUID.randomUUID();
java.nio.file.Files.writeString(data.resolve("player-preferences.yml"),
"unrelated: keep\nplayers:\n " + account + ":\n other: value\n");
try (var preferences = new PlayerFeedbackPreferences(data)) {
var disabled = preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS);
var enabled = preferences.set(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(preferences.publish(enabled));
assertFalse(preferences.publish(disabled));
assertTrue(preferences.enabled(account));
}
String saved = java.nio.file.Files.readString(data.resolve("player-preferences.yml"));
assertTrue(saved.contains("unrelated: keep"));
assertTrue(saved.contains("other: value"));
try (var reopened = new PlayerFeedbackPreferences(data)) { assertTrue(reopened.enabled(account)); }
}
@Test void failedOrClosedWritesRetainTheEffectivePreference() throws Exception {
UUID account = UUID.randomUUID();
var preferences = new PlayerFeedbackPreferences(data);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertThrows(Exception.class, () -> preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertTrue(preferences.enabled(account));
preferences.close();
assertThrows(Exception.class, () -> preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
assertTrue(preferences.enabled(account));
}
}
@@ -9,6 +9,10 @@ import org.junit.jupiter.api.io.TempDir;
class ShieldBashSettingsTest {
@TempDir Path data;
@Test void packagedDefaultCooldownIsThreeSeconds() {
assertEquals("3", ShieldBashSettings.defaultValue(ShieldBashSettings.COOLDOWN));
assertEquals(java.time.Duration.ofSeconds(3), ShieldBashSettings.parse(defaults()).cooldown());
}
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new ShieldBashSettings(data, defaults())) {
@@ -0,0 +1,66 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.training.TrainingDummy;
import games.dmg.fantasystick.training.TrainingDummyDirectory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class YamlTrainingDummyRepositoryTest {
@TempDir Path data;
@Test void directoryPersistsPlacementRemovalRestoreAndRestartWithoutPublishingFailedWrites() throws Exception {
var world = UUID.randomUUID();
var repository = new YamlTrainingDummyRepository(data);
TrainingDummy saved;
try (var directory = new TrainingDummyDirectory(repository)) {
saved = directory.place(" Trial Target ", world, 1.25, 64, -2.5, 90, 10).get();
assertEquals("Trial Target", saved.name());
assertEquals(saved, directory.find(saved.id()).orElseThrow());
assertEquals(saved, directory.remove(saved.id()).get().orElseThrow());
assertTrue(directory.all().isEmpty());
assertEquals(saved, directory.restore(saved).get());
}
try (var reopened = new TrainingDummyDirectory(repository)) {
assertEquals(java.util.List.of(saved), reopened.all());
Files.delete(data.resolve("training-dummies.yml"));
Files.createDirectory(data.resolve("training-dummies.yml"));
var failed = reopened.place("Other", world, 2, 64, 2, 0, 0);
assertThrows(java.util.concurrent.ExecutionException.class, failed::get);
assertEquals(java.util.List.of(saved), reopened.all());
}
}
@Test void malformedDuplicateAndUnsafeRecordsFailClosed() throws Exception {
UUID id = UUID.randomUUID(), world = UUID.randomUUID();
Files.writeString(data.resolve("training-dummies.yml"), """
schema-version: 1
dummies:
- dummy-id: %s
name: Safe
world: %s
x: '0'
y: '64'
z: '0'
yaw: '0'
pitch: '0'
- dummy-id: %s
name: Duplicate
world: %s
x: '1'
y: '64'
z: '1'
yaw: '0'
pitch: '0'
""".formatted(id, world, id, world));
assertThrows(java.io.IOException.class, () -> new YamlTrainingDummyRepository(data).load());
assertThrows(IllegalArgumentException.class,
() -> new TrainingDummy(UUID.randomUUID(), "bad\nname", world, 0, 64, 0, 0, 0));
assertThrows(IllegalArgumentException.class,
() -> new TrainingDummy(UUID.randomUUID(), "name", world, Double.NaN, 64, 0, 0, 0));
}
}