feat(hex): export accepted native prevention events
Release / release (push) Successful in 17m49s

This commit is contained in:
dmg committed 2026-09-26 07:40:13 -04:00
1 parent fc49ee1cfb
commit 93897ce516
11 files changed
+411 -44

No files matched your search

@@ -766,6 +766,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEngine.enableCastEventsFor("second_wind");
abilityEngine.enableCastEventsFor("stone_skin");
abilityEngine.enableCastEventsFor("shield_bash");
abilityEngine.enableCastEventsFor("hex");
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
@@ -73,7 +73,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
if (castEvents == null || record == null
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow")
|| definition.id().equals("quickstep") || definition.id().equals("second_wind")
|| definition.id().equals("shield_bash"))
|| definition.id().equals("shield_bash") || definition.id().equals("hex"))
|| !eventOwnedAbilities.contains(definition.id())) return;
resolve(castEvents.begin(input, record, definition), "refused", reason);
}
@@ -484,6 +484,18 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
castEvents.prevented(audit, ally, aided.character(), aided.session(), attacker,
incoming, actual, remaining, bondApplication);
}
@Override public void acceptedOutgoingMitigation(UUID application, Entity debuffedAttacker,
Entity protectedTarget, double incoming, double remaining, double actual) {
if (castEvents == null || audit == null) return;
var evidence = new java.util.LinkedHashMap<String, Object>();
if (protectedTarget instanceof Player player)
sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(active -> {
evidence.put("protected_character_id", active.character().toString());
evidence.put("protected_session_id", active.session().toString());
});
castEvents.outgoingPrevented(audit, application, debuffedAttacker, protectedTarget,
incoming, remaining, actual, evidence);
}
@Override public void acceptedBoost(EquipmentSessions.Active attacking, Player attacker,
Entity target, double base, double bonus, double finalDamage,
java.math.BigDecimal percent) {
@@ -42,6 +42,9 @@ public interface AbilityExecution extends AutoCloseable {
void acceptedPrevention(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player ally, Entity attacker, double incoming,
double actual, double remaining, UUID bondApplication);
/** Record only the winning owned outgoing reduction after final uncancelled native damage. */
default void acceptedOutgoingMitigation(UUID application, Entity debuffedAttacker, Entity protectedTarget,
double incoming, double remaining, double actual) { }
/** Record a winning outgoing bonus only after a positive uncancelled native hit. */
void acceptedBoost(games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target,
@@ -35,15 +35,37 @@ public final class Hex implements AbilityEffect,AutoCloseable{
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
AbilityDefinition.TargetPolicy.HOSTILE,this);}
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())||execution.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);}
@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.trainingDummy(target)) {
execution.resolve("miss", Map.of("reason", "target_ineligible_after_cooldown"));
execution.finish(); return;
}
var application = new java.util.concurrent.atomic.AtomicReference<UUID>();
var grant = mitigation.grantObserved(execution, target, current.reductionPercent(), accepted -> {
try { execution.acceptedOutgoingMitigation(application.get(), accepted.debuffedAttacker(),
accepted.recipient(), accepted.incoming(), accepted.remaining(), accepted.prevented()); }
catch (RuntimeException ignored) { /* Native damage and support do not depend on auditing. */ }
});
var state = new State(execution, target, grant);
var previous = hexes.put(target.getUniqueId(), state);
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
execution.own(state);
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
evidence.put("duration_seconds", seconds(current.duration()));
var activatedAt = execution.now();
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
application.set(execution.effectApplied("hex", target, evidence));
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
}
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
return source.getWorld()==destination.getWorld()&&source.distanceSquared(destination)<=range*range&&input.player().hasLineOfSight(target);
}catch(RuntimeException invalid){return false;}}
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(definition.cooldown(),p.get("range").doubleValue(),
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-reduction-percent"));}
private static String seconds(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values()))state.execution.finish();hexes.clear();}
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values())){
state.execution.resolve("interrupted"); state.execution.finish();}hexes.clear();}
}
@@ -25,24 +25,36 @@ import org.bukkit.projectiles.ProjectileSource;
public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
private final class Grant implements AutoCloseable {
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent; boolean closed;
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent;
final Consumer<Accepted> observation; boolean closed;
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent, Consumer<Accepted> observation) {
this.sequence=++sequences;this.execution=execution;this.target=target;this.percent=percent;
this.observation = observation;
}
@Override public void close(){if(closed)return;closed=true;var owned=grants.get(target.getUniqueId());
if(owned!=null){owned.remove(this);if(owned.isEmpty())grants.remove(target.getUniqueId());}}
}
private record Prevention(Grant grant, Entity recipient, BigDecimal amount) { }
public record Accepted(LivingEntity debuffedAttacker, Entity recipient,
double incoming, double remaining, double prevented) { }
private record Prevention(Grant grant, Entity recipient, double incoming, double reduced) { }
private final EquipmentSessions sessions; private final PartyRoster parties; private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new java.util.HashMap<>();
private final Map<EntityDamageEvent,Prevention> pending=new IdentityHashMap<>();
private long sequences; private boolean closed;
public OutgoingDamageMitigation(EquipmentSessions sessions,PartyRoster parties,Consumer<Support> supports){
this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,LivingEntity target,BigDecimal percent){
Objects.requireNonNull(execution);Objects.requireNonNull(target);Objects.requireNonNull(percent);
if(closed||percent.signum()<0||percent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid outgoing mitigation grant");
var grant=new Grant(execution,target,percent);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);return grant;}
public AutoCloseable grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
return grantObserved(execution, target, percent, null);
}
public AutoCloseable grantObserved(AbilityExecution execution, LivingEntity target, BigDecimal percent,
Consumer<Accepted> observation) {
Objects.requireNonNull(execution); Objects.requireNonNull(target); Objects.requireNonNull(percent);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid outgoing mitigation grant");
var grant = new Grant(execution, target, percent, observation);
grants.computeIfAbsent(target.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
return grant;
}
@EventHandler(priority=EventPriority.NORMAL,ignoreCancelled=true)
public void reduce(EntityDamageByEntityEvent event){
@@ -53,20 +65,31 @@ public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
double original=event.getDamage();if(strongest==null||!Double.isFinite(original)||original<=0)return;
double reduced=BigDecimal.valueOf(original).multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
if(!Double.isFinite(reduced)||reduced<0)return;event.setDamage(reduced);var prevented=BigDecimal.valueOf(original-reduced);
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),prevented));
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),original,reduced));
}
@EventHandler(priority=EventPriority.MONITOR)
public void accepted(EntityDamageEvent event){
var prevention=pending.remove(event);if(prevention==null||event.isCancelled()||!prevention.grant.execution.stillCurrent())return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION,prevention.grant.target,prevention.amount);
if(!(prevention.recipient instanceof Player player))return;
var prevention = pending.remove(event);
if (prevention == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|| event.getFinalDamage() <= 0 || !prevention.grant.execution.stillCurrent()) return;
double remaining = event.getDamage();
double actual = Math.min(prevention.incoming - prevention.reduced, prevention.incoming - remaining);
if (!Double.isFinite(remaining) || !Double.isFinite(actual) || actual <= 0) return;
var amount = BigDecimal.valueOf(actual);
try { prevention.grant.execution.report(AbilityReport.Type.PREVENTION, prevention.grant.target, amount); }
catch (RuntimeException ignored) { /* Feedback cannot interrupt accepted native prevention. */ }
if (prevention.grant.observation != null) try {
prevention.grant.observation.accept(new Accepted(prevention.grant.target,
prevention.recipient, prevention.incoming, remaining, actual));
} catch (RuntimeException ignored) { /* Audit cannot alter damage or rewards. */ }
if (!(prevention.recipient instanceof Player player)) return;
var aided=sessions.active(player.getUniqueId()).orElse(null);if(aided==null||!sessions.current(aided))return;
var input=prevention.grant.execution.input();if(aided.character().equals(input.session().character()))return;
var sourceParty=parties.partyOf(input.player().getUniqueId()).orElse(null);
var aidedParty=parties.partyOf(player.getUniqueId()).orElse(null);
if(sourceParty==null||aidedParty==null||!sourceParty.id().equals(aidedParty.id()))return;
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,prevention.amount));
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,amount));
}
private static Entity attacker(EntityDamageByEntityEvent event){
@@ -232,6 +232,30 @@ public final class AbilityCastEventBridge {
} catch (RuntimeException ignored) { /* Prevention authority belongs to the native damage event. */ }
}
/** Hex owns outgoing prevention even when the protected recipient is not a player. */
public void outgoingPrevented(Cast cast, UUID effectApplication, Entity debuffedAttacker,
Entity protectedTarget, double incoming, double remaining, double actual,
Map<String, Object> recipientEvidence) {
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>(recipientEvidence);
UUID application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("effect_application_id", effectApplication.toString());
data.put("causation_id", cast.actionId().toString());
data.put("debuffed_attacker_id", debuffedAttacker.getUniqueId().toString());
data.put("debuffed_attacker_type", entityType(debuffedAttacker));
data.put("attacker_location", locationData(debuffedAttacker.getLocation()));
data.put("protected_target_id", protectedTarget.getUniqueId().toString());
data.put("protected_target_type", entityType(protectedTarget));
data.put("protected_location", locationData(protectedTarget.getLocation()));
data.put("incoming_amount", incoming);
data.put("remaining_amount", remaining);
data.put("actual_amount", actual);
emit(cast, "combat.prevented", "accepted", data, application);
} catch (RuntimeException ignored) { /* Logging cannot alter accepted native damage. */ }
}
public void boosted(Cast cast, games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target, double base, double bonus,
double finalDamage, java.math.BigDecimal percent) {
@@ -72,11 +72,12 @@ public final class HexSettings implements AutoCloseable {
public Hex.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -1642,14 +1642,37 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void hexConfiguration(CommandSender sender, String[] args) {
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = hexSettings.value(key);
if (args.length == 2) {
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "hex", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Hex casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
try{operation=hexSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Hex setting is invalid; the effective value is unchanged.");return;}
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;
try{nextTick.accept(()->{if(closed)return;if(failure==null){hex.configure(saved.settings());abilities.replace(hex.definition());hexSettings.publish(saved);}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Hex casts; active effects and deadlines are unchanged."
:"Hex configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
try { operation = hexSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Hex setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
String effectiveBefore = failure == null ? hexSettings.value(key) : null;
boolean applied = failure == null && hexSettings.publish(saved);
if (applied) {
hex.configure(hexSettings.settings()); abilities.replace(hex.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "hex", key, effectiveBefore, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure != null
? "Hex configuration save failed; the effective setting is unchanged."
: applied ? "Saved " + key + "=" + saved.value() + " for future Hex casts; active effects and deadlines are unchanged."
: "Saved request was superseded; effective " + key + "=" + hexSettings.value(key) + ".");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void shieldBashConfiguration(CommandSender sender, String[] args) {
@@ -31,12 +31,12 @@ final class FishingQuestNativeTest {
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);when(actor.world.getName()).thenReturn("Fishing Realm");f.activate(actor);doReturn(null).when(actor.player).getFishHook();
assertEquals(List.of("fishing"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","f"}));
assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing",""}).contains("--name"));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","\"Moonlit Pond\"","--item","\"Silver Scale\"","--required","1","--chance","100","--xp","10","--coins","4","--radius","16","--mode","individual-only"});drain(f);
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","\"Moonlit Pond\"","--item","\"Silver Scale\"","--required","1","--chance","100","--xp","10","--coins","4","--radius","16","--mode","individual-only"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Placed fishing site")&&message.contains("Moonlit Pond")));
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var site=adventures.snapshot().sites().getFirst();assertEquals(AdventureQuestState.SiteType.FISHING,site.type());assertEquals("Moonlit Pond",site.name());assertEquals("Silver Scale",site.fishing().itemName());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,site.participationMode());
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString()});assertTrue(actor.messages.getLast().contains("Silver Scale"));assertEquals(List.of(site.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","fishing",""}));
int before=adventures.snapshot().sites().size();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","Broken","--item","X","--required","0","--chance","101","--xp","1","--coins","1"});assertEquals(before,adventures.snapshot().sites().size());
var oldCatalog=adventures.snapshot().catalog();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&adventures.snapshot().catalog()!=null&&(oldCatalog==null||!adventures.snapshot().catalog().revision().equals(oldCatalog.revision())));var catalog=adventures.snapshot().catalog();assertTrue(catalog.sites().contains(site.id()));
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});drain(f);var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());actor.core.location.setX(10);
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated fishing site")&&message.contains(site.id().toString())));var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());actor.core.location.setX(10);
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}var rod=actor.core.inventory.getItem(0);assertEquals(Material.FISHING_ROD,rod.getType());assertEquals(quest.id(),AdventureQuestNavigation.fishingQuest(rod));assertTrue(rod.getEnchantments().isEmpty());
var use=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,rod,null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(use);assertNotEquals(org.bukkit.event.Event.Result.DENY,use.useItemInHand(),"owned quest rod keeps native cast/reel mechanics");
var ordinaryHook=mock(FishHook.class);when(ordinaryHook.getUniqueId()).thenReturn(UUID.randomUUID());var ordinaryEvent=new PlayerFishEvent(actor.player,null,ordinaryHook,EquipmentSlot.HAND,PlayerFishEvent.State.REEL_IN);actor.core.inventory.setItem(0,new ItemStack(Material.FISHING_ROD));f.dispatcher.accept(ordinaryEvent);assertFalse(ordinaryEvent.isCancelled(),"ordinary fishing remains unchanged");actor.core.inventory.setItem(0,rod);
@@ -28,6 +28,7 @@ class HexNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookCastActualPreventionAndQualifyingAllyDamagePersistTwoRewardShares() throws Exception {
java.util.UUID actorId, characterId, sessionId, enemyId, protectedId, protectedCharacterId, protectedSessionId, worldId;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); var b = new FantasyStickLifecycleNativeTest.Actor(data);
@@ -39,11 +40,19 @@ class HexNativeTest {
assertTrue(book.contains("Hex")); assertTrue(book.contains("CHR14")); assertTrue(book.contains("WIS12"));
assertFalse(book.contains("Cost:")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(f.feedbackCommand.execute(a.player, "fantasyfeedback", new String[]{"off"})); f.tick();
var enemy = mock(org.bukkit.entity.LivingEntity.class); var enemyId = java.util.UUID.randomUUID();
actorId = a.core.id; characterId = a.core.record.id(); protectedId = b.core.id;
protectedCharacterId = b.core.record.id();
var activeSessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
sessionId = activeSessions.active(actorId).orElseThrow().session();
protectedSessionId = activeSessions.active(protectedId).orElseThrow().session();
var enemy = mock(org.bukkit.entity.LivingEntity.class); enemyId = java.util.UUID.randomUUID();
when(enemy.getUniqueId()).thenReturn(enemyId); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
worldId = java.util.UUID.randomUUID(); when(a.world.getUID()).thenReturn(worldId);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
doReturn(new Location(a.world, 0, 64, 1)).when(b.player).getLocation();
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(enemy);
doReturn(enemy).when(a.player).getTargetEntity(64);
a.core.inventory.getInventory().setSelectedSlot(2);
@@ -65,6 +74,75 @@ class HexNativeTest {
assertEquals(BigInteger.valueOf(5), storage.find(a.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5), storage.find(b.core.record.id()).get().orElseThrow().experience());
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
assertEquals(1, count(own, "ability.learned")); assertEquals(1, count(own, "ability.assigned"));
assertEquals(1, count(own, "ability.cast")); assertEquals(1, count(own, "effect.applied"));
assertEquals(1, count(own, "combat.prevented")); assertEquals(1, count(own, "effect.ended"));
var cast = record(own, "ability.cast"); var applied = record(own, "effect.applied");
var prevented = record(own, "combat.prevented"); var ended = record(own, "effect.ended");
assertEquals(2, cast.get("slot").getAsInt()); assertTrue(cast.has("cooldown_until"));
assertEquals(actorId.toString(), prevented.get("player_id").getAsString());
assertEquals(characterId.toString(), prevented.get("character_id").getAsString());
assertEquals(sessionId.toString(), prevented.get("session_id").getAsString());
assertEquals(enemyId.toString(), prevented.get("debuffed_attacker_id").getAsString());
assertEquals(protectedId.toString(), prevented.get("protected_target_id").getAsString());
assertEquals(protectedCharacterId.toString(), prevented.get("protected_character_id").getAsString());
assertEquals(protectedSessionId.toString(), prevented.get("protected_session_id").getAsString());
assertEquals(worldId.toString(), prevented.getAsJsonObject("attacker_location").get("world_id").getAsString());
assertEquals(8, prevented.getAsJsonObject("attacker_location").get("x").getAsDouble(), 1.0e-9);
assertEquals(worldId.toString(), prevented.getAsJsonObject("protected_location").get("world_id").getAsString());
assertEquals(1, prevented.getAsJsonObject("protected_location").get("z").getAsDouble(), 1.0e-9);
assertEquals(10, prevented.get("incoming_amount").getAsDouble(), 1.0e-9);
assertEquals(8, prevented.get("remaining_amount").getAsDouble(), 1.0e-9);
assertEquals(2, prevented.get("actual_amount").getAsDouble(), 1.0e-9);
assertEquals(cast.get("action_id").getAsString(), prevented.get("action_id").getAsString());
assertEquals(applied.get("application_id").getAsString(), ended.get("application_id").getAsString());
assertEquals(applied.get("application_id").getAsString(), prevented.get("effect_application_id").getAsString());
assertEquals("interrupted", record(own, "ability.resolved").get("outcome").getAsString());
}
@Test void rejectedEventWriterCannotAlterHexPreventionOrPersistedPartyReward() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
withHex(data, a, true);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
var enemy = mock(org.bukkit.entity.LivingEntity.class);
when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
doReturn(new Location(a.world, 1, 64, 0)).when(b.player).getLocation();
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0));
doReturn(true).when(a.player).hasLineOfSight(enemy);
doReturn(enemy).when(a.player).getTargetEntity(64);
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
var allySource = DamageSource.builder(DamageType.PLAYER_ATTACK)
.withCausingEntity(b.player).withDirectEntity(b.player).build();
f.dispatcher.accept(new EntityDamageByEntityEvent(b.player, enemy,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, allySource, 3));
var enemySource = DamageSource.builder(DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit = new EntityDamageByEntityEvent(enemy, b.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, enemySource, 10);
f.dispatcher.accept(hit); assertEquals(8, hit.getDamage(), 1.0e-9);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy, allySource,
new java.util.ArrayList<>(), 0));
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(5), storage.find(a.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5), storage.find(b.core.record.id()).get().orElseThrow().experience());
f.plugin.setEnabled(false);
}
assertEquals(0, events(data).stream().filter(event -> event.get("type").getAsString()
.endsWith("combat.prevented")).count());
}
@Test void trainingDummyConsumesNoOwnedHexEffect() {
@@ -87,10 +165,14 @@ class HexNativeTest {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);
use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
assertEquals(2,f.delayed.size());f.delayed.getFirst().run();
var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
var recipient=mock(org.bukkit.entity.Zombie.class);when(recipient.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(recipient.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(recipient.getLocation()).thenReturn(new Location(a.world,3,64,0));
var hit=new EntityDamageByEntityEvent(enemy,recipient,EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
f.dispatcher.accept(hit);assertEquals(8,hit.getDamage(),1.0e-9);f.delayed.getLast().run();
var expired=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1.0e-9);
@@ -98,6 +180,15 @@ class HexNativeTest {
var exited=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
f.dispatcher.accept(exited);assertEquals(10,exited.getDamage(),1.0e-9);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
assertEquals(3, count(own, "effect.applied"));
assertEquals(3, count(own, "effect.ended"));
assertEquals(1, count(own, "combat.prevented"));
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("replaced")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("expired")).count());
}
@Test void latestReplacementOwnsEffectivePreventionSupport() throws Exception {
@@ -109,6 +200,41 @@ class HexNativeTest {
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var ax=storage.find(a.core.record.id()).get().orElseThrow().experience();var cx=storage.find(c.core.record.id()).get().orElseThrow().experience();var bx=storage.find(b.core.record.id()).get().orElseThrow().experience();assertEquals(BigInteger.TEN,ax.add(cx).add(bx));assertTrue(List.of(ax,cx,bx).stream().allMatch(value->value.equals(BigInteger.valueOf(3))||value.equals(BigInteger.valueOf(4))));
}}
@Test void unusedOrLaterOverriddenDamageCannotClaimHexPrevention() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withHex(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a);
var enemy = mock(org.bukkit.entity.LivingEntity.class);
when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0));
doReturn(true).when(a.player).hasLineOfSight(enemy);
doReturn(enemy).when(a.player).getTargetEntity(64);
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var recipient = mock(org.bukkit.entity.Zombie.class);
when(recipient.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(recipient.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(recipient.getLocation()).thenReturn(new Location(a.world, 3, 64, 0));
class Override implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
public void restore(EntityDamageByEntityEvent event) { event.setDamage(10); }
}
f.dispatcher.register(new Override(), f.plugin);
var source = DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit = new EntityDamageByEntityEvent(enemy, recipient, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, 10);
f.dispatcher.accept(hit); assertEquals(10, hit.getDamage(), 1.0e-9);
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
assertEquals(1, count(own, "effect.applied"));
assertEquals(0, count(own, "combat.prevented"));
}
@Test void unusedProtectionCannotManufactureSupportShare() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);
@@ -124,6 +250,55 @@ class HexNativeTest {
}
}
@Test void failedPreventionFeedbackCannotInterruptNativeDamageOrOwnerAudit() {
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = mock(games.dmg.fantasystick.party.PartyRoster.class);
var mitigation = new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions, parties, ignored -> {});
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
when(execution.stillCurrent()).thenReturn(true);
doThrow(new IllegalStateException("simulated feedback failure")).when(execution).report(any(), any(), any());
var attacker = mock(org.bukkit.entity.Zombie.class);
when(attacker.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var target = mock(org.bukkit.entity.Zombie.class);
var accepted = new java.util.ArrayList<games.dmg.fantasystick.ability.OutgoingDamageMitigation.Accepted>();
mitigation.grantObserved(execution, attacker, new java.math.BigDecimal("20"), accepted::add);
var source = DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(attacker).withDirectEntity(attacker).build();
var hit = new EntityDamageByEntityEvent(attacker, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9);
assertDoesNotThrow(() -> mitigation.accepted(hit));
assertEquals(1, accepted.size()); assertEquals(2, accepted.getFirst().prevented(), 1.0e-9);
mitigation.close();
}
@Test void failedCooldownSaveCannotPublishHexEffectOrPrevention() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withHex(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a);
var enemy = mock(org.bukkit.entity.LivingEntity.class);
when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0));
doReturn(true).when(a.player).hasLineOfSight(enemy);
doReturn(enemy).when(a.player).getTargetEntity(64);
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).close();
use(f, a); var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(response); response.run();
var hit = new EntityDamageByEntityEvent(enemy, mock(org.bukkit.entity.Zombie.class),
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(DamageSource.class), 10);
f.dispatcher.accept(hit); assertEquals(10, hit.getDamage(), 1.0e-9);
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
assertEquals(0, count(own, "ability.cast"));
assertEquals(0, count(own, "effect.applied"));
assertEquals(0, count(own, "combat.prevented"));
assertEquals("failed", record(own, "ability.resolved").get("outcome").getAsString());
}
@Test void failedCooldownPersistenceAppliesNoHex() {
var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);
var mitigation=new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions,parties,ignored->{});var hex=new games.dmg.fantasystick.ability.Hex(games.dmg.fantasystick.ability.Hex.Settings.defaults(),mitigation);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(hex.definition());
@@ -139,11 +314,15 @@ class HexNativeTest {
@Test void cancelledDamageCreatesNoReductionOrSupportButRetainsAcknowledgedCooldown() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);
f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
class Veto implements org.bukkit.event.Listener{@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.LOWEST)public void damage(EntityDamageByEntityEvent event){if(event.getDamager()==enemy)event.setCancelled(true);}}
f.dispatcher.register(new Veto(),f.plugin);var source=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertTrue(hit.isCancelled());assertEquals(10,hit.getDamage(),1.0e-9);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
class Veto implements org.bukkit.event.Listener{@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)public void damage(EntityDamageByEntityEvent event){if(event.getDamager()==enemy)event.setCancelled(true);}}
f.dispatcher.register(new Veto(),f.plugin);var source=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertTrue(hit.isCancelled());assertEquals(8,hit.getDamage(),1.0e-9);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
assertEquals(1, count(own, "effect.applied"));
assertEquals(0, count(own, "combat.prevented"), "Cancellation after the outgoing stage cannot claim real prevention");
}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedPenalty() throws Exception {
@@ -176,6 +355,38 @@ class HexNativeTest {
for(String invalid:List.of("hex-cooldown-seconds: -1\n","hex-range-blocks: 0\n","hex-duration-seconds: 0\n","hex-damage-reduction-percent: 101\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}
}
@Test void authorizedHexConfigurationReadAndAcknowledgedChangeReachJsonlOnlyAfterSave() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "hex-damage-reduction-percent"});
assertTrue(admin.messages.getLast().contains("hex-damage-reduction-percent=20"));
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "hex-damage-reduction-percent", "30"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("30"), registry.find("hex").orElseThrow()
.parameters().get("damage-reduction-percent"));
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "hex-damage-reduction-percent", "40"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
}
admin.allowed = false;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "hex-damage-reduction-percent"});
f.plugin.setEnabled(false);
}
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")
&& (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
assertEquals(2, config.size());
assertEquals("20", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("30", config.getLast().getAsJsonObject("data").get("after").getAsString());
}
@Test void partyAndMixedModeAimedPlayersRefuseWithoutCooldown() throws Exception {
for(int route=0;route<2;route++){var dir=data.resolve("player-refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;
if(route==0){var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));}else{target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}
@@ -184,8 +395,35 @@ class HexNativeTest {
@Test void firstAimedOutOfRangeOrObstructedTargetRefusesWithoutFallbackOrCooldown() throws Exception {
for(int route=0;route<2;route++){var dir=data.resolve("refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,route==0?13:8,64,0));doReturn(route==0).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}}
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,route==0?13:8,64,0));doReturn(route==0).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}
var own = events(dir).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
assertEquals(0, count(own, "ability.cast"));
assertEquals(0, count(own, "effect.applied"));
assertEquals("refused", record(own, "ability.resolved").get("outcome").getAsString());
}
}
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
try (var segments = java.nio.file.Files.list(day)) {
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
}
}
return results;
}
private static long count(java.util.List<com.google.gson.JsonObject> own, String suffix) {
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)).count();
}
private static com.google.gson.JsonObject record(java.util.List<com.google.gson.JsonObject> own, String suffix) {
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix))
.findFirst().orElseThrow().getAsJsonObject("data");
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
@@ -18,6 +18,20 @@ class SessionRecoveryEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void recoveryReaderCountsOnlyExportedUtcDaySegments() throws Exception {
Path events = data.resolve("events");
Path pending = events.resolve("pending");
Path exported = events.resolve(java.time.LocalDate.now(java.time.ZoneOffset.UTC).toString());
Files.createDirectories(pending);
Files.createDirectories(exported);
Files.writeString(pending.resolve("unexported.jsonl"), "{\"type\":\"phantom.recovery\"}\n");
Files.writeString(exported.resolve("events-test-0.jsonl"), "{\"type\":\"recovery.resolved\"}\n");
var records = records();
assertEquals(1, records.size(), "Pending records are not exported delivery");
assertEquals("recovery.resolved", records.getFirst().get("type").getAsString());
}
@Test void registeredAuthorizedInspectionOfPendingReceiptIsRecordedWithoutEquipmentContents() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
@@ -502,13 +516,19 @@ class SessionRecoveryEventsNativeTest {
var result = new ArrayList<JsonObject>();
Path directory = data.resolve("events");
if (!Files.exists(directory)) return result;
try (var files = Files.walk(directory)) {
for (var path : files.filter(item -> item.getFileName().toString().endsWith(".jsonl")).toList()) {
var contents = Files.readString(path);
int end = contents.lastIndexOf('\n');
if (end < 0) continue;
for (var line : contents.substring(0, end).lines().toList())
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
try (var days = Files.list(directory)) {
for (var day : days.filter(path -> path.getFileName().toString()
.matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(item -> item.getFileName().toString().startsWith("events-")
&& item.getFileName().toString().endsWith(".jsonl")).toList()) {
var contents = Files.readString(path);
int end = contents.lastIndexOf('\n');
if (end < 0) continue;
for (var line : contents.substring(0, end).lines().toList())
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
}
}
}
}
return result;