Compare commits

...
1 Commits
Author SHA1 Message Date
dmg 0bdb43b2bb feat(events): record acknowledged character book views
Release / release (push) Failing after 13m19s
2026-09-24 21:47:58 -04:00
15 changed files with 187 additions and 31 deletions

No files matched your search

@@ -166,6 +166,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.RareEventBridge rareEvents;
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -703,13 +704,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
sessions.attachDownedEvents(downedEvents);
downedController.attachEvents(downedEvents);
revive.attachDownedEvents(downedEvents);
admin.attachDownedEvents(downedEvents);
getServer().getPluginManager().registerEvents(eventCapture, this);
} catch (java.io.IOException failure) {
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -812,7 +814,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -36,6 +36,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private games.dmg.fantasystick.shop.RareMagicCoreService rareMagicCores;
private final org.bukkit.Server server;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private final Map<UUID, games.dmg.fantasystick.events.BookEventBridge.Request> pendingBookEvents = new ConcurrentHashMap<>();
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
private final Map<UUID, SavedView> savedViews = new ConcurrentHashMap<>();
@@ -117,7 +119,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void attachFishingInventory(games.dmg.fantasystick.adventure.FishingDirectory fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing inventory already attached or books closed");this.fishing=Objects.requireNonNull(fishing);}
public void attachRareInventory(games.dmg.fantasystick.shop.RareInventoryDirectory inventory,games.dmg.fantasystick.shop.RarePotionService potions,games.dmg.fantasystick.shop.RareReskillService reskills,games.dmg.fantasystick.shop.RareMagicCoreService magicCores){if(closed||rareInventory!=null)throw new IllegalStateException("Rare inventory already attached or books closed");rareInventory=Objects.requireNonNull(inventory);rarePotions=Objects.requireNonNull(potions);rareReskills=Objects.requireNonNull(reskills);rareMagicCores=Objects.requireNonNull(magicCores);}
@Override public void accept(ControlInput input) { load(input, 0); }
public void attachEvents(games.dmg.fantasystick.events.BookEventBridge events){if(closed||bookEvents!=null)throw new IllegalStateException("Book events already attached or controller closed");bookEvents=Objects.requireNonNull(events);}
@Override public void accept(ControlInput input) { load(input, 0, true); }
public void openReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target) {
Objects.requireNonNull(administrator); Objects.requireNonNull(target);
@@ -233,7 +237,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
});
}
private void load(ControlInput input, int offset) {
private void load(ControlInput input, int offset, boolean userRequested) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
var owner = input.session().player();
@@ -244,32 +248,45 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
passiveViews.remove(owner);
rareViews.remove(owner);
reskillViews.remove(owner);
var current=sessions.currentRecord(input.session()).orElse(null);
var audit=userRequested&&bookEvents!=null&&current!=null?bookEvents.request(input,current,offset):null;
var superseded=pendingBookEvents.remove(owner);if(superseded!=null)bookEvents.refused(superseded,"superseded");
if(audit!=null)pendingBookEvents.put(owner,audit);
Object ticket = new Object();
pending.put(owner, ticket);
characters.find(input.session().character()).whenComplete((record, failure) -> {
if (closed || pending.get(owner) != ticket) return;
try {
nextTick.accept(() -> {
if (closed || !pending.remove(owner, ticket) || !input.stillCurrent()) return;
if (closed || !pending.remove(owner, ticket)) return;
if (!input.stillCurrent()) { finishRefused(owner,audit,"stale_session"); return; }
if (failure != null) {
finishFailed(owner,audit,"load_failed");
report(input, failure);
input.player().sendMessage("Character book could not be loaded; no character state was changed.");
return;
}
if (record.isEmpty() || record.orElseThrow().dead()) {
finishRefused(owner,audit,"character_unavailable");
input.player().sendMessage("This character is no longer available.");
return;
}
try { input.player().openBook(render(record.orElseThrow(), input, offset)); }
catch (RuntimeException error) { partyControls.forget(owner); views.remove(owner); report(input, error); }
try {
var book=render(record.orElseThrow(), input, offset);input.player().openBook(book);
if(audit!=null&&pendingBookEvents.remove(owner,audit)){var pages=((BookMeta)book.getItemMeta()).pages().size();bookEvents.opened(audit,record.orElseThrow(),pages,1);}
}
catch (RuntimeException error) { finishFailed(owner,audit,"native_open_failed");partyControls.forget(owner); views.remove(owner); report(input, error); }
});
} catch (RuntimeException rejected) {
pending.remove(owner, ticket);
pending.remove(owner, ticket);finishFailed(owner,audit,"scheduler_rejected");
report(input, rejected);
}
});
}
private void finishRefused(UUID owner,games.dmg.fantasystick.events.BookEventBridge.Request request,String reason){if(request!=null&&pendingBookEvents.remove(owner,request))bookEvents.refused(request,reason);}
private void finishFailed(UUID owner,games.dmg.fantasystick.events.BookEventBridge.Request request,String reason){if(request!=null&&pendingBookEvents.remove(owner,request))bookEvents.failed(request,reason);}
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
return renderBook(record, input.player(), input, offset, null, null, false);
}
@@ -743,15 +760,20 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var view = views.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
if (view == null) { refusePage(player,"missing_or_used_view"); return; }
if (view.input().player() != player || event.getTag() == null) { refusePage(player,"foreign_or_missing_payload"); return; }
String raw = event.getTag().string();
if (raw.length() > 64) return;
if (raw.length() > 64) { refusePage(player,"invalid_payload"); return; }
var match = PAGE_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
if (!match.matches() || !view.nonce().equals(match.group(1))) { refusePage(player,"stale_or_forged_payload"); return; }
int offset = match.group(2).equals("0") ? view.previous() : view.next();
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refusePage(player,"stale_or_unavailable_page"); return; }
// Reread authoritative data, rather than retaining a stale giant character snapshot.
load(view.input(), offset);
load(view.input(), offset, true);
}
private void refusePage(org.bukkit.entity.Player player,String reason){
if(bookEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)bookEvents.refused(player,active,current,"character_book_navigate",reason);
}
private void report(ControlInput input, Throwable failure) {
@@ -807,7 +829,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return;
}
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
load(input, 0);
load(input, 0, false);
return;
}
String message = switch (purchase.status()) {
@@ -817,7 +839,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
case APPLIED -> throw new IllegalStateException("Handled applied purchase");
};
input.player().sendMessage(message);
load(input, 0);
load(input, 0, false);
}
@org.bukkit.event.EventHandler
@@ -846,7 +868,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return;
}
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
load(view.input(), 0);
load(view.input(), 0, false);
});
}
@@ -874,7 +896,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return;
}
player.sendMessage(passiveMessage(unlock, passive, mutation.status()));
load(view.input(), 0);
load(view.input(), 0, false);
});
}
@@ -912,12 +934,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
@org.bukkit.event.EventHandler
public void rareAction(io.papermc.paper.event.player.PlayerCustomClickEvent event){if(closed||rarePotions==null||rareReskills==null||rareMagicCores==null||!RARE_USE.equals(event.getIdentifier())||!(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)||event.getTag()==null)return;var player=connection.getPlayer();var view=rareViews.get(player.getUniqueId());if(view==null||view.input().player()!=player)return;String raw=event.getTag().string();if(raw.length()>120)return;var match=RARE_PAYLOAD.matcher(raw);var product=match.matches()?games.dmg.fantasystick.shop.RareProduct.find(match.group(2)).orElse(null):null;if(product==null||!view.nonce().equals(match.group(1))||!view.quantities().getOrDefault(product.id(),java.math.BigInteger.ZERO).equals(rareInventory.quantity(view.input().session().character(),product))||!rareViews.remove(player.getUniqueId(),view)||!view.input().stillCurrent())return;if(product==games.dmg.fantasystick.shop.RareProduct.RESKILL){var preview=rareReskills.preview(player);if(preview.isEmpty()){player.sendMessage("Reskill preview refused: the character, session or quantity changed.");return;}if(preview.orElseThrow().refund().signum()==0){player.sendMessage("Reskill use refused: this character has no investments to reset.");load(view.input(),0);return;}var confirmation=new ReskillView(UUID.randomUUID().toString(),view.input(),preview.orElseThrow());reskillViews.put(player.getUniqueId(),confirmation);player.openBook(reskillConfirmation(confirmation));return;}if(product==games.dmg.fantasystick.shop.RareProduct.MAGIC_CORE){rareMagicCores.use(player,outcome->{if(closed)return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used Magic Core and gained 1 ordinary unspent point.";case NOT_AVAILABLE->"Magic Core use refused: that item is no longer available.";case STALE->"Magic Core use refused: the character, session, points or quantity changed.";case FAILED->"Magic Core could not be acknowledged; retained proof prevents replay.";});if(view.input().stillCurrent())load(view.input(),0);});return;}if(!product.heals())return;rarePotions.use(player,product,outcome->{if(closed||!view.input().stillCurrent())return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used "+product.displayName()+" and restored "+outcome.healed().toPlainString()+" health.";case FULL_HEALTH->"Potion use refused: health is already full.";case NO_EFFECT->"Potion use refused: no positive healing was accepted.";case NOT_AVAILABLE->"Potion use refused: that item is no longer available.";case STALE->"Potion use refused: the character, session or quantity changed.";case FAILED->"Potion use could not be acknowledged; retained proof prevents replay.";});load(view.input(),0);});}
public void rareAction(io.papermc.paper.event.player.PlayerCustomClickEvent event){if(closed||rarePotions==null||rareReskills==null||rareMagicCores==null||!RARE_USE.equals(event.getIdentifier())||!(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)||event.getTag()==null)return;var player=connection.getPlayer();var view=rareViews.get(player.getUniqueId());if(view==null||view.input().player()!=player)return;String raw=event.getTag().string();if(raw.length()>120)return;var match=RARE_PAYLOAD.matcher(raw);var product=match.matches()?games.dmg.fantasystick.shop.RareProduct.find(match.group(2)).orElse(null):null;if(product==null||!view.nonce().equals(match.group(1))||!view.quantities().getOrDefault(product.id(),java.math.BigInteger.ZERO).equals(rareInventory.quantity(view.input().session().character(),product))||!rareViews.remove(player.getUniqueId(),view)||!view.input().stillCurrent())return;if(product==games.dmg.fantasystick.shop.RareProduct.RESKILL){var preview=rareReskills.preview(player);if(preview.isEmpty()){player.sendMessage("Reskill preview refused: the character, session or quantity changed.");return;}if(preview.orElseThrow().refund().signum()==0){player.sendMessage("Reskill use refused: this character has no investments to reset.");load(view.input(),0,false);return;}var confirmation=new ReskillView(UUID.randomUUID().toString(),view.input(),preview.orElseThrow());reskillViews.put(player.getUniqueId(),confirmation);player.openBook(reskillConfirmation(confirmation));return;}if(product==games.dmg.fantasystick.shop.RareProduct.MAGIC_CORE){rareMagicCores.use(player,outcome->{if(closed)return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used Magic Core and gained 1 ordinary unspent point.";case NOT_AVAILABLE->"Magic Core use refused: that item is no longer available.";case STALE->"Magic Core use refused: the character, session, points or quantity changed.";case FAILED->"Magic Core could not be acknowledged; retained proof prevents replay.";});if(view.input().stillCurrent())load(view.input(),0,false);});return;}if(!product.heals())return;rarePotions.use(player,product,outcome->{if(closed||!view.input().stillCurrent())return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used "+product.displayName()+" and restored "+outcome.healed().toPlainString()+" health.";case FULL_HEALTH->"Potion use refused: health is already full.";case NO_EFFECT->"Potion use refused: no positive healing was accepted.";case NOT_AVAILABLE->"Potion use refused: that item is no longer available.";case STALE->"Potion use refused: the character, session or quantity changed.";case FAILED->"Potion use could not be acknowledged; retained proof prevents replay.";});load(view.input(),0,false);});}
private static ItemStack reskillConfirmation(ReskillView view){var item=new ItemStack(Material.WRITTEN_BOOK);var meta=(BookMeta)item.getItemMeta();meta.setTitle("Confirm Reskill");meta.setAuthor("Fantasy Stick");var page=Component.text("Confirm Reskill\n\nRefund: "+view.preview().refund()+" points\nStats → 0\nAbilities/passives removed\nHealth only clamps to the new maximum\nCooldowns retained\n\n").append(Component.text("[Confirm Reskill]",net.kyori.adventure.text.format.NamedTextColor.DARK_RED).clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(RARE_RESKILL_CONFIRM,net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\""+view.nonce()+"\""))));meta.pages(java.util.List.of(page));item.setItemMeta(meta);return item;}
@org.bukkit.event.EventHandler
public void reskillConfirm(io.papermc.paper.event.player.PlayerCustomClickEvent event){if(closed||rareReskills==null||!RARE_RESKILL_CONFIRM.equals(event.getIdentifier())||!(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)||event.getTag()==null)return;var player=connection.getPlayer();var view=reskillViews.get(player.getUniqueId());String raw=event.getTag().string();if(view==null||view.input().player()!=player||raw.length()>80)return;var match=RARE_CONFIRM_PAYLOAD.matcher(raw);if(!match.matches()||!view.nonce().equals(match.group(1))||!reskillViews.remove(player.getUniqueId(),view)||!view.input().stillCurrent())return;rareReskills.confirm(player,view.preview(),outcome->{if(closed)return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used Reskill; all purchased stats, abilities and passives were refunded.";case NOTHING_TO_RESET->"Reskill use refused: this character has no investments to reset.";case NOT_AVAILABLE->"Reskill use refused: that item is no longer available.";case STALE->"Reskill use refused: the character, session, build or quantity changed.";case FAILED->"Reskill could not be acknowledged; retained proof prevents replay.";});if(view.input().stillCurrent())load(view.input(),0);});}
public void reskillConfirm(io.papermc.paper.event.player.PlayerCustomClickEvent event){if(closed||rareReskills==null||!RARE_RESKILL_CONFIRM.equals(event.getIdentifier())||!(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)||event.getTag()==null)return;var player=connection.getPlayer();var view=reskillViews.get(player.getUniqueId());String raw=event.getTag().string();if(view==null||view.input().player()!=player||raw.length()>80)return;var match=RARE_CONFIRM_PAYLOAD.matcher(raw);if(!match.matches()||!view.nonce().equals(match.group(1))||!reskillViews.remove(player.getUniqueId(),view)||!view.input().stillCurrent())return;rareReskills.confirm(player,view.preview(),outcome->{if(closed)return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used Reskill; all purchased stats, abilities and passives were refunded.";case NOTHING_TO_RESET->"Reskill use refused: this character has no investments to reset.";case NOT_AVAILABLE->"Reskill use refused: that item is no longer available.";case STALE->"Reskill use refused: the character, session, build or quantity changed.";case FAILED->"Reskill could not be acknowledged; retained proof prevents replay.";});if(view.input().stillCurrent())load(view.input(),0,false);});}
@org.bukkit.event.EventHandler
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
@@ -926,6 +948,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
var bookRequest=pendingBookEvents.remove(owner);if(bookRequest!=null)bookEvents.refused(bookRequest,"player_quit");
adminPending.remove(owner);
adminViews.remove(owner);
savedViews.remove(owner);
@@ -939,6 +962,6 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
@Override public void close() {
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); rareViews.clear(); reskillViews.clear(); partyControls.close();
closed = true; pending.clear();pendingBookEvents.values().forEach(request->bookEvents.refused(request,"plugin_shutdown"));pendingBookEvents.clear(); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); rareViews.clear(); reskillViews.clear(); partyControls.close();
}
}
@@ -0,0 +1,106 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Stable feature-owned character-book outcomes; event delivery never authorizes or rolls back an open. */
public final class BookEventBridge {
public record Request(UUID actionId, EventInput.Context context, String actionKind, String viewKind,
int offset, BigInteger requestedRevision) {
public Request {
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(actionKind);
Objects.requireNonNull(viewKind); Objects.requireNonNull(requestedRevision);
if (offset < -1) throw new IllegalArgumentException("Invalid book offset");
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
public BookEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Request request(ControlInput input, CharacterRecord current, int offset) {
Objects.requireNonNull(input); Objects.requireNonNull(current);
var request = new Request(UUID.randomUUID(), context(input, current.name()),
offset == 0 ? "character_book_open" : "character_book_navigate", "active", offset,
current.revision());
emit("action.observed", request, "observed", null, null, null, null);
return request;
}
public void refused(Request request, String reason) {
emit("action.resolved", request, "refused", reason, null, null, null);
}
public void refused(org.bukkit.entity.Player player, games.dmg.fantasystick.session.EquipmentSessions.Active active,
CharacterRecord current, String actionKind, String reason) {
Objects.requireNonNull(player); Objects.requireNonNull(active); Objects.requireNonNull(current);
var request=new Request(UUID.randomUUID(),context(player,active,current.name()),actionKind,"active",-1,current.revision());
emit("action.observed",request,"observed",null,null,null,null);
refused(request,reason);
}
public void failed(Request request, String reason) {
emit("action.resolved", request, "failed", reason, null, null, null);
}
public void opened(Request request, CharacterRecord loaded, int presentedPages, int presentedVolumes) {
Objects.requireNonNull(loaded);
emit("action.resolved", request, "accepted", "acknowledged", loaded.revision(), presentedPages,
presentedVolumes);
emit(request.offset() == 0 ? "character_book.opened" : "character_book.navigated", request,
"accepted", "acknowledged", loaded.revision(), presentedPages, presentedVolumes);
}
private void emit(String type, Request request, String outcome, String reason, BigInteger loadedRevision,
Integer pages, Integer volumes) {
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", request.actionKind()); data.put("view_kind", request.viewKind());
data.put("section", "character_inspection"); data.put("control_kind", "book"); data.put("control_slot", "0");
data.put("offset", request.offset()<0?"unknown":Integer.toString(request.offset()));
data.put("requested_revision", request.requestedRevision().toString()); data.put("outcome", outcome);
if (reason != null) data.put(type.startsWith("character_book.") ? "native_open" : "reason", reason);
if (loadedRevision != null) data.put("loaded_revision", loadedRevision.toString());
if (pages != null) data.put("presented_pages", pages);
if (volumes != null) data.put("presented_volumes", volumes);
try {
sink.accept(new EventInput(id(request.actionId(), type), request.actionId(), clock.instant(), type,
request.context(), Map.copyOf(data)));
} catch (RuntimeException ignored) {
// Observability cannot change book authorization, rendering or native opening.
}
}
private EventInput.Context context(ControlInput input, String characterName) {
return context(input.player(),input.session(),characterName);
}
private EventInput.Context context(org.bukkit.entity.Player player,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
String characterName) {
return new EventInput.Context(player.getUniqueId(), player.getName(), active.character(),
characterName, active.session(), location(player.getLocation()));
}
private EventInput.Location location(Location location) {
return new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
}
private static UUID id(UUID action, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:book:" + action + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -26,7 +26,7 @@ class AdminExperienceNativeTest {
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(target.world);
f.players.put(viewer.core.id, viewer.player);
f.particlesCommand.execute(target.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
PartyBookNativeTest.disableAllParticles(f,target);
target.particleCalls.clear(); viewer.particleCalls.clear();
var sender = new EquipmentAdminCommandNativeTest.Sender();
var before = storage.find(target.core.record.id()).get().orElseThrow();
@@ -51,7 +51,7 @@ class ChainLightningNativeTest {
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(caster.world);
fixture.players.put(viewer.core.id, viewer.player);
fixture.particlesCommand.execute(caster.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.disableAllParticles(fixture,caster);
caster.particleCalls.clear(); viewer.particleCalls.clear();
var damage = new LinkedHashMap<UUID, Double>(); nativeDamage(fixture, first, damage); nativeDamage(fixture, second, damage);
use(fixture, caster); drain(fixture, 2);
@@ -21,6 +21,16 @@ class CharacterBookNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookEventSinkFailureCannotChangeTheAlreadyAuthorizedNativeBookPath() {
var player=org.mockito.Mockito.mock(org.bukkit.entity.Player.class);var world=org.mockito.Mockito.mock(org.bukkit.World.class);var record=org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterRecord.class);var playerId=UUID.randomUUID();var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);org.mockito.Mockito.when(player.getName()).thenReturn("Player");org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());org.mockito.Mockito.when(world.getName()).thenReturn("world");org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world,1,2,3));org.mockito.Mockito.when(record.name()).thenReturn("Hero");org.mockito.Mockito.when(record.revision()).thenReturn(java.math.BigInteger.ONE);var input=new games.dmg.fantasystick.session.ControlInput(player,active,0,"",games.dmg.fantasystick.session.ControlInput.Kind.BOOK,games.dmg.fantasystick.session.ControlInput.Gesture.USE,null,0,0,()->true);var bridge=new games.dmg.fantasystick.events.BookEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());assertDoesNotThrow(()->{var request=bridge.request(input,record,0);bridge.opened(request,record,3,1);bridge.refused(request,"retry");});
}
@Test void registeredOpenReachesOneAcknowledgedFeatureEventAfterAuthoritativeReadAndNativeOpen() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);click(f,a);drainReads(f);runQueued(f);assertEquals(1,a.books.size());f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}
var opened=events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).toList();assertEquals(1,opened.size());var data=opened.getFirst().getAsJsonObject("data");assertEquals("active",data.get("view_kind").getAsString());assertEquals("0",data.get("offset").getAsString());assertTrue(data.get("loaded_revision").getAsBigInteger().signum()>=0);assertTrue(data.get("presented_pages").getAsInt()>0);assertEquals(1,data.get("presented_volumes").getAsInt());assertEquals("acknowledged",data.get("native_open").getAsString());var action=data.get("action_id").getAsString();assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.observed")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());
}
@Test void queuedInspectionCannotOpenForAMovedSheetChangedSlotQuitOrDisabledLifetime() throws Exception {
for (int route = 0; route < 4; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("route-" + route))) {
f.plugin.setEnabled(true);
@@ -52,6 +62,7 @@ class CharacterBookNativeTest {
assertEquals(1, a.books.size());
assertTrue(text(a.books.getFirst()).contains("Latest book name"));
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_book_open")&&event.getAsJsonObject("data").has("reason")&&event.getAsJsonObject("data").get("reason").getAsString().equals("superseded")).count());
}
@Test void cappedProgressAndSavedAbilitiesRenderWithoutCappingStatsOrRewritingProgress() throws Exception {
@@ -101,6 +112,7 @@ class CharacterBookNativeTest {
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
"A rendered purchase action is single-use");
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count(),"The automatic post-purchase refresh must not duplicate a user-requested book-open outcome");
}
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() throws Exception {
@@ -210,6 +222,7 @@ class CharacterBookNativeTest {
}
@Test void largeRecordsRemainCompleteThroughBoundedSingleUseNavigation() throws Exception {
int expectedNavigations;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
@@ -247,13 +260,14 @@ class CharacterBookNativeTest {
drainReads(f); runQueued(f);
assertEquals(volumes + 1, a.books.size(), "One native action cannot open two volumes");
}
assertTrue(volumes > 0); assertTrue(all.toString().contains(digits), "Every digit remains reachable in order");
assertTrue(volumes > 0);expectedNavigations=volumes; assertTrue(all.toString().contains(digits), "Every digit remains reachable in order");
var previous = PartyBookNativeTest.button(a, "[Previous pages]");
a.core.inventory.getInventory().setSelectedSlot(1);
PartyBookNativeTest.click(f, a, previous); drainReads(f); runQueued(f);
assertEquals(volumes + 1, a.books.size(), "Stale held-slot navigation has no authority");
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml")));
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")).count());assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")&&event.getAsJsonObject("data").get("native_open").getAsString().equals("acknowledged")&&event.getAsJsonObject("data").get("offset").getAsInt()>0).count());
}
@Test void acknowledgedRenameTransferAndReloadInspectTheCurrentRecordWithoutCarryingTheFormerHoldersView() throws Exception {
@@ -110,7 +110,7 @@ class FantasyHealthNativeTest {
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(defeated.world);
f.players.put(defeated.core.id, defeated.player); f.players.put(viewer.core.id, viewer.player);
f.activate(defeated);
f.particlesCommand.execute(defeated.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
PartyBookNativeTest.disableAllParticles(f,defeated);
defeated.particleCalls.clear(); viewer.particleCalls.clear(); defeated.messages.clear();
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
@@ -610,7 +610,7 @@ class FantasyStickLifecycleNativeTest {
f.plugin.setEnabled(true);
var actor = new Actor(data); f.players.put(actor.core.id, actor.player);
actor.core.inventory.setItem(4, new org.bukkit.inventory.ItemStack(org.bukkit.Material.SHULKER_BOX));
f.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
PartyBookNativeTest.disableAllParticles(f,actor);
actor.messages.clear(); actor.particles.clear(); actor.particleCalls.clear();
var before = actor.core.snapshot(); f.changed(actor); f.tick(); f.tick();
assertArrayEquals(before, actor.core.snapshot()); assertNull(actor.core.marker());
@@ -52,7 +52,7 @@ class FireballNativeTest {
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(a.world);
f.players.put(viewer.core.id, viewer.player);
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(f);
PartyBookNativeTest.disableAllParticles(f,a);
a.particleCalls.clear(); viewer.particleCalls.clear();
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(a.particleCalls.isEmpty(), "The opted-out caster receives no Fireball particles");
@@ -80,7 +80,7 @@ class FrostNovaNativeTest {
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(caster.world);
fixture.players.put(viewer.core.id, viewer.player);
fixture.particlesCommand.execute(caster.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.disableAllParticles(fixture,caster);
caster.particleCalls.clear(); viewer.particleCalls.clear();
use(fixture, caster); drain(fixture, 2);
assertTrue(caster.particleCalls.isEmpty(), "The opted-out caster receives no Frost Nova particles");
@@ -55,7 +55,7 @@ class FrostShardNativeTest {
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(a.world);
f.players.put(viewer.core.id, viewer.player);
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(f);
PartyBookNativeTest.disableAllParticles(f,a);
a.particleCalls.clear(); viewer.particleCalls.clear();
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(a.particleCalls.isEmpty(), "The opted-out caster receives no Frost Shard particles");
@@ -30,7 +30,7 @@ class MeteorNativeTest {
@Test void characterBookPurchaseEquippedGroundWarningAndNativeImpact()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);f.particlesCommand.execute(caster.player,"fantasyparticles",new String[]{"all","off"});PartyBookNativeTest.drain(f);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Meteor warning or impact particles");assertTrue(viewer.particleCalls.stream().filter(call->call[0]==Particle.DUST&&call[2].equals(24)).count()>=40);assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.EXPLOSION_EMITTER&&call[2].equals(2)));assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.FLAME&&call[2].equals(48)));verify(caster.world,atLeastOnce()).playSound(any(Location.class),eq(Sound.ENTITY_GENERIC_EXPLODE),eq(1f),eq(.7f));verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);PartyBookNativeTest.disableAllParticles(f,caster);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Meteor warning or impact particles");assertTrue(viewer.particleCalls.stream().filter(call->call[0]==Particle.DUST&&call[2].equals(24)).count()>=40);assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.EXPLOSION_EMITTER&&call[2].equals(2)));assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.FLAME&&call[2].equals(48)));verify(caster.world,atLeastOnce()).playSound(any(Location.class),eq(Sound.ENTITY_GENERIC_EXPLODE),eq(1f),eq(.7f));verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
}
}
@@ -50,6 +50,17 @@ class PartyBookNativeTest {
Runnable task;
while ((task = f.tasks.poll()) != null) task.run();
}
static void disableAllParticles(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
int before=actor.messages.size();
f.particlesCommand.execute(actor.player,"fantasyparticles",new String[]{"all","off"});
for(int attempt=0;attempt<120;attempt++){
Runnable task=f.tasks.poll(25,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();
if(actor.messages.subList(Math.min(before,actor.messages.size()),actor.messages.size()).stream()
.anyMatch(message->message.contains("All Fantasy Stick particles")&&message.contains("off")))return;
}
fail("Particle preference acknowledgement was not published");
}
static net.minecraft.network.chat.ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a, String label) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
@@ -41,7 +41,7 @@ class SanctuaryNativeTest {
String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Sanctuary"));assertTrue(text.contains("WIS24"));assertTrue(text.contains("CHR20"));assertTrue(text.contains("CON10"));
PartyBookNativeTest.click(fixture,caster,button(caster,"[Learn]","sanctuary"));drain(fixture,2);
var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);fixture.players.put(viewer.core.id,viewer.player);
fixture.particlesCommand.execute(caster.player,"fantasyparticles",new String[]{"all","off"});PartyBookNativeTest.drain(fixture);caster.particleCalls.clear();viewer.particleCalls.clear();
PartyBookNativeTest.disableAllParticles(fixture,caster);caster.particleCalls.clear();viewer.particleCalls.clear();
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Sanctuary particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==org.bukkit.Particle.END_ROD&&call[2].equals(12)),"An enabled nearby viewer retains Sanctuary visuals");
@@ -35,7 +35,7 @@ class WarBannerNativeTest {
@Test void bookAcquisitionSolidTopPlacementBenefitsPartyAndRewardsUsedDamage()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);f.particlesCommand.execute(caster.player,"fantasyparticles",new String[]{"all","off"});PartyBookNativeTest.drain(f);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no War Banner particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.DUST&&call[2].equals(16)),"An enabled nearby viewer retains War Banner visuals");assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);PartyBookNativeTest.disableAllParticles(f,caster);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no War Banner particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.DUST&&call[2].equals(16)),"An enabled nearby viewer retains War Banner visuals");assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
}