Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f2f2481b | ||
|
|
6ec97569d3 | ||
|
|
fd5961ccd8 |
No files matched your search
@@ -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.
|
||||
@@ -166,7 +167,7 @@ A same-process retry can acknowledge gear already verified restored for that exa
|
||||
|
||||
## Native fantasy health, exit and defeat
|
||||
|
||||
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and shows public blue particles. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
|
||||
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fantasyparticles players on`. The account-owned aura preference defaults to off; `/fantasyparticles players` reports it and `/fantasyparticles players off` hides only this recurring aura. Defeat, progression and ability particles remain unchanged. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
|
||||
|
||||
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2618 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private EquipmentAdminCommand admin;
|
||||
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
|
||||
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
|
||||
private games.dmg.fantasystick.session.FantasyParticlesCommand particlesCommand;
|
||||
private games.dmg.fantasystick.presentation.CombatFeedback combatFeedback;
|
||||
private volatile Object lifetime;
|
||||
private CharacterPresentation presentation;
|
||||
@@ -274,6 +275,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
feedbackCommand = new games.dmg.fantasystick.session.FantasyFeedbackCommand(feedbackPreferences, nextTick,
|
||||
failure -> getLogger().log(Level.WARNING, "Player feedback preference failed", failure));
|
||||
particlesCommand = new games.dmg.fantasystick.session.FantasyParticlesCommand(feedbackPreferences, nextTick,
|
||||
failure -> getLogger().log(Level.WARNING, "Player particle preference failed", failure));
|
||||
java.util.function.Consumer<Throwable> presentationFailures = problem ->
|
||||
getLogger().log(Level.WARNING, "Character presentation failed", problem);
|
||||
// This supplier belongs to one enable, never the mutable plugin field after re-enable.
|
||||
@@ -608,6 +611,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);
|
||||
@@ -615,6 +619,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
"Missing packaged fantasyfeedback command");
|
||||
playerFeedbackCommand.setExecutor(feedbackCommand);
|
||||
playerFeedbackCommand.setTabCompleter(feedbackCommand);
|
||||
var playerParticlesCommand = java.util.Objects.requireNonNull(getCommand("fantasyparticles"),
|
||||
"Missing packaged fantasyparticles command");
|
||||
playerParticlesCommand.setExecutor(particlesCommand);
|
||||
playerParticlesCommand.setTabCompleter(particlesCommand);
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
getServer().getPluginManager().registerEvents(guard, this);
|
||||
getServer().getPluginManager().registerEvents(anvilNames, this);
|
||||
@@ -671,7 +679,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
|
||||
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
|
||||
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions, feedbackPreferences,
|
||||
failure -> getLogger().log(Level.WARNING, "Fantasy session particle delivery failed", failure));
|
||||
sessionVisuals.install(this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
@@ -697,6 +705,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingChat = chatNames; chatNames = null;
|
||||
var closingAdmin = admin; admin = null;
|
||||
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
|
||||
var closingParticlesCommand = particlesCommand; particlesCommand = null;
|
||||
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
|
||||
combatFeedback = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
@@ -805,6 +814,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
|
||||
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
|
||||
if (closingFeedbackCommand != null) cleanup("feedback command", closingFeedbackCommand::close);
|
||||
if (closingParticlesCommand != null) cleanup("particles command", closingParticlesCommand::close);
|
||||
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
|
||||
if (closingSettings != null) cleanup("configuration", closingSettings::close);
|
||||
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -22,22 +22,35 @@ import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable account-owned routine combat/effect feedback preference. */
|
||||
/** Durable account-owned presentation preferences, serialized through one save-before-publish worker. */
|
||||
public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
private static final String FILE = "player-preferences.yml";
|
||||
private static final String PLAYERS = "players";
|
||||
private static final String KEY = "combat-feedback";
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
|
||||
public record Update(UUID account, boolean enabled, long sequence) {
|
||||
public Update { Objects.requireNonNull(account); }
|
||||
public enum Preference {
|
||||
COMBAT_FEEDBACK("combat-feedback", true),
|
||||
PLAYER_AURAS("player-aura-particles", false),
|
||||
ALL_PARTICLES("all-particles", true);
|
||||
|
||||
private final String key;
|
||||
private final boolean defaultValue;
|
||||
Preference(String key, boolean defaultValue) { this.key = key; this.defaultValue = defaultValue; }
|
||||
}
|
||||
|
||||
private record AccountPreference(UUID account, Preference preference) {
|
||||
private AccountPreference { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
|
||||
}
|
||||
|
||||
public record Update(UUID account, Preference preference, boolean enabled, long sequence) {
|
||||
public Update { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-player-feedback-preferences"));
|
||||
private final Map<UUID, Boolean> effective = new java.util.HashMap<>();
|
||||
private final Map<UUID, Long> published = new java.util.HashMap<>();
|
||||
private final Map<AccountPreference, Boolean> effective = new java.util.HashMap<>();
|
||||
private final Map<AccountPreference, Long> published = new java.util.HashMap<>();
|
||||
private long persistedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
@@ -48,13 +61,34 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
effective.putAll(loadEnabled());
|
||||
}
|
||||
|
||||
public synchronized boolean enabled(UUID account) {
|
||||
/** Existing routine combat/effect feedback preference. */
|
||||
public synchronized boolean enabled(UUID account) { return value(account, Preference.COMBAT_FEEDBACK); }
|
||||
public synchronized boolean playerAurasEnabled(UUID account) { return value(account, Preference.PLAYER_AURAS); }
|
||||
public synchronized boolean allParticlesEnabled(UUID account) { return value(account, Preference.ALL_PARTICLES); }
|
||||
public synchronized boolean auraParticlesEnabled(UUID account) {
|
||||
return value(account, Preference.ALL_PARTICLES) && value(account, Preference.PLAYER_AURAS);
|
||||
}
|
||||
|
||||
private boolean value(UUID account, Preference preference) {
|
||||
Objects.requireNonNull(account);
|
||||
return effective.getOrDefault(account, true);
|
||||
return effective.getOrDefault(new AccountPreference(account, preference), preference.defaultValue);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(UUID account, boolean enabled) {
|
||||
Objects.requireNonNull(account);
|
||||
return set(account, Preference.COMBAT_FEEDBACK, enabled);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled) {
|
||||
return set(account, Preference.PLAYER_AURAS, enabled);
|
||||
}
|
||||
|
||||
/** Shared persisted precedence for the separately delivered all-particles command. */
|
||||
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled) {
|
||||
return set(account, Preference.ALL_PARTICLES, enabled);
|
||||
}
|
||||
|
||||
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
|
||||
Objects.requireNonNull(account); Objects.requireNonNull(preference);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
|
||||
pending++;
|
||||
@@ -62,11 +96,11 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
save(account, enabled);
|
||||
return new Update(account, enabled, ++persistedSequence);
|
||||
save(account, preference, enabled);
|
||||
return new Update(account, preference, enabled, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Combat feedback preference save refused or failed; effective setting unchanged", failure));
|
||||
"Player preference save refused or failed; effective setting unchanged", failure));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
@@ -78,34 +112,37 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (closed) return false;
|
||||
long previous = published.getOrDefault(update.account(), 0L);
|
||||
var key = new AccountPreference(update.account(), update.preference());
|
||||
long previous = published.getOrDefault(key, 0L);
|
||||
if (update.sequence() <= previous) return false;
|
||||
effective.put(update.account(), update.enabled());
|
||||
published.put(update.account(), update.sequence());
|
||||
effective.put(key, update.enabled());
|
||||
published.put(key, update.sequence());
|
||||
return true;
|
||||
}
|
||||
|
||||
private Map<UUID, Boolean> loadEnabled() throws IOException {
|
||||
private Map<AccountPreference, Boolean> loadEnabled() throws IOException {
|
||||
Map<String, Object> root = readRoot();
|
||||
Object rawPlayers = root.get(PLAYERS);
|
||||
if (rawPlayers == null) return Map.of();
|
||||
if (!(rawPlayers instanceof Map<?, ?> players)) throw new IOException("Invalid player preferences");
|
||||
var result = new LinkedHashMap<UUID, Boolean>();
|
||||
var result = new LinkedHashMap<AccountPreference, Boolean>();
|
||||
for (var entry : players.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String rawId)) throw new IOException("Invalid player preference identity");
|
||||
UUID account;
|
||||
try { account = UUID.fromString(rawId); }
|
||||
catch (IllegalArgumentException invalid) { throw new IOException("Invalid player preference identity", invalid); }
|
||||
if (!(entry.getValue() instanceof Map<?, ?> values)) throw new IOException("Invalid player preference entry");
|
||||
Object raw = values.get(KEY);
|
||||
if (raw == null) continue;
|
||||
if (!(raw instanceof Boolean value)) throw new IOException("Invalid combat feedback preference");
|
||||
result.put(account, value);
|
||||
for (var preference : Preference.values()) {
|
||||
Object raw = values.get(preference.key);
|
||||
if (raw == null) continue;
|
||||
if (!(raw instanceof Boolean value)) throw new IOException("Invalid " + preference.key + " preference");
|
||||
result.put(new AccountPreference(account, preference), value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(UUID account, boolean enabled) throws IOException {
|
||||
private void save(UUID account, Preference preference, boolean enabled) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
|
||||
Path lockFile = directory.resolve(".player-preferences.lock");
|
||||
@@ -118,7 +155,7 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
Map<String, Object> root = readRoot();
|
||||
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
|
||||
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
|
||||
values.put(KEY, enabled);
|
||||
values.put(preference.key, enabled);
|
||||
players.put(account.toString(), values);
|
||||
root.put(PLAYERS, players);
|
||||
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabExecutor;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Ordinary-player command for durable account-owned Fantasy Stick particle preferences. */
|
||||
public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable {
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
private boolean closed;
|
||||
|
||||
public FantasyParticlesCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
|
||||
Consumer<Throwable> failures) {
|
||||
this.preferences = Objects.requireNonNull(preferences);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Fantasy particles are a player-account preference.");
|
||||
return true;
|
||||
}
|
||||
if (args.length == 1 && args[0].equals("players")) {
|
||||
player.sendMessage(status(player.getUniqueId()));
|
||||
return true;
|
||||
}
|
||||
if (args.length != 2 || !args[0].equals("players")
|
||||
|| !(args[1].equals("on") || args[1].equals("off"))) return false;
|
||||
boolean enabled = args[1].equals("on");
|
||||
UUID account = player.getUniqueId();
|
||||
preferences.setPlayerAuras(account, enabled).whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(failure);
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure != null) {
|
||||
if (player.isOnline()) player.sendMessage(
|
||||
"Active-character aura preference could not be saved; " + status(account));
|
||||
return;
|
||||
}
|
||||
boolean published = preferences.publish(saved);
|
||||
if (player.isOnline()) player.sendMessage(acknowledgement(account, published));
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(rejected); }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (!(sender instanceof Player)) return List.of();
|
||||
if (args.length == 1) return matching(List.of("players"), args[0]);
|
||||
if (args.length == 2 && args[0].equals("players")) return matching(List.of("off", "on"), args[1]);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private String status(UUID account) {
|
||||
boolean saved = preferences.playerAurasEnabled(account);
|
||||
if (saved && !preferences.allParticlesEnabled(account))
|
||||
return "Active-character aura particles are on (effectively off while all particles are off).";
|
||||
return "Active-character aura particles are " + state(saved) + ".";
|
||||
}
|
||||
|
||||
private String acknowledgement(UUID account, boolean published) {
|
||||
boolean saved = preferences.playerAurasEnabled(account);
|
||||
String prefix = "Active-character aura particles are " + (published ? "now " : "already ") + state(saved);
|
||||
if (saved && !preferences.allParticlesEnabled(account))
|
||||
return prefix + " (effectively off while all particles are off).";
|
||||
return prefix + ".";
|
||||
}
|
||||
|
||||
private static List<String> matching(List<String> values, String rawPrefix) {
|
||||
String prefix = rawPrefix.toLowerCase(Locale.ROOT);
|
||||
return values.stream().filter(value -> value.startsWith(prefix)).toList();
|
||||
}
|
||||
|
||||
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
|
||||
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -11,21 +11,37 @@ import org.bukkit.scheduler.BukkitTask;
|
||||
/** Public particles only; never grants gameplay state, protection, health or persistence authority. */
|
||||
public final class SessionVisuals implements AutoCloseable {
|
||||
private final EquipmentSessions sessions;
|
||||
private final games.dmg.fantasystick.persistence.PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Throwable> failures;
|
||||
private BukkitTask task;
|
||||
private boolean closed;
|
||||
|
||||
public SessionVisuals(EquipmentSessions sessions, Consumer<Throwable> failures) {
|
||||
this.sessions = Objects.requireNonNull(sessions); this.failures = Objects.requireNonNull(failures);
|
||||
public SessionVisuals(EquipmentSessions sessions,
|
||||
games.dmg.fantasystick.persistence.PlayerFeedbackPreferences preferences,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.preferences = Objects.requireNonNull(preferences);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
public void install(Plugin plugin) {
|
||||
if (closed || task != null) throw new IllegalStateException("Visual lifetime already used");
|
||||
task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
|
||||
if (closed) return;
|
||||
for (var player : plugin.getServer().getOnlinePlayers()) {
|
||||
var online = java.util.List.copyOf(plugin.getServer().getOnlinePlayers());
|
||||
for (var source : online) {
|
||||
try {
|
||||
var active = sessions.active(player.getUniqueId());
|
||||
if (active.isPresent() && sessions.current(active.orElseThrow())) burst(player, Color.BLUE, 3);
|
||||
var active = sessions.active(source.getUniqueId());
|
||||
if (active.isEmpty() || !sessions.current(active.orElseThrow())) continue;
|
||||
var location = source.getLocation().add(0, 1, 0);
|
||||
var dust = new Particle.DustOptions(Color.BLUE, 1);
|
||||
var tracked = source.getTrackedBy();
|
||||
for (var viewer : online) {
|
||||
if (!viewer.isOnline() || viewer.getWorld() != source.getWorld()
|
||||
|| (!viewer.getUniqueId().equals(source.getUniqueId()) && !tracked.contains(viewer))
|
||||
|| !viewer.canSee(source)
|
||||
|| !preferences.auraParticlesEnabled(viewer.getUniqueId())) continue;
|
||||
viewer.spawnParticle(Particle.DUST, location, 3, 0.3, 0.4, 0.3, 0, dust);
|
||||
}
|
||||
} catch (RuntimeException failure) { failures.accept(failure); }
|
||||
}
|
||||
}, 1L, 10L);
|
||||
|
||||
@@ -8,11 +8,14 @@ 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.
|
||||
usage: /fantasyfeedback [on|off]
|
||||
fantasyparticles:
|
||||
description: Read or change Fantasy Stick particle visibility for your player account.
|
||||
usage: /fantasyparticles players [on|off]
|
||||
permissions:
|
||||
fantasystick.admin:
|
||||
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,16 +64,21 @@ class FantasyHealthNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void activeBlueMistAndOneRedDefeatBurstUseWorldLocationsAndStopWithTheSession() throws Exception {
|
||||
@Test void activeBlueMistDefaultsOffWhileOptInAndOneRedDefeatBurstRespectTheirOwnLifetimes() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data); ordinary.core.inventory.setItemInOffHand(null);
|
||||
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
|
||||
var entry = a.player.getLocation(); f.activate(a);
|
||||
f.pulses.forEach(Runnable::run);
|
||||
assertTrue(dust(a, org.bukkit.Color.BLUE) > 0, "Active fantasy mode must have visible public blue particles");
|
||||
assertEquals(0, dust(ordinary, org.bukkit.Color.BLUE));
|
||||
f.pulses.getLast().run();
|
||||
assertEquals(0, receivedDust(a, org.bukkit.Color.BLUE), "Active-character auras default off per viewer");
|
||||
assertEquals(0, receivedDust(ordinary, org.bukkit.Color.BLUE));
|
||||
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"players", "on"}); f.tick();
|
||||
f.pulses.getLast().run();
|
||||
assertTrue(receivedDust(a, org.bukkit.Color.BLUE) > 0, "An opted-in active viewer sees the current blue aura");
|
||||
assertEquals(0, receivedDust(ordinary, org.bukkit.Color.BLUE));
|
||||
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"players", "off"}); f.tick();
|
||||
a.core.location.add(20, 0, 20);
|
||||
var defeatLocation = a.player.getLocation().add(0, 1, 0);
|
||||
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
|
||||
@@ -82,19 +87,19 @@ class FantasyHealthNativeTest {
|
||||
net.kyori.adventure.text.Component.empty(), true);
|
||||
f.dispatcher.accept(death); assertTrue(death.isCancelled());
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, dust(a, org.bukkit.Color.RED));
|
||||
assertEquals(1, worldDust(a, org.bukkit.Color.RED), "Aura opt-out must not hide the defeat burst");
|
||||
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().anyMatch(call ->
|
||||
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.asList(call.getArguments()).contains(defeatLocation)));
|
||||
assertEquals(entry, a.player.getLocation()); assertEquals(28, a.player.getHealth());
|
||||
var tail = a.core.inventory.getItemInOffHand().clone();
|
||||
assertEquals(org.bukkit.Material.WRITTEN_BOOK, tail.getType()); assertEquals(1, tail.getAmount());
|
||||
long blue = dust(a, org.bukkit.Color.BLUE);
|
||||
long blue = receivedDust(a, org.bukkit.Color.BLUE);
|
||||
f.pulses.forEach(Runnable::run); f.dispatcher.accept(death);
|
||||
assertEquals(blue, dust(a, org.bukkit.Color.BLUE)); assertEquals(1, dust(a, org.bukkit.Color.RED));
|
||||
assertEquals(blue, receivedDust(a, org.bukkit.Color.BLUE)); assertEquals(1, worldDust(a, org.bukkit.Color.RED));
|
||||
assertEquals(tail, a.core.inventory.getItemInOffHand());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
f.pulses.forEach(Runnable::run);
|
||||
assertEquals(blue, dust(a, org.bukkit.Color.BLUE), "Retired timers cannot emit fantasy effects");
|
||||
assertEquals(blue, receivedDust(a, org.bukkit.Color.BLUE), "Retired timers cannot emit fantasy effects");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,12 +352,18 @@ class FantasyHealthNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static long dust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
|
||||
private static long worldDust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
|
||||
return org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().filter(call ->
|
||||
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.stream(call.getArguments())
|
||||
.anyMatch(argument -> argument instanceof org.bukkit.Particle.DustOptions dust && dust.getColor().equals(color))).count();
|
||||
}
|
||||
|
||||
private static long receivedDust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
|
||||
return a.particleCalls.stream().filter(call -> java.util.Arrays.stream(call)
|
||||
.anyMatch(argument -> argument instanceof org.bukkit.Particle.DustOptions dust
|
||||
&& dust.getColor().equals(color))).count();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.entity.ai.attributes.AttributeModifier modifier(String name, double amount,
|
||||
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation operation) {
|
||||
return new net.minecraft.world.entity.ai.attributes.AttributeModifier(net.minecraft.resources.Identifier.fromNamespaceAndPath("test", name), amount, operation);
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Particle;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered account command through the real recurring active-session aura pulse. */
|
||||
class FantasyParticlesNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredPlayersRouteDefaultsOffPersistsAndRejectsInvalidSendersAndArguments() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
assertNotNull(fixture.particlesCommand);
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(player.core.id, player.player);
|
||||
|
||||
assertTrue(fixture.particlesCommand.execute(player.player, "fantasyparticles",
|
||||
new String[]{"players"}));
|
||||
assertEquals("Active-character aura particles are off.", player.messages.getLast());
|
||||
assertEquals(List.of("players"), fixture.particlesCommand.tabComplete(
|
||||
player.player, "fantasyparticles", new String[]{""}));
|
||||
assertEquals(List.of("off", "on"), fixture.particlesCommand.tabComplete(
|
||||
player.player, "fantasyparticles", new String[]{"players", ""}));
|
||||
assertEquals(List.of("off"), fixture.particlesCommand.tabComplete(
|
||||
player.player, "fantasyparticles", new String[]{"players", "of"}));
|
||||
assertEquals(List.of("on"), fixture.particlesCommand.tabComplete(
|
||||
player.player, "fantasyparticles", new String[]{"players", "on"}));
|
||||
|
||||
int before = player.messages.size();
|
||||
assertTrue(fixture.particlesCommand.execute(player.player, "fantasyparticles",
|
||||
new String[]{"players", "on"}));
|
||||
assertEquals(before, player.messages.size(), "Persistence must complete before acknowledgement");
|
||||
fixture.tick();
|
||||
assertEquals("Active-character aura particles are now on.", player.messages.getLast());
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
var allOff = preferences.setAllParticles(player.core.id, false).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(preferences.publish(allOff));
|
||||
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
|
||||
assertEquals("Active-character aura particles are on (effectively off while all particles are off).",
|
||||
player.messages.getLast());
|
||||
var allOn = preferences.setAllParticles(player.core.id, true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(preferences.publish(allOn));
|
||||
|
||||
var console = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertTrue(fixture.particlesCommand.execute(console.port, "fantasyparticles",
|
||||
new String[]{"players", "off"}));
|
||||
assertTrue(console.messages.getLast().contains("player-account"));
|
||||
assertTrue(fixture.particlesCommand.tabComplete(console.port, "fantasyparticles",
|
||||
new String[]{""}).isEmpty());
|
||||
for (String[] invalid : List.of(new String[]{}, new String[]{"all"},
|
||||
new String[]{"players", "ON"}, new String[]{"players", "on", "extra"})) {
|
||||
assertFalse(fixture.particlesCommand.getExecutor().onCommand(player.player,
|
||||
fixture.particlesCommand, "fantasyparticles", invalid));
|
||||
}
|
||||
|
||||
var other = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(other.core.id, other.player);
|
||||
var playerSheet = player.core.inventory.getItemInOffHand();
|
||||
player.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand());
|
||||
other.core.inventory.setItemInOffHand(playerSheet);
|
||||
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
|
||||
assertEquals("Active-character aura particles are on.", player.messages.getLast(),
|
||||
"The preference stays with the account after character-sheet switching");
|
||||
fixture.particlesCommand.execute(other.player, "fantasyparticles", new String[]{"players"});
|
||||
assertEquals("Active-character aura particles are off.", other.messages.getLast(),
|
||||
"A transferred character does not transfer its prior holder's preference");
|
||||
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
|
||||
assertEquals("Active-character aura particles are on.", player.messages.getLast());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reversedCompetingCommandCallbacksCannotPublishStaleAuraState() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(player.core.id, player.player);
|
||||
fixture.particlesCommand.execute(player.player, "fantasyparticles",
|
||||
new String[]{"players", "on"});
|
||||
fixture.particlesCommand.execute(player.player, "fantasyparticles",
|
||||
new String[]{"players", "off"});
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
preferences.setPlayerAuras(java.util.UUID.randomUUID(), false).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
var callbacks = new java.util.ArrayList<Runnable>();
|
||||
fixture.tasks.drainTo(callbacks);
|
||||
assertEquals(2, callbacks.size());
|
||||
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
|
||||
assertFalse(preferences.playerAurasEnabled(player.core.id));
|
||||
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
|
||||
assertEquals("Active-character aura particles are off.", player.messages.getLast());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAuraPreferenceSaveKeepsPriorEffectiveStateAndReportsRefusal() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(player.core.id, player.player);
|
||||
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
|
||||
|
||||
fixture.particlesCommand.execute(player.player, "fantasyparticles",
|
||||
new String[]{"players", "on"});
|
||||
fixture.tick();
|
||||
assertTrue(player.messages.getLast().contains("could not be saved"));
|
||||
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
|
||||
assertEquals("Active-character aura particles are off.", player.messages.getLast());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void recurringAuraIsDefaultOffAndDeliveredOnlyToOptedInNativeViewers() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var first = new FantasyStickLifecycleNativeTest.Actor(data); first.accountName = "First";
|
||||
var second = new FantasyStickLifecycleNativeTest.Actor(data); second.accountName = "Second";
|
||||
var enabled = new FantasyStickLifecycleNativeTest.Actor(data); enabled.accountName = "Enabled";
|
||||
var disabled = new FantasyStickLifecycleNativeTest.Actor(data); disabled.accountName = "Disabled";
|
||||
var untracked = new FantasyStickLifecycleNativeTest.Actor(data); untracked.accountName = "Untracked";
|
||||
var hidden = new FantasyStickLifecycleNativeTest.Actor(data); hidden.accountName = "Hidden"; hidden.visible = false;
|
||||
var crossWorld = new FantasyStickLifecycleNativeTest.Actor(data); crossWorld.accountName = "CrossWorld";
|
||||
second.core.location.setWorld(first.world); second.core.location.add(5, 0, 0);
|
||||
enabled.core.location.setWorld(first.world);
|
||||
disabled.core.location.setWorld(first.world);
|
||||
untracked.core.location.setWorld(first.world);
|
||||
hidden.core.location.setWorld(first.world);
|
||||
enabled.core.inventory.setItemInOffHand(null);
|
||||
disabled.core.inventory.setItemInOffHand(null);
|
||||
untracked.core.inventory.setItemInOffHand(null);
|
||||
hidden.core.inventory.setItemInOffHand(null);
|
||||
crossWorld.core.inventory.setItemInOffHand(null);
|
||||
for (var actor : List.of(first, second, enabled, disabled, untracked, hidden, crossWorld))
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
first.tracked.add(enabled.player); first.tracked.add(disabled.player); first.tracked.add(hidden.player);
|
||||
first.tracked.add(crossWorld.player);
|
||||
second.tracked.add(enabled.player); second.tracked.add(disabled.player); second.tracked.add(hidden.player);
|
||||
second.tracked.add(first.player);
|
||||
fixture.activate(first); fixture.activate(second);
|
||||
var firstInventory = first.core.snapshot(); var secondInventory = second.core.snapshot();
|
||||
String firstMarker = first.core.marker(); String secondMarker = second.core.marker();
|
||||
clear(first, second, enabled, disabled, untracked, hidden, crossWorld);
|
||||
|
||||
auraPulse(fixture);
|
||||
assertTrue(first.particleCalls.isEmpty());
|
||||
assertTrue(second.particleCalls.isEmpty());
|
||||
assertTrue(enabled.particleCalls.isEmpty());
|
||||
assertTrue(disabled.particleCalls.isEmpty());
|
||||
assertTrue(hidden.particleCalls.isEmpty());
|
||||
org.mockito.Mockito.verify(first.world, org.mockito.Mockito.never()).spawnParticle(
|
||||
org.mockito.ArgumentMatchers.eq(Particle.DUST), org.mockito.ArgumentMatchers.any(org.bukkit.Location.class),
|
||||
org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyDouble(),
|
||||
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble(),
|
||||
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(Particle.DustOptions.class));
|
||||
|
||||
optIn(fixture, enabled);
|
||||
optIn(fixture, first);
|
||||
optIn(fixture, untracked);
|
||||
optIn(fixture, hidden);
|
||||
optIn(fixture, crossWorld);
|
||||
clear(first, second, enabled, disabled, untracked, hidden, crossWorld);
|
||||
auraPulse(fixture);
|
||||
|
||||
assertEquals(2, blueAuraCalls(enabled).size(), "An ordinary opted-in viewer sees both tracked sources");
|
||||
assertEquals(2, blueAuraCalls(first).size(), "An opted-in active viewer sees self and a tracked peer");
|
||||
assertTrue(disabled.particleCalls.isEmpty(), "Default-off viewers receive no substitute packet");
|
||||
assertTrue(untracked.particleCalls.isEmpty(), "Opt-in does not expand native tracking");
|
||||
assertTrue(hidden.particleCalls.isEmpty(), "Opt-in preserves native player visibility");
|
||||
assertTrue(crossWorld.particleCalls.isEmpty(), "Opt-in does not cross world boundaries");
|
||||
assertEquals(java.util.Set.of(first.player.getLocation().add(0, 1, 0),
|
||||
second.player.getLocation().add(0, 1, 0)), blueAuraCalls(enabled).stream()
|
||||
.map(call -> (org.bukkit.Location) call[1]).collect(java.util.stream.Collectors.toSet()));
|
||||
for (Object[] call : blueAuraCalls(enabled)) {
|
||||
assertEquals(3, call[2]);
|
||||
assertEquals(0.3, (Double) call[3]); assertEquals(0.4, (Double) call[4]);
|
||||
assertEquals(0.3, (Double) call[5]); assertEquals(0.0, (Double) call[6]);
|
||||
}
|
||||
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
var allOff = preferences.setAllParticles(enabled.core.id, false).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(preferences.publish(allOff));
|
||||
clear(enabled); auraPulse(fixture);
|
||||
assertTrue(enabled.particleCalls.isEmpty(), "All-particle off takes precedence over saved aura opt-in");
|
||||
assertTrue(preferences.playerAurasEnabled(enabled.core.id), "Precedence does not overwrite aura preference");
|
||||
var allOn = preferences.setAllParticles(enabled.core.id, true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(preferences.publish(allOn));
|
||||
clear(enabled); auraPulse(fixture);
|
||||
assertEquals(2, blueAuraCalls(enabled).size(), "Re-enabling all restores the saved aura preference");
|
||||
assertArrayEquals(firstInventory, first.core.snapshot()); assertArrayEquals(secondInventory, second.core.snapshot());
|
||||
assertEquals(firstMarker, first.core.marker()); assertEquals(secondMarker, second.core.marker());
|
||||
}
|
||||
}
|
||||
|
||||
private static void optIn(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
fixture.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"players", "on"});
|
||||
fixture.tick();
|
||||
}
|
||||
|
||||
private static void auraPulse(FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
fixture.pulses.getLast().run();
|
||||
}
|
||||
|
||||
private static List<Object[]> blueAuraCalls(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
return actor.particleCalls.stream().filter(call -> call[0] == Particle.DUST && call[2].equals(3)
|
||||
&& java.util.Arrays.stream(call).anyMatch(value -> value instanceof Particle.DustOptions dust
|
||||
&& dust.getColor().equals(Color.BLUE))).toList();
|
||||
}
|
||||
|
||||
private static void clear(FantasyStickLifecycleNativeTest.Actor... actors) {
|
||||
for (var actor : actors) { actor.particles.clear(); actor.particleCalls.clear(); }
|
||||
}
|
||||
}
|
||||
@@ -1129,6 +1129,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
final Map<java.util.UUID, org.bukkit.entity.Player> players = new HashMap<>();
|
||||
org.bukkit.command.PluginCommand command;
|
||||
org.bukkit.command.PluginCommand feedbackCommand;
|
||||
org.bukkit.command.PluginCommand particlesCommand;
|
||||
int playerLookups;
|
||||
String failRegistrationAt;
|
||||
boolean failRemoval;
|
||||
@@ -1211,7 +1212,11 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "getWorld" -> players.values().stream().map(org.bukkit.entity.Player::getWorld)
|
||||
.filter(world -> java.util.Objects.equals(world.getUID(), args[0])).findFirst().orElse(null);
|
||||
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
|
||||
case "getPluginCommand" -> "fantasyfeedback".equals(args[0]) ? feedbackCommand : command;
|
||||
case "getPluginCommand" -> switch ((String) args[0]) {
|
||||
case "fantasyfeedback" -> feedbackCommand;
|
||||
case "fantasyparticles" -> particlesCommand;
|
||||
default -> command;
|
||||
};
|
||||
case "createInventory" -> args[1] instanceof Integer size
|
||||
? new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], size, (net.kyori.adventure.text.Component) args[2])
|
||||
: new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], (org.bukkit.event.inventory.InventoryType) args[1], (net.kyori.adventure.text.Component) args[2]);
|
||||
@@ -1251,6 +1256,13 @@ class FantasyStickLifecycleNativeTest {
|
||||
feedbackCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
feedbackCommand.setUsage((String) feedbackDefinition.get("usage"));
|
||||
}
|
||||
var particlesDefinition = plugin.getDescription().getCommands().get("fantasyparticles");
|
||||
if (particlesDefinition != null) {
|
||||
particlesCommand = constructor.newInstance("fantasyparticles", plugin);
|
||||
particlesCommand.setPermission((String) particlesDefinition.get("permission"));
|
||||
particlesCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
particlesCommand.setUsage((String) particlesDefinition.get("usage"));
|
||||
}
|
||||
}
|
||||
|
||||
<T> List<T> packets(org.bukkit.entity.Player viewer, Class<T> type) {
|
||||
@@ -1352,6 +1364,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
|
||||
boolean healthScaled;
|
||||
boolean sprinting;
|
||||
boolean allowed = true;
|
||||
boolean visible = true;
|
||||
String accountName = "Account";
|
||||
double healthScale = 20;
|
||||
double absorption;
|
||||
Runnable scaleMutation = () -> {};
|
||||
@@ -1381,11 +1396,12 @@ 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;
|
||||
case "canSee" -> true;
|
||||
case "canSee" -> visible;
|
||||
case "getAttribute" -> {
|
||||
assertEquals(org.bukkit.attribute.Attribute.MAX_HEALTH, args[0]);
|
||||
yield new org.bukkit.craftbukkit.attribute.CraftAttributeInstance(maximumHealth, org.bukkit.attribute.Attribute.MAX_HEALTH);
|
||||
|
||||
@@ -29,13 +29,19 @@ class DistributionTest {
|
||||
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
|
||||
assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract");
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
|
||||
assertEquals(java.util.Set.of("fantasystick", "fantasyfeedback"), metadata.getCommands().keySet());
|
||||
assertEquals(java.util.Set.of("fantasystick", "fantasyfeedback", "fantasyparticles"),
|
||||
metadata.getCommands().keySet());
|
||||
assertNull(metadata.getCommands().get("fantasystick").get("permission"),
|
||||
"Player adventure inspection must reach command-level authorization");
|
||||
assertNull(metadata.getCommands().get("fantasyfeedback").get("permission"));
|
||||
assertNull(metadata.getCommands().get("fantasyparticles").get("permission"));
|
||||
assertEquals("/fantasyfeedback [on|off]", metadata.getCommands().get("fantasyfeedback").get("usage"));
|
||||
assertEquals("/fantasyparticles players [on|off]",
|
||||
metadata.getCommands().get("fantasyparticles").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());
|
||||
|
||||
@@ -46,14 +46,69 @@ class PlayerFeedbackPreferencesTest {
|
||||
try (var reopened = new PlayerFeedbackPreferences(data)) { assertTrue(reopened.enabled(account)); }
|
||||
}
|
||||
|
||||
@Test void auraDefaultsOffAndRemainsIndependentUnderAllParticlePrecedenceAcrossRestart() throws Exception {
|
||||
UUID account = UUID.randomUUID();
|
||||
java.nio.file.Files.writeString(data.resolve("player-preferences.yml"),
|
||||
"unrelated: keep\nplayers:\n " + account + ":\n combat-feedback: false\n other: value\n");
|
||||
try (var preferences = new PlayerFeedbackPreferences(data)) {
|
||||
assertFalse(preferences.playerAurasEnabled(account));
|
||||
assertTrue(preferences.allParticlesEnabled(account));
|
||||
assertFalse(preferences.auraParticlesEnabled(account));
|
||||
var auraOn = preferences.setPlayerAuras(account, true).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
assertFalse(preferences.playerAurasEnabled(account), "Durability precedes effective publication");
|
||||
assertTrue(preferences.publish(auraOn));
|
||||
assertTrue(preferences.auraParticlesEnabled(account));
|
||||
var allOff = preferences.setAllParticles(account, false).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(preferences.playerAurasEnabled(account), "Changing all preserves the saved aura value");
|
||||
assertTrue(preferences.publish(allOff));
|
||||
assertFalse(preferences.auraParticlesEnabled(account));
|
||||
}
|
||||
String saved = java.nio.file.Files.readString(data.resolve("player-preferences.yml"));
|
||||
assertTrue(saved.contains("unrelated: keep")); assertTrue(saved.contains("other: value"));
|
||||
try (var reopened = new PlayerFeedbackPreferences(data)) {
|
||||
assertTrue(reopened.playerAurasEnabled(account));
|
||||
assertFalse(reopened.allParticlesEnabled(account));
|
||||
assertFalse(reopened.auraParticlesEnabled(account));
|
||||
assertFalse(reopened.enabled(account), "Combat feedback remains independent");
|
||||
var allOn = reopened.setAllParticles(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertTrue(reopened.publish(allOn));
|
||||
assertTrue(reopened.auraParticlesEnabled(account));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void competingAuraWritesRejectStalePublicationAndAcceptedShutdownWorkDrains() throws Exception {
|
||||
UUID account = UUID.randomUUID();
|
||||
var preferences = new PlayerFeedbackPreferences(data);
|
||||
var enabled = preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var disabled = preferences.setPlayerAuras(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertTrue(preferences.publish(disabled));
|
||||
assertFalse(preferences.publish(enabled));
|
||||
assertFalse(preferences.playerAurasEnabled(account));
|
||||
|
||||
var accepted = preferences.setPlayerAuras(account, true).toCompletableFuture();
|
||||
preferences.close();
|
||||
preferences.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertNotNull(accepted.get(5, TimeUnit.SECONDS));
|
||||
assertFalse(preferences.playerAurasEnabled(account), "A closed lifetime does not publish its callback");
|
||||
try (var reopened = new PlayerFeedbackPreferences(data)) {
|
||||
assertTrue(reopened.playerAurasEnabled(account), "Accepted shutdown work is durable on restart");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedOrClosedWritesRetainTheEffectivePreference() throws Exception {
|
||||
UUID account = UUID.randomUUID();
|
||||
var preferences = new PlayerFeedbackPreferences(data);
|
||||
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
|
||||
assertThrows(Exception.class, () -> preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertThrows(Exception.class, () -> preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertTrue(preferences.enabled(account));
|
||||
assertFalse(preferences.playerAurasEnabled(account));
|
||||
preferences.close();
|
||||
assertThrows(Exception.class, () -> preferences.set(account, false).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertThrows(Exception.class, () -> preferences.setPlayerAuras(account, true).toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertTrue(preferences.enabled(account));
|
||||
assertFalse(preferences.playerAurasEnabled(account));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user