Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4cb7a3914 | ||
|
|
6aa7ea469b | ||
|
|
272acfde11 |
No files matched your search
@@ -31,6 +31,18 @@ The file adapter uses safe YAML construction, duplicate-key rejection, bounded r
|
||||
|
||||
File-level atomic replacement is **not** atomicity with Minecraft inventory/player saves. This checkpoint does not implement cross-save reconciliation, an administrator lock, automatic overflow drops or player/session persistence.
|
||||
|
||||
## Paper sheet and recipe definition
|
||||
|
||||
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and the exact offhand instruction. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
|
||||
|
||||
The `fantasystick:character_sheet` shapeless recipe definition contains one stick, paper, feather and ink sac. Character creation is **manual player crafting only** (inventory grid or crafting table). `ManualCraftingGuard` cancels this recipe's automated `CrafterCraftEvent` before native consumption/ejection, without touching other recipes or reversing cancellations; register the guard with the eventual completed-crafting adapter before exposing the recipe. Its preview has no character metadata and allocates no identity/name. It is deliberately **not registered** yet: actual completion, bulk crafting, cancellation/save handling and anvil rename persistence still need their event adapters. Presentation edits preserving metadata are tested, not a claim that complete anvil transactions work.
|
||||
|
||||
## Manual craft planning (not delivery)
|
||||
|
||||
`ManualCraftPlan` reads real crafting inventories without mutation. It calculates one result for cursor/number/offhand clicks, distinct empty destination slots for shift-click, and single/bulk intent for Q/Ctrl-Q. It bounds quantity by ingredients and storage capacity, preserves native reverse storage order, rejects cancelled/non-craft/wrong-recipe/malformed input, and snapshots full item data rather than retaining mutable native stack mirrors. Returned leftovers are detached copies; `matches` detects changed inputs, result, recipe, cursor or planned destinations without overwriting intervening items.
|
||||
|
||||
This is **not a transaction commit**. Nine native planner tests use actual 2×2/3×3 crafting containers, player storage, menu-carried stacks and recipe holders, with the absent player/network boundary stubbed. They do not execute packet dispatch or vanilla result consumption. A controller still needs one-use lifecycle tokens (a native menu may be reused after close/reopen), name/ID allocation, asynchronous persistence, reentrant matrix-update handling and delivery. Drop intent is not proof that a cancellable native drop succeeds. No recipe/listener registration, recovery policy or cross-save atomicity is added by this planner.
|
||||
|
||||
## Native equipment adapter
|
||||
|
||||
`EquipmentStorage` preflights and moves nine hotbar/four armor slots into an actual named shulker in one free main-inventory slot. Item counts, metadata/damage/enchantments and legal nested contents remain native item data, accessible without this plugin. Eligibility is tested against the real vanilla shulker slot for every nonlegacy native item material; a main-inventory shulker does not prevent entry. Oversized or ineligible equipment and missing space produce unchanged refusals.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
|
||||
/** Server-thread planning only: does not save, mint, cancel, consume or deliver anything. */
|
||||
public final class ManualCraftPlan {
|
||||
public enum Route { CURSOR, INVENTORY, DROP }
|
||||
private final ItemStack[] matrix;
|
||||
private final InventoryView view;
|
||||
private final CraftingInventory grid;
|
||||
private final PlayerInventory inventory;
|
||||
private final ItemStack result;
|
||||
private final ItemStack cursor;
|
||||
private final int quantity;
|
||||
private final Route route;
|
||||
private final List<Integer> inventorySlots;
|
||||
|
||||
private ManualCraftPlan(CraftItemEvent event, ItemStack[] matrix, int quantity, Route route, List<Integer> inventorySlots) {
|
||||
this.view = event.getView();
|
||||
this.grid = event.getInventory();
|
||||
this.inventory = (PlayerInventory) view.getBottomInventory();
|
||||
this.result = copy(grid.getResult());
|
||||
this.cursor = copy(view.getCursor());
|
||||
this.matrix = copy(matrix);
|
||||
this.quantity = quantity;
|
||||
this.route = route;
|
||||
this.inventorySlots = List.copyOf(inventorySlots);
|
||||
}
|
||||
|
||||
public static Optional<ManualCraftPlan> capture(CraftItemEvent event) {
|
||||
if (event.isCancelled() || event.getRawSlot() != 0 || event.getSlotType() != InventoryType.SlotType.RESULT
|
||||
|| !isSheetRecipe(event.getRecipe())
|
||||
|| !(event.getView().getTopInventory() instanceof CraftingInventory)
|
||||
|| !isSheetRecipe(event.getInventory().getRecipe())
|
||||
|| !(event.getView().getBottomInventory() instanceof PlayerInventory)
|
||||
|| empty(event.getCurrentItem()) || !event.getRecipe().getResult().equals(event.getCurrentItem())
|
||||
|| !new CharacterSheets().recipe().getResult().equals(event.getCurrentItem())
|
||||
|| !event.getInventory().getRecipe().getResult().equals(event.getCurrentItem())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var matrix = event.getInventory().getMatrix();
|
||||
if (matrix.length != 4 && matrix.length != 9) return Optional.empty();
|
||||
var required = EnumSet.of(Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC);
|
||||
int limit = Integer.MAX_VALUE;
|
||||
for (var item : matrix) {
|
||||
if (empty(item)) continue;
|
||||
if (!required.remove(item.getType()) || item.getAmount() > item.getMaxStackSize()) return Optional.empty();
|
||||
limit = Math.min(limit, item.getAmount());
|
||||
}
|
||||
if (!required.isEmpty()) return Optional.empty();
|
||||
if (event.getAction() == InventoryAction.MOVE_TO_OTHER_INVENTORY && event.isShiftClick()) {
|
||||
var slots = new ArrayList<Integer>();
|
||||
// Vanilla reverse result transfer: hotbar 8..0, then main storage 35..9.
|
||||
for (int slot = 8; slot >= 0 && slots.size() < limit; slot--) {
|
||||
if (empty(event.getView().getBottomInventory().getItem(slot))) slots.add(slot);
|
||||
}
|
||||
for (int slot = 35; slot >= 9 && slots.size() < limit; slot--) {
|
||||
if (empty(event.getView().getBottomInventory().getItem(slot))) slots.add(slot);
|
||||
}
|
||||
if (slots.isEmpty()) return Optional.empty();
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, slots.size(), Route.INVENTORY, slots));
|
||||
}
|
||||
if (event.getAction() == InventoryAction.HOTBAR_SWAP) {
|
||||
int slot;
|
||||
if (event.getClick() == ClickType.SWAP_OFFHAND) slot = 40;
|
||||
else if (event.getClick() == ClickType.NUMBER_KEY && event.getHotbarButton() >= 0
|
||||
&& event.getHotbarButton() <= 8) slot = event.getHotbarButton();
|
||||
else return Optional.empty();
|
||||
if (!empty(event.getView().getBottomInventory().getItem(slot))) return Optional.empty();
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, 1, Route.INVENTORY, List.of(slot)));
|
||||
}
|
||||
boolean singleDrop = event.getClick() == ClickType.DROP && event.getAction() == InventoryAction.DROP_ONE_SLOT;
|
||||
boolean bulkDrop = event.getClick() == ClickType.CONTROL_DROP && event.getAction() == InventoryAction.DROP_ALL_SLOT;
|
||||
if ((singleDrop || bulkDrop) && empty(event.getCursor())) {
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, bulkDrop ? limit : 1, Route.DROP, List.of()));
|
||||
}
|
||||
boolean cursorClick = event.getClick() == ClickType.LEFT && event.getAction() == InventoryAction.PICKUP_ALL
|
||||
|| event.getClick() == ClickType.RIGHT && event.getAction() == InventoryAction.PICKUP_HALF;
|
||||
if (!cursorClick || !empty(event.getCursor())) return Optional.empty();
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, 1, Route.CURSOR, List.of()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Content check only, not a commit or session authorization. The caller must also invalidate
|
||||
* tokens on close/reopen, disconnect and disable, including reuse of the same native menu.
|
||||
* Never call this from a worker or use an old view to write a new menu's cursor.
|
||||
*/
|
||||
public boolean matches(InventoryView current) {
|
||||
if (current != view || current.getTopInventory() != grid || current.getBottomInventory() != inventory) return false;
|
||||
var recipe = grid.getRecipe();
|
||||
return isSheetRecipe(recipe) && Objects.equals(result, recipe.getResult())
|
||||
&& Arrays.equals(matrix, copy(grid.getMatrix())) && Objects.equals(result, copy(grid.getResult()))
|
||||
&& Objects.equals(cursor, copy(current.getCursor()))
|
||||
&& inventorySlots.stream().allMatch(slot -> empty(inventory.getItem(slot)));
|
||||
}
|
||||
|
||||
public int quantity() { return quantity; }
|
||||
public Route route() { return route; }
|
||||
public List<Integer> inventorySlots() { return inventorySlots; }
|
||||
|
||||
public ItemStack[] remainingMatrix() {
|
||||
var remaining = copy(matrix);
|
||||
for (int slot = 0; slot < remaining.length; slot++) {
|
||||
var item = remaining[slot];
|
||||
if (item != null) {
|
||||
if (item.getAmount() == quantity()) remaining[slot] = null;
|
||||
else item.setAmount(item.getAmount() - quantity());
|
||||
}
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
private static boolean isSheetRecipe(Recipe recipe) {
|
||||
return recipe instanceof Keyed keyed && CharacterSheets.RECIPE.equals(keyed.getKey());
|
||||
}
|
||||
|
||||
private static boolean empty(ItemStack item) { return item == null || item.isEmpty(); }
|
||||
private static ItemStack copy(ItemStack item) { return empty(item) ? null : item.clone(); }
|
||||
|
||||
private static ItemStack[] copy(ItemStack[] items) {
|
||||
var result = new ItemStack[items.length];
|
||||
for (int slot = 0; slot < items.length; slot++) {
|
||||
result[slot] = copy(items[slot]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.CrafterCraftEvent;
|
||||
|
||||
/** Register alongside the completed manual-crafting adapter, before exposing the recipe. */
|
||||
public final class ManualCraftingGuard implements Listener {
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onAutomatedCraft(CrafterCraftEvent event) {
|
||||
if (CharacterSheets.RECIPE.equals(event.getRecipe().getKey())) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Sheet presentation/identity only; saved records and session eligibility remain authoritative. */
|
||||
public final class CharacterSheets {
|
||||
public static final NamespacedKey RECIPE = new NamespacedKey("fantasystick", "character_sheet");
|
||||
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
|
||||
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
|
||||
private static final String INSTRUCTION = "Character sheet: place in off hand to enter fantasy world.";
|
||||
|
||||
public ItemStack create(CharacterRecord character) {
|
||||
if (character.dead()) throw new IllegalArgumentException("Dead characters require authoritative revival before sheet creation");
|
||||
var item = paper(character.name());
|
||||
var meta = item.getItemMeta();
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Definition only: a future craft-completion adapter must persist and mint each actual result. */
|
||||
public ShapelessRecipe recipe() {
|
||||
return new ShapelessRecipe(RECIPE, paper("Character sheet"))
|
||||
.addIngredient(Material.STICK).addIngredient(Material.PAPER)
|
||||
.addIngredient(Material.FEATHER).addIngredient(Material.INK_SAC);
|
||||
}
|
||||
|
||||
private static ItemStack paper(String name) {
|
||||
var item = new ItemStack(Material.PAPER);
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(name));
|
||||
meta.lore(List.of(Component.text(INSTRUCTION)));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** A reference, not authorization to activate: the caller must load saved state and claim the session. */
|
||||
public Optional<UUID> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.PAPER || !item.hasItemMeta()) return Optional.empty();
|
||||
var data = item.getItemMeta().getPersistentDataContainer();
|
||||
if (!data.has(VERSION, PersistentDataType.INTEGER)
|
||||
|| !Integer.valueOf(1).equals(data.get(VERSION, PersistentDataType.INTEGER))
|
||||
|| !data.has(CHARACTER, PersistentDataType.STRING)) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(UUID.fromString(data.get(CHARACTER, PersistentDataType.STRING)));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.minecraft.nbt.NbtOps;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CharacterSheetsNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private static CharacterRecord character() {
|
||||
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
return new CharacterRecord(UUID.randomUUID(), "Ari of Ash", BigInteger.ZERO, stats, 1,
|
||||
BigInteger.ZERO, BigInteger.valueOf(40), BigDecimal.TEN, Set.of(), Collections.nCopies(7, ""),
|
||||
Map.of(), false, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void presentationEditsPreserveIdentityButLookalikesAndMalformedReferencesAreRejected() {
|
||||
var sheets = new CharacterSheets();
|
||||
var record = character();
|
||||
var sheet = sheets.create(record);
|
||||
var meta = sheet.getItemMeta();
|
||||
meta.displayName(Component.text("Different presentation"));
|
||||
sheet.setItemMeta(meta);
|
||||
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
|
||||
var fake = new ItemStack(Material.PAPER);
|
||||
var fakeMeta = fake.getItemMeta();
|
||||
fakeMeta.displayName(meta.displayName());
|
||||
fakeMeta.lore(meta.lore());
|
||||
fake.setItemMeta(fakeMeta);
|
||||
assertTrue(sheets.identify(fake).isEmpty());
|
||||
assertTrue(sheets.identify(new ItemStack(Material.STICK)).isEmpty());
|
||||
assertTrue(sheets.identify(null).isEmpty());
|
||||
var version = new NamespacedKey("fantasystick", "sheet_version");
|
||||
for (int variant = 0; variant < 3; variant++) {
|
||||
var invalid = sheet.clone();
|
||||
var invalidMeta = invalid.getItemMeta();
|
||||
var data = invalidMeta.getPersistentDataContainer();
|
||||
if (variant == 0) data.set(version, PersistentDataType.INTEGER, 2);
|
||||
else if (variant == 1) data.set(version, PersistentDataType.STRING, "1");
|
||||
else data.set(new NamespacedKey("fantasystick", "character_id"), PersistentDataType.STRING, "not-a-uuid");
|
||||
invalid.setItemMeta(invalidMeta);
|
||||
assertTrue(sheets.identify(invalid).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fourIngredientShapelessRecipeOnlyExposesAnUnidentifiedPaperPreview() {
|
||||
var sheets = new CharacterSheets();
|
||||
var recipe = sheets.recipe();
|
||||
assertEquals(new NamespacedKey("fantasystick", "character_sheet"), recipe.getKey());
|
||||
assertEquals(4, recipe.getChoiceList().size());
|
||||
assertTrue(recipe.getChoiceList().stream().allMatch(choice ->
|
||||
choice instanceof RecipeChoice.MaterialChoice materials && materials.getChoices().size() == 1));
|
||||
assertEquals(Set.of(Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC),
|
||||
recipe.getChoiceList().stream().map(choice -> ((RecipeChoice.MaterialChoice) choice).getChoices().getFirst())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
for (int i = 0; i < 10; i++) {
|
||||
var preview = sheets.recipe().getResult();
|
||||
assertEquals(Material.PAPER, preview.getType());
|
||||
assertEquals(1, preview.getAmount());
|
||||
assertTrue(sheets.identify(preview).isEmpty());
|
||||
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeadRecordCannotBeTurnedIntoASheetByTheItemFactory() {
|
||||
var original = character();
|
||||
var dead = new CharacterRecord(original.id(), original.name(), original.revision(), original.stats(),
|
||||
original.level(), original.experience(), original.unspentPoints(), BigDecimal.ZERO,
|
||||
original.learned(), original.loadout(), original.cooldowns(), true, UUID.randomUUID(), java.time.Instant.EPOCH);
|
||||
assertThrows(IllegalArgumentException.class, () -> new CharacterSheets().create(dead));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sheetShowsSavedNameAndInstructionAndSerializesIdentityWithoutAnOwnerLock() {
|
||||
var record = character();
|
||||
var sheets = new CharacterSheets();
|
||||
var sheet = sheets.create(record);
|
||||
assertEquals(Material.PAPER, sheet.getType());
|
||||
assertEquals(1, sheet.getAmount());
|
||||
assertEquals(Component.text(record.name()), sheet.getItemMeta().displayName());
|
||||
assertEquals(List.of(Component.text("Character sheet: place in off hand to enter fantasy world.")), sheet.getItemMeta().lore());
|
||||
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
|
||||
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"), new NamespacedKey("fantasystick", "character_id")),
|
||||
sheet.getItemMeta().getPersistentDataContainer().getKeys(), "No player owner, equipment or progress snapshot belongs on the sheet");
|
||||
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
|
||||
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(sheet)).getOrThrow();
|
||||
var reloaded = CraftItemStack.asBukkitCopy(net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow());
|
||||
assertEquals(record.id(), new CharacterSheets().identify(reloaded).orElseThrow());
|
||||
assertEquals(sheet, reloaded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.ManualCraftPlan;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import net.minecraft.world.entity.EntityEquipment;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.inventory.ResultContainer;
|
||||
import net.minecraft.world.inventory.TransientCraftingContainer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryCrafting;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.entity.HumanEntity;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.MenuType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ManualCraftPlanNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void plansOneCursorSheetWithoutConsumingIngredientsOrMintingAPreviewIdentity() {
|
||||
for (int width : new int[] {2, 3}) {
|
||||
var fixture = new Fixture(width, 2);
|
||||
var event = fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
var found = ManualCraftPlan.capture(event);
|
||||
assertTrue(found.isPresent(), "A manual result click with space must plan one character");
|
||||
var plan = found.orElseThrow();
|
||||
assertEquals(1, plan.quantity());
|
||||
assertEquals(ManualCraftPlan.Route.CURSOR, plan.route());
|
||||
assertTrue(plan.inventorySlots().isEmpty());
|
||||
var after = plan.remainingMatrix();
|
||||
for (int slot = 0; slot < 4; slot++) {
|
||||
assertEquals(1, after[slot].getAmount());
|
||||
assertEquals(2, fixture.grid.getMatrix()[slot].getAmount(), "Planning never debits live input");
|
||||
}
|
||||
assertTrue(new CharacterSheets().identify(fixture.grid.getResult()).isEmpty());
|
||||
assertTrue(fixture.view.getCursor().isEmpty());
|
||||
assertFalse(event.isCancelled(), "Planning itself does not alter event dispatch");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shiftCraftingUsesDistinctEmptyStorageSlotsInNativeOrderAndStopsAtEitherLimit() {
|
||||
for (int width : new int[] {2, 3}) {
|
||||
for (int ingredientLimit : new int[] {3, 64}) {
|
||||
var fixture = new Fixture(width, 64);
|
||||
var matrix = fixture.grid.getMatrix();
|
||||
matrix[3].setAmount(ingredientLimit);
|
||||
fixture.grid.setMatrix(matrix);
|
||||
for (int slot = 0; slot < 36; slot++) fixture.inventory.setItem(slot, new ItemStack(Material.COBBLESTONE, 64));
|
||||
for (int slot : new int[] {8, 6, 35, 9}) fixture.inventory.setItem(slot, null);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.SHIFT_LEFT,
|
||||
InventoryAction.MOVE_TO_OTHER_INVENTORY)).orElseThrow();
|
||||
int count = Math.min(4, ingredientLimit);
|
||||
assertEquals(count, plan.quantity(), "One free slot per unique sheet, bounded by ingredients");
|
||||
assertEquals(ManualCraftPlan.Route.INVENTORY, plan.route());
|
||||
assertEquals(java.util.List.of(8, 6, 35, 9).subList(0, count), plan.inventorySlots());
|
||||
assertEquals(64 - count, plan.remainingMatrix()[0].getAmount());
|
||||
if (count == ingredientLimit) assertNull(plan.remainingMatrix()[3]);
|
||||
else assertEquals(ingredientLimit - count, plan.remainingMatrix()[3].getAmount());
|
||||
assertEquals(64, fixture.grid.getMatrix()[0].getAmount());
|
||||
assertNull(fixture.inventory.getItem(8), "Planning must not deliver previews");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesCancelledNonCraftWrongRecipeAndMalformedInputWithoutChangingAnything() {
|
||||
var fixture = new Fixture(3, 2);
|
||||
var cancelled = fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
cancelled.setCancelled(true);
|
||||
assertTrue(ManualCraftPlan.capture(cancelled).isEmpty(), "Prior cancellation is authoritative");
|
||||
assertTrue(cancelled.isCancelled());
|
||||
for (var action : new InventoryAction[] {InventoryAction.NOTHING, InventoryAction.CLONE_STACK,
|
||||
InventoryAction.COLLECT_TO_CURSOR, InventoryAction.PLACE_ALL, InventoryAction.SWAP_WITH_CURSOR}) {
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.LEFT, action)).isEmpty(), action.toString());
|
||||
}
|
||||
var unrelated = new org.bukkit.inventory.ShapelessRecipe(new org.bukkit.NamespacedKey("other", "character_sheet"),
|
||||
new ItemStack(Material.PAPER)).addIngredient(Material.STICK);
|
||||
assertTrue(ManualCraftPlan.capture(new CraftItemEvent(unrelated, fixture.view, InventoryType.SlotType.RESULT,
|
||||
0, ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
assertTrue(ManualCraftPlan.capture(new CraftItemEvent(new CharacterSheets().recipe(), fixture.view,
|
||||
InventoryType.SlotType.CRAFTING, 1, ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
for (ItemStack bad : new ItemStack[] {null, new ItemStack(Material.DIAMOND),
|
||||
new ItemStack(Material.PAPER), new ItemStack(Material.STICK, 65)}) {
|
||||
var invalid = new Fixture(3, 2);
|
||||
var inputs = invalid.grid.getMatrix();
|
||||
inputs[0] = bad;
|
||||
invalid.grid.setMatrix(inputs);
|
||||
assertTrue(ManualCraftPlan.capture(invalid.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
}
|
||||
var extra = fixture.grid.getMatrix();
|
||||
extra[8] = new ItemStack(Material.PAPER);
|
||||
fixture.grid.setMatrix(extra);
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
assertEquals(2, fixture.grid.getMatrix()[0].getAmount());
|
||||
assertEquals(Material.PAPER, fixture.grid.getMatrix()[8].getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesMissingResultOccupiedCursorAndFullStorageButAllowsRightClick() {
|
||||
var fixture = new Fixture(2, 1);
|
||||
assertEquals(1, ManualCraftPlan.capture(fixture.event(ClickType.RIGHT,
|
||||
InventoryAction.PICKUP_HALF)).orElseThrow().quantity());
|
||||
fixture.view.setCursor(new ItemStack(Material.PAPER));
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
assertEquals(Material.PAPER, fixture.view.getCursor().getType());
|
||||
fixture.view.setCursor(null);
|
||||
for (int slot = 0; slot < 36; slot++) fixture.inventory.setItem(slot, new ItemStack(Material.PAPER));
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.SHIFT_RIGHT,
|
||||
InventoryAction.MOVE_TO_OTHER_INVENTORY)).isEmpty(), "Unique identities cannot merge with ordinary Paper");
|
||||
fixture.grid.setResult(null);
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
assertEquals(1, fixture.grid.getMatrix()[0].getAmount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void numberAndOffhandKeysPlanOnlyOneSheetIntoAnEmptyExactDestination() {
|
||||
for (int slot : new int[] {0, 8, 40}) {
|
||||
var fixture = new Fixture(3, 12);
|
||||
var click = slot == 40 ? ClickType.SWAP_OFFHAND : ClickType.NUMBER_KEY;
|
||||
var event = new CraftItemEvent(new CharacterSheets().recipe(), fixture.view, InventoryType.SlotType.RESULT,
|
||||
0, click, InventoryAction.HOTBAR_SWAP, slot == 40 ? -1 : slot);
|
||||
var plan = ManualCraftPlan.capture(event).orElseThrow();
|
||||
assertEquals(1, plan.quantity());
|
||||
assertEquals(ManualCraftPlan.Route.INVENTORY, plan.route());
|
||||
assertEquals(java.util.List.of(slot), plan.inventorySlots());
|
||||
fixture.inventory.setItem(slot, new ItemStack(Material.DIAMOND));
|
||||
assertTrue(ManualCraftPlan.capture(event).isEmpty(), "Never displace an existing hotbar/offhand stack");
|
||||
assertEquals(Material.DIAMOND, fixture.inventory.getItem(slot).getType());
|
||||
}
|
||||
var fixture = new Fixture(2, 1);
|
||||
for (int invalid : new int[] {-1, 9, 36, 40}) {
|
||||
assertTrue(ManualCraftPlan.capture(new CraftItemEvent(new CharacterSheets().recipe(), fixture.view,
|
||||
InventoryType.SlotType.RESULT, 0, ClickType.NUMBER_KEY, InventoryAction.HOTBAR_SWAP, invalid)).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinguishesSingleDropFromControlDropBulkWithoutSpawningOrConsuming() {
|
||||
var fixture = new Fixture(3, 5);
|
||||
var single = ManualCraftPlan.capture(fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT)).orElseThrow();
|
||||
var bulk = ManualCraftPlan.capture(fixture.event(ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT)).orElseThrow();
|
||||
assertEquals(1, single.quantity());
|
||||
assertEquals(5, bulk.quantity(), "Control-drop is a second native bulk route");
|
||||
assertEquals(ManualCraftPlan.Route.DROP, single.route());
|
||||
assertEquals(ManualCraftPlan.Route.DROP, bulk.route());
|
||||
assertTrue(bulk.inventorySlots().isEmpty());
|
||||
assertNull(bulk.remainingMatrix()[0]);
|
||||
assertEquals(5, fixture.grid.getMatrix()[0].getAmount());
|
||||
fixture.view.setCursor(new ItemStack(Material.DIAMOND));
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT)).isEmpty());
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT)).isEmpty());
|
||||
fixture.view.setCursor(null);
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.MIDDLE, InventoryAction.CLONE_STACK)).isEmpty());
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.DOUBLE_CLICK, InventoryAction.COLLECT_TO_CURSOR)).isEmpty());
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.DROP, InventoryAction.DROP_ALL_SLOT)).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotsDoNotAliasNativeMirrorsAndRejectStaleInputResultCursorRecipeOrView() {
|
||||
var fixture = new Fixture(3, 3);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).orElseThrow();
|
||||
assertTrue(plan.matches(fixture.view));
|
||||
plan.remainingMatrix()[0].setAmount(44);
|
||||
assertEquals(2, plan.remainingMatrix()[0].getAmount(), "Returned arrays/stacks cannot alter the saved plan");
|
||||
fixture.grid.getMatrix()[0].setAmount(2);
|
||||
assertEquals(2, fixture.grid.getMatrix()[0].getAmount(), "Fixture exercises native mirrors, not fake copied stacks");
|
||||
assertFalse(plan.matches(fixture.view), "Live amount changes invalidate an async snapshot");
|
||||
assertEquals(2, plan.remainingMatrix()[0].getAmount());
|
||||
for (java.util.function.Consumer<Fixture> change : java.util.List.<java.util.function.Consumer<Fixture>>of(
|
||||
f -> { var item = f.grid.getMatrix()[0]; var meta = item.getItemMeta();
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("Changed input")); item.setItemMeta(meta); },
|
||||
f -> f.grid.setResult(new ItemStack(Material.PAPER)),
|
||||
f -> f.view.setCursor(new ItemStack(Material.DIAMOND)),
|
||||
f -> f.matrix.setCurrentRecipe(null))) {
|
||||
var changed = new Fixture(2, 2);
|
||||
var pending = ManualCraftPlan.capture(changed.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).orElseThrow();
|
||||
change.accept(changed);
|
||||
assertFalse(pending.matches(changed.view));
|
||||
assertEquals(1, pending.remainingMatrix()[0].getAmount());
|
||||
}
|
||||
var other = new Fixture(3, 3);
|
||||
assertFalse(plan.matches(other.view), "Identical contents in a different view are not the original transaction");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectsOccupiedPlannedDestinationsWithoutBlockingUnrelatedInventoryChanges() {
|
||||
var fixture = new Fixture(3, 2);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.SHIFT_LEFT,
|
||||
InventoryAction.MOVE_TO_OTHER_INVENTORY)).orElseThrow();
|
||||
assertEquals(java.util.List.of(8, 7), plan.inventorySlots());
|
||||
fixture.inventory.setItem(12, new ItemStack(Material.DIAMOND));
|
||||
assertTrue(plan.matches(fixture.view));
|
||||
fixture.inventory.setItem(7, new ItemStack(Material.GOLD_INGOT));
|
||||
assertFalse(plan.matches(fixture.view), "Sorting/new acquisitions must not be overwritten by a stale craft");
|
||||
assertEquals(Material.GOLD_INGOT, fixture.inventory.getItem(7).getType());
|
||||
assertThrows(UnsupportedOperationException.class, () -> plan.inventorySlots().clear());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAReplacedRecipeResultEvenIfItReusesTheOwnedKey() {
|
||||
var fixture = new Fixture(2, 1);
|
||||
var changed = new CharacterSheets().recipe().getResult();
|
||||
changed.setAmount(2);
|
||||
fixture.grid.setResult(changed);
|
||||
var replaced = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE, changed)
|
||||
.addIngredient(Material.STICK).addIngredient(Material.PAPER)
|
||||
.addIngredient(Material.FEATHER).addIngredient(Material.INK_SAC);
|
||||
var event = new CraftItemEvent(replaced, fixture.view, InventoryType.SlotType.RESULT,
|
||||
0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
assertTrue(ManualCraftPlan.capture(event).isEmpty(), "One canonical nonplayable preview, not an arbitrary same-key result");
|
||||
assertEquals(2, fixture.grid.getResult().getAmount());
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final Menu menu = new Menu();
|
||||
final TransientCraftingContainer matrix;
|
||||
final CraftInventoryCrafting grid;
|
||||
final CraftInventoryPlayer inventory = new CraftInventoryPlayer(
|
||||
new net.minecraft.world.entity.player.Inventory(null, new EntityEquipment()) {
|
||||
@Override public InventoryHolder getOwner() { return null; }
|
||||
});
|
||||
final InventoryView view;
|
||||
|
||||
Fixture(int width, int amount) {
|
||||
matrix = new TransientCraftingContainer(menu, width, width);
|
||||
var result = new ResultContainer();
|
||||
matrix.resultInventory = result;
|
||||
grid = new CraftInventoryCrafting(matrix, result);
|
||||
view = new CraftAbstractInventoryView() {
|
||||
@Override public CraftInventoryCrafting getTopInventory() { return grid; }
|
||||
@Override public CraftInventoryPlayer getBottomInventory() { return inventory; }
|
||||
@Override public HumanEntity getPlayer() { throw new AssertionError("Unexpected player/network access"); }
|
||||
@Override public InventoryType getType() { return grid.getType(); }
|
||||
@Override public ItemStack getCursor() { return CraftItemStack.asCraftMirror(menu.getCarried()); }
|
||||
@Override public void setCursor(ItemStack item) { menu.setCarried(CraftItemStack.asNMSCopy(item)); }
|
||||
@Override public String getTitle() { return "Crafting fixture"; }
|
||||
@Override public String getOriginalTitle() { return getTitle(); }
|
||||
@Override public void setTitle(String title) { throw new AssertionError("Unexpected title change"); }
|
||||
@Override public MenuType getMenuType() { return null; }
|
||||
};
|
||||
menu.view = view;
|
||||
var contents = new ItemStack[width * width];
|
||||
var ingredients = new Material[] {Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC};
|
||||
for (int slot = 0; slot < ingredients.length; slot++) contents[slot] = new ItemStack(ingredients[slot], amount);
|
||||
grid.setMatrix(contents);
|
||||
grid.setResult(new CharacterSheets().recipe().getResult());
|
||||
var nativeRecipe = new net.minecraft.world.item.crafting.ShapelessRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
net.minecraft.world.item.crafting.CraftingBookCategory.MISC, ""),
|
||||
net.minecraft.world.item.ItemStackTemplate.fromNonEmptyStack(CraftItemStack.asNMSCopy(grid.getResult())),
|
||||
java.util.List.of(
|
||||
net.minecraft.world.item.crafting.Ingredient.of(net.minecraft.world.item.Items.STICK),
|
||||
net.minecraft.world.item.crafting.Ingredient.of(net.minecraft.world.item.Items.PAPER),
|
||||
net.minecraft.world.item.crafting.Ingredient.of(net.minecraft.world.item.Items.FEATHER),
|
||||
net.minecraft.world.item.crafting.Ingredient.of(net.minecraft.world.item.Items.INK_SAC)));
|
||||
matrix.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder<>(
|
||||
net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.parse(CharacterSheets.RECIPE.toString())), nativeRecipe));
|
||||
}
|
||||
|
||||
CraftItemEvent event(ClickType click, InventoryAction action) {
|
||||
return new CraftItemEvent(new CharacterSheets().recipe(), view, InventoryType.SlotType.RESULT, 0, click, action);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Menu extends AbstractContainerMenu {
|
||||
InventoryView view;
|
||||
Menu() { super(null, 0); }
|
||||
@Override public InventoryView getBukkitView() { return view; }
|
||||
@Override public boolean stillValid(net.minecraft.world.entity.player.Player player) { return true; }
|
||||
@Override public net.minecraft.world.item.ItemStack quickMoveStack(net.minecraft.world.entity.player.Player player, int slot) {
|
||||
throw new AssertionError("This fixture does not execute vanilla result consumption");
|
||||
}
|
||||
@Override public void slotsChanged(net.minecraft.world.Container inventory) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.ManualCraftingGuard;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.lang.reflect.Proxy;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.event.block.CrafterCraftEvent;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ManualCraftingGuardNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private static Block untouchedBlock() {
|
||||
// The event boundary supplies a block, but cancellation must not access/mutate world or inventory.
|
||||
return (Block) Proxy.newProxyInstance(Block.class.getClassLoader(), new Class<?>[]{Block.class},
|
||||
(proxy, method, arguments) -> { throw new AssertionError("Unexpected block access: " + method.getName()); });
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesOtherRecipesAndExistingCancellationsAlone() {
|
||||
var guard = new ManualCraftingGuard();
|
||||
for (var key : new NamespacedKey[]{new NamespacedKey("minecraft", "paper"), new NamespacedKey("other", "character_sheet")}) {
|
||||
var recipe = new ShapelessRecipe(key, new ItemStack(Material.PAPER)).addIngredient(Material.SUGAR_CANE);
|
||||
for (boolean cancelled : new boolean[]{false, true}) {
|
||||
var event = new CrafterCraftEvent(untouchedBlock(), recipe, recipe.getResult());
|
||||
event.setCancelled(cancelled);
|
||||
guard.onAutomatedCraft(event);
|
||||
assertEquals(cancelled, event.isCancelled());
|
||||
assertEquals(recipe.getResult(), event.getResult());
|
||||
}
|
||||
}
|
||||
var ours = new CharacterSheets().recipe();
|
||||
var alreadyCancelled = new CrafterCraftEvent(untouchedBlock(), ours, ours.getResult());
|
||||
alreadyCancelled.setCancelled(true);
|
||||
guard.onAutomatedCraft(alreadyCancelled);
|
||||
assertTrue(alreadyCancelled.isCancelled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void declaresTheNativeEventHookWithoutOverridingPriorCancellations() throws Exception {
|
||||
var annotation = ManualCraftingGuard.class.getMethod("onAutomatedCraft", CrafterCraftEvent.class).getAnnotation(EventHandler.class);
|
||||
assertNotNull(annotation);
|
||||
assertEquals(EventPriority.HIGHEST, annotation.priority());
|
||||
assertTrue(annotation.ignoreCancelled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelsAutomatedSheetCraftWithoutTouchingTheBlockOrResult() {
|
||||
var recipe = new CharacterSheets().recipe();
|
||||
var result = recipe.getResult();
|
||||
var event = new CrafterCraftEvent(untouchedBlock(), recipe, result);
|
||||
new ManualCraftingGuard().onAutomatedCraft(event);
|
||||
assertTrue(event.isCancelled(), "The native Crafter must stop before consumption/ejection");
|
||||
assertEquals(result, event.getResult());
|
||||
assertEquals(Material.PAPER, event.getResult().getType());
|
||||
assertTrue(new CharacterSheets().identify(event.getResult()).isEmpty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user