Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f42082383 | ||
|
|
9ddc139544 | ||
|
|
58d5ea82f1 |
No files matched your search
@@ -1,8 +1,8 @@
|
||||
# Fantasy Stick
|
||||
|
||||
**Transferable characters and standalone names (US-001), equipment restoration (US-002), and switch delay (US-023) have automated implementation coverage. The full RPG remains in development and is not deployment-ready.**
|
||||
**Transferable characters and standalone names (US-001), equipment restoration (US-002), inventory safety (US-003), paginated character inspection (US-005), book parties/native damage protection (US-012), and switch delay (US-023) have automated implementation coverage. The full RPG remains in development and is not deployment-ready.**
|
||||
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. Crafting and equipment-only offhand sessions are assembled; fantasy health/nutrition, book UI, combat progression and spells remain incomplete. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. YAML persistence, backend death/revival transitions and a native signed-book factory are also present. Saved-sheet crafting is wired; anvil-name persistence now observes native accepted takes; fantasy health and spell interactions remain incomplete.
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. Crafting, equipment-only offhand sessions, read-only character books and book-managed parties with native damage protection are assembled; fantasy health/nutrition, stat/ability book actions, combat progression and spells remain incomplete. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. YAML persistence, backend death/revival transitions and a native signed-book factory are also present. Saved-sheet crafting is wired; anvil-name persistence now observes native accepted takes; fantasy health and spell interactions remain incomplete.
|
||||
|
||||
## Build and verify
|
||||
|
||||
@@ -164,6 +164,26 @@ Exit, defeat and best-effort lifecycle/recovery cleanup restore only an owned Ad
|
||||
|
||||
A same-process retry can acknowledge gear already verified restored for that exact player while retrying a vetoed mode restoration; a serialized `RESTORED` marker alone cannot establish this after restart. Missing game-mode proof leaves the mode and receipt retained even when independently verified gear can be restored and synchronized. Player-data and YAML saves are not one transaction; unobserved out-of-band state edits are not detectable history. No forced player save, blanket Survival reset or deployment is introduced.
|
||||
|
||||
## Character inspection and inventory safety
|
||||
|
||||
The first-hotbar-slot book reads the active character's authoritative name, six stats, health, level, XP/MAX, points and learned identities. Inspection does not change character data or inventory. Plain-text pages wrap conservatively for the vanilla font without splitting Unicode code points. Large supported records use volumes of at most 80 inspection pages, leaving room for navigation and party controls within the native book limit. `[Next pages]` / `[Previous pages]` reread current data; unknown ability IDs and arbitrarily large saved numbers are not silently discarded.
|
||||
|
||||
Deferred reads and single-use navigation validate the player, session, character and held control. Superseded requests, ended sessions, quit/disable and invalid client tokens cannot open a stale view. Read failures preserve data and allow reopening. Native tests cover overflow, multiple volumes, rename, transfer, reload and unchanged inventory/data; actual client rendering and custom resource-pack fonts are not claimed as tested.
|
||||
|
||||
All nine hotbar controls remain bound, with seven selectable placeholders on a new character. Native containment tests cover export/consumption, hidden swaps, recipe autofill, armor/cosmetics, cursor custody and sorting relocation. The packaged inventory journey proves that these guards still permit the real book action and that removing the transferable offhand sheet restores ordinary equipment. Future attack/ability consumers must retain these same safeguards.
|
||||
|
||||
## Character-book parties and combat protection
|
||||
|
||||
Use the slot-1 book to create a party, invite online players, accept invitations, leave, kick members or disband. Parties hold up to six consenting players. Only the creator/leader may invite, kick or disband; a leader leaving disbands rather than transferring leadership. Invitations last 60 real seconds and reserve no capacity. Book actions are single-use and bound to the current player/session/view, with authority and capacity rechecked on execution. Membership belongs to the player, not a traded character sheet, and grants no XP by itself.
|
||||
|
||||
A disconnected leader remains leader while another member is online. All-offline parties, invitations and book views are discarded on shutdown/restart. This does not persist or restore party rosters.
|
||||
|
||||
Native damage protection blocks friendly fire between active fantasy party members and player-attributable mixed-mode damage in both directions. Ordinary-versus-ordinary and environmental/mob combat retain their native rules and prior cancellations. Projectiles, TNT, fangs, clouds, fireworks and pet attacks retain player attribution; delayed fantasy sources cannot borrow a new session after exit, transfer or reconnect. Valid native healing is unchanged; stale projectile impacts are rejected before execution. Full RPG attacks and support/XP abilities remain separate unfinished work.
|
||||
|
||||
Attributable poison, wither and burning retain bounded source/recipient provenance in native entity/player PDC (`combat_origin_v1`, `combat_dot_*_v1`). Each damage tick checks current membership, source/recipient session identity and native effect state. Poison/wither hidden layers keep their own source rather than inheriting a covering effect's origin. Removal, expiry and unrelated replacement clear only the matching provenance; cleanup does not remove native effects or change health, equipment, character YAML or XP. A tick-driven cleanup checks only the three relevant player metadata keys and stops on disable. Native PDC serialization retains source proofs across reload; ended fantasy sessions remain invalid, while parties themselves never survive restart. These are native save guarantees, not cross-save atomicity or dedicated cross-plugin integration.
|
||||
|
||||
Automated party tests exercise actual packaged listeners, native book custom-click payloads, native event dispatch, effect-merging semantics and PDC serialization. Player/network/world execution remains represented by explicit test ports; live multiplayer testing and deployment are separate.
|
||||
|
||||
## CI and publication
|
||||
|
||||
Gitea CI verifies pushes/PRs; main pushes run semantic-release and can publish immutable versioned JARs. Source checkout is anonymous from the public repository; external action revisions are pinned to full commit SHAs. CI and actual release publication must both be verified for the exact source commit before any deployment preparation. The operator has provisioned `RELEASE_TOKEN` and the bootstrap is pushed. Do not infer successful CI, publication or credential validity from the secret name alone; record actual exact-commit workflow and artifact results in the canonical wiki.
|
||||
|
||||
@@ -31,6 +31,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private EquipmentSessionStore receipts;
|
||||
private EquipmentSessions sessions;
|
||||
private EquipmentSessionEvents sessionEvents;
|
||||
private games.dmg.fantasystick.book.CharacterBookController books;
|
||||
private games.dmg.fantasystick.party.PartyRoster parties;
|
||||
private games.dmg.fantasystick.party.PartyEvents partyEvents;
|
||||
private games.dmg.fantasystick.party.PartyCombat partyCombat;
|
||||
private EquipmentAdminCommand admin;
|
||||
private volatile Object lifetime;
|
||||
private CharacterPresentation presentation;
|
||||
@@ -102,7 +106,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
|
||||
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics);
|
||||
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
|
||||
var player = getServer().getPlayer(id);
|
||||
return player != null && player.isOnline();
|
||||
});
|
||||
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
|
||||
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
|
||||
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Character book failed for player " + problem.player()
|
||||
+ "; character " + problem.character(), problem.cause()), parties, getServer(), id -> {
|
||||
String name = localPresentation.get().name(id);
|
||||
var player = getServer().getPlayer(id);
|
||||
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
|
||||
});
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, books);
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings);
|
||||
@@ -114,6 +131,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(anvilNames, this);
|
||||
getServer().getPluginManager().registerEvents(sessionEvents, this);
|
||||
getServer().getPluginManager().registerEvents(chatNames, this);
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
@@ -133,6 +154,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingChat = chatNames; chatNames = null;
|
||||
var closingAdmin = admin; admin = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingBooks = books; books = null;
|
||||
var closingPartyEvents = partyEvents; partyEvents = null;
|
||||
var closingPartyCombat = partyCombat; partyCombat = null;
|
||||
var closingParties = parties; parties = null;
|
||||
var closingCrafting = crafting; crafting = null;
|
||||
var closingGuard = guard; guard = null;
|
||||
var closingAnvil = anvilNames; anvilNames = null;
|
||||
@@ -149,11 +174,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
configurationDrain = closingSettings.drained();
|
||||
cleanup("configuration", closingSettings::close);
|
||||
}
|
||||
if (closingBooks != null) cleanup("character book", closingBooks::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
if (closingEvents != null) cleanup("session events", closingEvents::close);
|
||||
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
|
||||
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
|
||||
unregister(closingModes);
|
||||
unregister(closingChat);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingBooks);
|
||||
unregister(closingEvents);
|
||||
unregister(closingCrafting);
|
||||
unregister(closingGuard);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.fantasystick.book;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
/** Plain native book pages, conservatively bounded for the vanilla font (no styled/bold text). */
|
||||
public final class BookPagination {
|
||||
private BookPagination() { }
|
||||
|
||||
public static List<Component> pages(String text) {
|
||||
var lines = new ArrayList<String>();
|
||||
var line = new StringBuilder();
|
||||
int width = 0;
|
||||
for (int offset = 0; offset < text.length();) {
|
||||
int codePoint = text.codePointAt(offset);
|
||||
offset += Character.charCount(codePoint);
|
||||
if (codePoint == '\n') {
|
||||
lines.add(line.toString()); line.setLength(0); width = 0;
|
||||
continue;
|
||||
}
|
||||
// ASCII is at most six pixels; vanilla's Unicode fallback is at most nine.
|
||||
// Leave six pixels of horizontal slack and one line of vertical slack.
|
||||
int advance = codePoint < 128 ? 6 : 9;
|
||||
if (width + advance > 108) {
|
||||
lines.add(line.toString()); line.setLength(0); width = 0;
|
||||
}
|
||||
line.appendCodePoint(codePoint); width += advance;
|
||||
}
|
||||
lines.add(line.toString());
|
||||
var pages = new ArrayList<Component>();
|
||||
for (int start = 0; start < lines.size(); start += 13) {
|
||||
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
|
||||
}
|
||||
return List.copyOf(pages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package games.dmg.fantasystick.book;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.NativeHealthLimit;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
|
||||
/** Authoritative inspection and validated party controls; character mutation actions remain unfinished. */
|
||||
public final class CharacterBookController implements Consumer<ControlInput>, AutoCloseable, org.bukkit.event.Listener {
|
||||
public record Failure(UUID player, UUID character, Throwable cause) { }
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules rules;
|
||||
private final PartyBookControls partyControls;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
|
||||
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
|
||||
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
|
||||
private record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
|
||||
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) { load(input, 0); }
|
||||
|
||||
private void load(ControlInput input, int offset) {
|
||||
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|
||||
|| !input.stillCurrent()) return;
|
||||
var owner = input.session().player();
|
||||
partyControls.forget(owner);
|
||||
views.remove(owner);
|
||||
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 (failure != null) {
|
||||
report(input, failure);
|
||||
input.player().sendMessage("Character book could not be loaded; no character state was changed.");
|
||||
return;
|
||||
}
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) {
|
||||
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); }
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
pending.remove(owner, ticket);
|
||||
report(input, rejected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
|
||||
var first = new StringBuilder(record.name()).append("\n\n");
|
||||
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
|
||||
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
|
||||
String xp = record.level() >= rules.maximumLevel() ? "MAX"
|
||||
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
|
||||
String learned = record.learned().isEmpty() ? "none" : String.join(", ", record.learned().stream().sorted().toList());
|
||||
String second = "Level: " + record.level() + "\nXP: " + xp + "\nUnspent points: " + record.unspentPoints()
|
||||
+ "\nHealth: " + record.health().min(maximum).stripTrailingZeros().toPlainString() + "/" + maximum.stripTrailingZeros().toPlainString()
|
||||
+ "\n\nAbilities: " + learned;
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
meta.setTitle("Character");
|
||||
meta.setAuthor("Fantasy Stick");
|
||||
var all = BookPagination.pages(first + "\n" + second);
|
||||
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, all.size());
|
||||
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
|
||||
if (all.size() > VOLUME_SIZE) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < all.size() ? end : -1);
|
||||
views.put(input.session().player(), view);
|
||||
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + all.size());
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
|
||||
pages.add(navigation);
|
||||
}
|
||||
pages.addAll(partyControls.pages(input));
|
||||
meta.pages(pages);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static Component pageButton(String label, View view, int direction) {
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(PAGES,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void pageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !PAGES.equals(event.getIdentifier())
|
||||
|| !(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;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 64) return;
|
||||
var match = PAGE_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
|
||||
int offset = match.group(2).equals("0") ? view.previous() : view.next();
|
||||
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
// Reread authoritative data, rather than retaining a stale giant character snapshot.
|
||||
load(view.input(), offset);
|
||||
}
|
||||
|
||||
private void report(ControlInput input, Throwable failure) {
|
||||
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
|
||||
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
|
||||
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
|
||||
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
var owner = event.getPlayer().getUniqueId();
|
||||
pending.remove(owner);
|
||||
views.remove(owner);
|
||||
partyControls.forget(owner);
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; pending.clear(); views.clear(); partyControls.close(); }
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package games.dmg.fantasystick.book;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import io.papermc.paper.connection.PlayerGameConnection;
|
||||
import io.papermc.paper.event.player.PlayerCustomClickEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import net.kyori.adventure.key.Key;
|
||||
import net.kyori.adventure.nbt.api.BinaryTagHolder;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
|
||||
/** One single-use view per player; client payloads select server-owned actions, never arbitrary UUIDs/commands. */
|
||||
public final class PartyBookControls implements AutoCloseable {
|
||||
private static final Key ACTION = Key.key("fantasystick", "party_book");
|
||||
private static final Pattern PAYLOAD = Pattern.compile("\"([a-f0-9-]{36}):([0-9]{1,3})\"");
|
||||
private static final int PAGE_SIZE = 8;
|
||||
private enum Menu { ROOT, PLAYERS, INVITATIONS }
|
||||
private record View(String nonce, ControlInput input, List<Runnable> actions) {
|
||||
private View { actions = List.copyOf(actions); }
|
||||
}
|
||||
private final Map<UUID, View> views = new HashMap<>();
|
||||
private final PartyRoster parties;
|
||||
private final Server server;
|
||||
private final Function<UUID, Component> names;
|
||||
private final Consumer<ControlInput> refresh;
|
||||
private final BiConsumer<ControlInput, Throwable> failures;
|
||||
private boolean closed;
|
||||
|
||||
public PartyBookControls(PartyRoster parties, Server server, Function<UUID, Component> names,
|
||||
Consumer<ControlInput> refresh, BiConsumer<ControlInput, Throwable> failures) {
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.server = Objects.requireNonNull(server);
|
||||
this.names = Objects.requireNonNull(names);
|
||||
this.refresh = Objects.requireNonNull(refresh);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public List<Component> pages(ControlInput input) { return render(input, Menu.ROOT, 0); }
|
||||
|
||||
private List<Component> render(ControlInput input, Menu menu, int offset) {
|
||||
if (closed || !input.stillCurrent()) return List.of();
|
||||
var build = new Pages(input);
|
||||
var owner = input.session().player();
|
||||
var party = parties.partyOf(owner);
|
||||
if (menu == Menu.PLAYERS && (party.isEmpty() || !party.orElseThrow().leader().equals(owner))) menu = Menu.ROOT;
|
||||
if (menu == Menu.ROOT) {
|
||||
Component summary = Component.text("Party\n\n");
|
||||
if (party.isEmpty()) {
|
||||
summary = summary.append(build.button("Create party", () -> mutate(input, () -> parties.create(owner).isPresent())))
|
||||
.append(Component.newline()).append(build.button("Invitations", () -> show(input, Menu.INVITATIONS, 0)));
|
||||
build.pages.add(summary);
|
||||
} else {
|
||||
var group = party.orElseThrow();
|
||||
boolean leader = group.leader().equals(owner);
|
||||
summary = summary.append(Component.text(group.members().size() + "/6 members\n\n"))
|
||||
.append(build.button("Leave party", () -> mutate(input, () -> parties.leave(owner, group.id()))));
|
||||
if (leader) {
|
||||
summary = summary.append(Component.newline())
|
||||
.append(build.button("Disband party", () -> mutate(input, () -> parties.disband(owner, group.id()))));
|
||||
if (group.members().size() < 6) summary = summary.append(Component.newline())
|
||||
.append(build.button("Invite players", () -> show(input, Menu.PLAYERS, 0)));
|
||||
}
|
||||
build.pages.add(summary);
|
||||
for (var member : group.members()) {
|
||||
Component row = Component.text(member.equals(group.leader()) ? "Leader\n\n" : "Member\n\n").append(names.apply(member));
|
||||
if (leader && !member.equals(owner)) row = row.append(Component.text("\n\n"))
|
||||
.append(build.button("Kick", () -> mutate(input, () -> parties.kick(owner, group.id(), member))));
|
||||
build.pages.add(row);
|
||||
}
|
||||
}
|
||||
} else if (menu == Menu.PLAYERS) {
|
||||
var group = party.orElseThrow();
|
||||
var candidates = server.getOnlinePlayers().stream().filter(player -> player.isOnline())
|
||||
.map(player -> player.getUniqueId()).filter(id -> parties.partyOf(id).isEmpty())
|
||||
.sorted(Comparator.comparing(UUID::toString)).toList();
|
||||
int start = start(offset, candidates.size());
|
||||
build.navigation("Invite players", Menu.PLAYERS, start, candidates.size());
|
||||
for (int i = start; i < Math.min(candidates.size(), start + PAGE_SIZE); i++) {
|
||||
UUID target = candidates.get(i);
|
||||
build.pages.add(Component.text("Invite\n\n").append(names.apply(target)).append(Component.text("\n\n"))
|
||||
.append(build.button("Invite", () -> mutate(input, () -> {
|
||||
boolean invited = parties.invite(owner, group.id(), target).isPresent();
|
||||
if (invited) {
|
||||
var recipient = server.getPlayer(target);
|
||||
if (recipient != null && recipient.isOnline()) recipient.sendMessage(
|
||||
"Party invitation received. Open your character book to accept within 60 seconds.");
|
||||
}
|
||||
return invited;
|
||||
}))));
|
||||
}
|
||||
} else {
|
||||
var invitations = parties.invitationsFor(owner);
|
||||
int start = start(offset, invitations.size());
|
||||
build.navigation("Invitations", Menu.INVITATIONS, start, invitations.size());
|
||||
for (int i = start; i < Math.min(invitations.size(), start + PAGE_SIZE); i++) {
|
||||
var invitation = invitations.get(i);
|
||||
build.pages.add(Component.text("Invitation from\n\n").append(names.apply(invitation.leader()))
|
||||
.append(Component.text("\n\n")).append(build.button("Accept",
|
||||
() -> mutate(input, () -> parties.accept(owner, invitation.id())))));
|
||||
}
|
||||
}
|
||||
views.put(owner, new View(build.nonce, input, build.actions));
|
||||
return List.copyOf(build.pages);
|
||||
}
|
||||
|
||||
private static int start(int requested, int size) {
|
||||
return Math.min(Math.max(0, requested), Math.max(0, (size - 1) / PAGE_SIZE * PAGE_SIZE));
|
||||
}
|
||||
|
||||
private final class Pages {
|
||||
final ControlInput input;
|
||||
final String nonce = UUID.randomUUID().toString();
|
||||
final List<Component> pages = new ArrayList<>();
|
||||
final List<Runnable> actions = new ArrayList<>();
|
||||
Pages(ControlInput input) { this.input = input; }
|
||||
Component button(String label, Runnable action) {
|
||||
int index = actions.size();
|
||||
actions.add(action);
|
||||
return Component.text("[" + label + "]", NamedTextColor.DARK_BLUE).clickEvent(ClickEvent.custom(ACTION,
|
||||
BinaryTagHolder.binaryTagHolder("\"" + nonce + ":" + index + "\"")));
|
||||
}
|
||||
void navigation(String title, Menu menu, int start, int count) {
|
||||
Component page = Component.text(title + "\n\n" + (count == 0 ? "None available\n\n" : ""))
|
||||
.append(button("Character book", () -> refresh.accept(input)));
|
||||
if (start > 0) page = page.append(Component.newline()).append(button("Previous", () -> show(input, menu, start - PAGE_SIZE)));
|
||||
if (start + PAGE_SIZE < count) page = page.append(Component.newline()).append(button("Next", () -> show(input, menu, start + PAGE_SIZE)));
|
||||
pages.add(page);
|
||||
}
|
||||
}
|
||||
|
||||
private void show(ControlInput input, Menu menu, int offset) {
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
meta.setTitle("Party"); meta.setAuthor("Fantasy Stick");
|
||||
meta.pages(render(input, menu, offset));
|
||||
item.setItemMeta(meta);
|
||||
input.player().openBook(item);
|
||||
}
|
||||
|
||||
public void click(PlayerCustomClickEvent event) {
|
||||
if (closed || !ACTION.equals(event.getIdentifier()) || !(event.getCommonConnection() instanceof PlayerGameConnection connection)) return;
|
||||
var player = connection.getPlayer();
|
||||
var view = views.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player) return;
|
||||
var tag = event.getTag();
|
||||
if (tag == null) return;
|
||||
String raw = tag.string();
|
||||
if (raw.length() > 64) return;
|
||||
var match = PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
|
||||
int index = Integer.parseInt(match.group(2));
|
||||
if (index >= view.actions().size()) return;
|
||||
views.remove(player.getUniqueId());
|
||||
if (!view.input().stillCurrent()) return;
|
||||
try { view.actions().get(index).run(); }
|
||||
catch (RuntimeException failure) { forget(player.getUniqueId()); failures.accept(view.input(), failure); }
|
||||
}
|
||||
|
||||
private void mutate(ControlInput input, BooleanSupplier operation) {
|
||||
if (!operation.getAsBoolean()) input.player().sendMessage("Party action unavailable: membership, authority or invitation changed.");
|
||||
refresh.accept(input);
|
||||
}
|
||||
public void forget(UUID player) { views.remove(player); }
|
||||
@Override public void close() { closed = true; views.clear(); }
|
||||
}
|
||||
@@ -107,6 +107,14 @@ public final class SessionControls {
|
||||
}
|
||||
}
|
||||
|
||||
/** A structurally recognized selectable ability; still not session/cast authorization. */
|
||||
public Optional<String> ability(ItemStack item) {
|
||||
if (identify(item).isEmpty()) return Optional.empty();
|
||||
var data = item.getPersistentDataContainer();
|
||||
return "ability".equals(data.get(KIND, PersistentDataType.STRING))
|
||||
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
|
||||
}
|
||||
|
||||
private static boolean validKind(Material material, int slot, PersistentDataContainer data) {
|
||||
String kind = data.get(KIND, PersistentDataType.STRING);
|
||||
if (slot == 0) return kind.equals("book") && material == Material.BOOK && !data.has(ABILITY);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Source identity travels with delayed native entities, never with a replacement sheet or shooter. */
|
||||
final class CombatSources {
|
||||
static final NamespacedKey ORIGIN = new NamespacedKey("fantasystick", "combat_origin_v1");
|
||||
record Origin(UUID player, UUID session, UUID character) {
|
||||
String encode() { return player + "/" + (session == null ? "-/-" : session + "/" + character); }
|
||||
static Origin decode(String value) {
|
||||
if (value == null || value.length() > 110) return null;
|
||||
var parts = value.split("/", -1);
|
||||
if (parts.length != 3) return null;
|
||||
try {
|
||||
var origin = new Origin(UUID.fromString(parts[0]), parts[1].equals("-") ? null : UUID.fromString(parts[1]),
|
||||
parts[2].equals("-") ? null : UUID.fromString(parts[2]));
|
||||
return (origin.session == null) == (origin.character == null) && value.equals(origin.encode()) ? origin : null;
|
||||
} catch (IllegalArgumentException malformed) { return null; }
|
||||
}
|
||||
}
|
||||
private final EquipmentSessions sessions;
|
||||
CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
|
||||
Origin player(UUID player) {
|
||||
return sessions.active(player).map(active -> new Origin(player, active.session(), active.character()))
|
||||
.orElseGet(() -> new Origin(player, null, null));
|
||||
}
|
||||
boolean current(Origin origin) {
|
||||
return origin.equals(player(origin.player())) && (origin.session() == null
|
||||
|| sessions.current(new EquipmentSessions.Active(origin.player(), origin.session(), origin.character())));
|
||||
}
|
||||
Origin read(Entity entity) { return read(entity, 0); }
|
||||
private Origin read(Entity entity, int depth) {
|
||||
if (entity == null || depth >= 8) return null;
|
||||
if (entity instanceof Player player) return player(player.getUniqueId());
|
||||
var saved = Origin.decode(entity.getPersistentDataContainer().get(ORIGIN, PersistentDataType.STRING));
|
||||
if (saved != null) return saved;
|
||||
if (entity instanceof org.bukkit.entity.Tameable pet && pet.getOwnerUniqueId() != null) {
|
||||
return player(pet.getOwnerUniqueId()); // A pet's new attack, not its old taming session.
|
||||
}
|
||||
var owner = owner(entity, depth + 1);
|
||||
// No launch proof (e.g. predating plugin startup): never assign a current fantasy session.
|
||||
return owner == null ? null : new Origin(owner.player(), null, null);
|
||||
}
|
||||
private Origin owner(Entity entity, int depth) {
|
||||
if (entity instanceof Projectile projectile && projectile.getShooter() instanceof Entity shooter) return read(shooter, depth);
|
||||
if (entity instanceof org.bukkit.entity.Firework firework && firework.getSpawningEntity() != null) return player(firework.getSpawningEntity());
|
||||
if (entity instanceof org.bukkit.entity.TNTPrimed tnt) return read(tnt.getSource(), depth);
|
||||
if (entity instanceof org.bukkit.entity.EvokerFangs fangs) return read(fangs.getOwner(), depth);
|
||||
if (entity instanceof org.bukkit.entity.AreaEffectCloud cloud && cloud.getSource() instanceof Entity source) return read(source, depth);
|
||||
return null;
|
||||
}
|
||||
void launch(Entity entity) {
|
||||
if (entity.getPersistentDataContainer().has(ORIGIN)) return;
|
||||
store(entity, owner(entity, 0));
|
||||
}
|
||||
void store(Entity entity, Origin origin) {
|
||||
if (origin != null) entity.getPersistentDataContainer().set(ORIGIN, PersistentDataType.STRING, origin.encode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Bounded provenance for native damage layers, not copied effects or character/XP state.
|
||||
* Native effects remain authoritative. Durations reconcile in native ticks (also across offline saves),
|
||||
* so a hidden effect regaining the foreground keeps its own source, not a covering effect's source.
|
||||
*/
|
||||
final class OngoingDamage {
|
||||
record Application(CombatSources.Origin source, CombatSources.Origin target) { }
|
||||
private record Layer(int amplifier, int duration, Application application) { }
|
||||
private static final List<DamageCause> CAUSES = List.of(DamageCause.POISON, DamageCause.WITHER, DamageCause.FIRE_TICK);
|
||||
private static NamespacedKey key(DamageCause cause) {
|
||||
return new NamespacedKey("fantasystick", "combat_dot_" + cause.name().toLowerCase(java.util.Locale.ROOT) + "_v1");
|
||||
}
|
||||
static DamageCause cause(PotionEffectType type) {
|
||||
if (type.equals(PotionEffectType.POISON)) return DamageCause.POISON;
|
||||
if (type.equals(PotionEffectType.WITHER)) return DamageCause.WITHER;
|
||||
return null;
|
||||
}
|
||||
static void clear(Player player, DamageCause cause) { player.getPersistentDataContainer().remove(key(cause)); }
|
||||
static void potion(Player player, PotionEffect old, PotionEffect incoming, boolean override, Application application) {
|
||||
var cause = cause(incoming.getType());
|
||||
var layers = old == null ? new ArrayList<Layer>() : reconcile(load(player, cause), old.getAmplifier(), old.getDuration());
|
||||
var next = new Layer(incoming.getAmplifier(), incoming.getDuration(), application);
|
||||
// Purpur also stores a longer weaker effect behind the active one when override=false.
|
||||
if (layers.isEmpty() || override || next.amplifier() < layers.getFirst().amplifier()
|
||||
&& longer(next.duration(), layers.getFirst().duration())) merge(layers, 0, next);
|
||||
save(player, cause, layers);
|
||||
}
|
||||
private static boolean longer(int a, int b) { return a == -1 ? b != -1 : b != -1 && a > b; }
|
||||
private static void merge(List<Layer> layers, int index, Layer next) {
|
||||
if (index == layers.size()) { layers.add(next); return; }
|
||||
var old = layers.get(index);
|
||||
if (next.amplifier() > old.amplifier()) {
|
||||
if (longer(old.duration(), next.duration())) layers.add(index, next);
|
||||
else layers.set(index, next);
|
||||
} else if (next.amplifier() == old.amplifier()) {
|
||||
if (longer(next.duration(), old.duration())) layers.set(index, next);
|
||||
} else if (longer(next.duration(), old.duration())) merge(layers, index + 1, next);
|
||||
}
|
||||
static void fire(Player player, int duration, Application application) {
|
||||
save(player, DamageCause.FIRE_TICK, List.of(new Layer(0, duration, application)));
|
||||
}
|
||||
static Application read(Player player, DamageCause cause) {
|
||||
if (!CAUSES.contains(cause) || !player.getPersistentDataContainer().has(key(cause))) return null;
|
||||
List<Layer> layers = load(player, cause);
|
||||
if (cause == DamageCause.FIRE_TICK) {
|
||||
int fire = player.getFireTicks();
|
||||
layers = fire > 0 && !layers.isEmpty() && fire <= layers.getFirst().duration()
|
||||
? List.of(new Layer(0, fire, layers.getFirst().application())) : List.of();
|
||||
} else {
|
||||
var effect = player.getPotionEffect(cause == DamageCause.POISON ? PotionEffectType.POISON : PotionEffectType.WITHER);
|
||||
layers = effect == null ? List.of() : reconcile(layers, effect.getAmplifier(), effect.getDuration());
|
||||
}
|
||||
save(player, cause, layers);
|
||||
return layers.isEmpty() ? null : layers.getFirst().application();
|
||||
}
|
||||
static void sweep(Player player) { for (var cause : CAUSES) read(player, cause); }
|
||||
|
||||
private static ArrayList<Layer> reconcile(List<Layer> saved, int amplifier, int duration) {
|
||||
var result = new ArrayList<Layer>();
|
||||
for (int i = 0; i < saved.size(); i++) {
|
||||
var layer = saved.get(i);
|
||||
if (layer.amplifier() != amplifier || longer(duration, layer.duration())) continue;
|
||||
int elapsed = layer.duration() == -1 ? 0 : layer.duration() - duration;
|
||||
for (int j = i; j < saved.size(); j++) {
|
||||
var remaining = saved.get(j);
|
||||
int ticks = remaining.duration() == -1 ? -1 : Math.max(0, remaining.duration() - elapsed);
|
||||
if (ticks != 0) result.add(new Layer(remaining.amplifier(), ticks, remaining.application()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Unknown/out-of-band replacement never borrows old attribution or gains party immunity.
|
||||
if (duration != 0) result.add(new Layer(amplifier, duration, null));
|
||||
return result;
|
||||
}
|
||||
private static List<Layer> load(Player player, DamageCause cause) {
|
||||
String value = player.getPersistentDataContainer().get(key(cause), PersistentDataType.STRING);
|
||||
if (value == null || value.length() > 65536) return List.of();
|
||||
var result = new ArrayList<Layer>();
|
||||
try {
|
||||
for (var row : value.split("\n", -1)) {
|
||||
var cells = row.split(",", -1);
|
||||
if (cells.length != 4 || result.size() >= 256) return List.of();
|
||||
int amplifier = Integer.parseInt(cells[0]), duration = Integer.parseInt(cells[1]);
|
||||
if (amplifier < 0 || amplifier > 255 || duration < -1 || duration == 0
|
||||
|| !result.isEmpty() && amplifier >= result.getLast().amplifier()) return List.of();
|
||||
Application application = null;
|
||||
if (!cells[2].equals("-") || !cells[3].equals("-")) {
|
||||
var source = CombatSources.Origin.decode(cells[2]);
|
||||
var target = CombatSources.Origin.decode(cells[3]);
|
||||
if (source == null || target == null || !target.player().equals(player.getUniqueId())) return List.of();
|
||||
application = new Application(source, target);
|
||||
}
|
||||
result.add(new Layer(amplifier, duration, application));
|
||||
}
|
||||
} catch (IllegalArgumentException malformed) { return List.of(); }
|
||||
return result;
|
||||
}
|
||||
private static void save(Player player, DamageCause cause, List<Layer> layers) {
|
||||
if (layers.stream().noneMatch(layer -> layer.application() != null)) { clear(player, cause); return; }
|
||||
var value = new StringBuilder();
|
||||
for (var layer : layers) {
|
||||
if (!value.isEmpty()) value.append('\n');
|
||||
value.append(layer.amplifier()).append(',').append(layer.duration()).append(',');
|
||||
var application = layer.application();
|
||||
value.append(application == null ? "-,-" : application.source().encode() + "," + application.target().encode());
|
||||
}
|
||||
player.getPersistentDataContainer().set(key(cause), PersistentDataType.STRING, value.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntitySpawnEvent;
|
||||
import org.bukkit.event.entity.LingeringPotionSplashEvent;
|
||||
|
||||
/** Server-thread native damage protection; never uncancels another protection's decision. */
|
||||
public final class PartyCombat implements Listener, AutoCloseable {
|
||||
private org.bukkit.scheduler.BukkitTask cleanup;
|
||||
private boolean closed;
|
||||
private final PartyRoster parties;
|
||||
private final CombatSources sources;
|
||||
|
||||
public PartyCombat(PartyRoster parties, EquipmentSessions sessions) {
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.sources = new CombatSources(sessions);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
cleanup = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
|
||||
if (!closed) for (var player : plugin.getServer().getOnlinePlayers()) OngoingDamage.sweep(player);
|
||||
}, 1, 1);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
if (cleanup != null) cleanup.cancel();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void launch(EntitySpawnEvent event) { sources.launch(event.getEntity()); }
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void impact(org.bukkit.event.entity.ProjectileHitEvent event) {
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && (!sources.current(origin) || !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
|
||||
&& event.getHitEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void splash(org.bukkit.event.entity.PotionSplashEvent event) {
|
||||
// Native splash passes only its owner to addEffect, losing the projectile's session.
|
||||
// Revoke stale continuation here. Valid healing and unrelated recipients are untouched;
|
||||
// actual harmful applications and instantaneous damage have their own guards below.
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && !sources.current(origin)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void staleLingering(LingeringPotionSplashEvent event) {
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && !sources.current(origin)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void lingering(LingeringPotionSplashEvent event) {
|
||||
sources.store(event.getAreaEffectCloud(), sources.read(event.getEntity()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
}
|
||||
var origin = sources.read(event.getDamageSource().getDirectEntity());
|
||||
if (origin == null && event instanceof EntityDamageByEntityEvent entity) origin = sources.read(entity.getDamager());
|
||||
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
|
||||
if (origin != null && (!sources.current(origin)
|
||||
|| event.getEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void potion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target) || event.getNewEffect() == null
|
||||
|| OngoingDamage.cause(event.getModifiedType()) == null) return;
|
||||
var origin = sources.read(event.getSource());
|
||||
if (origin != null && blocked(origin, target)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedPotion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) return;
|
||||
var cause = OngoingDamage.cause(event.getModifiedType());
|
||||
if (cause == null) return;
|
||||
if (event.getNewEffect() == null) OngoingDamage.clear(target, cause);
|
||||
else {
|
||||
var origin = sources.read(event.getSource());
|
||||
OngoingDamage.potion(target, event.getOldEffect(), event.getNewEffect(), event.isOverride(), origin == null ? null
|
||||
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void combust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) return;
|
||||
var origin = sources.read(event.getCombuster());
|
||||
if (origin != null && blocked(origin, target)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedCombust(org.bukkit.event.entity.EntityCombustEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) return;
|
||||
var origin = event instanceof org.bukkit.event.entity.EntityCombustByEntityEvent entity
|
||||
? sources.read(entity.getCombuster()) : null;
|
||||
int duration = Math.max(target.getFireTicks(), (int) Math.ceil(event.getDuration() * 20));
|
||||
OngoingDamage.fire(target, duration, origin == null ? null
|
||||
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
|
||||
}
|
||||
|
||||
private boolean blocked(CombatSources.Origin origin, Player target) {
|
||||
var recipient = sources.player(target.getUniqueId());
|
||||
boolean fantasy = origin.session() != null;
|
||||
return !sources.current(origin) || fantasy != (recipient.session() != null) || fantasy
|
||||
&& !origin.player().equals(recipient.player())
|
||||
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
|
||||
/** Quit is authoritative even before Bukkit's online flag changes. */
|
||||
public final class PartyEvents implements Listener {
|
||||
private final PartyRoster parties;
|
||||
public PartyEvents(PartyRoster parties) { this.parties = Objects.requireNonNull(parties); }
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void join(org.bukkit.event.player.PlayerJoinEvent event) { parties.arrive(event.getPlayer().getUniqueId()); }
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void quit(PlayerQuitEvent event) { parties.disconnect(event.getPlayer().getUniqueId()); }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/** Server-thread-confined, player-owned parties. No state survives a plugin/server restart. */
|
||||
public final class PartyRoster implements AutoCloseable {
|
||||
public record Party(UUID id, UUID leader, List<UUID> members) {
|
||||
public Party { members = List.copyOf(members); }
|
||||
}
|
||||
public record Invitation(UUID id, UUID party, UUID leader, UUID invitee, Instant expires) { }
|
||||
private final Map<UUID, Party> membership = new HashMap<>();
|
||||
private final Map<UUID, Invitation> invitations = new HashMap<>();
|
||||
private final Set<UUID> departed = new HashSet<>();
|
||||
private boolean closed;
|
||||
private final Clock clock;
|
||||
private final Predicate<UUID> online;
|
||||
|
||||
public PartyRoster(Clock clock, Predicate<UUID> online) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.online = Objects.requireNonNull(online);
|
||||
}
|
||||
public Optional<Party> create(UUID player) {
|
||||
if (closed || !connected(player) || membership.containsKey(player)) return Optional.empty();
|
||||
var party = new Party(UUID.randomUUID(), player, List.of(player));
|
||||
membership.put(player, party);
|
||||
return Optional.of(party);
|
||||
}
|
||||
public Optional<Party> partyOf(UUID player) { return Optional.ofNullable(membership.get(player)); }
|
||||
public List<Invitation> invitationsFor(UUID player) {
|
||||
expire();
|
||||
return invitations.values().stream().filter(i -> i.invitee().equals(player))
|
||||
.sorted(java.util.Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();
|
||||
}
|
||||
public Optional<Invitation> invite(UUID leader, UUID party, UUID invitee) {
|
||||
expire();
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null || group.members().size() >= 6 || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
|
||||
var existing = invitations.values().stream().filter(i -> i.party().equals(party) && i.invitee().equals(invitee)).findFirst();
|
||||
if (existing.isPresent()) return existing;
|
||||
var invitation = new Invitation(UUID.randomUUID(), party, leader, invitee, clock.instant().plusSeconds(60));
|
||||
invitations.put(invitation.id(), invitation);
|
||||
return Optional.of(invitation);
|
||||
}
|
||||
public boolean accept(UUID player, UUID invitation) {
|
||||
expire();
|
||||
var offer = invitations.get(invitation);
|
||||
if (offer == null || !offer.invitee().equals(player) || !connected(player) || membership.containsKey(player)) return false;
|
||||
var group = membership.get(offer.leader());
|
||||
if (group == null || group.members().size() >= 6 || !group.id().equals(offer.party())) return false;
|
||||
var members = new ArrayList<>(group.members());
|
||||
members.add(player);
|
||||
var updated = new Party(group.id(), group.leader(), members);
|
||||
for (var member : members) membership.put(member, updated);
|
||||
invitations.values().removeIf(i -> i.invitee().equals(player));
|
||||
return true;
|
||||
}
|
||||
public boolean kick(UUID leader, UUID party, UUID member) {
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null || !group.members().contains(member) || member.equals(leader)) return false;
|
||||
removeMember(group, member);
|
||||
return true;
|
||||
}
|
||||
public boolean disband(UUID leader, UUID party) {
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null) return false;
|
||||
remove(group);
|
||||
return true;
|
||||
}
|
||||
private Party managedBy(UUID leader, UUID party) {
|
||||
var group = membership.get(leader);
|
||||
return group != null && group.id().equals(party) && group.leader().equals(leader) && connected(leader) ? group : null;
|
||||
}
|
||||
public boolean leave(UUID player, UUID party) {
|
||||
var group = membership.get(player);
|
||||
if (group == null || !group.id().equals(party) || !connected(player)) return false;
|
||||
if (group.leader().equals(player)) remove(group);
|
||||
else removeMember(group, player);
|
||||
return true;
|
||||
}
|
||||
private void removeMember(Party group, UUID player) {
|
||||
var remaining = group.members().stream().filter(member -> !member.equals(player)).toList();
|
||||
if (remaining.stream().noneMatch(this::connected)) { remove(group); return; }
|
||||
membership.remove(player);
|
||||
departed.remove(player);
|
||||
var updated = new Party(group.id(), group.leader(), remaining);
|
||||
for (var member : remaining) membership.put(member, updated);
|
||||
}
|
||||
public void arrive(UUID player) { departed.remove(player); }
|
||||
public void disconnect(UUID player) {
|
||||
var group = membership.get(player);
|
||||
if (group == null) return;
|
||||
departed.add(player);
|
||||
if (group.members().stream().noneMatch(this::connected)) remove(group);
|
||||
}
|
||||
private boolean connected(UUID player) { return !departed.contains(player) && online.test(player); }
|
||||
private void expire() {
|
||||
var now = clock.instant();
|
||||
invitations.values().removeIf(i -> !now.isBefore(i.expires()));
|
||||
}
|
||||
private void remove(Party group) {
|
||||
for (var member : group.members()) membership.remove(member);
|
||||
departed.removeAll(group.members());
|
||||
invitations.values().removeIf(i -> i.party().equals(group.id()));
|
||||
}
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
membership.clear();
|
||||
invitations.clear();
|
||||
departed.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/**
|
||||
* Internal server-thread input port for book/combat/ability adapters, not an add-on API.
|
||||
* Containment of vanilla item behavior is separate from executing an ability. Recipients
|
||||
* must revalidate after deferred work; this notification is never durable cast authority.
|
||||
*/
|
||||
public record ControlInput(Player player, EquipmentSessions.Active session, int slot, String ability,
|
||||
Kind kind, Gesture gesture, Entity target, int heldTicks, float attackStrength,
|
||||
BooleanSupplier validity) {
|
||||
public enum Kind { BOOK, BASIC_ATTACK, ABILITY }
|
||||
public enum Gesture { USE, ATTACK, DRAW_RELEASE }
|
||||
|
||||
public ControlInput {
|
||||
Objects.requireNonNull(player);
|
||||
Objects.requireNonNull(session);
|
||||
Objects.requireNonNull(ability);
|
||||
Objects.requireNonNull(kind);
|
||||
Objects.requireNonNull(gesture);
|
||||
Objects.requireNonNull(validity);
|
||||
if (slot < 0 || slot > 8 || heldTicks < 0 || !Float.isFinite(attackStrength)
|
||||
|| attackStrength < 0 || attackStrength > 1) throw new IllegalArgumentException("Invalid control input");
|
||||
}
|
||||
|
||||
public boolean stillCurrent() { return validity.getAsBoolean(); }
|
||||
}
|
||||
@@ -70,6 +70,9 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
}
|
||||
}
|
||||
private boolean closed;
|
||||
private Consumer<ControlInput> inputs = ignored -> { };
|
||||
private record Draw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) { }
|
||||
private final Map<UUID, Draw> draws = new HashMap<>();
|
||||
|
||||
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick) {
|
||||
this(sessions, nextTick, failure -> System.getLogger(EquipmentSessionEvents.class.getName())
|
||||
@@ -82,7 +85,14 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.diagnostics = Objects.requireNonNull(diagnostics);
|
||||
sessions.attachCursorCustody(this::cancelMove);
|
||||
sessions.attachCursorCustody(this::cancelCustody);
|
||||
}
|
||||
|
||||
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
|
||||
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
|
||||
this(sessions, nextTick, diagnostics);
|
||||
this.inputs = Objects.requireNonNull(inputs);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -205,6 +215,31 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
if (blockedClick(player, event)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void recipeBook(com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent event) {
|
||||
var player = event.getPlayer();
|
||||
// Native recipe-book placement can pull directly from the hotbar without a click
|
||||
// event. Exact recipe ingredients also bypass vanilla's custom-name exclusion.
|
||||
if (Arrays.stream(player.getOpenInventory().getTopInventory().getContents()).anyMatch(this::special)) {
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
var recipe = player.getServer().getRecipe(event.getRecipe());
|
||||
java.util.Collection<org.bukkit.inventory.RecipeChoice> choices;
|
||||
if (recipe instanceof org.bukkit.inventory.ShapedRecipe shaped) choices = shaped.getChoiceMap().values();
|
||||
else if (recipe instanceof org.bukkit.inventory.ShapelessRecipe shapeless) choices = shapeless.getChoiceList();
|
||||
else if (recipe instanceof org.bukkit.inventory.CookingRecipe<?> cooking) choices = java.util.List.of(cooking.getInputChoice());
|
||||
else return; // Other recipe types do not use these native auto-fill routes.
|
||||
for (var item : player.getInventory().getStorageContents()) {
|
||||
if (!special(item)) continue;
|
||||
boolean ordinaryEligible = net.minecraft.world.entity.player.Inventory.isUsableForCrafting(
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(item));
|
||||
if (choices.stream().filter(Objects::nonNull).anyMatch(choice -> choice.test(item)
|
||||
&& (ordinaryEligible || choice instanceof org.bukkit.inventory.RecipeChoice.ExactChoice))) {
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean cursorIndependent(InventoryClickEvent event) {
|
||||
return switch (event.getAction()) {
|
||||
case DROP_ONE_SLOT, DROP_ALL_SLOT, MOVE_TO_OTHER_INVENTORY, HOTBAR_SWAP -> true;
|
||||
@@ -253,7 +288,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
if (event.isShiftClick() && !empty(current)) {
|
||||
if (ownInventory(player, inventory) && event.getView().getType() == InventoryType.CRAFTING && slot >= 9 && slot < 36) {
|
||||
// Native InventoryMenu tries armor first, otherwise routes main -> hotbar.
|
||||
return !(cosmetic(current) && empty(player.getInventory().getHelmet()));
|
||||
return !(cosmetic(current) && !forbiddenEquip(current) && empty(player.getInventory().getHelmet()));
|
||||
}
|
||||
if (!ownInventory(player, inventory) && mainCapacity(player, current) < current.getAmount()) return true;
|
||||
}
|
||||
@@ -294,15 +329,124 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
// also run for RIGHT_CLICK_AIR / an existing block veto, without changing that veto.
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void use(org.bukkit.event.player.PlayerInteractEvent event) {
|
||||
boolean allowed = event.useItemInHand() != org.bukkit.event.Event.Result.DENY;
|
||||
if (special(event.getItem()) || active(event.getPlayer()) && forbiddenEquip(event.getItem())) {
|
||||
// Do not veto an unrelated door/chest interaction or undo another plugin's block veto.
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
|
||||
}
|
||||
if (allowed && event.getHand() == EquipmentSlot.HAND && (event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|
||||
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK)) {
|
||||
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
|
||||
if (selected != null && useGesture(selected)) emit(event.getPlayer(), event.getItem(), selected,
|
||||
ControlInput.Gesture.USE, null, 0, 0);
|
||||
else if (selected != null && selected.ability().equals("longbow")) beginDraw(event.getPlayer(), event.getItem(), selected);
|
||||
}
|
||||
}
|
||||
|
||||
private void beginDraw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (closed || draws.containsKey(owner) || player.hasActiveItem()) return;
|
||||
var draw = new Draw(player, item.clone(), selected);
|
||||
draws.put(owner, draw);
|
||||
try {
|
||||
// Keep the native bow animation/timing, without BowItem.use's ammunition lookup.
|
||||
player.startUsingItem(EquipmentSlot.HAND);
|
||||
if (closed || draws.get(owner) != draw || !sessions.selectedControl(player, draw.item()).filter(selected::equals).isPresent()
|
||||
|| !draw.item().equals(player.getActiveItem())) cancelDraw(draw);
|
||||
} catch (RuntimeException failure) {
|
||||
cancelDraw(draw);
|
||||
report(owner, selected.session().session(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void release(io.papermc.paper.event.player.PlayerStopUsingItemEvent event) {
|
||||
var player = event.getPlayer();
|
||||
UUID owner = player.getUniqueId();
|
||||
var draw = draws.get(owner);
|
||||
if (draw != null && draw.player() != player) draw = null;
|
||||
if (draw != null) draws.remove(owner, draw); // One-use, before a native setter or recipient can reenter.
|
||||
try {
|
||||
boolean matching = draw != null && draw.item().equals(event.getItem())
|
||||
&& draw.item().equals(player.getActiveItem()) && event.getTicksHeldFor() >= 0;
|
||||
// Purpur 2618 reloads LivingEntity.useItem AFTER this event, then calls releaseUsing.
|
||||
// Clear it first: cancelling EntityShootBowEvent alone is too late to own ammo draw.
|
||||
if (special(event.getItem()) && event.getItem().equals(player.getActiveItem())) player.clearActiveItem();
|
||||
if (matching) emit(player, draw.item(), draw.selected(), ControlInput.Gesture.DRAW_RELEASE,
|
||||
null, event.getTicksHeldFor(), 0);
|
||||
} catch (RuntimeException failure) {
|
||||
// Unknown native state is not a completed cast or authority to replay an input.
|
||||
report(owner, draw == null ? null : draw.selected().session().session(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void held(org.bukkit.event.player.PlayerItemHeldEvent event) {
|
||||
var draw = draws.get(event.getPlayer().getUniqueId());
|
||||
if (draw != null && draw.player() == event.getPlayer() && event.getPreviousSlot() != event.getNewSlot()) cancelDraw(draw);
|
||||
}
|
||||
|
||||
private void cancelCustody(UUID owner) {
|
||||
try { cancelMove(owner); }
|
||||
finally {
|
||||
var draw = draws.get(owner);
|
||||
if (draw != null) cancelDraw(draw);
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelDraw(Draw draw) {
|
||||
draws.remove(draw.player().getUniqueId(), draw);
|
||||
try {
|
||||
if (draw.item().equals(draw.player().getActiveItem())) draw.player().clearActiveItem();
|
||||
} catch (RuntimeException failure) {
|
||||
report(draw.player().getUniqueId(), draw.selected().session().session(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean useGesture(EquipmentSessions.SelectedControl selected) {
|
||||
return selected.slot() == 0 || selected.slot() >= 2
|
||||
&& !selected.ability().equals("great_sword") && !selected.ability().equals("longbow");
|
||||
}
|
||||
|
||||
private void emit(Player player, ItemStack item, EquipmentSessions.SelectedControl selected,
|
||||
ControlInput.Gesture gesture, org.bukkit.entity.Entity target, int heldTicks, float strength) {
|
||||
var captured = item.clone();
|
||||
var kind = selected.slot() == 0 ? ControlInput.Kind.BOOK
|
||||
: selected.slot() == 1 ? ControlInput.Kind.BASIC_ATTACK : ControlInput.Kind.ABILITY;
|
||||
var input = new ControlInput(player, selected.session(), selected.slot(), selected.ability(), kind,
|
||||
gesture, target, heldTicks, strength,
|
||||
() -> !closed && sessions.selectedControl(player, captured).filter(selected::equals).isPresent());
|
||||
if (!input.stillCurrent()) return;
|
||||
try { inputs.accept(input); }
|
||||
catch (RuntimeException failure) { report(player.getUniqueId(), selected.session().session(), failure); }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void useEntity(org.bukkit.event.player.PlayerInteractEntityEvent event) {
|
||||
if (special(event.getPlayer().getInventory().getItem(event.getHand()))) event.setCancelled(true);
|
||||
var player = event.getPlayer();
|
||||
var item = player.getInventory().getItem(event.getHand());
|
||||
if (special(item)) event.setCancelled(true);
|
||||
if (event.getHand() == EquipmentSlot.HAND) {
|
||||
var selected = sessions.selectedControl(player, item).orElse(null);
|
||||
if (selected != null && useGesture(selected)) emit(player, item, selected,
|
||||
ControlInput.Gesture.USE, event.getRightClicked(), 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void attack(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event) {
|
||||
// The ability/basic-attack adapter owns intentional damage. Never also execute
|
||||
// vanilla Player.attack with the icon's material, enchantments or attributes.
|
||||
var player = event.getPlayer();
|
||||
var item = player.getInventory().getItemInMainHand();
|
||||
boolean willAttack = event.willAttack();
|
||||
if (special(item)) event.setCancelled(true);
|
||||
if (willAttack) {
|
||||
var selected = sessions.selectedControl(player, item).orElse(null);
|
||||
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword"))) {
|
||||
emit(player, item, selected, ControlInput.Gesture.ATTACK, event.getAttacked(), 0, player.getAttackCooldown());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -571,7 +715,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
|
||||
UUID owner = event.getPlayer().getUniqueId();
|
||||
cancelMove(owner);
|
||||
cancelCustody(owner);
|
||||
swaps.remove(owner);
|
||||
sessions.invalidateTail(owner);
|
||||
}
|
||||
@@ -695,6 +839,9 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|
||||
private void reconcile(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
var draw = draws.get(owner);
|
||||
if (draw != null && draw.player() == player
|
||||
&& !sessions.selectedControl(player, draw.item()).filter(draw.selected()::equals).isPresent()) cancelDraw(draw);
|
||||
if (closed || writing.contains(owner) || !player.isOnline()) return;
|
||||
var marker = EquipmentSessionMarker.read(player).orElse(null);
|
||||
if (marker != null && (marker.phase() == EquipmentSessionMarker.Phase.PACKING
|
||||
@@ -735,6 +882,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
for (var owner : java.util.List.copyOf(moves.keySet())) cancelMove(owner);
|
||||
for (var draw : java.util.List.copyOf(draws.values())) cancelDraw(draw);
|
||||
queued.clear();
|
||||
swaps.clear();
|
||||
observed.clear();
|
||||
|
||||
@@ -33,6 +33,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public record Notice(UUID player, String code, String message) { }
|
||||
public record Failure(UUID player, UUID session, Throwable cause) { }
|
||||
public record Active(UUID player, UUID session, UUID character) { }
|
||||
public record SelectedControl(Active session, int slot, String ability) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessionStore receipts;
|
||||
@@ -64,6 +65,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
final Player player;
|
||||
final UUID owner;
|
||||
UUID character;
|
||||
CharacterRecord record;
|
||||
SessionClaims.Claim claim;
|
||||
UUID session;
|
||||
ItemStack[] snapshot;
|
||||
@@ -178,6 +180,34 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
return attempt == null ? Optional.empty() : Optional.of(new Active(player, attempt.session, attempt.character));
|
||||
}
|
||||
|
||||
/** Server-thread effect authorization: registry identity alone is not live custody. */
|
||||
public synchronized boolean current(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
return !closed && attempt != null && attempt.session.equals(session.session())
|
||||
&& attempt.character.equals(session.character()) && attempt.player.isOnline()
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
}
|
||||
|
||||
/** Server-thread authorization for the currently held, issued control and saved loadout. */
|
||||
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
|
||||
|| !sheets.identify(player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int slot = player.getInventory().getHeldItemSlot();
|
||||
var actual = player.getInventory().getItem(slot);
|
||||
var identity = controls.identify(actual).orElse(null);
|
||||
if (identity == null || identity.slot() != slot || !identity.owner().equals(attempt.owner)
|
||||
|| !identity.session().equals(attempt.session) || !Objects.equals(actual, reported)) return Optional.empty();
|
||||
String ability = "";
|
||||
if (slot >= 2) {
|
||||
ability = controls.ability(actual).orElse("");
|
||||
if (ability.isEmpty() || !ability.equals(attempt.record.loadout().get(slot - 2))) return Optional.empty();
|
||||
}
|
||||
return Optional.of(new SelectedControl(new Active(attempt.owner, attempt.session, attempt.character), slot, ability));
|
||||
}
|
||||
|
||||
public synchronized void enter(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (closed) return;
|
||||
@@ -321,6 +351,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
|
||||
pending.remove(attempt.owner, attempt);
|
||||
if (!attempt.exitRequested) {
|
||||
attempt.record = record;
|
||||
active.put(attempt.owner, attempt);
|
||||
attempt.presentationInstalled = true;
|
||||
try { presentation.activate(attempt.player, attempt.session, record); }
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Actual packaged input/book assembly with native pages/items and explicit client/scheduler ports. */
|
||||
class CharacterBookNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@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);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("route-" + route));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
click(f, a); drainReads(f);
|
||||
assertEquals(1, f.tasks.size());
|
||||
switch (route) {
|
||||
case 0 -> { a.core.inventory.setItemInOffHand(null); f.changed(a); }
|
||||
case 1 -> a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
case 2 -> { a.core.online = false; f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty())); }
|
||||
case 3 -> { f.plugin.setEnabled(false); f.awaitWorkers(); }
|
||||
default -> throw new AssertionError(route);
|
||||
}
|
||||
runQueued(f);
|
||||
assertTrue(a.books.isEmpty(), "Stale book callback opened a UI on route " + route);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void latestRequestWinsAndReloadsNamesWithoutRetainingAnOlderRead() 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);
|
||||
var characters = characters(f);
|
||||
characters.renameCharacter(a.core.record.id(), "Latest book name").get(5, TimeUnit.SECONDS);
|
||||
click(f, a); drainReads(f); runQueued(f);
|
||||
assertEquals(1, a.books.size());
|
||||
assertTrue(text(a.books.getFirst()).contains("Latest book name"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cappedProgressAndSavedAbilitiesRenderWithoutCappingStatsOrRewritingProgress() 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);
|
||||
var old = a.core.record;
|
||||
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
|
||||
stats.putAll(old.stats());
|
||||
var strength = new java.math.BigInteger("123456789012345678901234567890");
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.STR, strength);
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, 30, java.math.BigInteger.valueOf(2900), java.math.BigInteger.valueOf(9), old.health(),
|
||||
java.util.Set.of("revive", "unavailable_saved_id"), old.loadout(), java.util.Map.of(), false, null, null);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
|
||||
click(f, a); drainReads(f); runQueued(f);
|
||||
assertEquals(1, a.books.size());
|
||||
String rendered = text(a.books.getFirst()).replace("\n", "");
|
||||
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "STR: " + strength,
|
||||
"revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
|
||||
assertEquals(saved, characters(f).find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() 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);
|
||||
var old = a.core.record;
|
||||
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
|
||||
stats.putAll(old.stats());
|
||||
String digits = "1234567890".repeat(400);
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
|
||||
String name = "雪🐈".repeat(80);
|
||||
var learned = java.util.stream.IntStream.range(0, 50).mapToObj(i -> "unknown_ability_" + i).collect(java.util.stream.Collectors.toSet());
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), name, old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), learned, old.loadout(), old.cooldowns(), false, null, null);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
|
||||
var before = a.core.snapshot();
|
||||
click(f, a); drainReads(f); runQueued(f);
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
for (var page : content.pages()) {
|
||||
String text = page.raw().getString();
|
||||
assertTrue(text.split("\\n", -1).length <= 13, "Native page exceeds visible line count");
|
||||
for (String line : text.split("\\n", -1)) assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108,
|
||||
"Native line exceeds conservative visible width: " + line.length());
|
||||
}
|
||||
String complete = text(a.books.getLast()).replace("\n", "");
|
||||
assertTrue(complete.contains(name)); assertTrue(complete.contains(digits));
|
||||
for (String ability : learned) assertTrue(complete.contains(ability), ability);
|
||||
assertArrayEquals(before, a.core.snapshot());
|
||||
assertEquals(saved, characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void valuesBeyondOneNativeBookRemainCompleteThroughBoundedSingleUseNavigation() 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);
|
||||
String digits = "1234567890".repeat(4000);
|
||||
var old = a.core.record;
|
||||
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
|
||||
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
|
||||
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
|
||||
byte[] bytes = Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml"));
|
||||
click(f, a); drainReads(f); runQueued(f);
|
||||
assertEquals(1, a.books.size(), "Large but supported records still open a native book");
|
||||
StringBuilder all = new StringBuilder();
|
||||
int volumes = 0;
|
||||
while (true) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content); assertTrue(content.pages().size() <= 100, "Never exceed the native book page limit");
|
||||
for (var page : content.pages()) if (!hasClick(page.raw())) all.append(page.raw().getString().replace("\n", ""));
|
||||
if (!text(a.books.getLast()).contains("[Next pages]")) break;
|
||||
assertTrue(++volumes < 10, "Navigation must terminate, not repeat a truncated volume");
|
||||
var next = PartyBookNativeTest.button(a, "[Next pages]");
|
||||
if (volumes == 1) {
|
||||
var stranger = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
PartyBookNativeTest.click(f, stranger, next);
|
||||
for (var payload : java.util.List.of(net.minecraft.nbt.StringTag.valueOf("x".repeat(1000)),
|
||||
net.minecraft.nbt.StringTag.valueOf("not-a-token"), new net.minecraft.nbt.CompoundTag())) {
|
||||
PartyBookNativeTest.click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(next.id(), java.util.Optional.of(payload)));
|
||||
}
|
||||
assertTrue(stranger.books.isEmpty());
|
||||
}
|
||||
PartyBookNativeTest.click(f, a, next); PartyBookNativeTest.click(f, a, next);
|
||||
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");
|
||||
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")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedRenameTransferAndReloadInspectTheCurrentRecordWithoutCarryingTheFormerHoldersView() 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);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
|
||||
click(f, a); drainReads(f); // Old holder has a queued result, not a delivered book.
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var renamed = characters(f).renameCharacter(a.core.record.id(), "Traded 雪 character").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
runQueued(f); assertTrue(a.books.isEmpty());
|
||||
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamed));
|
||||
f.activate(b); click(f, b); drainReads(f); runQueued(f);
|
||||
assertTrue(text(b.books.getLast()).replace("\n", "").contains(renamed.name()));
|
||||
assertTrue(a.books.isEmpty());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
f.players.clear(); f.plugin.setEnabled(true);
|
||||
f.players.put(b.core.id, b.player); f.reenter(b);
|
||||
var before = b.core.snapshot();
|
||||
var path = data.resolve("characters").resolve(renamed.id() + ".yml");
|
||||
byte[] saved = Files.readAllBytes(path);
|
||||
click(f, b); drainReads(f); runQueued(f);
|
||||
assertTrue(text(b.books.getLast()).replace("\n", "").contains(renamed.name()));
|
||||
var meta = (org.bukkit.inventory.meta.BookMeta) b.books.getLast().getItemMeta();
|
||||
assertEquals("Character", meta.getTitle()); assertEquals("Fantasy Stick", meta.getAuthor());
|
||||
assertArrayEquals(saved, Files.readAllBytes(path)); assertArrayEquals(before, b.core.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasClick(net.minecraft.network.chat.Component component) {
|
||||
return component.getStyle().getClickEvent() != null || component.getSiblings().stream().anyMatch(CharacterBookNativeTest::hasClick);
|
||||
}
|
||||
|
||||
@Test void malformedRecordReadLeavesDataAndInventoryUntouchedAndCanBeRetriedAfterRepair() 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);
|
||||
var path = data.resolve("characters").resolve(a.core.record.id() + ".yml");
|
||||
byte[] valid = Files.readAllBytes(path);
|
||||
byte[] invalid = "schema-version: malformed\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var before = a.core.snapshot();
|
||||
Files.write(path, invalid); click(f, a); drainReads(f); runQueued(f);
|
||||
assertTrue(a.books.isEmpty());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be loaded")));
|
||||
assertArrayEquals(invalid, Files.readAllBytes(path)); assertArrayEquals(before, a.core.snapshot());
|
||||
Files.write(path, valid); click(f, a); drainReads(f); runQueued(f);
|
||||
assertEquals(1, a.books.size());
|
||||
assertArrayEquals(valid, Files.readAllBytes(path)); assertArrayEquals(before, a.core.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bookAdapterIgnoresOtherControlsAndNativeItemVetoes() 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);
|
||||
var vetoed = interaction(a); vetoed.setUseItemInHand(Event.Result.DENY); f.dispatcher.accept(vetoed);
|
||||
a.core.inventory.getInventory().setSelectedSlot(1); click(f, a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2); click(f, a);
|
||||
drainReads(f); runQueued(f);
|
||||
assertTrue(a.books.isEmpty());
|
||||
assertTrue(a.messages.isEmpty(), "Inert controls do not manufacture book errors or mutate items");
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(org.bukkit.inventory.ItemStack book) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
return content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
}
|
||||
private static CharacterStorage characters(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static void drainReads(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
characters(f).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
private static PlayerInteractEvent interaction(FantasyStickLifecycleNativeTest.Actor a) {
|
||||
return new PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, a.core.inventory.getItemInMainHand(), null,
|
||||
BlockFace.SELF, EquipmentSlot.HAND);
|
||||
}
|
||||
private static void click(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) {
|
||||
var event = interaction(a); f.dispatcher.accept(event);
|
||||
assertEquals(Event.Result.DENY, event.useItemInHand());
|
||||
}
|
||||
private static void runQueued(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
Runnable task;
|
||||
while ((task = f.tasks.poll()) != null) task.run();
|
||||
}
|
||||
}
|
||||
@@ -1236,6 +1236,285 @@ class EquipmentSessionEventsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void bookInputIsDeliveredAfterVanillaContainmentAndExpiresWithItsSession() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
a.inventory.getItem(0), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.onInput = input -> assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand(),
|
||||
"Vanilla must already be contained before a callback can reenter");
|
||||
f.dispatcher.accept(event);
|
||||
assertEquals(1, f.inputs.size(), "The custody guard must not swallow the intentional book action");
|
||||
var input = f.inputs.getFirst();
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Kind.BOOK, input.kind());
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, input.gesture());
|
||||
assertEquals(f.core.sessions.active(a.id).orElseThrow(), input.session());
|
||||
assertEquals(0, input.slot()); assertEquals("", input.ability());
|
||||
assertTrue(input.stillCurrent());
|
||||
f.core.sessions.exit(a.player);
|
||||
assertFalse(input.stillCurrent(), "Queued handlers cannot reuse input after an exit");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void basicAndSwordAttackInputsPreserveNativeChargeWithoutAnOrdinarySecondHit() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.loadout("great_sword");
|
||||
var a = f.actor; f.core.activate(a);
|
||||
a.attackStrength = 0.375f;
|
||||
var target = port(org.bukkit.entity.Zombie.class);
|
||||
for (int slot : new int[]{1, 2}) {
|
||||
a.inventory.getInventory().setSelectedSlot(slot);
|
||||
var event = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.onInput = input -> assertTrue(event.isCancelled());
|
||||
int before = f.inputs.size(); f.dispatcher.accept(event);
|
||||
assertEquals(before + 1, f.inputs.size(), "Native melee intent must reach the owned action adapter");
|
||||
var input = f.inputs.getLast();
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, input.gesture());
|
||||
assertEquals(slot == 1 ? "" : "great_sword", input.ability());
|
||||
assertEquals(slot == 1 ? games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK
|
||||
: games.dmg.fantasystick.session.ControlInput.Kind.ABILITY, input.kind());
|
||||
assertSame(target, input.target()); assertEquals(0.375f, input.attackStrength());
|
||||
assertTrue(input.stillCurrent());
|
||||
f.dispatcher.accept(event); assertEquals(before + 1, f.inputs.size(), "Contained event replay is not another input");
|
||||
}
|
||||
a.inventory.getInventory().setSelectedSlot(0);
|
||||
var bookHit = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(bookHit); assertTrue(bookHit.isCancelled()); assertEquals(2, f.inputs.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void selectedAbilityUseReachesItsHandlerWithoutVanillaThrowDrinkEquipBlockOrPlacement() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
String[] abilities = {"fireball", "purify", "guardian_bond", "shield_bash", "rallying_cry", "war_banner", "revive"};
|
||||
f.loadout(abilities);
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var target = port(org.bukkit.entity.Player.class);
|
||||
for (int index = 0; index < abilities.length; index++) {
|
||||
int slot = index + 2; a.inventory.getInventory().setSelectedSlot(slot);
|
||||
var item = a.inventory.getItem(slot);
|
||||
var air = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
item, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
int before = f.inputs.size(); f.dispatcher.accept(air);
|
||||
assertEquals(before + 1, f.inputs.size(), abilities[index]);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, air.useItemInHand());
|
||||
assertEquals(abilities[index], f.inputs.getLast().ability());
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Kind.ABILITY, f.inputs.getLast().kind());
|
||||
assertTrue(f.inputs.getLast().stillCurrent());
|
||||
var entity = new org.bukkit.event.player.PlayerInteractEntityEvent(a.player, target, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(entity);
|
||||
assertTrue(entity.isCancelled()); assertEquals(before + 2, f.inputs.size());
|
||||
assertSame(target, f.inputs.getLast().target());
|
||||
assertEquals(abilities[index], f.inputs.getLast().ability());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inputRoutingRejectsVetoesWrongHandsForgedSlotsAndAbilitiesAndEndedSessions() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.loadout("revive", "", "removed_spell");
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var book = a.inventory.getItem(0).clone();
|
||||
var use = new java.util.function.BiFunction<ItemStack, org.bukkit.inventory.EquipmentSlot, org.bukkit.event.player.PlayerInteractEvent>() {
|
||||
@Override public org.bukkit.event.player.PlayerInteractEvent apply(ItemStack item, org.bukkit.inventory.EquipmentSlot hand) {
|
||||
return new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
item, null, org.bukkit.block.BlockFace.SELF, hand);
|
||||
}
|
||||
};
|
||||
var vetoed = use.apply(book, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
vetoed.setUseItemInHand(org.bukkit.event.Event.Result.DENY); f.dispatcher.accept(vetoed);
|
||||
f.dispatcher.accept(use.apply(book, org.bukkit.inventory.EquipmentSlot.OFF_HAND));
|
||||
var wrongReport = book.clone(); wrongReport.editMeta(meta -> meta.displayName(net.kyori.adventure.text.Component.text("stale")));
|
||||
f.dispatcher.accept(use.apply(wrongReport, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
for (String key : new String[]{"control_owner_id", "control_session_id"}) {
|
||||
var forged = book.clone(); forged.editMeta(meta -> meta.getPersistentDataContainer().set(
|
||||
new org.bukkit.NamespacedKey("fantasystick", key), org.bukkit.persistence.PersistentDataType.STRING,
|
||||
java.util.UUID.randomUUID().toString()));
|
||||
a.inventory.setItem(0, forged); f.dispatcher.accept(use.apply(forged, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
}
|
||||
a.inventory.setItem(0, book);
|
||||
a.inventory.getInventory().setSelectedSlot(2);
|
||||
var equipped = a.inventory.getItem(2).clone();
|
||||
// Keep valid owner/session/slot but substitute another structurally valid echo-shard spell reference.
|
||||
var forged = equipped.clone(); forged.editMeta(meta -> meta.getPersistentDataContainer().set(
|
||||
new org.bukkit.NamespacedKey("fantasystick", "control_ability_id"), org.bukkit.persistence.PersistentDataType.STRING, "fireball"));
|
||||
forged.setType(Material.SNOWBALL); a.inventory.setItem(2, forged);
|
||||
assertTrue(new games.dmg.fantasystick.items.SessionControls().identify(forged).isPresent(),
|
||||
"This must exercise authoritative loadout matching, not only malformed item rejection");
|
||||
f.dispatcher.accept(use.apply(forged, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
a.inventory.setItem(2, equipped);
|
||||
for (int slot : new int[]{3, 4}) {
|
||||
a.inventory.getInventory().setSelectedSlot(slot);
|
||||
f.dispatcher.accept(use.apply(a.inventory.getItem(slot), org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
}
|
||||
assertTrue(f.inputs.isEmpty(), "No ability/cast authority from invalid or inert inputs");
|
||||
a.inventory.getInventory().setSelectedSlot(0);
|
||||
var allowed = use.apply(book, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
allowed.setUseInteractedBlock(org.bukkit.event.Event.Result.DENY);
|
||||
allowed.setUseItemInHand(org.bukkit.event.Event.Result.ALLOW);
|
||||
f.dispatcher.accept(allowed);
|
||||
assertEquals(1, f.inputs.size(), "An independent native block denial does not forbid an explicitly allowed item action");
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, allowed.useInteractedBlock());
|
||||
f.dispatcher.accept(allowed); assertEquals(1, f.inputs.size());
|
||||
var input = f.inputs.getFirst();
|
||||
a.inventory.getInventory().setSelectedSlot(1); assertFalse(input.stillCurrent());
|
||||
a.inventory.getInventory().setSelectedSlot(0);
|
||||
a.inventory.setItemInOffHand(null); assertFalse(input.stillCurrent());
|
||||
f.core.sessions.exit(a.player);
|
||||
a.inventory.setItem(0, book); f.dispatcher.accept(use.apply(book, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
assertEquals(1, f.inputs.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void longbowUsesNativeDrawingAndReportsReleaseWithoutVanillaAmmoOrWeaponUse() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.loadout("longbow");
|
||||
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
|
||||
var bow = a.inventory.getItem(2).clone();
|
||||
a.inventory.setItem(20, new ItemStack(Material.ARROW, 32));
|
||||
var before = a.snapshot();
|
||||
var start = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(start);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, start.useItemInHand());
|
||||
assertEquals(1, a.useStarts, "Begin native drawing directly, not vanilla BowItem.use's ammunition path");
|
||||
assertEquals(bow, a.activeItem); assertTrue(f.inputs.isEmpty(), "The spell consumes a release, not each right-click");
|
||||
assertEquals(net.minecraft.world.item.ItemUseAnimation.BOW,
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.activeItem).getUseAnimation());
|
||||
f.onInput = input -> assertTrue(a.activeItem.isEmpty(), "Stop native release before BowItem can draw ordinary ammunition");
|
||||
var release = new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 23);
|
||||
f.dispatcher.accept(release);
|
||||
assertEquals(1, f.inputs.size()); assertEquals(1, a.useClears);
|
||||
var input = f.inputs.getFirst();
|
||||
assertEquals("longbow", input.ability());
|
||||
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.DRAW_RELEASE, input.gesture());
|
||||
assertEquals(23, input.heldTicks()); assertTrue(input.stillCurrent());
|
||||
// Execute the actual post-event ItemStack continuation on the cleared native use item.
|
||||
// Null world/entity ports deliberately fail if it tries to run a weapon/effect instead of AIR.
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.activeItem).releaseUsing(null, null, 0);
|
||||
assertArrayEquals(before, a.snapshot(), "No arrows, durability, controls or ordinary stacks consumed");
|
||||
f.dispatcher.accept(release); assertEquals(1, f.inputs.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void drawingIsRevokedOnExitHeldChangeSlotReplacementAndLifecycleWithoutClearingUnrelatedUse() throws Exception {
|
||||
for (int route = 0; route < 9; route++) try (var f = new Fixture()) {
|
||||
f.loadout("longbow");
|
||||
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
|
||||
var bow = a.inventory.getItem(2).clone();
|
||||
var start = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
if (route == 8) a.startUse = () -> f.core.sessions.exit(a.player);
|
||||
f.dispatcher.accept(start); assertEquals(1, a.useStarts);
|
||||
switch (route) {
|
||||
case 0 -> f.core.sessions.exit(a.player);
|
||||
case 1 -> f.bridge.close();
|
||||
case 2 -> f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty()));
|
||||
case 3 -> { f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC)); f.core.tick(); }
|
||||
case 4 -> f.dispatcher.accept(new org.bukkit.event.player.PlayerItemHeldEvent(a.player, 2, 3));
|
||||
case 5 -> { a.inventory.setItem(2, new ItemStack(Material.DIAMOND)); f.changed(); }
|
||||
case 6 -> { a.activeItem = new ItemStack(Material.APPLE); f.core.sessions.exit(a.player); }
|
||||
case 7 -> f.dispatcher.accept(new org.bukkit.event.player.PlayerRespawnEvent(a.player, a.location, false));
|
||||
case 8 -> { /* Exit ran inside the native start callback. */ }
|
||||
default -> throw new AssertionError(route);
|
||||
}
|
||||
if (route == 6) {
|
||||
assertEquals(new ItemStack(Material.APPLE), a.activeItem); assertEquals(0, a.useClears);
|
||||
} else assertTrue(a.activeItem.isEmpty(), "Own drawing survived lifecycle route " + route);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 20));
|
||||
assertTrue(f.inputs.isEmpty(), "Stale draw release must never trigger a spell");
|
||||
if (route == 6) assertEquals(new ItemStack(Material.APPLE), a.activeItem);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void releaseSetterFailureIsReportedWithoutEmittingOrReplayingTheOwnedInput() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.loadout("longbow");
|
||||
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
|
||||
var bow = a.inventory.getItem(2).clone();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
a.clearUse = () -> { throw new IllegalStateException("Native clear failed after changing state"); };
|
||||
var release = new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 20);
|
||||
assertDoesNotThrow(() -> f.bridge.release(release), "The native handler must contain and report setter failure");
|
||||
assertEquals(1, f.bridgeFailures.size(), "Native uncertainty must reach transition diagnostics");
|
||||
assertInstanceOf(IllegalStateException.class, f.bridgeFailures.getFirst().cause());
|
||||
assertTrue(f.inputs.isEmpty()); assertTrue(a.activeItem.isEmpty());
|
||||
a.clearUse = () -> {};
|
||||
f.dispatcher.accept(release); assertTrue(f.inputs.isEmpty());
|
||||
f.bridgeFailures.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inputCallbacksMayExitOrFailWithoutReleasingVanillaContainmentOrReplaying() throws Exception {
|
||||
for (boolean fail : new boolean[]{false, true}) try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var book = a.inventory.getItem(0).clone();
|
||||
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
book, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.onInput = input -> {
|
||||
assertTrue(input.stillCurrent());
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
|
||||
if (fail) throw new IllegalStateException("Action adapter failed");
|
||||
f.core.sessions.exit(a.player);
|
||||
assertFalse(input.stillCurrent());
|
||||
};
|
||||
f.dispatcher.accept(event); f.dispatcher.accept(event);
|
||||
assertEquals(1, f.inputs.size());
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
|
||||
if (fail) {
|
||||
assertEquals(1, f.bridgeFailures.size());
|
||||
assertInstanceOf(IllegalStateException.class, f.bridgeFailures.getFirst().cause());
|
||||
f.bridgeFailures.clear();
|
||||
} else assertEquals(Material.DIAMOND_SWORD, a.inventory.getItem(0).getType());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void drawVetoesDuplicateStartsAndUnrelatedNativeUseArePreserved() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.loadout("longbow");
|
||||
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
|
||||
var bow = a.inventory.getItem(2).clone();
|
||||
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
|
||||
f.dispatcher.accept(event); assertEquals(0, a.useStarts);
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DEFAULT);
|
||||
a.activeItem = new ItemStack(Material.APPLE);
|
||||
f.dispatcher.accept(event); assertEquals(0, a.useStarts); assertEquals(0, a.useClears);
|
||||
a.activeItem = new ItemStack(Material.AIR);
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DEFAULT); f.dispatcher.accept(event);
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DEFAULT); f.dispatcher.accept(event);
|
||||
assertEquals(1, a.useStarts);
|
||||
var held = new org.bukkit.event.player.PlayerItemHeldEvent(a.player, 2, 3);
|
||||
held.setCancelled(true); f.dispatcher.accept(held);
|
||||
assertEquals(bow, a.activeItem); assertEquals(0, a.useClears);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 0));
|
||||
assertEquals(1, f.inputs.size()); assertEquals(0, f.inputs.getFirst().heldTicks(),
|
||||
"Input routing must not invent partial-draw balance or charge thresholds");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void protectedIconsCannotAttackAsVanillaWeaponsButOrdinaryAttacksStayUntouched() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var target = port(org.bukkit.entity.Zombie.class);
|
||||
var before = a.snapshot();
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
a.inventory.getInventory().setSelectedSlot(slot);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(attack);
|
||||
assertTrue(attack.isCancelled(), "Control slot " + slot + " must not deal a second ordinary weapon hit");
|
||||
}
|
||||
assertArrayEquals(before, a.snapshot());
|
||||
f.core.sessions.exit(a.player);
|
||||
a.inventory.getInventory().setSelectedSlot(0);
|
||||
var ordinary = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
f.dispatcher.accept(ordinary); assertFalse(ordinary.isCancelled());
|
||||
var vetoed = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
vetoed.setCancelled(true); f.dispatcher.accept(vetoed); assertTrue(vetoed.isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeUseDropTransferCraftAndDispenseCannotConsumeOrExportProtectedItems() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
@@ -1690,6 +1969,30 @@ class EquipmentSessionEventsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void shiftClickCannotEquipAHeadWhoseNativeComponentTargetsChestLegsOrFeet() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
for (var slot : new org.bukkit.inventory.EquipmentSlot[]{org.bukkit.inventory.EquipmentSlot.CHEST,
|
||||
org.bukkit.inventory.EquipmentSlot.LEGS, org.bukkit.inventory.EquipmentSlot.FEET}) {
|
||||
var head = new ItemStack(Material.PLAYER_HEAD);
|
||||
head.setData(io.papermc.paper.datacomponent.DataComponentTypes.EQUIPPABLE,
|
||||
io.papermc.paper.datacomponent.item.Equippable.equippable(slot));
|
||||
a.inventory.setItem(20, head);
|
||||
var before = a.snapshot();
|
||||
var event = f.click(20, org.bukkit.event.inventory.ClickType.SHIFT_LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||
f.dispatcher.accept(event);
|
||||
assertTrue(event.isCancelled(), "Native quick-move uses the component's " + slot + " destination, not the head material");
|
||||
assertArrayEquals(before, a.snapshot());
|
||||
}
|
||||
a.inventory.setItem(20, new ItemStack(Material.PLAYER_HEAD));
|
||||
var allowed = f.click(20, org.bukkit.event.inventory.ClickType.SHIFT_LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
|
||||
f.dispatcher.accept(allowed);
|
||||
assertFalse(allowed.isCancelled(), "A genuine unprotected head may still equip in HEAD");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cosmeticAttributesAndNonHeadEquippableComponentsCannotBypassArmorGuard() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
@@ -1779,6 +2082,78 @@ class EquipmentSessionEventsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void recipeBookAutoFillCannotPullAnExactBoundControlFromTheHotbar() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var control = a.inventory.getItem(1).clone();
|
||||
var key = new org.bukkit.NamespacedKey("test", "exact_control_recipe");
|
||||
var recipe = new org.bukkit.inventory.ShapelessRecipe(key, new ItemStack(Material.DIAMOND))
|
||||
.addIngredient(new org.bukkit.inventory.RecipeChoice.ExactChoice(control));
|
||||
f.recipes.put(key, recipe);
|
||||
var nativeControl = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(control);
|
||||
assertFalse(net.minecraft.world.entity.player.Inventory.isUsableForCrafting(nativeControl),
|
||||
"Ordinary auto-fill ignores named controls, but exact ingredients bypass that check");
|
||||
assertEquals(1, a.inventory.getInventory().findSlotMatchingCraftingIngredient(
|
||||
new io.papermc.paper.inventory.recipe.ItemOrExact.Exact(nativeControl), net.minecraft.world.item.ItemStack.EMPTY));
|
||||
var before = a.snapshot();
|
||||
for (boolean all : new boolean[]{false, true}) {
|
||||
var event = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, all);
|
||||
f.dispatcher.accept(event);
|
||||
assertTrue(event.isCancelled(), "Auto-fill has no ordinary InventoryClickEvent custody check");
|
||||
assertArrayEquals(before, a.snapshot());
|
||||
assertEquals(key, event.getRecipe()); assertEquals(all, event.isMakeAll());
|
||||
}
|
||||
var ordinaryKey = new org.bukkit.NamespacedKey("test", "ordinary_recipe");
|
||||
f.recipes.put(ordinaryKey, new org.bukkit.inventory.ShapelessRecipe(ordinaryKey, new ItemStack(Material.DIAMOND))
|
||||
.addIngredient(Material.STONE));
|
||||
a.inventory.setItem(20, new ItemStack(Material.STONE));
|
||||
var ordinary = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, ordinaryKey, false);
|
||||
f.dispatcher.accept(ordinary); assertFalse(ordinary.isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void autoFillGuardsShapedCookingUnnamedAndGridControlsWithoutBlockingOrdinaryRecipes() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var key = new org.bukkit.NamespacedKey("test", "autofill");
|
||||
var control = a.inventory.getItem(1).clone();
|
||||
for (var recipe : new org.bukkit.inventory.Recipe[]{
|
||||
new org.bukkit.inventory.ShapedRecipe(key, new ItemStack(Material.DIAMOND)).shape("S")
|
||||
.setIngredient('S', new org.bukkit.inventory.RecipeChoice.ExactChoice(control)),
|
||||
new org.bukkit.inventory.FurnaceRecipe(key, new ItemStack(Material.DIAMOND),
|
||||
new org.bukkit.inventory.RecipeChoice.ExactChoice(control), 0, 100)}) {
|
||||
f.recipes.put(key, recipe);
|
||||
var event = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
|
||||
var before = a.snapshot(); f.dispatcher.accept(event);
|
||||
assertTrue(event.isCancelled()); assertArrayEquals(before, a.snapshot());
|
||||
}
|
||||
f.recipes.put(key, new org.bukkit.inventory.ShapelessRecipe(key, new ItemStack(Material.DIAMOND)).addIngredient(Material.STICK));
|
||||
var named = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
|
||||
f.dispatcher.accept(named); assertFalse(named.isCancelled(), "Native name exclusion already prevents choosing the icon");
|
||||
var meta = control.getItemMeta(); meta.displayName(null); control.setItemMeta(meta);
|
||||
a.inventory.setItem(1, control);
|
||||
assertTrue(new games.dmg.fantasystick.items.SessionControls().identify(control).isPresent());
|
||||
var unnamed = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, true);
|
||||
f.dispatcher.accept(unnamed); assertTrue(unnamed.isCancelled(), "Custody cannot depend on a display name");
|
||||
var grid = (org.bukkit.inventory.CraftingInventory) a.view.getTopInventory();
|
||||
grid.setMatrix(new ItemStack[]{control, null, null, null});
|
||||
var stale = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer,
|
||||
new org.bukkit.NamespacedKey("test", "missing_recipe"), false);
|
||||
f.dispatcher.accept(stale); assertTrue(stale.isCancelled(), "Do not return existing protected grid contents through vanilla auto-fill");
|
||||
grid.setMatrix(new ItemStack[4]);
|
||||
var vetoed = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
|
||||
vetoed.setCancelled(true); f.dispatcher.accept(vetoed); assertTrue(vetoed.isCancelled());
|
||||
f.core.sessions.exit(a.player);
|
||||
// A recovered/remnant control outside an active session is still not a crafting ingredient.
|
||||
a.inventory.setItem(20, control);
|
||||
var remnant = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
|
||||
f.dispatcher.accept(remnant); assertTrue(remnant.isCancelled());
|
||||
a.inventory.setItem(20, new ItemStack(Material.STICK));
|
||||
var ordinary = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
|
||||
f.dispatcher.accept(ordinary); assertFalse(ordinary.isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void genericTransformationResultsAndArmorStandConsumptionProtectHiddenControlInputs() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
@@ -1880,8 +2255,11 @@ class EquipmentSessionEventsNativeTest {
|
||||
boolean throwingDiagnostics;
|
||||
final EquipmentSessionEvents bridge;
|
||||
final java.util.ArrayList<games.dmg.fantasystick.session.EquipmentSessions.Failure> bridgeFailures = new java.util.ArrayList<>();
|
||||
final java.util.List<games.dmg.fantasystick.session.ControlInput> inputs = new java.util.ArrayList<>();
|
||||
java.util.function.Consumer<games.dmg.fantasystick.session.ControlInput> onInput = ignored -> { };
|
||||
final PlayerSheetDropsNativeTest.WorldFixture world = new PlayerSheetDropsNativeTest.WorldFixture();
|
||||
org.bukkit.entity.Player eventPlayer;
|
||||
final java.util.Map<org.bukkit.NamespacedKey, org.bukkit.inventory.Recipe> recipes = new java.util.HashMap<>();
|
||||
final Plugin plugin = FantasyStickLifecycleNativeTest.port(Plugin.class, (name, args) -> switch (name) {
|
||||
case "isEnabled" -> true;
|
||||
case "getPluginMeta", "getDescription" -> new org.bukkit.plugin.PluginDescriptionFile("EventBridgeTest", "1", "test.Test");
|
||||
@@ -1906,6 +2284,7 @@ class EquipmentSessionEventsNativeTest {
|
||||
});
|
||||
var server = FantasyStickLifecycleNativeTest.port(Server.class, (name, args) -> {
|
||||
if (name.equals("getPluginManager")) return manager;
|
||||
if (name.equals("getRecipe")) return recipes.get(args[0]);
|
||||
throw new AssertionError(name);
|
||||
});
|
||||
eventPlayer = (org.bukkit.entity.Player) java.lang.reflect.Proxy.newProxyInstance(org.bukkit.entity.Player.class.getClassLoader(),
|
||||
@@ -1934,13 +2313,24 @@ class EquipmentSessionEventsNativeTest {
|
||||
}, failure -> {
|
||||
bridgeFailures.add(failure);
|
||||
if (throwingDiagnostics) throw new IllegalStateException("Broken diagnostics");
|
||||
});
|
||||
}, input -> { inputs.add(input); onInput.accept(input); });
|
||||
dispatcher.register(bridge, plugin);
|
||||
}
|
||||
org.bukkit.event.inventory.InventoryClickEvent click(int raw, org.bukkit.event.inventory.ClickType click,
|
||||
org.bukkit.event.inventory.InventoryAction action, int button) {
|
||||
return new org.bukkit.event.inventory.InventoryClickEvent(actor.view, actor.view.getSlotType(raw), raw, click, action, button);
|
||||
}
|
||||
void loadout(String... assigned) throws Exception {
|
||||
var original = actor.record;
|
||||
var slots = new java.util.ArrayList<>(java.util.Collections.nCopies(7, ""));
|
||||
for (int i = 0; i < assigned.length; i++) slots.set(i, assigned[i]);
|
||||
var learned = slots.stream().filter(id -> !id.isEmpty()).collect(java.util.stream.Collectors.toSet());
|
||||
var record = new games.dmg.fantasystick.persistence.CharacterRecord(java.util.UUID.randomUUID(), original.name(),
|
||||
original.revision(), original.stats(), original.level(), original.experience(), original.unspentPoints(),
|
||||
original.health(), learned, slots, java.util.Map.of(), false, null, null);
|
||||
core.characters.create(record).get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
actor.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(record));
|
||||
}
|
||||
void pickupBox() {
|
||||
var event = click(9, org.bukkit.event.inventory.ClickType.LEFT,
|
||||
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL, -1);
|
||||
|
||||
@@ -1685,6 +1685,12 @@ class EquipmentSessionsNativeTest {
|
||||
org.bukkit.GameMode gameMode = org.bukkit.GameMode.SURVIVAL;
|
||||
Runnable beforeMode = () -> {};
|
||||
double health = 20;
|
||||
float attackStrength = 1;
|
||||
ItemStack activeItem = new ItemStack(Material.AIR);
|
||||
int useStarts;
|
||||
int useClears;
|
||||
Runnable startUse = () -> {};
|
||||
Runnable clearUse = () -> {};
|
||||
double normalMaximum = 28;
|
||||
int teleports;
|
||||
final org.bukkit.attribute.AttributeInstance maximum = FantasyStickLifecycleNativeTest.port(
|
||||
@@ -1714,6 +1720,15 @@ class EquipmentSessionsNativeTest {
|
||||
case "getGameMode" -> gameMode;
|
||||
case "setGameMode" -> { beforeMode.run(); gameMode = (org.bukkit.GameMode) args[0]; yield null; }
|
||||
case "getHealth" -> health;
|
||||
case "getAttackCooldown" -> attackStrength;
|
||||
case "getActiveItem" -> activeItem.clone();
|
||||
case "hasActiveItem" -> !activeItem.isEmpty();
|
||||
case "getActiveItemHand" -> org.bukkit.inventory.EquipmentSlot.HAND;
|
||||
case "startUsingItem" -> {
|
||||
assertEquals(org.bukkit.inventory.EquipmentSlot.HAND, args[0]);
|
||||
useStarts++; activeItem = inventory.getItemInMainHand().clone(); startUse.run(); yield null;
|
||||
}
|
||||
case "clearActiveItem" -> { useClears++; activeItem = new ItemStack(Material.AIR); clearUse.run(); yield null; }
|
||||
case "setHealth" -> { health = (Double) args[0]; yield null; }
|
||||
case "getAttribute" -> { assertEquals(org.bukkit.attribute.Attribute.MAX_HEALTH, args[0]); yield maximum; }
|
||||
case "teleport" -> {
|
||||
|
||||
@@ -60,13 +60,46 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(1, fixture.timers.size());
|
||||
assertEquals(2, fixture.timers.size(), "Presentation and bounded combat-provenance cleanup");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertNotNull(fixture.command);
|
||||
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
|
||||
assertTrue(fixture.tasks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredBookInputOpensAuthoritativeNativePagesWithoutGivingOrChangingItems() throws Exception {
|
||||
try (var f = new Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var original = a.core.snapshot();
|
||||
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
|
||||
var saved = characters.renameCharacter(a.core.record.id(), "Current saved name").get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(saved.isPresent());
|
||||
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
a.core.inventory.getItem(0), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(event);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
|
||||
// Drain reads before checking the scheduled action; production never waits on the tick thread.
|
||||
characters.find(java.util.UUID.randomUUID()).get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
f.tick();
|
||||
assertEquals(1, a.books.size(), "Registered book input must reach a real book consumer");
|
||||
var item = a.books.getFirst();
|
||||
assertEquals(org.bukkit.Material.WRITTEN_BOOK, item.getType());
|
||||
var nativeItem = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(item);
|
||||
var content = nativeItem.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
String text = content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
for (String expected : List.of("Current saved name", "STR: 0", "DEX: 0", "CON: 0", "INT: 0", "WIS: 0", "CHR: 0",
|
||||
"Level: 1", "XP: 0/100", "Unspent points: 40", "Health: 10/10", "Abilities: none")) {
|
||||
assertTrue(text.contains(expected), expected + " missing from " + text);
|
||||
}
|
||||
assertArrayEquals(original, a.core.snapshot(), "Opening a virtual book never replaces or grants an inventory item");
|
||||
assertEquals(saved, characters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableRemovesOnlyItsRecipeAndListenersAndIsSafeToRepeat() throws Exception {
|
||||
try (var fixture = new Fixture(data)) {
|
||||
@@ -1143,6 +1176,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
// Location holds a WeakReference: the fixture must model the server's world ownership.
|
||||
final org.bukkit.World world = mock(org.bukkit.World.class);
|
||||
final List<String> messages = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
|
||||
final List<Integer> particles = new ArrayList<>();
|
||||
java.util.function.Consumer<org.bukkit.event.player.PlayerGameModeChangeEvent> modeEvents = ignored -> { };
|
||||
final org.bukkit.craftbukkit.entity.CraftPlayer player;
|
||||
@@ -1181,6 +1215,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "isSneaking", "isInvisible" -> false;
|
||||
case "getScoreboard" -> craftBoard;
|
||||
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
|
||||
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
|
||||
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
|
||||
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.items.SessionControls;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.entity.Item;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.inventory.InventoryMoveItemEvent;
|
||||
import org.bukkit.event.player.PlayerDropItemEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerItemConsumeEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Supported offhand entry -> guarded delivered controls -> actual book -> ordinary exit. */
|
||||
class InventoryJourneyNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void allNineDeliveredControlsStayContainedWhileTheActualBookWorksAndTheSheetCanLeave() 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);
|
||||
var ordinary = a.core.snapshot();
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
f.activate(a);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(a.core.id).orElseThrow();
|
||||
var delivered = a.core.snapshot();
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
var item = a.core.inventory.getItem(slot);
|
||||
var identity = new SessionControls().identify(item).orElseThrow();
|
||||
assertEquals(slot, identity.slot());
|
||||
assertEquals(active.player(), identity.owner());
|
||||
assertEquals(active.session(), identity.session());
|
||||
assertEquals(slot == 0 ? Material.BOOK : slot == 1 ? Material.STICK : Material.GRAY_DYE, item.getType());
|
||||
var entity = mock(Item.class); when(entity.getItemStack()).thenReturn(item);
|
||||
var drop = new PlayerDropItemEvent(a.player, entity);
|
||||
f.dispatcher.accept(drop); assertTrue(drop.isCancelled());
|
||||
var consume = new PlayerItemConsumeEvent(a.player, item, EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(consume); assertTrue(consume.isCancelled());
|
||||
var transfer = new InventoryMoveItemEvent(a.core.inventory, item, a.core.view.getTopInventory(), true);
|
||||
f.dispatcher.accept(transfer); assertTrue(transfer.isCancelled());
|
||||
a.core.inventory.getInventory().setSelectedSlot(slot);
|
||||
var use = new PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, item, null, BlockFace.SELF, EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(use); assertEquals(Event.Result.DENY, use.useItemInHand());
|
||||
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
characters.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
Runnable task; while ((task = f.tasks.poll()) != null) task.run();
|
||||
assertEquals(1, a.books.size(), "Only slot 1 opens the actual native book; other delivered controls are inert");
|
||||
}
|
||||
assertArrayEquals(delivered, a.core.snapshot(), "Containment and inspection never consume/export inventory");
|
||||
assertEquals(sheet, a.core.inventory.getItemInOffHand());
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
assertTrue(sessions.active(a.core.id).isEmpty(), "Transferable sheet removal exits without a combat lock");
|
||||
ordinary[40] = null;
|
||||
assertArrayEquals(ordinary, a.core.snapshot(), "Ordinary gear is restored without replacing the removed sheet");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Native book payloads feed real Paper custom-click events through packaged startup registration. */
|
||||
class PartyBookNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookCreatesPlayerOwnedPartyThroughNativeCustomClickWithoutInventoryChanges() 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);
|
||||
var before = a.core.snapshot();
|
||||
open(f, a);
|
||||
var create = button(a, "[Create party]");
|
||||
click(f, a, create);
|
||||
var party = PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).orElseThrow();
|
||||
assertEquals(a.core.id, party.leader());
|
||||
assertEquals(java.util.List.of(a.core.id), party.members());
|
||||
click(f, a, create); // The old page cannot perform the action twice.
|
||||
assertEquals(party, PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).orElseThrow());
|
||||
assertArrayEquals(before, a.core.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void memberBookLeavePreservesPartyButLeaderBookLeaveDisbandsWithoutSuccession() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
f.activate(leader); f.activate(member);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var party = parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
open(f, member); click(f, member, button(member, "[Leave party]"));
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertEquals(party.id(), parties.partyOf(leader.core.id).orElseThrow().id());
|
||||
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
open(f, leader); click(f, leader, button(leader, "[Leave party]"));
|
||||
assertTrue(parties.partyOf(leader.core.id).isEmpty());
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeBookInvitesRequireAcceptanceDisplayCharacterNamesAndAllowLeaderKick() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
f.activate(leader); f.activate(member);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
open(f, leader); click(f, leader, button(leader, "[Create party]")); drain(f);
|
||||
click(f, leader, button(leader, "[Invite players]"));
|
||||
assertTrue(text(leader).contains(member.core.record.name()));
|
||||
click(f, leader, button(leader, "[Invite]"));
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertTrue(member.messages.stream().anyMatch(message -> message.contains("invitation")));
|
||||
open(f, member); click(f, member, button(member, "[Invitations]"));
|
||||
assertTrue(text(member).contains(leader.core.record.name()));
|
||||
click(f, member, button(member, "[Accept]")); drain(f);
|
||||
assertEquals(parties.partyOf(leader.core.id), parties.partyOf(member.core.id));
|
||||
assertTrue(text(member).contains(leader.core.record.name()));
|
||||
assertTrue(text(member).contains(member.core.record.name()));
|
||||
open(f, leader); click(f, leader, button(leader, "[Kick]"));
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertEquals(java.util.List.of(leader.core.id), parties.partyOf(leader.core.id).orElseThrow().members());
|
||||
drain(f); click(f, leader, button(leader, "[Disband party]"));
|
||||
assertTrue(parties.partyOf(leader.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void copiedReplacedMalformedAndWrongNamespaceActionsCannotCreateParties() 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);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
open(f, a); var old = button(a, "[Create party]");
|
||||
open(f, b); click(f, b, old);
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(b.core.id).isEmpty());
|
||||
open(f, a); var current = button(a, "[Create party]");
|
||||
click(f, a, old);
|
||||
for (var payload : java.util.List.of(net.minecraft.nbt.StringTag.valueOf("x".repeat(1000)),
|
||||
net.minecraft.nbt.StringTag.valueOf("not-a-token"), new net.minecraft.nbt.CompoundTag())) {
|
||||
click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(current.id(), java.util.Optional.of(payload)));
|
||||
}
|
||||
click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("other", "party_book"), current.payload()));
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).isEmpty());
|
||||
click(f, a, current);
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).isPresent(), "Invalid packets must not consume the legitimate view");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void changedSlotOrEndedCharacterCannotUseOldPartyControlsEvenAfterReturningToBookSlot() throws Exception {
|
||||
for (int route = 0; route < 3; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("route-" + route))) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("route-" + route));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
open(f, a); var action = button(a, "[Create party]");
|
||||
if (route == 0) a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
else if (route == 1) { a.core.inventory.setItemInOffHand(null); f.changed(a); }
|
||||
else { f.plugin.setEnabled(false); f.awaitWorkers(); }
|
||||
click(f, a, action);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
click(f, a, action);
|
||||
assertTrue(parties.partyOf(a.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void managementRechecksPartyIdentityRatherThanDisbandingAReplacementFromAnOldPage() 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);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var previous = parties.create(a.core.id).orElseThrow();
|
||||
open(f, a); var old = button(a, "[Disband party]");
|
||||
assertTrue(parties.leave(a.core.id, previous.id()));
|
||||
var replacement = parties.create(a.core.id).orElseThrow();
|
||||
click(f, a, old);
|
||||
assertEquals(replacement, parties.partyOf(a.core.id).orElseThrow());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Party action unavailable")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inviteChoicesArePagedAndMovingPagesRevokesTheOldPagesActions() 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);
|
||||
var candidates = new java.util.ArrayList<UUID>();
|
||||
for (int i = 0; i < 101; i++) {
|
||||
var id = UUID.randomUUID(); candidates.add(id);
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id); when(player.isOnline()).thenReturn(true);
|
||||
when(player.getName()).thenReturn("Candidate " + i); f.players.put(id, player);
|
||||
}
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
parties.create(a.core.id).orElseThrow();
|
||||
open(f, a); click(f, a, button(a, "[Invite players]"));
|
||||
var old = button(a, "[Invite]");
|
||||
assertTrue(((org.bukkit.inventory.meta.BookMeta) a.books.getLast().getItemMeta()).getPageCount() <= 100);
|
||||
click(f, a, button(a, "[Next]")); click(f, a, old);
|
||||
assertTrue(candidates.stream().allMatch(id -> parties.invitationsFor(id).isEmpty()));
|
||||
click(f, a, button(a, "[Invite]"));
|
||||
assertEquals(1, candidates.stream().filter(id -> !parties.invitationsFor(id).isEmpty()).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void anInvitationAlreadyRenderedInTheBookCannotBeAcceptedAtItsRealTimeDeadline() throws Exception {
|
||||
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.EPOCH);
|
||||
var clock = new java.time.Clock() {
|
||||
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
|
||||
@Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; }
|
||||
@Override public java.time.Instant instant() { return now.get(); }
|
||||
};
|
||||
try (var clocks = mockStatic(java.time.Clock.class, CALLS_REAL_METHODS)) {
|
||||
clocks.when(java.time.Clock::systemUTC).thenReturn(clock);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
f.activate(leader); f.activate(member);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var group = parties.create(leader.core.id).orElseThrow();
|
||||
parties.invite(leader.core.id, group.id(), member.core.id).orElseThrow();
|
||||
open(f, member); click(f, member, button(member, "[Invitations]"));
|
||||
var accept = button(member, "[Accept]");
|
||||
now.set(java.time.Instant.EPOCH.plusSeconds(60));
|
||||
click(f, member, accept);
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertTrue(parties.invitationsFor(member.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void anOpenAcceptancePageCannotOverfillAPartyThatFilledAfterRendering() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
f.activate(leader); f.activate(member);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var group = parties.create(leader.core.id).orElseThrow();
|
||||
parties.invite(leader.core.id, group.id(), member.core.id).orElseThrow();
|
||||
open(f, member); click(f, member, button(member, "[Invitations]"));
|
||||
var accept = button(member, "[Accept]");
|
||||
for (int i = 0; i < 5; i++) {
|
||||
UUID id = UUID.randomUUID();
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id); when(player.isOnline()).thenReturn(true);
|
||||
f.players.put(id, player);
|
||||
assertTrue(parties.accept(id, parties.invite(leader.core.id, group.id(), id).orElseThrow().id()));
|
||||
}
|
||||
click(f, member, accept);
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertEquals(6, parties.partyOf(leader.core.id).orElseThrow().members().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void sheetTransferAndCharacterSwitchKeepMembershipOnThePlayerAndInvalidateOldBookActions() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
var otherSheet = b.core.inventory.getItemInOffHand().clone();
|
||||
f.activate(a);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var party = parties.create(a.core.id).orElseThrow();
|
||||
open(f, a); var oldDisband = button(a, "[Disband party]");
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
|
||||
open(f, b);
|
||||
assertTrue(text(b).contains(a.core.record.name()));
|
||||
assertTrue(parties.partyOf(b.core.id).isEmpty(), "A traded sheet must not transfer party membership");
|
||||
click(f, b, oldDisband);
|
||||
a.core.inventory.setItemInOffHand(otherSheet); f.activate(a);
|
||||
open(f, a);
|
||||
assertTrue(text(a).contains(b.core.record.name()));
|
||||
assertEquals(party.id(), parties.partyOf(a.core.id).orElseThrow().id());
|
||||
click(f, a, oldDisband);
|
||||
assertEquals(party.id(), parties.partyOf(a.core.id).orElseThrow().id());
|
||||
}
|
||||
}
|
||||
|
||||
static String text(FantasyStickLifecycleNativeTest.Actor a) {
|
||||
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT).pages().stream()
|
||||
.map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
static void open(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) throws Exception {
|
||||
f.dispatcher.accept(new PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, a.core.inventory.getItemInMainHand(),
|
||||
null, BlockFace.SELF, EquipmentSlot.HAND));
|
||||
drain(f);
|
||||
}
|
||||
static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
|
||||
.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
Runnable task;
|
||||
while ((task = f.tasks.poll()) != null) task.run();
|
||||
}
|
||||
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);
|
||||
assertNotNull(book);
|
||||
for (var page : book.pages()) {
|
||||
var click = find(page.raw(), label);
|
||||
if (click != null) return click;
|
||||
}
|
||||
throw new AssertionError("Missing native book action " + label);
|
||||
}
|
||||
static net.minecraft.network.chat.ClickEvent.Custom find(net.minecraft.network.chat.Component component, String label) {
|
||||
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom) return custom;
|
||||
for (var child : component.getSiblings()) {
|
||||
var found = find(child, label);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
static void click(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a,
|
||||
net.minecraft.network.chat.ClickEvent.Custom click) {
|
||||
var connection = mock(io.papermc.paper.connection.PlayerGameConnection.class);
|
||||
when(connection.getPlayer()).thenReturn(a.player);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PaperPlayerCustomClickEvent(
|
||||
net.kyori.adventure.key.Key.key(click.id().toString()), connection, click.payload().orElse(null)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Actual packaged listeners and book clicks; native damage events have explicit world/source ports. */
|
||||
class PartyCombatNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
private static final java.util.Map<org.bukkit.entity.Player, java.util.Map<org.bukkit.potion.PotionEffectType, net.minecraft.world.effect.MobEffectInstance>> effects = new java.util.WeakHashMap<>();
|
||||
private static final java.util.Map<org.bukkit.entity.Player, Integer> fires = new java.util.WeakHashMap<>();
|
||||
|
||||
@Test void bookConsentProtectsCombatAndBookLeaveKickDisbandImmediatelyRemoveProtection() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f);
|
||||
f.activate(a); f.activate(b);
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled(), "Non-party fantasy combat remains available");
|
||||
createAndInvite(f, a, b);
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled(), "An invitation is not membership");
|
||||
accept(f, b);
|
||||
assertTrue(hit(f, a.player, b.player).isCancelled(), "Book acceptance must protect native combat");
|
||||
assertTrue(hit(f, b.player, a.player).isCancelled());
|
||||
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled());
|
||||
invite(f, a); accept(f, b);
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Kick");
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled());
|
||||
invite(f, a); accept(f, b);
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void mixedModesAreProtectedBothWaysButOrdinaryCombatMobsSelfDamageAndPriorVetoesAreUnchanged() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f);
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled());
|
||||
f.activate(a);
|
||||
assertTrue(hit(f, a.player, b.player).isCancelled());
|
||||
assertTrue(hit(f, b.player, a.player).isCancelled());
|
||||
assertFalse(hit(f, entity(org.bukkit.entity.Zombie.class), a.player).isCancelled());
|
||||
assertFalse(hit(f, a.player, entity(org.bukkit.entity.Zombie.class)).isCancelled());
|
||||
assertFalse(hit(f, a.player, a.player).isCancelled());
|
||||
var environmental = new org.bukkit.event.entity.EntityDamageEvent(a.player, DamageCause.FALL, mock(DamageSource.class), 4);
|
||||
f.dispatcher.accept(environmental); assertFalse(environmental.isCancelled());
|
||||
f.activate(b);
|
||||
var damage = mock(DamageSource.class);
|
||||
when(damage.getCausingEntity()).thenReturn(a.player);
|
||||
var veto = new EntityDamageByEntityEvent(a.player, b.player, DamageCause.ENTITY_ATTACK, damage, 4);
|
||||
veto.setCancelled(true); f.dispatcher.accept(veto);
|
||||
assertTrue(veto.isCancelled()); assertEquals(4, veto.getDamage());
|
||||
var group = PartyLifecycleNativeTest.parties(f).create(a.core.id).orElseThrow();
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).accept(b.core.id,
|
||||
PartyLifecycleNativeTest.parties(f).invite(a.core.id, group.id(), b.core.id).orElseThrow().id()));
|
||||
exit(f, a); exit(f, b);
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled(), "Retained membership is not ordinary PvP immunity");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void projectilesRecheckPartyAtImpactAndCannotBorrowANewSessionAfterExitOrSwitch() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f);
|
||||
f.activate(a); f.activate(b);
|
||||
var arrow = projectile(f, a.player);
|
||||
assertFalse(hit(f, arrow, b.player).isCancelled());
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertTrue(hit(f, arrow, b.player).isCancelled(), "Native indirect attribution must use current party");
|
||||
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
|
||||
assertFalse(hit(f, arrow, b.player).isCancelled());
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
exit(f, a);
|
||||
assertTrue(hit(f, arrow, b.player).isCancelled(), "Fantasy projectile loses authority on exit");
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
assertTrue(hit(f, arrow, b.player).isCancelled(), "Same character reentry is still a different session");
|
||||
assertFalse(hit(f, projectile(f, a.player), b.player).isCancelled());
|
||||
var stranger = actor(f);
|
||||
var ordinaryArrow = projectile(f, stranger.player);
|
||||
assertTrue(hit(f, ordinaryArrow, b.player).isCancelled());
|
||||
f.activate(stranger);
|
||||
assertTrue(hit(f, ordinaryArrow, b.player).isCancelled(), "Old ordinary projectile must not become fantasy damage");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void explosiveFangsCloudPetAndFireworkSourcesRespectPartiesAndCapturedSessions() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
var tnt = entity(org.bukkit.entity.TNTPrimed.class); when(tnt.getSource()).thenReturn(a.player);
|
||||
var fangs = entity(org.bukkit.entity.EvokerFangs.class); when(fangs.getOwner()).thenReturn(a.player);
|
||||
var cloud = entity(org.bukkit.entity.AreaEffectCloud.class); when(cloud.getSource()).thenReturn(a.player);
|
||||
var pet = entity(org.bukkit.entity.Wolf.class); when(pet.getOwnerUniqueId()).thenReturn(a.core.id);
|
||||
var firework = entity(org.bukkit.entity.Firework.class); when(firework.getSpawningEntity()).thenReturn(a.core.id);
|
||||
for (var source : java.util.List.of(tnt, fangs, cloud, firework)) f.dispatcher.accept(new org.bukkit.event.entity.EntitySpawnEvent(source));
|
||||
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) {
|
||||
assertTrue(hit(f, source, b.player).isCancelled(), source.getClass().toString());
|
||||
}
|
||||
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
|
||||
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) assertFalse(hit(f, source, b.player).isCancelled());
|
||||
exit(f, a);
|
||||
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) assertTrue(hit(f, source, b.player).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void lingeringCloudInheritsTheOriginalThrownPotionSessionRatherThanItsOwnersReplacement() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var potion = entity(org.bukkit.entity.ThrownPotion.class); when(potion.getShooter()).thenReturn(a.player);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(potion));
|
||||
var cloud = entity(org.bukkit.entity.AreaEffectCloud.class); when(cloud.getSource()).thenReturn(a.player);
|
||||
var splash = new org.bukkit.event.entity.LingeringPotionSplashEvent(potion, cloud);
|
||||
f.dispatcher.accept(splash); assertFalse(splash.isCancelled());
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone(); exit(f, a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntitySpawnEvent(cloud));
|
||||
assertTrue(hit(f, cloud, b.player).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void poisonAndWitherRevalidateEachTickAndClearOnRemovalReplacementWithoutTouchingHealing() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
for (var type : java.util.List.of(org.bukkit.potion.PotionEffectType.POISON, org.bukkit.potion.PotionEffectType.WITHER)) {
|
||||
var effect = new org.bukkit.potion.PotionEffect(type, 200, 0);
|
||||
var cause = type.equals(org.bukkit.potion.PotionEffectType.POISON) ? DamageCause.POISON : DamageCause.WITHER;
|
||||
assertFalse(apply(f, b, null, effect, a.player).isCancelled());
|
||||
assertFalse(tickDamage(f, b, cause).isCancelled());
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertTrue(tickDamage(f, b, cause).isCancelled(), "Tick loses native attacker but not our application provenance");
|
||||
var heal = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.REGENERATION, 200, 0);
|
||||
assertFalse(apply(f, b, null, heal, a.player).isCancelled());
|
||||
assertTrue(apply(f, b, effect, new org.bukkit.potion.PotionEffect(type, 300, 1), a.player).isCancelled());
|
||||
var remove = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, effect, null, null,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
f.dispatcher.accept(remove); effects.get(b.player).remove(type);
|
||||
assertFalse(tickDamage(f, b, cause).isCancelled(), "Removed attribution must not protect unrelated later damage");
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
|
||||
assertFalse(apply(f, b, null, effect, a.player).isCancelled());
|
||||
// An unrelated mob/environmental replacement clears only this effect's provenance.
|
||||
assertFalse(apply(f, b, effect, new org.bukkit.potion.PotionEffect(type, 300, 1), null).isCancelled());
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertFalse(tickDamage(f, b, cause).isCancelled());
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ongoingDamageCannotMoveToANewSourceOrTargetSessionAndCancelledChangesDoNotEraseProvenance() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
|
||||
apply(f, b, null, effect, a.player);
|
||||
var replacement = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, effect, null, null,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
replacement.setCancelled(true); f.dispatcher.accept(replacement);
|
||||
var sheet = b.core.inventory.getItemInOffHand().clone(); exit(f, b);
|
||||
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
|
||||
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled(), "An old poison cannot attack a replacement target session");
|
||||
apply(f, b, effect, new org.bukkit.potion.PotionEffect(effect.getType(), 300, 1), a.player);
|
||||
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
exit(f, a);
|
||||
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void burningChecksPartyEveryTickAndCancelledOrEnvironmentalIgnitionCannotStealAttribution() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var arrow = projectile(f, a.player);
|
||||
var fire = new org.bukkit.event.entity.EntityCombustByEntityEvent(arrow, b.player, 10.0f);
|
||||
f.dispatcher.accept(fire); assertFalse(fire.isCancelled()); fires.put(b.player, 200);
|
||||
assertFalse(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertTrue(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
var refused = new org.bukkit.event.entity.EntityCombustByEntityEvent(arrow, b.player, 15.0f);
|
||||
f.dispatcher.accept(refused); assertTrue(refused.isCancelled());
|
||||
var cancelledEnvironmental = new org.bukkit.event.entity.EntityCombustEvent(b.player, 5.0f);
|
||||
cancelledEnvironmental.setCancelled(true); f.dispatcher.accept(cancelledEnvironmental);
|
||||
assertTrue(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityCombustEvent(b.player, 15.0f));
|
||||
assertFalse(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
assertFalse(tickDamage(f, b, DamageCause.LAVA).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void expiredAndHiddenPotionLayersKeepOnlyTheirOwnSourceAndFireExpiryIsCleaned() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var type = org.bukkit.potion.PotionEffectType.POISON;
|
||||
var poison = new org.bukkit.potion.PotionEffect(type, 200, 0);
|
||||
apply(f, b, null, poison, a.player);
|
||||
// A shorter, stronger environmental layer temporarily covers the attributable one.
|
||||
apply(f, b, poison, new org.bukkit.potion.PotionEffect(type, 40, 1), null);
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled(), "Environmental top layer must not inherit old source");
|
||||
var nativeEffect = effects.get(b.player).get(type);
|
||||
assertNotNull(nativeEffect.hiddenEffect);
|
||||
effects.get(b.player).put(type, nativeEffect.hiddenEffect);
|
||||
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled(), "Native hidden-layer restoration retains its own source");
|
||||
effects.get(b.player).clear();
|
||||
for (var pulse : f.pulses) pulse.run();
|
||||
assertFalse(b.core.pdc.getKeys().stream().anyMatch(key -> key.getKey().startsWith("combat_dot_")));
|
||||
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityCombustByEntityEvent(a.player, b.player, 2.0f));
|
||||
fires.put(b.player, 40);
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertTrue(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
fires.put(b.player, 0);
|
||||
for (var pulse : f.pulses) pulse.run();
|
||||
assertFalse(b.core.pdc.getKeys().stream().anyMatch(key -> key.getKey().startsWith("combat_dot_")));
|
||||
fires.put(b.player, 20); // Later unrelated fire must not borrow a stale player source.
|
||||
assertFalse(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void staleNativeSplashIsCancelledBeforeOwnerOnlyEffectAttributionAndValidHealingIsUntouched() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var potion = entity(org.bukkit.entity.ThrownPotion.class); when(potion.getShooter()).thenReturn(a.player);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(potion));
|
||||
var splash = new org.bukkit.event.entity.PotionSplashEvent(potion, new java.util.HashMap<>(java.util.Map.of(b.player, 1.0)));
|
||||
f.dispatcher.accept(splash); assertFalse(splash.isCancelled());
|
||||
assertEquals(1.0, splash.getIntensity(b.player));
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone(); exit(f, a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
var stale = new org.bukkit.event.entity.PotionSplashEvent(potion, new java.util.HashMap<>(java.util.Map.of(b.player, 1.0)));
|
||||
f.dispatcher.accept(stale);
|
||||
assertTrue(stale.isCancelled(), "Native splash passes only owner to addEffect: stop stale continuation before attribution is lost");
|
||||
var arrow = projectile(f, a.player);
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
var impact = new org.bukkit.event.entity.ProjectileHitEvent(arrow, b.player);
|
||||
f.dispatcher.accept(impact); assertTrue(impact.isCancelled());
|
||||
var heal = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.REGENERATION, 100, 0);
|
||||
assertFalse(apply(f, b, null, heal, a.player).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledApplicationsAndLaunchesRetainUnrelatedDataAndCleanupStopsOnDisable() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var arrow = entity(org.bukkit.entity.Arrow.class); when(arrow.getShooter()).thenReturn(a.player);
|
||||
var launch = new org.bukkit.event.entity.ProjectileLaunchEvent(arrow); launch.setCancelled(true);
|
||||
var unrelated = new org.bukkit.NamespacedKey("other", "state");
|
||||
arrow.getPersistentDataContainer().set(unrelated, org.bukkit.persistence.PersistentDataType.STRING, "retained");
|
||||
f.dispatcher.accept(launch);
|
||||
assertEquals(java.util.Set.of(unrelated), arrow.getPersistentDataContainer().getKeys());
|
||||
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
|
||||
var veto = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, null, effect, a.player,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false);
|
||||
veto.setCancelled(true); f.dispatcher.accept(veto);
|
||||
assertTrue(veto.isCancelled());
|
||||
assertFalse(b.core.pdc.getKeys().stream().anyMatch(key -> key.getKey().startsWith("combat_dot_")));
|
||||
var before = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(a.core.record.id());
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertEquals(before, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(a.core.record.id()), "Party proximity does not grant progress or XP");
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
for (var timer : f.timers) verify(timer, atLeastOnce()).cancel();
|
||||
for (var pulse : f.pulses) pulse.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void aCapturedFantasySourceRequiresCurrentOffhandCustodyAndConnectionNotJustRegistryPresence() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var arrow = projectile(f, a.player);
|
||||
a.core.online = false;
|
||||
assertTrue(hit(f, arrow, b.player).isCancelled());
|
||||
a.core.online = true;
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); // Before the slot-change/recovery callback.
|
||||
assertTrue(hit(f, arrow, b.player).isCancelled());
|
||||
a.core.inventory.setItemInOffHand(sheet);
|
||||
assertFalse(hit(f, arrow, b.player).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeSerializationAndRestartKeepStaleDamageProvenanceButNeverRestorePartiesOrXp() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var arrow = projectile(f, a.player);
|
||||
var restoredArrow = entity(org.bukkit.entity.Arrow.class); when(restoredArrow.getShooter()).thenReturn(a.player);
|
||||
restoredArrow.getPersistentDataContainer().readFromBytes(arrow.getPersistentDataContainer().serializeToBytes(), true);
|
||||
var poison = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
|
||||
apply(f, b, null, poison, a.player);
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
b.core.pdc.readFromBytes(b.core.pdc.serializeToBytes(), true);
|
||||
f.plugin.setEnabled(true);
|
||||
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).isEmpty());
|
||||
assertTrue(hit(f, restoredArrow, b.player).isCancelled());
|
||||
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
assertFalse(hit(f, a.player, b.player).isCancelled());
|
||||
assertFalse(tickDamage(f, b, DamageCause.LAVA).isCancelled());
|
||||
// Malformed plugin-owned provenance cannot confer immunity or erase other namespaces.
|
||||
var key = b.core.pdc.getKeys().stream().filter(k -> k.getKey().startsWith("combat_dot_")).findFirst().orElseThrow();
|
||||
var other = new org.bukkit.NamespacedKey("other", "state");
|
||||
b.core.pdc.set(other, org.bukkit.persistence.PersistentDataType.STRING, "preserved");
|
||||
b.core.pdc.set(key, org.bukkit.persistence.PersistentDataType.STRING, "malformed");
|
||||
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled());
|
||||
assertFalse(b.core.pdc.has(key));
|
||||
assertEquals("preserved", b.core.pdc.get(other, org.bukkit.persistence.PersistentDataType.STRING));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void weakerHiddenEffectsAndInfiniteDurationsMatchNativeReplacementSemantics() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
|
||||
var type = org.bukkit.potion.PotionEffectType.WITHER;
|
||||
var strong = new org.bukkit.potion.PotionEffect(type, 40, 2);
|
||||
apply(f, b, null, strong, null);
|
||||
var weaker = new org.bukkit.potion.PotionEffect(type, -1, 0);
|
||||
var hidden = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, strong, weaker, a.player,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.CHANGED, false);
|
||||
f.dispatcher.accept(hidden); assertFalse(hidden.isCancelled());
|
||||
var nativeEffect = effects.get(b.player).get(type);
|
||||
nativeEffect.update(new net.minecraft.world.effect.MobEffectInstance(
|
||||
org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(type), -1, 0));
|
||||
createAndInvite(f, a, b); accept(f, b);
|
||||
assertFalse(tickDamage(f, b, DamageCause.WITHER).isCancelled());
|
||||
effects.get(b.player).put(type, nativeEffect.hiddenEffect);
|
||||
assertTrue(tickDamage(f, b, DamageCause.WITHER).isCancelled());
|
||||
// An infinite active effect is not replaced by a shorter same-strength application.
|
||||
assertFalse(apply(f, b, weaker, new org.bukkit.potion.PotionEffect(type, 20, 0), null).isCancelled());
|
||||
assertTrue(tickDamage(f, b, DamageCause.WITHER).isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
static org.bukkit.event.entity.EntityPotionEffectEvent apply(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor target, org.bukkit.potion.PotionEffect old,
|
||||
org.bukkit.potion.PotionEffect effect, Entity source) {
|
||||
var event = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player, old, effect, source,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
|
||||
old == null ? org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED : org.bukkit.event.entity.EntityPotionEffectEvent.Action.CHANGED, true);
|
||||
f.dispatcher.accept(event);
|
||||
if (!event.isCancelled()) {
|
||||
var nativeEffect = new net.minecraft.world.effect.MobEffectInstance(
|
||||
org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(effect.getType()), effect.getDuration(), effect.getAmplifier());
|
||||
var current = effects.get(target.player).get(effect.getType());
|
||||
if (current == null) effects.get(target.player).put(effect.getType(), nativeEffect);
|
||||
else if (event.isOverride() || current.getAmplifier() > nativeEffect.getAmplifier()
|
||||
&& current.isShorterDurationThan(nativeEffect)) current.update(nativeEffect);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
static org.bukkit.event.entity.EntityDamageEvent tickDamage(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor target, DamageCause cause) {
|
||||
var event = new org.bukkit.event.entity.EntityDamageEvent(target.player, cause, mock(DamageSource.class), 1);
|
||||
f.dispatcher.accept(event); return event;
|
||||
}
|
||||
|
||||
static org.bukkit.entity.Arrow projectile(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Player owner) {
|
||||
var arrow = entity(org.bukkit.entity.Arrow.class);
|
||||
when(arrow.getShooter()).thenReturn(owner);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(arrow));
|
||||
return arrow;
|
||||
}
|
||||
static <T extends Entity> T entity(Class<T> type) {
|
||||
var entity = mock(type);
|
||||
when(entity.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(entity.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
return entity;
|
||||
}
|
||||
|
||||
static void exit(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) throws Exception {
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
PartyBookNativeTest.drain(f);
|
||||
}
|
||||
|
||||
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player);
|
||||
var active = new java.util.HashMap<org.bukkit.potion.PotionEffectType, net.minecraft.world.effect.MobEffectInstance>();
|
||||
effects.put(a.player, active); fires.put(a.player, 0);
|
||||
doAnswer(call -> {
|
||||
var effect = active.get(call.getArgument(0));
|
||||
return effect == null ? null : new org.bukkit.potion.PotionEffect(call.getArgument(0), effect.getDuration(), effect.getAmplifier());
|
||||
}).when(a.player).getPotionEffect(any());
|
||||
doAnswer(call -> fires.get(a.player)).when(a.player).getFireTicks();
|
||||
return a;
|
||||
}
|
||||
static void createAndInvite(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor a, FantasyStickLifecycleNativeTest.Actor b) throws Exception {
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Create party"); invite(f, a);
|
||||
}
|
||||
static void invite(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) throws Exception {
|
||||
PartyBookNativeTest.open(f, a); action(f, a, "Invite players"); action(f, a, "Invite");
|
||||
}
|
||||
static void accept(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor b) throws Exception {
|
||||
PartyBookNativeTest.open(f, b); action(f, b, "Invitations"); action(f, b, "Accept");
|
||||
}
|
||||
static void action(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a, String label) throws Exception {
|
||||
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[" + label + "]"));
|
||||
PartyBookNativeTest.drain(f);
|
||||
}
|
||||
static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, Entity source, Entity target) {
|
||||
var damage = mock(DamageSource.class);
|
||||
when(damage.getCausingEntity()).thenReturn(source);
|
||||
when(damage.getDirectEntity()).thenReturn(source);
|
||||
var event = new EntityDamageByEntityEvent(source, target, DamageCause.ENTITY_ATTACK, damage, 4);
|
||||
f.dispatcher.accept(event);
|
||||
return event;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import java.nio.file.Path;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Packaged plugin startup and registered native lifecycle events; not proof of book/effect controls. */
|
||||
class PartyLifecycleNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredQuitHandlesLastMemberEvenWhileNativeOnlineFlagIsStillTrue() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
var parties = parties(f);
|
||||
var party = parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
|
||||
leader.core.online = false;
|
||||
assertEquals(leader.core.id, parties.partyOf(member.core.id).orElseThrow().leader());
|
||||
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
assertTrue(parties.partyOf(leader.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disablingAndReenablingDisbandsAndCannotReuseOldInvitationsOrServices() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
var old = parties(f);
|
||||
var party = old.create(leader.core.id).orElseThrow();
|
||||
var invitation = old.invite(leader.core.id, party.id(), member.core.id).orElseThrow();
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
assertTrue(old.partyOf(leader.core.id).isEmpty());
|
||||
assertFalse(old.accept(member.core.id, invitation.id()));
|
||||
assertTrue(old.create(leader.core.id).isEmpty());
|
||||
f.plugin.setEnabled(true);
|
||||
var fresh = parties(f);
|
||||
assertNotSame(old, fresh);
|
||||
assertTrue(fresh.partyOf(leader.core.id).isEmpty());
|
||||
assertFalse(fresh.accept(member.core.id, invitation.id()));
|
||||
assertNotEquals(party.id(), fresh.create(leader.core.id).orElseThrow().id());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void consecutiveQuitEventsAreAuthoritativeEvenIfBothOnlineFlagsLag() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
var parties = parties(f);
|
||||
var party = parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
|
||||
assertEquals(leader.core.id, parties.partyOf(member.core.id).orElseThrow().leader());
|
||||
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
|
||||
assertTrue(parties.partyOf(leader.core.id).isEmpty());
|
||||
assertTrue(parties.partyOf(member.core.id).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejoiningBeforeTheOtherMemberQuitsRetainsTheSamePartyAndLeader() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
var parties = parties(f);
|
||||
var party = parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(leader.player, Component.empty()));
|
||||
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
|
||||
assertEquals(party.id(), parties.partyOf(leader.core.id).orElseThrow().id());
|
||||
assertEquals(leader.core.id, parties.partyOf(leader.core.id).orElseThrow().leader());
|
||||
}
|
||||
}
|
||||
|
||||
static PartyRoster parties(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (PartyRoster) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "parties");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PartyRosterTest {
|
||||
final MutableClock clock = new MutableClock();
|
||||
final Set<UUID> online = new HashSet<>();
|
||||
final PartyRoster parties = new PartyRoster(clock, online::contains);
|
||||
|
||||
@Test void creatorLeadsExactlyOnePlayerOwnedPartyAndSnapshotsCannotMutateMembership() {
|
||||
UUID leader = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertEquals(leader, party.leader());
|
||||
assertEquals(List.of(leader), party.members());
|
||||
assertEquals(party, parties.partyOf(leader).orElseThrow());
|
||||
assertTrue(parties.create(leader).isEmpty());
|
||||
assertThrows(UnsupportedOperationException.class, () -> party.members().clear());
|
||||
assertTrue(parties.create(UUID.randomUUID()).isEmpty());
|
||||
}
|
||||
|
||||
@Test void invitationsRequireLeaderAuthorityAndRecipientConsentAndCannotBeReplayed() {
|
||||
UUID leader = player(), member = player(), stranger = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.invite(stranger, party.id(), member).isEmpty());
|
||||
assertTrue(parties.invite(leader, UUID.randomUUID(), member).isEmpty());
|
||||
var invitation = parties.invite(leader, party.id(), member).orElseThrow();
|
||||
assertEquals(invitation, parties.invite(leader, party.id(), member).orElseThrow());
|
||||
assertTrue(parties.partyOf(member).isEmpty(), "Inviting must not force membership");
|
||||
assertFalse(parties.accept(stranger, invitation.id()));
|
||||
assertTrue(parties.accept(member, invitation.id()));
|
||||
assertFalse(parties.accept(member, invitation.id()));
|
||||
assertEquals(List.of(leader, member), parties.partyOf(leader).orElseThrow().members());
|
||||
assertEquals(parties.partyOf(leader), parties.partyOf(member));
|
||||
assertEquals(List.of(leader), party.members(), "Old snapshots stay immutable");
|
||||
assertTrue(parties.invite(member, party.id(), stranger).isEmpty());
|
||||
}
|
||||
|
||||
@Test void leaderDisconnectRetainsLeadershipUntilTheLastOnlineMemberDisconnects() {
|
||||
UUID leader = player(), member = player(), invited = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
|
||||
var outstanding = parties.invite(leader, party.id(), invited).orElseThrow();
|
||||
parties.disconnect(leader); // Bukkit may still report the departing player as online inside QuitEvent.
|
||||
online.remove(leader);
|
||||
assertEquals(leader, parties.partyOf(member).orElseThrow().leader());
|
||||
assertEquals(List.of(leader, member), parties.partyOf(member).orElseThrow().members());
|
||||
parties.disconnect(member); // The invited non-member does not keep the party alive.
|
||||
assertTrue(parties.partyOf(leader).isEmpty());
|
||||
assertTrue(parties.partyOf(member).isEmpty());
|
||||
assertFalse(parties.accept(invited, outstanding.id()));
|
||||
online.add(leader);
|
||||
assertNotEquals(party.id(), parties.create(leader).orElseThrow().id());
|
||||
}
|
||||
|
||||
@Test void explicitLeaderLeaveDisbandsWithoutSuccessionAndStaleActionsCannotTouchANewParty() {
|
||||
UUID leader = player(), member = player(), invited = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
|
||||
var offer = parties.invite(leader, party.id(), invited).orElseThrow();
|
||||
assertFalse(parties.leave(member, UUID.randomUUID()));
|
||||
assertTrue(parties.leave(leader, party.id()));
|
||||
assertTrue(parties.partyOf(member).isEmpty());
|
||||
assertTrue(parties.partyOf(leader).isEmpty());
|
||||
var replacement = parties.create(leader).orElseThrow();
|
||||
assertFalse(parties.leave(leader, party.id()));
|
||||
assertFalse(parties.accept(invited, offer.id()));
|
||||
assertEquals(replacement, parties.partyOf(leader).orElseThrow());
|
||||
}
|
||||
|
||||
@Test void invitationsExpireAtExactlySixtyRealSecondsAndNewInvitesDoNotReviveOldTokens() {
|
||||
UUID leader = player(), before = player(), at = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
var beforeOffer = parties.invite(leader, party.id(), before).orElseThrow();
|
||||
var atOffer = parties.invite(leader, party.id(), at).orElseThrow();
|
||||
clock.now = Instant.EPOCH.plusSeconds(60).minusNanos(1);
|
||||
assertTrue(parties.accept(before, beforeOffer.id()));
|
||||
clock.now = Instant.EPOCH.plusSeconds(60);
|
||||
assertFalse(parties.accept(at, atOffer.id()));
|
||||
var fresh = parties.invite(leader, party.id(), at).orElseThrow();
|
||||
assertNotEquals(atOffer.id(), fresh.id());
|
||||
assertFalse(parties.accept(at, atOffer.id()));
|
||||
assertTrue(parties.accept(at, fresh.id()));
|
||||
}
|
||||
|
||||
@Test void invitationsDoNotReserveSlotsAndAcceptanceRechecksTheSixPlayerCapacity() {
|
||||
UUID leader = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
var offers = new java.util.ArrayList<PartyRoster.Invitation>();
|
||||
for (int i = 0; i < 7; i++) offers.add(parties.invite(leader, party.id(), player()).orElseThrow());
|
||||
assertEquals(1, parties.partyOf(leader).orElseThrow().members().size());
|
||||
for (int i = 0; i < 5; i++) assertTrue(parties.accept(offers.get(i).invitee(), offers.get(i).id()));
|
||||
assertFalse(parties.accept(offers.get(5).invitee(), offers.get(5).id()));
|
||||
assertFalse(parties.accept(offers.get(6).invitee(), offers.get(6).id()));
|
||||
assertEquals(6, parties.partyOf(leader).orElseThrow().members().size());
|
||||
assertTrue(parties.invite(leader, party.id(), player()).isEmpty());
|
||||
assertTrue(parties.leave(offers.getFirst().invitee(), party.id()));
|
||||
assertTrue(parties.accept(offers.get(5).invitee(), offers.get(5).id()));
|
||||
assertEquals(6, parties.partyOf(leader).orElseThrow().members().size());
|
||||
}
|
||||
|
||||
@Test void onlyTheCurrentOnlineLeaderCanKickOrDisbandTheMatchingParty() {
|
||||
UUID leader = player(), member = player(), stranger = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
|
||||
assertFalse(parties.kick(member, party.id(), leader));
|
||||
assertFalse(parties.disband(member, party.id()));
|
||||
assertFalse(parties.disband(stranger, party.id()));
|
||||
assertFalse(parties.kick(leader, UUID.randomUUID(), member));
|
||||
assertFalse(parties.kick(leader, party.id(), stranger));
|
||||
online.remove(leader);
|
||||
assertFalse(parties.kick(leader, party.id(), member));
|
||||
assertFalse(parties.disband(leader, party.id()));
|
||||
online.add(leader);
|
||||
assertTrue(parties.kick(leader, party.id(), member));
|
||||
assertTrue(parties.partyOf(member).isEmpty());
|
||||
assertEquals(List.of(leader), parties.partyOf(leader).orElseThrow().members());
|
||||
assertTrue(parties.disband(leader, party.id()));
|
||||
assertTrue(parties.partyOf(leader).isEmpty());
|
||||
assertFalse(parties.disband(leader, party.id()));
|
||||
}
|
||||
|
||||
@Test void shutdownRevokesAllPartiesInvitationsAndOldServiceAuthorityWithoutRestartPersistence() {
|
||||
UUID leader = player(), member = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
var offer = parties.invite(leader, party.id(), member).orElseThrow();
|
||||
parties.close(); parties.close();
|
||||
assertTrue(parties.partyOf(leader).isEmpty());
|
||||
assertFalse(parties.accept(member, offer.id()));
|
||||
assertTrue(parties.create(leader).isEmpty());
|
||||
assertTrue(parties.invite(leader, party.id(), member).isEmpty());
|
||||
try (var restarted = new PartyRoster(clock, online::contains)) {
|
||||
assertTrue(restarted.partyOf(leader).isEmpty());
|
||||
assertFalse(restarted.accept(member, offer.id()));
|
||||
assertNotEquals(party.id(), restarted.create(leader).orElseThrow().id());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void quitImmediatelyRevokesManagementDespiteALaggingOnlineFlagAndJoinRestoresIt() {
|
||||
UUID leader = player(), member = player(), target = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
|
||||
parties.disconnect(leader);
|
||||
assertTrue(parties.invite(leader, party.id(), target).isEmpty());
|
||||
assertFalse(parties.kick(leader, party.id(), member));
|
||||
assertFalse(parties.disband(leader, party.id()));
|
||||
assertFalse(parties.leave(leader, party.id()));
|
||||
parties.arrive(leader);
|
||||
assertTrue(parties.invite(leader, party.id(), target).isPresent());
|
||||
assertTrue(parties.leave(member, party.id()));
|
||||
assertEquals(List.of(leader), parties.partyOf(leader).orElseThrow().members());
|
||||
}
|
||||
|
||||
@Test void lastOnlineMemberLeavingAlsoDisbandsRatherThanStrandingOfflineMembers() {
|
||||
UUID leader = player(), member = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
|
||||
parties.disconnect(leader);
|
||||
assertTrue(parties.leave(member, party.id()));
|
||||
assertTrue(parties.partyOf(leader).isEmpty());
|
||||
assertTrue(parties.partyOf(member).isEmpty());
|
||||
}
|
||||
|
||||
UUID player() { UUID id = UUID.randomUUID(); online.add(id); return id; }
|
||||
static final class MutableClock extends Clock {
|
||||
Instant now = Instant.EPOCH;
|
||||
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(ZoneId zone) { return this; }
|
||||
@Override public Instant instant() { return now; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user