Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37be6b13fa | ||
|
|
1dcc7cfae8 | ||
|
|
e16570cb35 | ||
|
|
be8870502f | ||
|
|
8b5461753c | ||
|
|
91412fa1ae | ||
|
|
61bc67da3b |
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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()),
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -175,7 +175,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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public final class Hex implements AbilityEffect,AutoCloseable{
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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){}}
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -205,7 +205,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) {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -26,6 +26,7 @@ public final class PartyManagement implements AutoCloseable {
|
||||
private void mutate(Player player,UUID id,java.util.function.Function<Context,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}try{action.apply(context.orElseThrow()).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
|
||||
private void mutateParty(Player player,UUID id,java.util.function.BiFunction<Context,CharacterParty,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}try{action.apply(context.orElseThrow(),party).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
|
||||
private void complete(CharacterPartyDirectory.Result value,Throwable failure,Consumer<Outcome> result){if(failure!=null){fail(failure,result);return;}publish(()->result.accept(new Outcome(value.status()==CharacterPartyDirectory.Status.APPLIED?Status.APPLIED:map(value.status()),value.party())));}
|
||||
public int registrationPrice(){return prices.registrationPrice();}public int leaderChangePrice(){return prices.leaderChangePrice();}
|
||||
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
|
||||
private Optional<Context> context(Player player,UUID registrarId){if(!parties.ready()||!recovered)return Optional.empty();var registrar=registrars.find(registrarId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(registrar==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Optional.empty();double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();if(dx*dx+dy*dy+dz*dz>36)return Optional.empty();return Optional.of(new Context(active,record,registrar));}
|
||||
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.FAILED,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
|
||||
|
||||
@@ -22,10 +22,11 @@ public final class PartyRegistrarMenus implements Listener,AutoCloseable {
|
||||
private final Server server;private final PartyManagement management;private final NamespacedKey actionKey,targetKey;private boolean closed;
|
||||
public PartyRegistrarMenus(Plugin plugin,PartyManagement management){server=plugin.getServer();this.management=Objects.requireNonNull(management);actionKey=new NamespacedKey(plugin,"party_registrar_action");targetKey=new NamespacedKey(plugin,"party_registrar_target");}
|
||||
public void open(Player player,UUID registrar){open(player,registrar,Screen.ROOT,0);}
|
||||
private void open(Player player,UUID registrar,Screen screen,int page){if(closed)return;var record=management.active(player,registrar);if(record.isEmpty()){player.sendMessage("You must be actively playing a living fantasy character beside this Party Registrar.");return;}if(screen==Screen.NAME){openName(player,registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow());return;}var inventory=server.createInventory(new Holder(registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow(),screen,page),screen==Screen.ROOT?27:54,net.kyori.adventure.text.Component.text("Party Registrar"));var party=management.party(player,registrar);if(screen==Screen.ROOT){if(party.isEmpty()){inventory.setItem(11,item(Material.WRITABLE_BOOK,"Register a party","register",null));inventory.setItem(15,item(Material.MAP,"Invitations","invitations",null));}else{var group=party.orElseThrow();inventory.setItem(4,item(Material.PAPER,"Party: "+group.displayName(),"info",null));if(group.leader().equals(record.orElseThrow().id())){inventory.setItem(10,item(Material.EMERALD,"Invite active characters","invite",null));inventory.setItem(13,item(Material.PLAYER_HEAD,"Manage members","members",null));inventory.setItem(16,item(Material.BARRIER,"Disband party","disband",null));inventory.setItem(22,item(Material.MAP,"Pending invitations","outgoing",null));}else inventory.setItem(13,item(Material.BARRIER,"Leave party","leave",null));}}else if(screen==Screen.INVITATIONS){var values=management.invitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Invitation (left accept, right decline)","invitation",value.id()));}else if(screen==Screen.OUTGOING){var values=management.outgoingInvitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Cancel invitation for "+value.character(),"cancel-invitation",value.id()));}else if(screen==Screen.INVITE){var values=server.getOnlinePlayers().stream().filter(Player::isOnline).map(target->management.activeCharacter(target).orElse(null)).filter(Objects::nonNull).filter(target->management.eligibleInvitationTarget(target.id())).map(value->value.id()).distinct().sorted().toList();fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Invite "+value,"invite-target",value));}else{var values=party.map(value->value.members().stream().filter(member->!member.equals(value.leader())).sorted().toList()).orElse(List.of());fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Member "+value+" (left kick, right transfer)","member",value));}player.openInventory(inventory);}
|
||||
private void open(Player player,UUID registrar,Screen screen,int page){if(closed)return;var record=management.active(player,registrar);if(record.isEmpty()){player.sendMessage("You must be actively playing a living fantasy character beside this Party Registrar.");return;}if(screen==Screen.NAME){openName(player,registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow());return;}var inventory=server.createInventory(new Holder(registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow(),screen,page),screen==Screen.ROOT?27:54,net.kyori.adventure.text.Component.text("Party Registrar"));var party=management.party(player,registrar);if(screen==Screen.ROOT){if(party.isEmpty()){inventory.setItem(11,item(Material.WRITABLE_BOOK,"Register a party — "+management.registrationPrice()+" coins","register",null));inventory.setItem(15,item(Material.MAP,"Invitations","invitations",null));}else{var group=party.orElseThrow();inventory.setItem(4,item(Material.PAPER,"Party: "+group.displayName(),"info",null));if(group.leader().equals(record.orElseThrow().id())){inventory.setItem(10,item(Material.EMERALD,"Invite active characters","invite",null));inventory.setItem(13,item(Material.PLAYER_HEAD,"Manage members","members",null));inventory.setItem(16,item(Material.BARRIER,"Disband party","disband",null));inventory.setItem(22,item(Material.MAP,"Pending invitations","outgoing",null));}else inventory.setItem(13,item(Material.BARRIER,"Leave party","leave",null));}}else if(screen==Screen.INVITATIONS){var values=management.invitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Invitation (left accept, right decline)","invitation",value.id()));}else if(screen==Screen.OUTGOING){var values=management.outgoingInvitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Cancel invitation for "+value.character(),"cancel-invitation",value.id()));}else if(screen==Screen.INVITE){var values=server.getOnlinePlayers().stream().filter(Player::isOnline).map(target->management.activeCharacter(target).orElse(null)).filter(Objects::nonNull).filter(target->management.eligibleInvitationTarget(target.id())).map(value->value.id()).distinct().sorted().toList();fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Invite "+value,"invite-target",value));}else{var values=party.map(value->value.members().stream().filter(member->!member.equals(value.leader())).sorted().toList()).orElse(List.of());fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Member "+value,"member",value,List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — "+management.leaderChangePrice()+" coins"))));}player.openInventory(inventory);}
|
||||
private <T> void fill(Inventory inventory,int page,List<T> values,java.util.function.Function<T,ItemStack> item){int start=Math.max(0,page)*45;for(int index=start;index<Math.min(values.size(),start+45);index++)inventory.setItem(index-start,item.apply(values.get(index)));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","previous",null));if(start+45<values.size())inventory.setItem(53,item(Material.ARROW,"Next","next",null));}
|
||||
private void openName(Player player,UUID registrar,UUID character,UUID session){var inventory=server.createInventory(new Holder(registrar,character,session,Screen.NAME,0),org.bukkit.event.inventory.InventoryType.ANVIL,net.kyori.adventure.text.Component.text("Choose party name"));inventory.setItem(0,item(Material.PAPER,"Party name","name",null));player.openInventory(inventory);}
|
||||
private ItemStack item(Material material,String name,String action,UUID target){var item=new ItemStack(material);var meta=item.getItemMeta();meta.customName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(actionKey,PersistentDataType.STRING,action);if(target!=null)meta.getPersistentDataContainer().set(targetKey,PersistentDataType.STRING,target.toString());item.setItemMeta(meta);return item;}
|
||||
private ItemStack item(Material material,String name,String action,UUID target){return item(material,name,action,target,List.of());}
|
||||
private ItemStack item(Material material,String name,String action,UUID target,List<net.kyori.adventure.text.Component> lore){var item=new ItemStack(material);var meta=item.getItemMeta();meta.customName(net.kyori.adventure.text.Component.text(name));if(!lore.isEmpty())meta.lore(lore);meta.getPersistentDataContainer().set(actionKey,PersistentDataType.STRING,action);if(target!=null)meta.getPersistentDataContainer().set(targetKey,PersistentDataType.STRING,target.toString());item.setItemMeta(meta);return item;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder)||!(event.getWhoClicked() instanceof Player player))return;event.setCancelled(true);if(closed)return;if(!management.active(player,holder.registrar()).map(value->value.id().equals(holder.character())).orElse(false)||!management.activeSession(player,holder.registrar()).map(holder.session()::equals).orElse(false)){player.sendMessage("Party action refused: the character or session changed.");return;}if(holder.screen()==Screen.NAME&&event.getRawSlot()==2){String name=event.getView() instanceof org.bukkit.inventory.view.AnvilView anvil?anvil.getRenameText():null;if(name!=null)management.register(player,holder.registrar(),name,outcome->respond(player,holder.registrar(),outcome,"Registered party."));return;}var clicked=event.getCurrentItem();if(clicked==null)return;var data=clicked.getItemMeta().getPersistentDataContainer();String action=data.get(actionKey,PersistentDataType.STRING);UUID target=uuid(data.get(targetKey,PersistentDataType.STRING));if("register".equals(action)){open(player,holder.registrar(),Screen.NAME,0);return;}if("invitations".equals(action)){open(player,holder.registrar(),Screen.INVITATIONS,0);return;}if("invite".equals(action)){open(player,holder.registrar(),Screen.INVITE,0);return;}if("outgoing".equals(action)){open(player,holder.registrar(),Screen.OUTGOING,0);return;}if("members".equals(action)){open(player,holder.registrar(),Screen.MEMBERS,0);return;}if("previous".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()-1);return;}if("next".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()+1);return;}if("leave".equals(action)){management.leave(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Left party."));return;}if("disband".equals(action)){management.disband(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Party ended."));return;}if(target==null)return;if("invite-target".equals(action)){management.invite(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation saved."));return;}if("cancel-invitation".equals(action)){management.cancel(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation cancelled."));return;}if("invitation".equals(action)){if(event.isRightClick())management.decline(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation declined."));else management.accept(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Joined party."));return;}if("member".equals(action)){if(event.isRightClick())management.transfer(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Leadership transferred."));else management.kick(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Member removed."));}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);}
|
||||
private void respond(Player player,UUID registrar,PartyManagement.Outcome outcome,String success){if(!player.isOnline())return;if(outcome.status()==PartyManagement.Status.APPLIED){player.sendMessage(success);open(player,registrar);}else player.sendMessage("Party action refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -57,7 +57,7 @@ public final class ArmorShopMenus implements Listener,AutoCloseable {
|
||||
var meta=item.getItemMeta();
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("Buy "+title(tier.name())+" "+title(slot.name())+
|
||||
" — "+offer.price()+" coins"));
|
||||
meta.lore(List.of(net.kyori.adventure.text.Component.text("Shared remaining: "+offer.remaining())));
|
||||
meta.lore(List.of(net.kyori.adventure.text.Component.text("Stock: "+offer.remaining())));
|
||||
meta.getPersistentDataContainer().set(slotKey,PersistentDataType.STRING,slot.name());
|
||||
meta.getPersistentDataContainer().set(tierKey,PersistentDataType.STRING,tier.name());
|
||||
item.setItemMeta(meta);return item;
|
||||
@@ -68,9 +68,7 @@ public final class ArmorShopMenus implements Listener,AutoCloseable {
|
||||
long seconds=Math.max(0,Duration.between(now,next).toSeconds());
|
||||
long hours=seconds/3600,minutes=(seconds%3600)/60;
|
||||
var item=new ItemStack(Material.CLOCK);var meta=item.getItemMeta();
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("Daily at 00:00 UTC"));
|
||||
meta.lore(List.of(net.kyori.adventure.text.Component.text("Next: "+next),
|
||||
net.kyori.adventure.text.Component.text("Restock in: "+hours+"h "+minutes+"m")));
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("Restock in: "+hours+"h "+minutes+"m"));
|
||||
item.setItemMeta(meta);return item;
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -60,10 +60,12 @@ 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,8 +327,9 @@ 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");
|
||||
|
||||
@@ -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"};
|
||||
|
||||
@@ -2,5 +2,5 @@ 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.*;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import org.bukkit.*;import org.bukkit.entity.*;import org.bukkit.event.inventory.InventoryClickEvent;import org.bukkit.event.player.PlayerInteractEntityEvent;import org.bukkit.inventory.EquipmentSlot;import org.bukkit.persistence.*;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
|
||||
class FantasyArmorerNativeTest{
|
||||
@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@SuppressWarnings("unchecked")@Test void adminPlacementDistinctNpcAndActiveShopPurchaseUseActualRegisteredPath()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");var console=mock(org.bukkit.command.CommandSender.class);when(console.hasPermission("fantasystick.admin")).thenReturn(true);var consoleMessages=new ArrayList<String>();doAnswer(call->{consoleMessages.add(call.getArgument(0));return null;}).when(console).sendMessage(anyString());UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);int x=actor.core.location.getBlockX()>>4,z=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(x);when(chunk.getZ()).thenReturn(z);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(x,z)).thenReturn(chunk);when(actor.world.isChunkLoaded(x,z)).thenReturn(true);when(actor.world.getEntities()).thenReturn(List.of());assertEquals(List.of("gear-price-copper","gear-price-diamond","gear-price-gold","gear-price-iron","gear-price-leather","gear-price-netherite","gear-price-variance-percent"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","gear-price-"}));assertEquals(List.of("gear-stock-copper-max","gear-stock-copper-min","gear-stock-diamond-max","gear-stock-diamond-min","gear-stock-gold-max","gear-stock-gold-min","gear-stock-iron-max","gear-stock-iron-min","gear-stock-leather-max","gear-stock-leather-min","gear-stock-netherite-max","gear-stock-netherite-min"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","gear-stock-"}));assertEquals(List.of("list","place","remove","restock"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"shop",""}));assertEquals(List.of("list","remove","restock"),f.command.tabComplete(console,"fantasystick",new String[]{"shop",""}));Runnable task;f.command.execute(console,"fantasystick",new String[]{"config","gear-price-variance-percent","0"});for(int attempt=0;attempt<10&&consoleMessages.stream().noneMatch(message->message.contains("Saved gear-price-variance-percent=0"));attempt++){task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}f.command.execute(console,"fantasystick",new String[]{"config","gear-stock-iron-min","20"});for(int attempt=0;attempt<10&&consoleMessages.stream().noneMatch(message->message.contains("Saved gear-stock-iron-min=20"));attempt++){task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}var villager=mock(Villager.class);when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.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()));doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));f.command.execute(actor.player,"fantasystick",new String[]{"shop","place","Forge"});task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();var directory=(games.dmg.fantasystick.shop.FantasyArmorerDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"armorers");var shop=directory.all().getFirst();assertEquals("Forge",shop.name());assertEquals(List.of(shop.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"shop","remove",shop.id().toString().substring(0,8)}));var armorer=(games.dmg.fantasystick.shop.FantasyArmorerController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"armorerController");var registrar=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(armorer.tagged(villager));assertFalse(registrar.tagged(villager));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});armorer.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));verify(villager,times(2)).setAI(false);verify(villager,times(2)).setGravity(false);verify(villager,times(2)).setSilent(true);verify(villager,times(2)).setInvulnerable(true);verify(villager,times(2)).setCollidable(false);var damage=mock(org.bukkit.event.entity.EntityDamageEvent.class);when(damage.getEntity()Line truncated
|
||||
@SuppressWarnings("unchecked")@Test void adminPlacementDistinctNpcAndActiveShopPurchaseUseActualRegisteredPath()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");var console=mock(org.bukkit.command.CommandSender.class);when(console.hasPermission("fantasystick.admin")).thenReturn(true);var consoleMessages=new ArrayList<String>();doAnswer(call->{consoleMessages.add(call.getArgument(0));return null;}).when(console).sendMessage(anyString());UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);int x=actor.core.location.getBlockX()>>4,z=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(x);when(chunk.getZ()).thenReturn(z);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(x,z)).thenReturn(chunk);when(actor.world.isChunkLoaded(x,z)).thenReturn(true);when(actor.world.getEntities()).thenReturn(List.of());assertEquals(List.of("gear-price-copper","gear-price-diamond","gear-price-gold","gear-price-iron","gear-price-leather","gear-price-netherite","gear-price-variance-percent"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","gear-price-"}));assertEquals(List.of("gear-stock-copper-max","gear-stock-copper-min","gear-stock-diamond-max","gear-stock-diamond-min","gear-stock-gold-max","gear-stock-gold-min","gear-stock-iron-max","gear-stock-iron-min","gear-stock-leather-max","gear-stock-leather-min","gear-stock-netherite-max","gear-stock-netherite-min"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","gear-stock-"}));assertEquals(List.of("list","place","remove","restock"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"shop",""}));assertEquals(List.of("list","remove","restock"),f.command.tabComplete(console,"fantasystick",new String[]{"shop",""}));Runnable task;f.command.execute(console,"fantasystick",new String[]{"config","gear-price-variance-percent","0"});for(int attempt=0;attempt<10&&consoleMessages.stream().noneMatch(message->message.contains("Saved gear-price-variance-percent=0"));attempt++){task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}f.command.execute(console,"fantasystick",new String[]{"config","gear-stock-iron-min","20"});for(int attempt=0;attempt<10&&consoleMessages.stream().noneMatch(message->message.contains("Saved gear-stock-iron-min=20"));attempt++){task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}var villager=mock(Villager.class);when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.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()));doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));f.command.execute(actor.player,"fantasystick",new String[]{"shop","place","Forge"});task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();var directory=(games.dmg.fantasystick.shop.FantasyArmorerDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"armorers");var shop=directory.all().getFirst();assertEquals("Forge",shop.name());assertEquals(List.of(shop.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"shop","remove",shop.id().toString().substring(0,8)}));var armorer=(games.dmg.fantasystick.shop.FantasyArmorerController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"armorerController");var registrar=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(armorer.tagged(villager));assertFalse(registrar.tagged(villager));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});armorer.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));verify(villager,times(2)).setAI(false);verify(villager,times(2)).setGravity(false);verify(villager,times(2)).setSilent(true);verify(villager,times(2)).setInvulnerable(true);verify(villager,times(2)).setCollidable(false);var damage=mock(org.bukkit.event.entity.EntityDamageEvent.class);when(damage.getEntity()Line truncated
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);}
|
||||
|
||||
@@ -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();
|
||||
@@ -201,6 +204,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 +247,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) {
|
||||
|
||||
@@ -51,10 +51,10 @@ class PartyRegistrarNativeTest {
|
||||
doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
|
||||
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","place","Central"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();verify(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));verify(villager).setAI(false);verify(villager).setInvulnerable(true);
|
||||
var directory=(games.dmg.fantasystick.party.PartyRegistrarDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrars");var registrar=directory.all().getFirst();assertEquals("Central",registrar.name());
|
||||
var old=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts()),old.revision());f.activate(actor);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");management.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(management.active(actor.player,registrar.id()).isPresent());var controller=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(controller.tagged(villager));
|
||||
var old=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts()),old.revision());f.activate(actor);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");management.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(management.active(actor.player,registrar.id()).isPresent());f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price","7"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-registration-price=7"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price","6"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-leader-change-price=6"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}var controller=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(controller.tagged(villager));
|
||||
int openMenus=actor.inventories.size(),openBooks=actor.books.size();var mainInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);f.dispatcher.accept(mainInteraction);var offhandDuplicate=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.OFF_HAND);f.dispatcher.accept(offhandDuplicate);assertTrue(mainInteraction.isCancelled());assertTrue(offhandDuplicate.isCancelled());assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());assertEquals(Material.WRITABLE_BOOK,actor.inventories.getLast().getItem(11).getType());var vetoedInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);vetoedInteraction.setCancelled(true);f.dispatcher.accept(vetoedInteraction);assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());
|
||||
var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvil=actor.inventories.getLast();var submit=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(submit.getInventory()).thenReturn(anvil);when(submit.getWhoClicked()).thenReturn(actor.player);when(submit.getRawSlot()).thenReturn(2);var anvilView=mock(org.bukkit.inventory.view.AnvilView.class);when(anvilView.getRenameText()).thenReturn(" Cafe\u0301 Heroes ");when(submit.getView()).thenReturn(anvilView);menus.click(submit);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.TEN,characters.find(old.id()).join().orElseThrow().coins());
|
||||
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var leaderRoot=actor.inventories.getLast();click(menus,actor.player,leaderRoot,10,false);var eligible=actor.inventories.getLast();click(menus,actor.player,eligible,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();click(menus,actor.player,actor.inventories.getLast(),22,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.invitationsFor(member.core.record.id()).isEmpty());click(menus,actor.player,actor.inventories.getLast(),10,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));var memberRoot=member.inventories.getLast();click(menus,member.player,memberRoot,15,false);var invitationMenu=member.inventories.getLast();click(menus,member.player,invitationMenu,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isPresent());controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);click(menus,actor.player,actor.inventories.getLast(),0,true);characters.find(UUID.randomUUID()).join();parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(member.core.record.id(),parties.partyOf(old.id()).orElseThrow().leader());assertEquals(BigInteger.ZERO,characters.find(old.id()).join().orElseThrow().coins());
|
||||
var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();assertEquals(net.kyori.adventure.text.Component.text("Register a party — 7 coins"),root.getItem(11).getItemMeta().displayName());var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvil=actor.inventories.getLast();var submit=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(submit.getInventory()).thenReturn(anvil);when(submit.getWhoClicked()).thenReturn(actor.player);when(submit.getRawSlot()).thenReturn(2);var anvilView=mock(org.bukkit.inventory.view.AnvilView.class);when(anvilView.getRenameText()).thenReturn(" Cafe\u0301 Heroes ");when(submit.getView()).thenReturn(anvilView);menus.click(submit);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.valueOf(13),characters.find(old.id()).join().orElseThrow().coins());
|
||||
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var leaderRoot=actor.inventories.getLast();click(menus,actor.player,leaderRoot,10,false);var eligible=actor.inventories.getLast();click(menus,actor.player,eligible,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();click(menus,actor.player,actor.inventories.getLast(),22,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.invitationsFor(member.core.record.id()).isEmpty());click(menus,actor.player,actor.inventories.getLast(),10,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));var memberRoot=member.inventories.getLast();click(menus,member.player,memberRoot,15,false);var invitationMenu=member.inventories.getLast();click(menus,member.player,invitationMenu,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isPresent());controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);assertEquals(List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — 6 coins")),actor.inventories.getLast().getItem(0).getItemMeta().lore());click(menus,actor.player,actor.inventories.getLast(),0,true);characters.find(UUID.randomUUID()).join();parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(member.core.record.id(),parties.partyOf(old.id()).orElseThrow().leader());assertEquals(BigInteger.valueOf(7),characters.find(old.id()).join().orElseThrow().coins());
|
||||
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","list"});assertTrue(actor.messages.stream().anyMatch(message->message.contains(registrar.id().toString())&&message.contains("Central")));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});controller.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));var damage=new org.bukkit.event.entity.EntityDamageEvent(villager,org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE,1);controller.damage(damage);assertTrue(damage.isCancelled());var combust=new org.bukkit.event.entity.EntityCombustEvent(villager,5);controller.combust(combust);assertTrue(combust.isCancelled());var interaction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);controller.interact(interaction);assertTrue(interaction.isCancelled());var leash=new org.bukkit.event.entity.PlayerLeashEntityEvent(villager,actor.player,actor.player,EquipmentSlot.HAND);controller.leash(leash);assertTrue(leash.isCancelled());
|
||||
when(actor.world.getEntities()).thenReturn(List.of(villager));f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","remove",registrar.id().toString()});directory.remove(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(directory.find(registrar.id()).isEmpty());verify(villager).remove();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user