Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db1c7b5906 | ||
|
|
9ab5bffd2c | ||
|
|
a58c192358 |
No files matched your search
@@ -9,6 +9,7 @@ import games.dmg.fantasystick.crafting.CharacterAnvilNames;
|
||||
import games.dmg.fantasystick.crafting.ManualCraftingController;
|
||||
import games.dmg.fantasystick.crafting.ManualCraftingGuard;
|
||||
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
|
||||
import games.dmg.fantasystick.crafting.RecipeDiscovery;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
@@ -18,6 +19,7 @@ import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
@@ -27,6 +29,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private ManualCraftingController crafting;
|
||||
private ManualCraftingGuard guard;
|
||||
private CharacterAnvilNames anvilNames;
|
||||
private RecipeDiscovery recipeDiscovery;
|
||||
private boolean recipeRegistered;
|
||||
private EquipmentSessionStore receipts;
|
||||
private EquipmentSessions sessions;
|
||||
@@ -277,6 +280,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
|
||||
inspireSettings, inspire, abilityRegistry);
|
||||
admin.attachAdminRevival(adminRevival, adminReviveSettings);
|
||||
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
|
||||
characters, rules, sessions, nextTick, getServer()::getPlayer,
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Admin experience grant for character " + problem.character(), problem.cause())));
|
||||
admin.attachSwitchDelay(switchDelaySettings);
|
||||
admin.attachRoleplaySettings(roleplaySettings);
|
||||
admin.attachPartySettings(partySettings);
|
||||
@@ -319,8 +326,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
sessionVisuals.install(this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
for (var player : getServer().getOnlinePlayers()) { sessions.arrive(player); revive.reconcile(player); }
|
||||
sheetResourcePack.deliverAll(getServer().getOnlinePlayers());
|
||||
recipeDiscovery = new RecipeDiscovery(CharacterSheets.RECIPE, getLogger());
|
||||
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
|
||||
var onlinePlayers = List.copyOf(getServer().getOnlinePlayers());
|
||||
recipeDiscovery.discoverAll(onlinePlayers);
|
||||
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
|
||||
sheetResourcePack.deliverAll(onlinePlayers);
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { onDisable(); }
|
||||
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
|
||||
@@ -343,6 +354,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingCrafting = crafting; crafting = null;
|
||||
var closingGuard = guard; guard = null;
|
||||
var closingAnvil = anvilNames; anvilNames = null;
|
||||
var closingRecipeDiscovery = recipeDiscovery; recipeDiscovery = null;
|
||||
var closingSessions = sessions; sessions = null;
|
||||
var closingReceipts = receipts; receipts = null;
|
||||
var closingCharacters = characters; characters = null;
|
||||
@@ -482,6 +494,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingCrafting);
|
||||
unregister(closingGuard);
|
||||
unregister(closingAnvil);
|
||||
unregister(closingRecipeDiscovery);
|
||||
if (recipeRegistered) cleanup("character-sheet recipe", () -> {
|
||||
getServer().removeRecipe(CharacterSheets.RECIPE);
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
/** Idempotent recipe-book visibility; recipe registration and crafting remain separate authorities. */
|
||||
public final class RecipeDiscovery implements Listener {
|
||||
private final NamespacedKey recipe;
|
||||
private final Logger logger;
|
||||
|
||||
public RecipeDiscovery(NamespacedKey recipe, Logger logger) {
|
||||
this.recipe = Objects.requireNonNull(recipe, "recipe");
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public void discoverAll(Iterable<? extends Player> players) {
|
||||
for (var player : players) discover(player);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
discover(event.getPlayer());
|
||||
}
|
||||
|
||||
public void discover(Player player) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
try {
|
||||
if (player.hasDiscoveredRecipe(recipe)) return;
|
||||
player.discoverRecipes(List.of(recipe));
|
||||
if (!player.hasDiscoveredRecipe(recipe)) {
|
||||
logger.warning("Could not discover character-sheet recipe for " + label(player));
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
logger.log(Level.WARNING, "Could not discover character-sheet recipe for " + label(player), failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String label(Player player) {
|
||||
try { return player.getName(); }
|
||||
catch (RuntimeException ignored) { return "an unidentified player"; }
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,11 @@ public final class CharacterSheets {
|
||||
|
||||
/** Definition only: a future craft-completion adapter must persist and mint each actual result. */
|
||||
public ShapelessRecipe recipe() {
|
||||
return new ShapelessRecipe(RECIPE, paper("Character sheet"))
|
||||
var recipe = new ShapelessRecipe(RECIPE, paper("Character sheet"))
|
||||
.addIngredient(Material.STICK).addIngredient(Material.PAPER)
|
||||
.addIngredient(Material.FEATHER).addIngredient(Material.INK_SAC);
|
||||
recipe.setCategory(org.bukkit.inventory.recipe.CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private static ItemStack paper(String name) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Acknowledged administrator XP mutation captured against one exact active character/session. */
|
||||
public final class AdminExperienceGrant implements AutoCloseable {
|
||||
public enum Status { APPLIED, AT_CAP, STALE, UNAVAILABLE, FAILED }
|
||||
public record Result(Status status, String name, BigInteger requested, BigInteger applied,
|
||||
int level, int levels) { }
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules progression;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Function<UUID, Player> players;
|
||||
private final Consumer<Failure> failures;
|
||||
private volatile boolean closed;
|
||||
|
||||
public AdminExperienceGrant(CharacterStorage characters, ProgressionRules progression, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Function<UUID, Player> players, Consumer<Failure> failures) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.progression = Objects.requireNonNull(progression);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.players = Objects.requireNonNull(players);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
/** Returns false before persistence when the captured active identity is no longer authoritative. */
|
||||
public boolean grant(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
|
||||
Consumer<Result> completion) {
|
||||
Objects.requireNonNull(active); Objects.requireNonNull(captured);
|
||||
Objects.requireNonNull(amount); Objects.requireNonNull(completion);
|
||||
if (amount.signum() <= 0) throw new IllegalArgumentException("Experience grant must be positive");
|
||||
if (closed || !sessions.current(active)
|
||||
|| sessions.currentRecord(active).filter(captured::equals).isEmpty()) return false;
|
||||
characters.awardExperience(captured.id(), captured.revision(), amount, progression)
|
||||
.whenComplete((award, failure) -> schedule(active, captured, amount, completion, award, failure));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void schedule(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
|
||||
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
|
||||
if (failure != null) report(captured.id(), failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> complete(active, captured, amount, completion, award, failure));
|
||||
} catch (RuntimeException rejected) { report(captured.id(), rejected); }
|
||||
}
|
||||
|
||||
private void complete(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
|
||||
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
|
||||
if (closed) return;
|
||||
if (failure != null) {
|
||||
completion.accept(new Result(Status.FAILED, captured.name(), amount, BigInteger.ZERO,
|
||||
captured.level(), 0));
|
||||
return;
|
||||
}
|
||||
var saved = award.record().orElse(captured);
|
||||
Status status = switch (award.status()) {
|
||||
case APPLIED -> Status.APPLIED;
|
||||
case AT_CAP, UNCHANGED -> Status.AT_CAP;
|
||||
case STALE -> Status.STALE;
|
||||
case UNAVAILABLE -> Status.UNAVAILABLE;
|
||||
};
|
||||
BigInteger applied = status == Status.APPLIED
|
||||
? saved.experience().subtract(captured.experience()) : BigInteger.ZERO;
|
||||
if (status == Status.APPLIED && sessions.current(active)) {
|
||||
boolean synchronizedSession = sessions.acknowledgeProgress(active, captured.revision(), saved);
|
||||
if (!synchronizedSession && sessions.current(active)
|
||||
&& sessions.currentRecord(active).filter(saved::equals).isEmpty()) {
|
||||
report(captured.id(), new IllegalStateException(
|
||||
"Saved administrator XP could not synchronize with its captured active session"));
|
||||
}
|
||||
if (synchronizedSession && sessions.current(active)) feedback(active, saved, applied, award.levels());
|
||||
}
|
||||
completion.accept(new Result(status, captured.name(), amount, applied, saved.level(), award.levels()));
|
||||
}
|
||||
|
||||
private void feedback(EquipmentSessions.Active active, CharacterRecord saved, BigInteger applied, int levels) {
|
||||
Player player;
|
||||
try { player = players.apply(active.player()); }
|
||||
catch (RuntimeException failure) { report(saved.id(), failure); return; }
|
||||
if (player == null || !player.isOnline() || !sessions.current(active)
|
||||
|| sessions.currentRecord(active).filter(saved::equals).isEmpty()) return;
|
||||
try {
|
||||
player.sendMessage("Received " + applied + " fantasy XP from an administrator.");
|
||||
for (int gained = 1; gained <= levels; gained++) {
|
||||
int level = saved.level() - levels + gained;
|
||||
player.sendMessage("Level " + level + "! Gained 1 character point.");
|
||||
SessionVisuals.burst(player, Color.BLUE, 12);
|
||||
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
|
||||
}
|
||||
} catch (RuntimeException failure) { report(saved.id(), failure); }
|
||||
}
|
||||
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics do not replay a grant. */ }
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
@@ -40,6 +41,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.ability.Inspire inspire;
|
||||
private AdminCharacterIssuer characterIssuer;
|
||||
private AdminRevival adminRevival;
|
||||
private AdminExperienceGrant experienceGrant;
|
||||
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
@@ -220,6 +222,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
|
||||
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
|
||||
|
||||
public void attachExperienceGrant(AdminExperienceGrant grant) {
|
||||
if (closed || experienceGrant != null) throw new IllegalStateException("Experience grant already attached or command closed");
|
||||
experienceGrant = Objects.requireNonNull(grant);
|
||||
}
|
||||
|
||||
public void attachAdminRevival(AdminRevival revival, games.dmg.fantasystick.persistence.AdminReviveSettings settings) {
|
||||
if (closed || adminRevival != null) throw new IllegalStateException("Admin revival already attached or command closed");
|
||||
adminRevival=Objects.requireNonNull(revival);adminReviveSettings=Objects.requireNonNull(settings);
|
||||
@@ -247,6 +254,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args.length > 0 && args[0].equals("revive")) {
|
||||
adminRevive(sender,args);return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equals("experience")) {
|
||||
experience(sender, args); return true;
|
||||
}
|
||||
UUID owner;
|
||||
try {
|
||||
if (args.length != 2 || !(args[0].equals("inspect") || args[0].equals("recover"))) {
|
||||
@@ -307,6 +317,54 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
BigInteger amount;
|
||||
try { amount = new BigInteger(args[args.length - 1]); }
|
||||
catch (NumberFormatException invalid) {
|
||||
sender.sendMessage("Experience amount is invalid; enter a positive whole number."); return;
|
||||
}
|
||||
if (amount.signum() <= 0) {
|
||||
sender.sendMessage("Experience amount is invalid; enter a positive whole number."); return;
|
||||
}
|
||||
String name = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length - 1));
|
||||
if (name.isBlank()) {
|
||||
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
|
||||
}
|
||||
var matches = sessions.activeCharacters().stream().filter(active -> active.record().name().equals(name)).toList();
|
||||
if (matches.isEmpty()) {
|
||||
sender.sendMessage("Experience grant refused: character '" + name + "' is not active. No fantasy XP changed."); return;
|
||||
}
|
||||
if (matches.size() != 1) {
|
||||
sender.sendMessage("Experience grant refused: active character name '" + name
|
||||
+ "' is ambiguous. No fantasy XP changed."); return;
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) {
|
||||
sender.sendMessage("Experience grant refused before persistence. No fantasy XP changed."); return;
|
||||
}
|
||||
var target = matches.getFirst();
|
||||
boolean accepted = experienceGrant.grant(target.session(), target.record(), amount, result -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(switch (result.status()) {
|
||||
case APPLIED -> "Granted " + result.requested() + " fantasy XP to " + result.name()
|
||||
+ "; applied " + result.applied() + " after cap clamping; resulting level " + result.level() + ".";
|
||||
case AT_CAP -> "Granted 0 of requested " + result.requested() + " fantasy XP to " + result.name()
|
||||
+ "; the character is already capped at level " + result.level() + ".";
|
||||
case STALE -> "Experience grant refused: " + result.name()
|
||||
+ " changed concurrently. No grant success was recorded.";
|
||||
case UNAVAILABLE -> "Experience grant refused: " + result.name()
|
||||
+ " is unavailable or dead. No fantasy XP changed.";
|
||||
case FAILED -> "Experience grant failed before acknowledgment for " + result.name()
|
||||
+ "; no success was reported. See server diagnostics.";
|
||||
});
|
||||
});
|
||||
if (!accepted) sender.sendMessage("Experience grant refused: the captured character session changed before persistence. No fantasy XP changed.");
|
||||
}
|
||||
|
||||
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
|
||||
|
||||
private void give(CommandSender sender, String[] args) {
|
||||
@@ -597,7 +655,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
|
||||
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
|
||||
? List.of("config", "give", "inspect", "recover", "revive") : List.of("config", "give", "inspect", "recover"), args[0]);
|
||||
? List.of("config", "experience", "give", "inspect", "recover", "revive") : List.of("config", "experience", "give", "inspect", "recover"), args[0]);
|
||||
if (args.length == 2 && args[0].equals("experience")) return matching(List.of("give"), args[1]);
|
||||
if (args.length >= 3 && args[0].equals("experience") && args[1].equals("give")) return experienceNames(args);
|
||||
if (args.length == 2 && (args[0].equals("inspect") || args[0].equals("recover"))) return matching(
|
||||
server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
|
||||
.map(player -> player.getUniqueId().toString()).toList(), args[1]);
|
||||
@@ -661,8 +721,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<String> experienceNames(String[] args) {
|
||||
int priorTokens = args.length - 3;
|
||||
String prefix = args[args.length - 1];
|
||||
return matching(sessions.activeCharacters().stream().map(active -> active.record().name())
|
||||
.map(name -> name.split(" ")).filter(tokens -> tokens.length > priorTokens)
|
||||
.filter(tokens -> java.util.stream.IntStream.range(0, priorTokens)
|
||||
.allMatch(index -> tokens[index].equals(args[index + 2])))
|
||||
.map(tokens -> tokens[priorTokens]).toList(), prefix);
|
||||
}
|
||||
|
||||
private static List<String> matching(java.util.Collection<String> values, String prefix) {
|
||||
return values.stream().filter(value -> value.startsWith(prefix)).distinct().sorted().toList();
|
||||
}
|
||||
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); }
|
||||
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(experienceGrant!=null)experienceGrant.close(); }
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -34,6 +35,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public record Notice(UUID player, String code, String message) { }
|
||||
public record Failure(UUID player, UUID session, Throwable cause) { }
|
||||
public record Active(UUID player, UUID session, UUID character) { }
|
||||
public record ActiveCharacter(Active session, CharacterRecord record) { }
|
||||
public record SelectedControl(Active session, int slot, String ability) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
@@ -224,6 +226,14 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
}
|
||||
|
||||
/** Side-effect-free server-thread snapshots for exact active-character command resolution/autocomplete. */
|
||||
public synchronized List<ActiveCharacter> activeCharacters() {
|
||||
if (closed) return List.of();
|
||||
return active.values().stream().filter(attempt -> attempt.record != null)
|
||||
.map(attempt -> new ActiveCharacter(new Active(attempt.owner, attempt.session, attempt.character), attempt.record))
|
||||
.filter(snapshot -> current(snapshot.session())).toList();
|
||||
}
|
||||
|
||||
/** Current active holder for a transferable identity, used only to synchronize acknowledged character writes. */
|
||||
public synchronized Optional<Active> activeCharacter(UUID character) {
|
||||
Objects.requireNonNull(character);
|
||||
|
||||
@@ -7,10 +7,10 @@ description: Transferable characters, native fantasy health, recoverable equipme
|
||||
author: dmg.games
|
||||
commands:
|
||||
fantasystick:
|
||||
description: Issue or revive character sheets, inspect/recover sessions or configure Fantasy Stick.
|
||||
usage: /fantasystick <give|revive> <online-player> | <inspect|recover> <player-uuid> | config <setting> [value]
|
||||
description: Issue or revive sheets, grant fantasy experience, recover sessions or configure Fantasy Stick.
|
||||
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value]
|
||||
permission: fantasystick.admin
|
||||
permissions:
|
||||
fantasystick.admin:
|
||||
description: Issue/revive sheets, inspect/recover sessions and configure fantasy mode.
|
||||
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
|
||||
default: op
|
||||
@@ -0,0 +1,73 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.session.AdminExperienceGrant;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdminExperienceGrantNativeTest {
|
||||
@Test void acceptedPersistenceCannotRedirectFeedbackAfterCharacterSwitch() {
|
||||
var storage = mock(CharacterStorage.class); var sessions = mock(EquipmentSessions.class);
|
||||
var rules = ProgressionRules.defaults(); CharacterRecord captured = new CharacterFactory().create(rules);
|
||||
var active = new EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), captured.id());
|
||||
var current = new AtomicBoolean(true);
|
||||
when(sessions.current(active)).thenAnswer(call -> current.get());
|
||||
when(sessions.currentRecord(active)).thenReturn(Optional.of(captured));
|
||||
var future = new CompletableFuture<CharacterStorage.ExperienceAward>();
|
||||
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.TEN, rules)).thenReturn(future);
|
||||
var tasks = new ArrayList<Runnable>(); var results = new ArrayList<AdminExperienceGrant.Result>();
|
||||
var playerLookups = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var grant = new AdminExperienceGrant(storage, rules, sessions, tasks::add,
|
||||
ignored -> { playerLookups.incrementAndGet(); return null; }, failure -> fail(failure.cause()));
|
||||
|
||||
assertTrue(grant.grant(active, captured, BigInteger.TEN, results::add));
|
||||
current.set(false);
|
||||
var saved = new CharacterRecord(captured.id(), captured.name(), BigInteger.ONE, captured.stats(), 1,
|
||||
BigInteger.TEN, captured.unspentPoints(), captured.health(), captured.learned(), captured.loadout(),
|
||||
captured.cooldowns(), captured.restrictedKills(), false, captured.deathId(), captured.diedAt());
|
||||
future.complete(new CharacterStorage.ExperienceAward(CharacterStorage.ExperienceAwardStatus.APPLIED,
|
||||
Optional.of(saved), 0));
|
||||
assertEquals(1, tasks.size()); tasks.getFirst().run();
|
||||
|
||||
assertEquals(AdminExperienceGrant.Status.APPLIED, results.getFirst().status());
|
||||
assertEquals(BigInteger.TEN, results.getFirst().applied());
|
||||
verify(sessions, never()).acknowledgeProgress(any(), any(), any());
|
||||
assertEquals(0, playerLookups.get(), "A replacement holder/session cannot receive captured feedback");
|
||||
}
|
||||
|
||||
@Test void persistenceFailureReportsOnceAndClosedLifetimePublishesNoStaleCallback() {
|
||||
var storage = mock(CharacterStorage.class); var sessions = mock(EquipmentSessions.class);
|
||||
var rules = ProgressionRules.defaults(); CharacterRecord captured = new CharacterFactory().create(rules);
|
||||
var active = new EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), captured.id());
|
||||
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(Optional.of(captured));
|
||||
var failed = new CompletableFuture<CharacterStorage.ExperienceAward>();
|
||||
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.ONE, rules)).thenReturn(failed);
|
||||
var tasks = new ArrayList<Runnable>(); var results = new ArrayList<AdminExperienceGrant.Result>();
|
||||
var failures = new ArrayList<AdminExperienceGrant.Failure>();
|
||||
var grant = new AdminExperienceGrant(storage, rules, sessions, tasks::add, ignored -> null, failures::add);
|
||||
assertTrue(grant.grant(active, captured, BigInteger.ONE, results::add));
|
||||
failed.completeExceptionally(new java.io.IOException("save failed"));
|
||||
assertEquals(1, failures.size()); assertEquals(1, tasks.size()); tasks.removeFirst().run();
|
||||
assertEquals(List.of(AdminExperienceGrant.Status.FAILED), results.stream().map(AdminExperienceGrant.Result::status).toList());
|
||||
|
||||
var pending = new CompletableFuture<CharacterStorage.ExperienceAward>();
|
||||
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.TWO, rules)).thenReturn(pending);
|
||||
assertTrue(grant.grant(active, captured, BigInteger.TWO, results::add)); grant.close();
|
||||
pending.complete(new CharacterStorage.ExperienceAward(CharacterStorage.ExperienceAwardStatus.AT_CAP,
|
||||
Optional.of(captured), 0));
|
||||
assertTrue(tasks.isEmpty()); assertEquals(1, results.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AdminExperienceNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredCommandResolvesExactActiveCharacterPersistsThenSynchronizesFeedbackAndBook() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var storage = storage(f);
|
||||
storage.renameCharacter(target.core.record.id(), "Mira 雪").get();
|
||||
f.players.put(target.core.id, target.player); f.activate(target);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var before = storage.find(target.core.record.id()).get().orElseThrow();
|
||||
|
||||
assertEquals(List.of("experience"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"exp"}));
|
||||
assertEquals(List.of("give"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "g"}));
|
||||
assertEquals(List.of("Mira"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mi"}));
|
||||
assertEquals(List.of("雪"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", ""}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", "雪", ""}).isEmpty());
|
||||
assertTrue(f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", "雪", "250"}));
|
||||
storage.find(target.core.record.id()).get(); f.tick();
|
||||
|
||||
var saved = storage.find(target.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(250), saved.experience());
|
||||
assertEquals(3, saved.level());
|
||||
assertEquals(before.unspentPoints().add(BigInteger.TWO), saved.unspentPoints());
|
||||
assertEquals(before.stats(), saved.stats()); assertEquals(before.health(), saved.health());
|
||||
assertEquals(before.learned(), saved.learned()); assertEquals(before.loadout(), saved.loadout());
|
||||
assertEquals(before.cooldowns(), saved.cooldowns()); assertEquals(before.restrictedKills(), saved.restrictedKills());
|
||||
assertTrue(sender.messages.getLast().contains("Mira 雪"));
|
||||
assertTrue(sender.messages.getLast().contains("250")); assertTrue(sender.messages.getLast().contains("level 3"));
|
||||
assertTrue(target.messages.stream().anyMatch(message -> message.contains("250 fantasy XP")));
|
||||
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 2")));
|
||||
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 3")));
|
||||
assertEquals(2, target.sounds.size());
|
||||
assertEquals(2, org.mockito.Mockito.mockingDetails(target.world).getInvocations().stream()
|
||||
.filter(invocation -> invocation.getMethod().getName().equals("spawnParticle")).count());
|
||||
PartyBookNativeTest.open(f, target);
|
||||
String text = bookText(target.books.getLast());
|
||||
assertTrue(text.contains("Level: 3"), text); assertTrue(text.contains("XP: 50/100"), text);
|
||||
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertEquals(BigInteger.valueOf(250), storage(f).find(target.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void commandValidationAmbiguityExactCaseAndAutocompleteFailClosedWithoutMutation() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var first = active(f, data, "Twin Hero"); var second = active(f, data, "Twin Hero");
|
||||
var unicode = active(f, data, "Árvíztűrő Tükörfúrógép");
|
||||
var inactive = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
storage(f).renameCharacter(inactive.core.record.id(), "Hidden Hero").get();
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var firstBefore = storage(f).find(first.core.record.id()).get().orElseThrow();
|
||||
var secondBefore = storage(f).find(second.core.record.id()).get().orElseThrow();
|
||||
|
||||
assertEquals(List.of("Twin"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Tw"}));
|
||||
assertEquals(List.of("Hero"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Twin", "H"}));
|
||||
assertEquals(List.of("Árvíztűrő"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Á"}));
|
||||
assertEquals(List.of("Tükörfúrógép"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Árvíztűrő", "T"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Hidden", ""}).isEmpty());
|
||||
int queued = f.tasks.size();
|
||||
for (var args : List.of(
|
||||
new String[]{"experience", "give", "Twin", "Hero", "5"},
|
||||
new String[]{"experience", "give", "twin", "Hero", "5"},
|
||||
new String[]{"experience", "give", "Missing", "5"},
|
||||
new String[]{"experience", "give", "Hidden", "Hero", "5"},
|
||||
new String[]{"experience", "give", "Account", "5"},
|
||||
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "0"},
|
||||
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "-1"},
|
||||
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "1.5"},
|
||||
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "5", "extra"},
|
||||
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép"},
|
||||
new String[]{"experience", "extra", "Árvíztűrő", "Tükörfúrógép", "5"})) {
|
||||
f.command.execute(sender.port, "fantasystick", args);
|
||||
}
|
||||
assertEquals(queued, f.tasks.size());
|
||||
assertEquals(firstBefore, storage(f).find(first.core.record.id()).get().orElseThrow());
|
||||
assertEquals(secondBefore, storage(f).find(second.core.record.id()).get().orElseThrow());
|
||||
assertTrue(sender.messages.stream().anyMatch(message -> message.contains("ambiguous") && message.contains("Twin Hero")));
|
||||
assertTrue(sender.messages.stream().anyMatch(message -> message.contains("not active") && message.contains("Missing")));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"exp"}).isEmpty());
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "5"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied"));
|
||||
assertEquals(BigInteger.ZERO, storage(f).find(unicode.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void arbitraryPrecisionGrantClampsAtCapPreservesCombatHistoryAndNeverTouchesNativeXp() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data); var storage = storage(f);
|
||||
var position = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(java.util.UUID.randomUUID(),
|
||||
java.math.BigDecimal.ONE, java.math.BigDecimal.TWO, java.math.BigDecimal.TEN);
|
||||
var progressed = storage.awardCombatExperience(target.core.record.id(), target.core.record.revision(), BigInteger.TEN,
|
||||
new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.valueOf(40), BigInteger.valueOf(100), 30),
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), position).get().record().orElseThrow();
|
||||
storage.renameCharacter(target.core.record.id(), "Cap Hero").get();
|
||||
f.players.put(target.core.id, target.player); f.activate(target);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); String huge = "9".repeat(1000);
|
||||
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Cap", "Hero", huge});
|
||||
storage.find(target.core.record.id()).get(); f.tick();
|
||||
var capped = storage.find(target.core.record.id()).get().orElseThrow();
|
||||
assertEquals(30, capped.level()); assertEquals(BigInteger.valueOf(2900), capped.experience());
|
||||
assertEquals(progressed.restrictedKills(), capped.restrictedKills());
|
||||
assertTrue(sender.messages.getLast().contains("applied 2890"));
|
||||
int feedback = target.messages.size(); int sounds = target.sounds.size();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Cap", "Hero", "1"});
|
||||
storage.find(target.core.record.id()).get(); f.tick();
|
||||
assertTrue(sender.messages.getLast().contains("Granted 0"));
|
||||
assertEquals(feedback, target.messages.size()); assertEquals(sounds, target.sounds.size());
|
||||
assertEquals(capped, storage.find(target.core.record.id()).get().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void revisionLogoutFailureAndRepeatedGrantRacesNeverRedirectDuplicateOrClaimFalseSuccess() throws Exception {
|
||||
var staleDir = data.resolve("stale");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(staleDir)) {
|
||||
f.plugin.setEnabled(true); var target = active(f, staleDir, "Before Rename"); var storage = storage(f);
|
||||
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(storage, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var gate = worker.submit(() -> { entered.countDown(); try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException interrupted) { throw new AssertionError(interrupted); } });
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Before", "Rename", "75"});
|
||||
var old = new games.dmg.fantasystick.persistence.YamlCharacterRepository(staleDir).load(target.core.record.id()).orElseThrow();
|
||||
var renamed = copy(old, "After Rename", old.revision().add(BigInteger.ONE));
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(staleDir).replace(renamed, old.revision());
|
||||
release.countDown(); gate.get(); storage.find(old.id()).get(); f.tick();
|
||||
assertTrue(sender.messages.getLast().contains("changed concurrently"));
|
||||
assertEquals(BigInteger.ZERO, storage.find(old.id()).get().orElseThrow().experience());
|
||||
assertTrue(target.messages.stream().noneMatch(message -> message.contains("administrator")));
|
||||
}
|
||||
var logoutDir = data.resolve("logout");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(logoutDir)) {
|
||||
f.plugin.setEnabled(true); var target = active(f, logoutDir, "Departing Hero"); var storage = storage(f);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Departing", "Hero", "75"});
|
||||
target.core.online = false;
|
||||
storage.find(target.core.record.id()).get(); f.tick();
|
||||
assertEquals(BigInteger.valueOf(75), storage.find(target.core.record.id()).get().orElseThrow().experience());
|
||||
assertTrue(sender.messages.getLast().contains("applied 75"));
|
||||
assertTrue(target.messages.stream().noneMatch(message -> message.contains("administrator")));
|
||||
}
|
||||
var repeatDir = data.resolve("repeat");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(repeatDir)) {
|
||||
f.plugin.setEnabled(true); var target = active(f, repeatDir, "One Award"); var storage = storage(f);
|
||||
var first = new EquipmentAdminCommandNativeTest.Sender(); var second = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(first.port, "fantasystick", new String[]{"experience", "give", "One", "Award", "25"});
|
||||
f.command.execute(second.port, "fantasystick", new String[]{"experience", "give", "One", "Award", "25"});
|
||||
storage.find(target.core.record.id()).get(); f.tick(); f.tick();
|
||||
assertEquals(BigInteger.valueOf(25), storage.find(target.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(1, List.of(first.messages.getLast(), second.messages.getLast()).stream()
|
||||
.filter(message -> message.contains("applied 25")).count());
|
||||
assertEquals(1, List.of(first.messages.getLast(), second.messages.getLast()).stream()
|
||||
.filter(message -> message.contains("changed concurrently")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void realFilesystemSaveFailureReportsNoSuccessOrPlayerFeedback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var target = active(f, data, "Broken Save");
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var path = data.resolve("characters").resolve(target.core.record.id() + ".yml");
|
||||
java.nio.file.Files.delete(path); java.nio.file.Files.createDirectory(path);
|
||||
int feedback = target.messages.size();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Broken", "Save", "20"});
|
||||
f.tick();
|
||||
assertTrue(sender.messages.getLast().contains("failed before acknowledgment"));
|
||||
assertEquals(feedback, target.messages.size());
|
||||
assertTrue(sender.messages.stream().noneMatch(message -> message.startsWith("Granted 20")));
|
||||
}
|
||||
}
|
||||
|
||||
private static CharacterRecord copy(CharacterRecord old, String name, BigInteger revision) {
|
||||
return new CharacterRecord(old.id(), name, revision, old.stats(), old.level(), old.experience(), old.unspentPoints(),
|
||||
old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt());
|
||||
}
|
||||
|
||||
private static FantasyStickLifecycleNativeTest.Actor active(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
Path data, String name) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
storage(f).renameCharacter(actor.core.record.id(), name).get();
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor); return actor;
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static String bookText(org.bukkit.inventory.ItemStack book) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
return content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Pinned Purpur recipe-book persistence and placement without starting a listening server/client. */
|
||||
class CharacterSheetRecipeBookNativeTest {
|
||||
private static Server previousServer;
|
||||
private static Server server;
|
||||
|
||||
@BeforeAll static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap(); previousServer = Bukkit.getServer(); server = mock(Server.class); setServer(server);
|
||||
}
|
||||
@BeforeEach void configure() {
|
||||
reset(server);
|
||||
when(server.getItemFactory()).thenAnswer(call -> CraftItemFactory.instance());
|
||||
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(server.isPrimaryThread()).thenReturn(true);
|
||||
}
|
||||
@AfterAll static void restore() throws Exception { setServer(previousServer); }
|
||||
private static void setServer(Server value) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server"); field.setAccessible(true); field.set(null, value);
|
||||
}
|
||||
|
||||
@Test void nativeBookAwardsOneMiscHighlightAndPreservesItWithUnrelatedState() {
|
||||
var bukkit = new CharacterSheets().recipe(); var nativeRecipe = nativeRecipe(bukkit); var key = key();
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(key, nativeRecipe);
|
||||
var events = new ArrayList<org.bukkit.event.Event>(); var manager = mock(PluginManager.class);
|
||||
doAnswer(call -> { events.add(call.getArgument(0)); return null; }).when(manager).callEvent(any());
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
var book = new Book((requested, consumer) -> consumer.accept(new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayId(7), nativeRecipe.display().getFirst(),
|
||||
OptionalInt.empty(), nativeRecipe.recipeBookCategory(), Optional.of(nativeRecipe.placementInfo().ingredients()))));
|
||||
var player = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
when(player.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
|
||||
assertEquals(1, book.addRecipes(List.of(holder), player));
|
||||
assertEquals(0, book.addRecipes(List.of(holder), player));
|
||||
assertTrue(book.contains(key));
|
||||
assertEquals(List.of(key), book.pack().highlight());
|
||||
assertEquals(net.minecraft.world.item.crafting.CraftingBookCategory.MISC, nativeRecipe.category());
|
||||
assertEquals(1, events.size());
|
||||
var event = assertInstanceOf(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class, events.getFirst());
|
||||
assertTrue(event.shouldShowNotification());
|
||||
|
||||
var unrelated = nativeKey(new org.bukkit.NamespacedKey("minecraft", "stick"));
|
||||
book.add(unrelated); book.highlight(unrelated);
|
||||
book.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
|
||||
var tag = net.minecraft.stats.ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, book.pack()).getOrThrow();
|
||||
var packed = net.minecraft.stats.ServerRecipeBook.Packed.CODEC.parse(net.minecraft.nbt.NbtOps.INSTANCE, tag).getOrThrow();
|
||||
var restored = new Book(); restored.loadUntrusted(packed, ignored -> true);
|
||||
assertTrue(restored.contains(key)); assertTrue(restored.contains(unrelated));
|
||||
assertEquals(new LinkedHashSet<>(packed.highlight()), new LinkedHashSet<>(restored.pack().highlight()));
|
||||
assertTrue(restored.isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
assertTrue(restored.isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
int known = restored.pack().known().size(); int highlighted = restored.pack().highlight().size();
|
||||
restored.add(key);
|
||||
assertEquals(known, restored.pack().known().size()); assertEquals(highlighted, restored.pack().highlight().size());
|
||||
}
|
||||
|
||||
@Test void nativeAutofillPlacesExactIngredientsAndPreviewCannotMintACharacter() {
|
||||
var sheets = new CharacterSheets(); var bukkit = sheets.recipe(); var nativeRecipe = nativeRecipe(bukkit);
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(key(), nativeRecipe);
|
||||
var player = mock(net.minecraft.world.entity.player.Player.class);
|
||||
when(player.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(player, new net.minecraft.world.entity.EntityEquipment());
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
var ingredients = List.of(Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC);
|
||||
for (int index = 0; index < ingredients.size(); index++) inventory.setItem(index,
|
||||
CraftItemStack.asNMSCopy(new ItemStack(ingredients.get(index))));
|
||||
var unrelated = new ItemStack(Material.DIAMOND, 7); inventory.setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
var grid = new net.minecraft.world.inventory.TransientCraftingContainer(
|
||||
mock(net.minecraft.world.inventory.AbstractContainerMenu.class), 3, 3, player);
|
||||
grid.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 3)));
|
||||
var slots = java.util.stream.IntStream.range(0, 9)
|
||||
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0)).toList();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<net.minecraft.world.item.crafting.ShapelessRecipe>() {
|
||||
@Override public void fillCraftSlotsStackedContents(net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
@Override public void clearCraftingContent() { grid.clearContent(); }
|
||||
@Override public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<net.minecraft.world.item.crafting.ShapelessRecipe> candidate) {
|
||||
return candidate.value().matches(input(grid), mock(net.minecraft.world.level.Level.class));
|
||||
}
|
||||
};
|
||||
|
||||
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(
|
||||
access, 3, 3, slots, slots, inventory, holder, false, false);
|
||||
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
|
||||
assertTrue(nativeRecipe.matches(input(grid), mock(net.minecraft.world.level.Level.class)));
|
||||
for (var material : ingredients) assertEquals(1, count(grid, material));
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertEquals(3, inventory.getNonEquipmentItems().stream().map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == Material.DIRT).mapToInt(ItemStack::getAmount).sum());
|
||||
var before = grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList();
|
||||
var preview = CraftItemStack.asBukkitCopy(nativeRecipe.assemble(input(grid)));
|
||||
assertEquals(Material.PAPER, preview.getType()); assertTrue(sheets.identify(preview).isEmpty());
|
||||
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
|
||||
assertEquals(before, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList());
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapelessRecipe nativeRecipe(org.bukkit.inventory.ShapelessRecipe recipe) {
|
||||
var ingredients = recipe.getChoiceList().stream().map(choice -> CraftRecipe.toIngredient(choice, false)).toList();
|
||||
return new net.minecraft.world.item.crafting.ShapelessRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
CraftItemStack.asTemplate(recipe.getResult()), ingredients);
|
||||
}
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key() {
|
||||
return nativeKey(CharacterSheets.RECIPE);
|
||||
}
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeKey(org.bukkit.NamespacedKey key) {
|
||||
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(key.getNamespace(), key.getKey()));
|
||||
}
|
||||
private static net.minecraft.world.item.crafting.CraftingInput input(net.minecraft.world.Container grid) {
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3,
|
||||
java.util.stream.IntStream.range(0, grid.getContainerSize()).mapToObj(grid::getItem).toList());
|
||||
}
|
||||
private static long count(net.minecraft.world.Container grid, Material material) {
|
||||
return java.util.stream.IntStream.range(0, grid.getContainerSize()).mapToObj(grid::getItem)
|
||||
.map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material).mapToInt(ItemStack::getAmount).sum();
|
||||
}
|
||||
private static final class Book extends net.minecraft.stats.ServerRecipeBook {
|
||||
Book() { this((key, consumer) -> { }); }
|
||||
Book(DisplayResolver resolver) { super(resolver); }
|
||||
void highlight(net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key) { highlight.add(key); }
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ class CharacterSheetsNativeTest {
|
||||
var sheets = new CharacterSheets();
|
||||
var recipe = sheets.recipe();
|
||||
assertEquals(new NamespacedKey("fantasystick", "character_sheet"), recipe.getKey());
|
||||
assertEquals(org.bukkit.inventory.recipe.CraftingBookCategory.MISC, recipe.getCategory());
|
||||
assertEquals(4, recipe.getChoiceList().size());
|
||||
assertTrue(recipe.getChoiceList().stream().allMatch(choice ->
|
||||
choice instanceof RecipeChoice.MaterialChoice materials && materials.getChoices().size() == 1));
|
||||
|
||||
@@ -53,7 +53,7 @@ class EquipmentAdminCommandNativeTest {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
|
||||
var sender=new Sender();
|
||||
assertEquals(List.of("config","give","inspect","recover"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("config","experience","give","inspect","recover"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
|
||||
@@ -94,7 +94,7 @@ class EquipmentAdminCommandNativeTest {
|
||||
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
|
||||
var sender = new Sender();
|
||||
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
|
||||
assertEquals(List.of("config", "give", "inspect", "recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("config", "experience", "give", "inspect", "recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
|
||||
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
|
||||
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
|
||||
|
||||
@@ -73,6 +73,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
|
||||
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
|
||||
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
|
||||
assertTrue(fixture.order.indexOf("RecipeDiscovery") > recipe,
|
||||
"Discovery must not become active before successful recipe registration");
|
||||
assertNotNull(fixture.command);
|
||||
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
|
||||
assertTrue(fixture.tasks.isEmpty());
|
||||
@@ -138,7 +140,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
|
||||
@Test
|
||||
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer")) {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer", "RecipeDiscovery")) {
|
||||
boolean collision = stage.equals("collision");
|
||||
try (var fixture = new Fixture(data)) {
|
||||
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
|
||||
@@ -173,6 +175,10 @@ class FantasyStickLifecycleNativeTest {
|
||||
try (var fixture = new Fixture(directory)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
|
||||
((games.dmg.fantasystick.crafting.RecipeDiscovery) Fixture.field(fixture.plugin, "recipeDiscovery"))
|
||||
.discover(actor.player);
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), actor.knownRecipes);
|
||||
assertEquals(1, actor.recipeAwards);
|
||||
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
|
||||
assertTrue(new CharacterSheets().identify(fixture.recipes.get(CharacterSheets.RECIPE).getResult()).isEmpty());
|
||||
var cancelled = actor.fixture.event(org.bukkit.event.inventory.ClickType.LEFT,
|
||||
@@ -1305,6 +1311,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
final List<Integer> particles = new ArrayList<>();
|
||||
final List<org.bukkit.Sound> sounds = new ArrayList<>();
|
||||
final List<Object[]> resourcePacks = new ArrayList<>();
|
||||
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
|
||||
final List<List<org.bukkit.NamespacedKey>> recipeAwards = new ArrayList<>();
|
||||
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
|
||||
net.minecraft.world.entity.ai.attributes.AttributeSupplier.builder().add(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).build());
|
||||
final net.minecraft.world.entity.ai.attributes.AttributeInstance maximumHealth = java.util.Objects.requireNonNull(
|
||||
@@ -1372,6 +1380,14 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
|
||||
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
|
||||
case "addResourcePack" -> { resourcePacks.add(args.clone()); yield null; }
|
||||
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
|
||||
case "discoverRecipes" -> {
|
||||
var requested = ((java.util.Collection<?>) args[0]).stream()
|
||||
.map(org.bukkit.NamespacedKey.class::cast).toList();
|
||||
recipeAwards.add(requested); int before = knownRecipes.size(); knownRecipes.addAll(requested);
|
||||
yield knownRecipes.size() - before;
|
||||
}
|
||||
case "undiscoverRecipes" -> throw new AssertionError("Recipe discovery must not be revoked");
|
||||
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -348,7 +348,7 @@ class LongbowNativeTest {
|
||||
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
|
||||
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(java.util.List.of("config", "give", "inspect", "recover"),
|
||||
assertEquals(java.util.List.of("config", "experience", "give", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
|
||||
|
||||
@@ -424,6 +424,8 @@ class ManualCraftingControllerNativeTest {
|
||||
org.bukkit.Location eyeLocation;
|
||||
boolean online = true;
|
||||
int syncs;
|
||||
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
|
||||
int recipeAwards;
|
||||
Runnable onSync = () -> { };
|
||||
|
||||
Actor(int width, int amount) {
|
||||
@@ -438,6 +440,14 @@ class ManualCraftingControllerNativeTest {
|
||||
case "getEyeLocation" -> java.util.Objects.requireNonNull(eyeLocation).clone();
|
||||
case "isOnline" -> online;
|
||||
case "updateInventory" -> { syncs++; onSync.run(); yield null; }
|
||||
case "getName" -> "CraftingActor";
|
||||
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
|
||||
case "discoverRecipes" -> {
|
||||
recipeAwards++; int before = knownRecipes.size();
|
||||
((java.util.Collection<?>) args[0]).stream().map(org.bukkit.NamespacedKey.class::cast)
|
||||
.forEach(knownRecipes::add);
|
||||
yield knownRecipes.size() - before;
|
||||
}
|
||||
case "equals" -> object == args[0];
|
||||
case "hashCode" -> System.identityHashCode(object);
|
||||
case "toString" -> "Crafting actor fixture";
|
||||
|
||||
@@ -202,7 +202,7 @@ class QuickstepNativeTest {
|
||||
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
|
||||
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("config", "give", "inspect", "recover"),
|
||||
assertEquals(List.of("config", "experience", "give", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class RecipeDiscoveryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void packagedRegistrationDiscoversOnlineAndJoinPlayersOnceWithoutMintingAndRetainsKnowledge() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
var online = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(online.core.id, online.player);
|
||||
long records = records(data);
|
||||
|
||||
f.plugin.setEnabled(true);
|
||||
|
||||
var recipe = (org.bukkit.inventory.ShapelessRecipe) f.recipes.get(CharacterSheets.RECIPE);
|
||||
assertNotNull(recipe);
|
||||
assertEquals(org.bukkit.inventory.recipe.CraftingBookCategory.MISC, recipe.getCategory());
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), online.knownRecipes);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), online.recipeAwards);
|
||||
assertEquals(records, records(data));
|
||||
assertTrue(new CharacterSheets().identify(recipe.getResult()).isEmpty());
|
||||
var joining = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(joining.core.id, joining.player);
|
||||
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "joined"));
|
||||
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "joined again"));
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
|
||||
assertEquals(records + 1, records(data));
|
||||
|
||||
f.plugin.setEnabled(false);
|
||||
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "disabled"));
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), joining.knownRecipes);
|
||||
f.plugin.setEnabled(true);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), online.recipeAwards);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void playerFailureIsLoggedIsolatedAndRetryableButRegistrationFailureAwardsNobody() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("player-failure"))) {
|
||||
var failing = new FantasyStickLifecycleNativeTest.Actor(data.resolve("player-failure"));
|
||||
var healthy = new FantasyStickLifecycleNativeTest.Actor(data.resolve("player-failure"));
|
||||
var fail = new AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
if (fail.getAndSet(false)) throw new IllegalStateException("simulated discovery failure");
|
||||
var requested = ((java.util.Collection<?>) call.getArgument(0)).stream()
|
||||
.map(org.bukkit.NamespacedKey.class::cast).toList();
|
||||
failing.recipeAwards.add(requested); failing.knownRecipes.addAll(requested); return requested.size();
|
||||
}).when(failing.player).discoverRecipes(anyCollection());
|
||||
f.players.put(failing.core.id, failing.player); f.players.put(healthy.core.id, healthy.player);
|
||||
var logs = new java.util.ArrayList<LogRecord>();
|
||||
var handler = new Handler() {
|
||||
@Override public void publish(LogRecord record) { logs.add(record); }
|
||||
@Override public void flush() { }
|
||||
@Override public void close() { }
|
||||
};
|
||||
f.plugin.getLogger().addHandler(handler);
|
||||
try {
|
||||
f.plugin.setEnabled(true);
|
||||
assertTrue(f.recipes.containsKey(CharacterSheets.RECIPE));
|
||||
assertTrue(failing.knownRecipes.isEmpty());
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), healthy.knownRecipes);
|
||||
assertTrue(logs.stream().anyMatch(log -> log.getMessage().contains("discover character-sheet recipe")));
|
||||
f.dispatcher.accept(new PlayerJoinEvent(failing.player, "retry"));
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), failing.knownRecipes);
|
||||
} finally { f.plugin.getLogger().removeHandler(handler); }
|
||||
}
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("registration-failure"))) {
|
||||
var online = new FantasyStickLifecycleNativeTest.Actor(data.resolve("registration-failure"));
|
||||
f.players.put(online.core.id, online.player); f.failRegistrationAt = "recipe";
|
||||
assertThrows(IllegalStateException.class, () -> f.plugin.setEnabled(true));
|
||||
assertTrue(online.knownRecipes.isEmpty());
|
||||
assertTrue(online.recipeAwards.isEmpty());
|
||||
assertTrue(f.recipes.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static long records(Path directory) throws Exception {
|
||||
var characters = directory.resolve("characters");
|
||||
if (!java.nio.file.Files.isDirectory(characters)) return 0;
|
||||
try (var files = java.nio.file.Files.list(characters)) {
|
||||
return files.filter(file -> file.getFileName().toString().endsWith(".yml")).count();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class SecondWindNativeTest {
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
|
||||
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("config", "give", "inspect", "recover"),
|
||||
assertEquals(List.of("config", "experience", "give", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
|
||||
|
||||
@@ -2,9 +2,10 @@ package games.dmg.fantasystick;
|
||||
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.*;import static org.mockito.Mockito.*;import java.nio.file.Path;import java.util.*;import net.kyori.adventure.text.Component;import org.bukkit.event.player.PlayerResourcePackStatusEvent;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class SheetResourcePackNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
record Request(UUID id,String url,byte[] hash,String prompt,boolean required){}
|
||||
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertTrue(request.url().contains("/v0.1.0-SNAPSHOT/purpur-fantasy-stick-0.1.0-SNAPSHOT-resource-pack.zip"));assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);f.awaitCharacterAndReceipt(active);f.tick();f.tick();assertNotNull(active.core.marker(),active.messages::toString);assertTrue(active.core.marker().endsWith("|PACKED"));assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
|
||||
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertEquals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults().url(),request.url());assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
|
||||
@Test void invalidStartupAndFailedRegisteredWriteNeverDeliverOrChangeEffectiveDefaults()throws Exception{int route=0;for(String value:List.of("sheet-resource-pack-url: 'http://bad.test/x.zip'\n","sheet-resource-pack-sha1: bad\n")){var dir=data.resolve("invalid-"+route++);java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),value);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty(),"native registration order "+f.order);assertTrue(f.tasks.isEmpty(),"queued tasks");}}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"),sender.messages::toString);assertTrue(a.resourcePacks.isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("git.garvis.dev"));}}
|
||||
@Test void registeredConfigurationDurablyPublishesAndRedeliversWhileFailureRetainsPair()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(List.of("sheet-resource-pack-sha1","sheet-resource-pack-url"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}));assertEquals(1,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url",""}).size());sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/sheet.zip"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip","extra"});assertTrue(sender.messages.getLast().contains("Usage"));for(var pair:List.of(new String[]{"sheet-resource-pack-url","http://bad.test/x.zip"},new String[]{"sheet-resource-pack-sha1","bad"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",pair[0],pair[1]});assertTrue(sender.messages.getLast().contains("invalid"));}save(f,sender,"sheet-resource-pack-url","https://packs.example.test/sheet.zip");assertEquals(1,a.resourcePacks.size());assertEquals("https://packs.example.test/sheet.zip",request(a.resourcePacks.getLast()).url());save(f,sender,"sheet-resource-pack-sha1","a".repeat(40));assertEquals(2,a.resourcePacks.size());assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),request(a.resourcePacks.getLast()).hash());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("https://packs.example.test/sheet.zip"));}}
|
||||
static void awaitPacked(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(10);while(System.nanoTime()<deadline){var marker=actor.core.marker();if(marker!=null&&marker.endsWith("|PACKED"))return;var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();}fail("Session did not activate; notices="+actor.messages);}
|
||||
static Request request(Object[] args){return new Request((UUID)args[0],(String)args[1],(byte[])args[2],(String)args[3],(Boolean)args[4]);}
|
||||
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String key,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config",key,value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("Saved "+key));}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class DistributionTest {
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
|
||||
assertEquals(java.util.Set.of("fantasystick"), metadata.getCommands().keySet());
|
||||
assertEquals("fantasystick.admin", metadata.getCommands().get("fantasystick").get("permission"));
|
||||
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("experience give"));
|
||||
assertEquals(1, metadata.getPermissions().size());
|
||||
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
|
||||
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RecipeDiscoveryTest {
|
||||
@Test void onlineJoinRepeatedFailureAndRetryAreIsolatedAndIdempotent() {
|
||||
var warnings = new ArrayList<LogRecord>();
|
||||
var logger = Logger.getAnonymousLogger(); logger.setUseParentHandlers(false);
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { warnings.add(record); }
|
||||
@Override public void flush() { }
|
||||
@Override public void close() { }
|
||||
});
|
||||
var firstKnown = new LinkedHashSet<NamespacedKey>(); var firstAwards = new ArrayList<List<NamespacedKey>>();
|
||||
var secondKnown = new LinkedHashSet<NamespacedKey>(); var secondAwards = new ArrayList<List<NamespacedKey>>();
|
||||
var fail = new AtomicBoolean(true);
|
||||
var first = player("First", firstKnown, firstAwards, fail);
|
||||
var second = player("Second", secondKnown, secondAwards, new AtomicBoolean(false));
|
||||
var discovery = new RecipeDiscovery(CharacterSheets.RECIPE, logger);
|
||||
|
||||
discovery.discoverAll(List.of(first, second));
|
||||
discovery.onJoin(new PlayerJoinEvent(first, "joined"));
|
||||
discovery.onJoin(new PlayerJoinEvent(first, "joined again"));
|
||||
discovery.discover(second);
|
||||
|
||||
assertEquals(Set.of(CharacterSheets.RECIPE), firstKnown);
|
||||
assertEquals(Set.of(CharacterSheets.RECIPE), secondKnown);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), firstAwards);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), secondAwards);
|
||||
assertEquals(1, warnings.size());
|
||||
assertTrue(warnings.getFirst().getMessage().contains("First"));
|
||||
}
|
||||
|
||||
@Test void missingNativePostconditionIsLoggedAndRetried() {
|
||||
var warnings = new ArrayList<LogRecord>();
|
||||
var logger = Logger.getAnonymousLogger(); logger.setUseParentHandlers(false);
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { warnings.add(record); }
|
||||
@Override public void flush() { }
|
||||
@Override public void close() { }
|
||||
});
|
||||
var known = new LinkedHashSet<NamespacedKey>(); var awards = new ArrayList<List<NamespacedKey>>();
|
||||
var omit = new AtomicBoolean(true);
|
||||
var player = proxy("Ada", known, awards, omit);
|
||||
var discovery = new RecipeDiscovery(CharacterSheets.RECIPE, logger);
|
||||
|
||||
discovery.discover(player);
|
||||
discovery.discover(player);
|
||||
|
||||
assertEquals(Set.of(CharacterSheets.RECIPE), known);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE), List.of(CharacterSheets.RECIPE)), awards);
|
||||
assertEquals(1, warnings.size());
|
||||
}
|
||||
|
||||
private static Player player(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
|
||||
AtomicBoolean fail) {
|
||||
return (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[]{Player.class}, (proxy, method, args) -> {
|
||||
if (method.getName().equals("getName")) return name;
|
||||
if (method.getName().equals("hasDiscoveredRecipe")) return known.contains(args[0]);
|
||||
if (method.getName().equals("discoverRecipes")) {
|
||||
if (fail.getAndSet(false)) throw new IllegalStateException("simulated discovery failure");
|
||||
var requested = ((Collection<?>) args[0]).stream().map(NamespacedKey.class::cast).toList();
|
||||
awards.add(requested); known.addAll(requested); return requested.size();
|
||||
}
|
||||
if (method.getName().equals("toString")) return name;
|
||||
if (method.getName().equals("hashCode")) return System.identityHashCode(proxy);
|
||||
if (method.getName().equals("equals")) return proxy == args[0];
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
|
||||
private static Player proxy(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
|
||||
AtomicBoolean omitFirst) {
|
||||
return (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[]{Player.class}, (proxy, method, args) -> {
|
||||
if (method.getName().equals("getName")) return name;
|
||||
if (method.getName().equals("hasDiscoveredRecipe")) return known.contains(args[0]);
|
||||
if (method.getName().equals("discoverRecipes")) {
|
||||
var requested = ((Collection<?>) args[0]).stream().map(NamespacedKey.class::cast).toList();
|
||||
awards.add(requested); if (!omitFirst.getAndSet(false)) known.addAll(requested); return requested.size();
|
||||
}
|
||||
if (method.getName().equals("toString")) return name;
|
||||
if (method.getName().equals("hashCode")) return System.identityHashCode(proxy);
|
||||
if (method.getName().equals("equals")) return proxy == args[0];
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user