Compare commits

...
1 Commits
Author SHA1 Message Date
dmg 1dcc7cfae8 feat(training): add durable damage dummies
Release / release (push) Successful in 10m2s
2026-09-21 15:48:52 -04:00
34 changed files with 1091 additions and 25 deletions

No files matched your search

@@ -39,6 +39,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.party.CharacterPartyDirectory characterParties;
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
private games.dmg.fantasystick.party.PartyManagement partyManagement;
private games.dmg.fantasystick.party.PartyRegistrarMenus partyRegistrarMenus;
private games.dmg.fantasystick.party.PartyEvents partyEvents;
@@ -284,9 +286,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,
@@ -296,6 +300,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
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();
@@ -470,7 +475,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 -> { }, combatFeedback,
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);
@@ -481,7 +486,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, Clock.systemUTC(), combatFeedback);
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);
@@ -533,6 +539,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);
@@ -561,10 +568,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;
@@ -639,6 +648,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;
@@ -775,6 +786,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
: 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(),
@@ -867,6 +880,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);
@@ -883,6 +898,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingPartyRegistrarController);
unregister(closingTrainingDummyController);
unregister(closingPartyRegistrarMenus);
unregister(closingArmorerController);unregister(closingArmorShopMenus);
unregister(closingCombatExperience);
@@ -40,6 +40,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
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<>();
@@ -51,17 +52,27 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
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) -> { }, failures);
(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.failures = Objects.requireNonNull(failures);
this.feedback = Objects.requireNonNull(feedback); this.trainingDamage = Objects.requireNonNull(trainingDamage);
this.failures = Objects.requireNonNull(failures);
}
public void install(org.bukkit.plugin.Plugin plugin) {
@@ -334,6 +345,13 @@ 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
@@ -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();
@@ -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; }
}
@@ -127,7 +127,7 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
.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();
@@ -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();
@@ -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){}}
@@ -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(); }
}
@@ -26,6 +26,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
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;
@@ -49,9 +50,15 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
}
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) {
@@ -68,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));
@@ -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)); }
}
@@ -80,6 +80,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 +367,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
partyRegistrars=Objects.requireNonNull(directory); partyRegistrarController=Objects.requireNonNull(controller);
}
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 +383,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 +530,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){
@@ -1204,9 +1325,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)
@@ -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); }
}
+1 -1
View File
@@ -8,7 +8,7 @@ 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.
@@ -61,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);
@@ -229,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,
@@ -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", ""}));
@@ -1286,7 +1286,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");
@@ -112,6 +112,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.Snowball.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);
@@ -391,6 +443,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();
@@ -93,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());
@@ -262,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)){
@@ -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());
@@ -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);}
@@ -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());
@@ -130,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());
@@ -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;
}
}
@@ -35,6 +35,7 @@ class DistributionTest {
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,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));
}
}