Compare commits

...
2 Commits
Author SHA1 Message Date
dmg 6ec97569d3 feat(admin): add read-only active character view
Release / release (push) Successful in 11m51s
2026-09-23 00:02:07 -04:00
dmg fd5961ccd8 fix(sessions): simplify switch cooldown feedback
Release / release (push) Successful in 11m8s
2026-09-22 23:26:29 -04:00
11 changed files with 602 additions and 44 deletions

No files matched your search

+1
View File
@@ -136,6 +136,7 @@ Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the
Administration (permission `fantasystick.admin`, default operators):
- `/fantasystick character view <online-player>` — opens the exact online account's current active character as a complete read-only virtual book for an in-game administrator.
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
- `/fantasystick recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
- `/fantasystick config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
@@ -608,6 +608,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
"Admin character issue " + problem.operation() + " for player " + problem.player()
+ "; character " + problem.character(), problem.cause())));
admin.attachCharacterBooks(books);
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
@@ -29,10 +29,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private final games.dmg.fantasystick.passive.PassiveRegistry passives;
private final games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
private final org.bukkit.Server server;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
private volatile boolean closed;
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and party pages.
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
private static final net.kyori.adventure.key.Key ADMIN_PAGES = net.kyori.adventure.key.Key.key("fantasystick", "admin_character_pages");
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
@@ -42,6 +46,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
@@ -87,11 +94,76 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
this.abilityEngine = abilityEngine;
this.passives = Objects.requireNonNull(passives);
this.passiveEngine = passiveEngine;
this.server = Objects.requireNonNull(server);
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
}
@Override public void accept(ControlInput input) { load(input, 0); }
public void openReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target) {
Objects.requireNonNull(administrator); Objects.requireNonNull(target);
if (closed) return;
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission is required.");
return;
}
UUID targetId = target.getUniqueId();
var active = sessions.active(targetId).filter(sessions::current).orElse(null);
if (active == null) {
administrator.sendMessage("Character view refused: the target has no current active character.");
return;
}
loadReadOnly(administrator, target, active, 0);
}
private void loadReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active, int offset) {
if (closed) return;
UUID administratorId = administrator.getUniqueId();
UUID targetId = target.getUniqueId();
Object ticket = new Object();
adminViews.remove(administratorId);
adminPending.put(administratorId, ticket);
characters.find(active.character()).whenComplete((record, failure) -> {
if (closed || adminPending.get(administratorId) != ticket) return;
try {
nextTick.accept(() -> {
if (closed || !adminPending.remove(administratorId, ticket)) return;
var currentTarget = server.getPlayer(targetId);
var current = currentTarget == null ? null : sessions.active(targetId)
.filter(sessions::current).orElse(null);
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission changed.");
return;
}
if (currentTarget != target || !target.isOnline() || !active.equals(current)) {
administrator.sendMessage("Character view refused: the target character session changed.");
return;
}
if (failure != null) {
report(new Failure(administratorId, active.character(), failure));
administrator.sendMessage("Character view could not be loaded; no state was changed.");
return;
}
if (record.isEmpty() || record.orElseThrow().dead()) {
administrator.sendMessage("Character view refused: the target character is unavailable.");
return;
}
try { administrator.openBook(renderReadOnly(record.orElseThrow(), administrator,
currentTarget, active, offset)); }
catch (RuntimeException error) {
adminViews.remove(administratorId);
report(new Failure(administratorId, active.character(), error));
administrator.sendMessage("Character view could not be opened; no state was changed.");
}
});
} catch (RuntimeException rejected) {
adminPending.remove(administratorId, ticket);
report(new Failure(administratorId, active.character(), rejected));
}
});
}
private void load(ControlInput input, int offset) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
@@ -128,18 +200,33 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
return renderBook(record, input.player(), input, offset, null, null);
}
private ItemStack renderReadOnly(CharacterRecord record, org.bukkit.entity.Player administrator,
org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int offset) {
return renderBook(record, target, null, offset, administrator, active);
}
private ItemStack renderBook(CharacterRecord record, org.bukkit.entity.Player subject,
ControlInput input, int offset, org.bukkit.entity.Player administrator,
games.dmg.fantasystick.session.EquipmentSessions.Active adminActive) {
boolean interactive = input != null;
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
var allOverview = overviewPages(record, input, statView);
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
var allOverview = overviewPages(record, subject, statView);
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
if(end==allOverview.size())pages.add(gearPage(record));
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
if (interactive && record.unspentPoints().signum() > 0 && start == 0)
statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::category)
@@ -151,14 +238,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
.thenComparing(games.dmg.fantasystick.passive.PassiveDefinition::id))
.toList();
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
var abilityView = interactive ? new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
if (interactive && !definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = interactive ? new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
if (interactive && !passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id,
games.dmg.fantasystick.passive.PassiveDefinition::name));
@@ -208,15 +295,29 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
passiveDetails.forEach(pages::addAll);
}
if (allOverview.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.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));
if (interactive) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
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));
} else {
var view = new AdminView(UUID.randomUUID().toString(), administrator.getUniqueId(),
subject, adminActive, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
adminViews.put(administrator.getUniqueId(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
.append(adminPageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline())
.append(adminPageButton("Next pages", view, 1));
}
pages.add(navigation);
}
if (end == allOverview.size()) pages.addAll(partyControls.pages(input));
if (end == allOverview.size()) pages.addAll(interactive
? partyControls.pages(input) : partyControls.readOnlyPages(subject.getUniqueId()));
meta.pages(pages);
item.setItemMeta(meta);
return item;
@@ -225,12 +326,13 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
private java.util.List<Component> overviewPages(CharacterRecord record, org.bukkit.entity.Player subject,
StatView view) {
var builder = new PageBuilder();
builder.text(record.name());
builder.blank();
for (var stat : Stat.values()) {
Component action = record.unspentPoints().signum() > 0
Component action = view != null && record.unspentPoints().signum() > 0
? statButton(stat, view) : null;
builder.inline(stat + ": " + record.stats().get(stat), action);
}
@@ -241,7 +343,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
builder.text("XP: " + xp);
builder.text("Unspent points: " + record.unspentPoints());
builder.text("Coins: " + record.coins());
builder.text("Health: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
builder.text("Health: " + java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString());
return builder.pages();
}
@@ -362,7 +464,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
String state = learned ? "Learned" : !prerequisites || !stats ? "Unavailable: requirements"
: !points ? "Unavailable: points" : !slot ? "Unavailable: passive slots" : "Available";
var builder = new PageBuilder(); builder.text(definition.name()); builder.text(requirements);
if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
if (view == null) builder.text(state);
else if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
else if (prerequisites && stats && points && slot) builder.component(passiveButton("Learn", PASSIVE_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank(); definition.description().forEach(builder::text); builder.blank(); builder.text(state);
@@ -387,7 +490,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
builder.text(definition.name());
builder.text(definition.category().name());
builder.text(requirements);
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
if (view == null) builder.text(state);
else if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank();
@@ -475,6 +579,28 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
private static Component adminPageButton(String label, AdminView view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(ADMIN_PAGES,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
@org.bukkit.event.EventHandler
public void adminPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !ADMIN_PAGES.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var administrator = connection.getPlayer();
var view = adminViews.get(administrator.getUniqueId());
if (view == null || event.getTag() == null || !view.administrator().equals(administrator.getUniqueId())) 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 || !adminViews.remove(administrator.getUniqueId(), view)) return;
loadReadOnly(administrator, view.target(), view.active(), offset);
}
@org.bukkit.event.EventHandler
public void pageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !PAGES.equals(event.getIdentifier())
@@ -493,7 +619,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private void report(ControlInput input, Throwable failure) {
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
report(new Failure(input.session().player(), input.session().character(), failure));
}
private void report(Failure failure) {
try { failures.accept(failure); }
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
}
@@ -652,6 +782,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
adminPending.remove(owner);
adminViews.remove(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
@@ -660,6 +792,6 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
@Override public void close() {
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
}
}
@@ -54,6 +54,12 @@ public final class PartyBookControls implements AutoCloseable {
public List<Component> pages(ControlInput input) { return render(input, Menu.ROOT, 0); }
/** Informational party pages with no registered action or retained viewer state. */
public List<Component> readOnlyPages(UUID account) {
if (closed) return List.of();
return rootPages(Objects.requireNonNull(account));
}
private List<Component> render(ControlInput input, Menu menu, int offset) {
if (closed || !input.stillCurrent()) return List.of();
var build = new Pages(input);
@@ -61,18 +67,7 @@ public final class PartyBookControls implements AutoCloseable {
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()) {
build.pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
} else {
var group = party.orElseThrow();
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size() + " active\n\nManage this party\nonly at a Party\nRegistrar."));
build.pages.add(summary);
for (var member : group.characters()) {
Component label=parties.activePlayer(member).map(names).orElse(Component.text("Character\n"+member.toString().substring(0,8)));
build.pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
build.pages.addAll(rootPages(owner));
} else if (menu == Menu.PLAYERS) {
var group = party.orElseThrow();
var candidates = server.getOnlinePlayers().stream().filter(player -> player.isOnline())
@@ -108,6 +103,26 @@ public final class PartyBookControls implements AutoCloseable {
return List.copyOf(build.pages);
}
private List<Component> rootPages(UUID owner) {
var pages = new ArrayList<Component>();
var party = parties.partyOf(owner);
Component summary = Component.text("Party\n\n");
if (party.isEmpty()) {
pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
} else {
var group = party.orElseThrow();
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size()
+ " active\n\nManage this party\nonly at a Party\nRegistrar."));
pages.add(summary);
for (var member : group.characters()) {
Component label = parties.activePlayer(member).map(names)
.orElse(Component.text("Character\n" + member.toString().substring(0, 8)));
pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
return List.copyOf(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));
}
@@ -92,6 +92,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
private games.dmg.fantasystick.book.CharacterBookController characterBooks;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -379,6 +380,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterIssuer = Objects.requireNonNull(issuer);
}
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books) {
if (closed || characterBooks != null) throw new IllegalStateException("Character books already attached or command closed");
characterBooks = Objects.requireNonNull(books);
}
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
games.dmg.fantasystick.party.PartyRegistrarController controller) {
if (closed || partyRegistrars != null) throw new IllegalStateException("Party Registrars already attached or command closed");
@@ -415,6 +421,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("quest-giver")) {
adventureQuestCommands.giver(sender, args); return true;
}
if (args.length > 0 && args[0].equals("character")) {
characterView(sender, args); return true;
}
if(dungeonCommands!=null&&args.length>0&&args[0].equals("dungeon")){dungeonCommands.execute(sender,args);return true;}
if(args.length>0&&args[0].equals("shop")){shop(sender,args);return true;}
if (args.length > 0 && args[0].equals("training-dummy")) {
@@ -497,6 +506,23 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
private void characterView(CommandSender sender, String[] args) {
if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)
|| args.length != 3 || !args[1].equals("view") || args[2].isBlank()) {
sender.sendMessage("Usage: /fantasystick character view <online-player> (in-game administrator only)");
return;
}
var matches = server.getOnlinePlayers().stream()
.filter(org.bukkit.entity.Player::isOnline)
.filter(player -> player.getName().equals(args[2]))
.toList();
if (matches.size() != 1) {
sender.sendMessage("Character view refused: target must exactly name one online player.");
return;
}
characterBooks.openReadOnly(administrator, matches.getFirst());
}
private void experience(CommandSender sender, String[] args) {
if (experienceGrant == null || args.length < 4 || !args[1].equals("give")) {
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
@@ -1438,8 +1464,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (!sender.hasPermission(PERMISSION)) return List.of();
if(dungeonCommands!=null&&args.length>1){var dungeon=dungeonCommands.complete(sender,args);if(!dungeon.isEmpty())return dungeon;if(args[0].equals("dungeon"))return List.of();}
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("adventure", "adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
? List.of("adventure", "adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
: List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), args[0]);
if (args.length == 2 && args[0].equals("character") && sender instanceof org.bukkit.entity.Player)
return matching(List.of("view"), args[1]);
if (args.length == 3 && args[0].equals("character") && args[1].equals("view")
&& sender instanceof org.bukkit.entity.Player)
return matching(server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
.filter(player -> sessions.active(player.getUniqueId()).filter(sessions::current).isPresent())
.map(org.bukkit.entity.Player::getName).toList(), args[2]);
if(args.length==2&&args[0].equals("shop"))return matching(sender instanceof org.bukkit.entity.Player?List.of("list","place","remove","restock"):List.of("list","remove","restock"),args[1]);
if(args.length==3&&args[0].equals("shop")&&args[1].equals("remove")&&armorers!=null)return matching(armorers.all().stream().map(value->value.id().toString()).toList(),args[2]);
if (args.length == 2 && args[0].equals("training-dummy")) return matching(
@@ -505,8 +505,8 @@ public final class EquipmentSessions implements AutoCloseable {
if (!state.canEnter(attempt.character, switchDelay.get(), now)) {
Duration remaining = Duration.between(now, state.lastExit().deadline());
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
refuse(attempt, "COOLDOWN", "Wait " + seconds + " seconds to switch characters (until "
+ state.lastExit().deadline() + "); the same character may be resumed now."); return;
refuse(attempt, "COOLDOWN", "Wait " + seconds
+ " seconds to switch characters; the same character may be resumed now."); return;
}
}
if (!entryNativeSafe(attempt, loaded.state().orElse(null))) {
+1 -1
View File
@@ -8,7 +8,7 @@ author: dmg.games
commands:
fantasystick:
description: Inspect daily adventures or administer Fantasy Stick.
usage: /fantasystick adventure [reroll confirm] | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|teleport <site-uuid>|list> | dungeon <teleport <dungeon-uuid>|...> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
usage: /fantasystick adventure [reroll confirm] | character view <online-player> | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|teleport <site-uuid>|list> | dungeon <teleport <dungeon-uuid>|...> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
fantasyfeedback:
description: Read or change routine Fantasy Stick combat and ability feedback for your player account.
@@ -0,0 +1,366 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.ClickEvent;
import net.minecraft.network.chat.Component;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Actual registered admin command through authoritative storage and native virtual-book output. */
class AdminCharacterBookNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredCommandOpensTheTargetsAuthoritativeReadOnlyBook() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
admin.accountName = "Admin";
target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
target.core.health = 7.0;
org.bukkit.inventory.ItemStack[] adminInventory = admin.core.snapshot();
org.bukkit.inventory.ItemStack[] targetInventory = target.core.snapshot();
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] savedCharacter = Files.readAllBytes(character);
assertTrue(fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"}));
assertTrue(admin.books.isEmpty(), "Authoritative storage must complete before opening");
assertTrue(admin.messages.isEmpty(), admin.messages::toString);
var controller = FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "books");
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(controller,
"adminPending")).size());
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
fixture.tick();
assertEquals(1, admin.books.size());
assertTrue(target.books.isEmpty(), "Inspection must not interrupt the target");
var pages = nativePages(admin);
String text = pages.stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n"));
assertTrue(text.replace("\n", "").contains(target.core.record.name()));
assertTrue(text.contains("Health: 7/10"), text);
assertTrue(text.contains("Active abilities"));
assertTrue(text.contains("Passive abilities"));
assertTrue(text.contains("Party"));
assertFalse(text.contains("[+1") || text.contains("[Learn]") || text.contains("[Unlearn]"));
assertTrue(pages.stream().allMatch(AdminCharacterBookNativeTest::onlyNativePageLinks));
dispatchCustom(fixture, admin, "fantasystick:character_stat",
"\"" + UUID.randomUUID() + ":STR\"");
dispatchCustom(fixture, admin, "fantasystick:ability_unlock",
"\"" + UUID.randomUUID() + ":fireball\"");
assertArrayEquals(adminInventory, admin.core.snapshot());
assertArrayEquals(targetInventory, target.core.snapshot());
assertArrayEquals(savedCharacter, Files.readAllBytes(character));
}
}
@Test void largeAuthoritativeRecordsRemainCompleteThroughBoundedReadOnlyNavigation() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.accountName = "Target";
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(target.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
String digits = "1234567890".repeat(4_000);
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
var cooldowns = new java.util.HashMap<>(old.cooldowns());
cooldowns.put("unknown_saved", java.time.Instant.parse("2030-01-02T03:04:05Z"));
var large = 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(), java.util.Set.of("unknown_saved"),
java.util.List.of("unknown_saved", "", "", "", "", "", ""), cooldowns,
old.restrictedKills(), false, null, null);
repository.replace(large, old.revision());
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] savedCharacter = Files.readAllBytes(character);
org.bukkit.inventory.ItemStack[] adminInventory = admin.core.snapshot();
org.bukkit.inventory.ItemStack[] targetInventory = target.core.snapshot();
fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
var all = new StringBuilder();
var content = new StringBuilder();
int volumes = 0;
while (true) {
volumes++;
var pages = nativePages(admin);
assertTrue(pages.size() <= 100, "Every administrator view must stay bounded");
pages.forEach(page -> {
String text = page.getString();
all.append(text);
if (!text.startsWith("Character pages\n\n")) content.append(text);
});
var next = customLinkOrNull(pages, "[Next pages]");
if (next == null) break;
int before = admin.books.size();
PartyBookNativeTest.click(fixture, admin, next);
if (volumes == 1) PartyBookNativeTest.click(fixture, admin, next);
drainReads(fixture); runQueued(fixture);
assertEquals(before + 1, admin.books.size(), "Repeated navigation payloads are single-use");
}
assertTrue(volumes > 1);
assertTrue(content.toString().replace("\n", "").contains("STR: " + digits + "DEX:"),
"Every persisted stat digit must remain reachable in order across bounded volumes");
assertTrue(all.toString().contains("Gear"));
assertTrue(all.toString().contains("Active abilities"));
assertTrue(all.toString().contains("unknown_saved"));
assertTrue(all.toString().contains("Party"));
assertArrayEquals(adminInventory, admin.core.snapshot());
assertArrayEquals(targetInventory, target.core.snapshot());
assertArrayEquals(savedCharacter, Files.readAllBytes(character));
}
}
@Test void accountNameSelectionSupportsSelfAndDuplicateFantasyNames() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var other = new FantasyStickLifecycleNativeTest.Actor(data); other.accountName = "Other";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(other.core.id, other.player);
var storage = characters(fixture);
var renamedAdmin = storage.renameCharacter(admin.core.record.id(), "Duplicate Fantasy Name")
.get(5, TimeUnit.SECONDS).orElseThrow();
var renamedOther = storage.renameCharacter(other.core.record.id(), "Duplicate Fantasy Name")
.get(5, TimeUnit.SECONDS).orElseThrow();
admin.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamedAdmin));
other.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamedOther));
fixture.activate(admin); fixture.activate(other);
admin.core.health = 6.0; other.core.health = 8.0;
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Admin"});
drainReads(fixture); runQueued(fixture);
assertTrue(text(admin).contains("Health: 6/10"));
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Other"});
drainReads(fixture); runQueued(fixture);
assertTrue(text(admin).contains("Health: 8/10"));
assertEquals(2, admin.books.size());
}
}
@Test void stalePermissionSessionAndSupersededReadsNeverOpenCharacterData() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var first = new FantasyStickLifecycleNativeTest.Actor(data); first.accountName = "First";
var second = new FantasyStickLifecycleNativeTest.Actor(data); second.accountName = "Second";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(first.core.id, first.player);
fixture.players.put(second.core.id, second.player);
fixture.activate(first); fixture.activate(second);
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Second"});
drainReads(fixture); runQueued(fixture);
assertEquals(1, admin.books.size(), "Only the latest authoritative request may open");
assertTrue(nativePages(admin).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining()).contains(second.core.record.name()));
int opened = admin.books.size();
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
drainReads(fixture);
var permissionResponse = fixture.tasks.poll(); assertNotNull(permissionResponse);
admin.allowed = false; permissionResponse.run();
assertEquals(opened, admin.books.size());
assertTrue(admin.messages.getLast().contains("permission changed"));
admin.allowed = true;
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
drainReads(fixture);
var sessionResponse = fixture.tasks.poll(); assertNotNull(sessionResponse);
((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).exit(first.player);
sessionResponse.run();
assertEquals(opened, admin.books.size());
assertTrue(admin.messages.getLast().contains("session changed"));
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Second"});
drainReads(fixture);
var retiredLifetimeResponse = fixture.tasks.poll(); assertNotNull(retiredLifetimeResponse);
fixture.plugin.setEnabled(false);
retiredLifetimeResponse.run();
assertEquals(opened, admin.books.size(), "A retired plugin lifetime cannot open character data");
}
}
@Test void malformedAuthoritativeReadReportsFailureAndAllowsAValidRetry() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] valid = Files.readAllBytes(character);
Files.writeString(character, "schema-version: malformed\n");
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertTrue(admin.books.isEmpty());
assertTrue(admin.messages.getLast().contains("could not be loaded"));
assertEquals("schema-version: malformed\n", Files.readString(character));
Files.write(character, valid);
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertEquals(1, admin.books.size());
assertArrayEquals(valid, Files.readAllBytes(character));
}
}
@Test void deadAndMissingAuthoritativeCharactersAreRefusedWithoutOpening() throws Exception {
for (String state : java.util.List.of("dead", "missing")) {
Path dir = data.resolve(state);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(dir); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(dir); target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = dir.resolve("characters").resolve(target.core.record.id() + ".yml");
if (state.equals("missing")) {
Files.delete(character);
} else {
var old = target.core.record;
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), true, UUID.randomUUID(), java.time.Instant.parse("2026-09-23T00:00:00Z"));
new games.dmg.fantasystick.persistence.YamlCharacterRepository(dir)
.replace(dead, old.revision());
}
fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertTrue(admin.books.isEmpty());
assertTrue(admin.messages.getLast().contains("unavailable"));
}
}
}
@Test void commandAndCompletionRequireAnAuthorizedOnlinePlayerAndExactActiveAccount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var active = new FantasyStickLifecycleNativeTest.Actor(data); active.accountName = "ActiveTarget";
var inactive = new FantasyStickLifecycleNativeTest.Actor(data); inactive.accountName = "InactiveTarget";
var offline = new FantasyStickLifecycleNativeTest.Actor(data); offline.accountName = "OfflineTarget";
offline.core.online = false;
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(active.core.id, active.player);
fixture.players.put(inactive.core.id, inactive.player);
fixture.players.put(offline.core.id, offline.player);
fixture.activate(active);
assertEquals(java.util.List.of("character", "config"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"c"}));
assertEquals(java.util.List.of("view"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", ""}));
assertEquals(java.util.List.of("ActiveTarget"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", ""}));
assertEquals(java.util.List.of("ActiveTarget"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", "Act"}));
assertTrue(fixture.command.tabComplete(admin.player, "fantasystick",
new String[]{"character", "view", "ActiveTarget", ""}).isEmpty());
admin.allowed = false;
assertTrue(fixture.command.tabComplete(admin.player, "fantasystick",
new String[]{"character", ""}).isEmpty());
fixture.command.getExecutor().onCommand(admin.player, fixture.command, "fantasystick",
new String[]{"character", "view", "ActiveTarget"});
assertTrue(admin.books.isEmpty()); assertTrue(fixture.tasks.isEmpty());
admin.allowed = true;
var console = new EquipmentAdminCommandNativeTest.Sender();
assertTrue(fixture.command.tabComplete(console.port, "fantasystick",
new String[]{"character", ""}).isEmpty());
fixture.command.execute(console.port, "fantasystick",
new String[]{"character", "view", "ActiveTarget"});
assertTrue(console.messages.getLast().contains("in-game administrator"));
for (String[] args : java.util.List.of(
new String[]{"character"},
new String[]{"character", "view"},
new String[]{"character", "view", "activetarget"},
new String[]{"character", "view", "InactiveTarget"},
new String[]{"character", "view", "OfflineTarget"},
new String[]{"character", "view", "ActiveTarget", "extra"})) {
fixture.command.execute(admin.player, "fantasystick", args);
assertTrue(admin.books.isEmpty()); assertTrue(fixture.tasks.isEmpty());
}
}
}
private static void dispatchCustom(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
String key, String payload) {
var connection = org.mockito.Mockito.mock(io.papermc.paper.connection.PlayerGameConnection.class);
org.mockito.Mockito.when(connection.getPlayer()).thenReturn(actor.player);
fixture.dispatcher.accept(new io.papermc.paper.event.player.PaperPlayerCustomClickEvent(
net.kyori.adventure.key.Key.key(key), connection,
net.minecraft.nbt.StringTag.valueOf(payload)));
}
private static net.minecraft.network.chat.ClickEvent.Custom customLinkOrNull(
java.util.List<Component> pages, String label) {
for (var page : pages) {
var click = PartyBookNativeTest.find(page, label);
if (click != null) return click;
}
return null;
}
private static void drainReads(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
}
private static void runQueued(FantasyStickLifecycleNativeTest.Fixture fixture) {
Runnable task;
while ((task = fixture.tasks.poll()) != null) task.run();
}
private static games.dmg.fantasystick.persistence.CharacterStorage characters(
FantasyStickLifecycleNativeTest.Fixture fixture) {
return (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
}
private static String text(FantasyStickLifecycleNativeTest.Actor actor) {
return nativePages(actor).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n"));
}
private static java.util.List<Component> nativePages(FantasyStickLifecycleNativeTest.Actor actor) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
return content.pages().stream().map(page -> page.raw()).toList();
}
private static boolean onlyNativePageLinks(Component component) {
var click = component.getStyle().getClickEvent();
if (click != null && !(click instanceof ClickEvent.ChangePage)) return false;
return component.getSiblings().stream().allMatch(AdminCharacterBookNativeTest::onlyNativePageLinks);
}
}
@@ -1292,8 +1292,9 @@ class EquipmentSessionsNativeTest {
}
}
@Test void cooldownExplainsRemainingSecondsAndSameCharacterBeforeEquipmentPreflight() throws Exception {
try (var f = new Fixture()) {
@Test void cooldownShowsOnlyFreshRoundedRemainingSecondsBeforeEquipmentPreflight() throws Exception {
var clock = new MutableClock();
try (var f = new Fixture(Duration.ofSeconds(13), clock)) {
var actor = f.actor(); f.activate(actor);
f.sessions.exit(actor.player);
var exited = f.state(actor);
@@ -1304,14 +1305,18 @@ class EquipmentSessionsNativeTest {
actor.inventory.setItem(0, new ItemStack(Material.SHULKER_BOX));
actor.cursor = new ItemStack(Material.EMERALD);
var before = actor.snapshot(); var cursor = actor.cursor.clone(); var marker = actor.marker();
for (int retry = 0; retry < 2; retry++) {
clock.advance(Duration.ofMillis(900));
for (String expected : java.util.List.of(
"Wait 13 seconds to switch characters; the same character may be resumed now.",
"Wait 12 seconds to switch characters; the same character may be resumed now.")) {
f.sessions.enter(actor.player); f.tick();
var notice = f.notices.getLast();
assertEquals("COOLDOWN", notice.code());
assertTrue(notice.message().matches("(?s).*Wait [1-9][0-9]* seconds.*"), notice::message);
assertTrue(notice.message().contains("same character may be resumed now"), notice::message);
assertEquals(expected, notice.message());
assertFalse(notice.message().contains(exited.lastExit().deadline().toString()));
assertArrayEquals(before, actor.snapshot()); assertEquals(cursor, actor.cursor);
assertEquals(marker, actor.marker()); assertEquals(exited, f.state(actor));
clock.advance(Duration.ofMillis(100));
}
}
}
@@ -1352,6 +1352,8 @@ class FantasyStickLifecycleNativeTest {
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
boolean healthScaled;
boolean sprinting;
boolean allowed = true;
String accountName = "Account";
double healthScale = 20;
double absorption;
Runnable scaleMutation = () -> {};
@@ -1381,7 +1383,8 @@ class FantasyStickLifecycleNativeTest {
case "equals" -> call.getMock() == args[0];
case "getHandle" -> nativePlayer;
case "getEntityId" -> 42;
case "getName" -> "Account";
case "getName" -> accountName;
case "hasPermission" -> allowed;
case "getWorld" -> core.location.getWorld();
case "getHeight" -> 1.8;
case "getTrackedBy" -> tracked;
@@ -36,6 +36,8 @@ class DistributionTest {
assertEquals("/fantasyfeedback [on|off]", metadata.getCommands().get("fantasyfeedback").get("usage"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("adventure-site"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("quest-giver"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString()
.contains("character view <online-player>"));
assertEquals(1, metadata.getPermissions().size());
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());