Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.cyr1en.promptpaper.screen.playerui;

import com.destroystokyo.paper.profile.PlayerProfile;
import dev.cyr1en.promptpaper.CommandPrompter;
import dev.cyr1en.promptpaper.hook.HookContainer;
import dev.cyr1en.promptpaper.hook.hooks.FilterHook;
Expand All @@ -11,20 +12,23 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.Material;
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;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.SkullMeta;
import org.bukkit.persistence.PersistentDataType;

/**
* Maintains a bounded LRU cache of player-head {@link ItemStack}s used by {@link PlayerUIScreen}
Expand All @@ -41,18 +45,27 @@ public class HeadCache implements Listener {
private final CommandPrompter plugin;
private final Scheduler scheduler;
private final int maxCacheSize;
private final Map<UUID, Optional<ItemStack>> cache;
private final NamespacedKey ownerKey;
// Compound operations hold the map's own lock (synchronized (cache)).
private final Map<UUID, CachedHead> cache;
private final List<CacheFilter> filters;

/**
* A built head plus the textures key it was built from ({@code null} for a head built without
* textures). Both live in one value, so there is no second structure to keep in sync.
*/
private record CachedHead(ItemStack item, String textures) {}

public HeadCache(CommandPrompter plugin, Scheduler scheduler) {
this.plugin = plugin;
this.scheduler = scheduler;
this.maxCacheSize = plugin.getConfigLoader().getPromptConfig().cacheSize();
this.ownerKey = new NamespacedKey(plugin, "head_owner");
this.cache =
Collections.synchronizedMap(
new LinkedHashMap<UUID, Optional<ItemStack>>(16, 0.75f, true) {
new LinkedHashMap<UUID, CachedHead>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<UUID, Optional<ItemStack>> eldest) {
protected boolean removeEldestEntry(Map.Entry<UUID, CachedHead> eldest) {
int max = maxCacheSize;
return max > 0 && size() > max;
}
Expand Down Expand Up @@ -151,25 +164,79 @@ private TokenMatch longestMatchAt(String key, int pos) {
*/
public Optional<ItemStack> getHeadFor(Player player) {
if (isVanished(player)) return Optional.empty();
return cache.computeIfAbsent(
player.getUniqueId(),
uuid -> {
if (!Bukkit.getOnlinePlayers().contains(player)) return Optional.empty();
var skull = new ItemStack(Material.PLAYER_HEAD);
var meta = (SkullMeta) Bukkit.getItemFactory().getItemMeta(Material.PLAYER_HEAD);
if (meta != null) {
meta.setOwningPlayer(player);
var promptConfig = plugin.getConfigLoader().getPromptConfig();
var format = promptConfig.skullNameFormat();
var cmData = promptConfig.skullCustomModelData();
meta.displayName(ComponentUtil.mini("<!italic>" + format.formatted(player.getName())));
if (cmData != 0) {
applyCustomModelData(meta, cmData);
}
skull.setItemMeta(meta);
}
return Optional.of(skull);
});
var uuid = player.getUniqueId();
var liveTextures = texturesOf(player.getPlayerProfile());
synchronized (cache) {
// A head built from other textures (none yet, or a skin changed mid-session, e.g. by a skin
// plugin after join) is rebuilt. Every other cached head is reused as-is.
var cached = cache.get(uuid);
if (cached != null && !Objects.equals(cached.textures(), liveTextures)) cache.remove(uuid);
var head = cache.computeIfAbsent(uuid, ignored -> createHead(player, liveTextures));
// Not cached: the player is not online, so there is nothing to show and no entry to clean up.
return head != null ? Optional.of(head.item()) : Optional.empty();
}
}

/**
* Returns the name of the player a head from this cache belongs to, or null if the item is not
* such a head. The name is stored on the item itself, so heads without an owning profile can
* still be identified.
*/
public String getOwnerName(ItemStack head) {
if (head == null) return null;
var meta = head.getItemMeta();
if (meta == null) return null;
var name = meta.getPersistentDataContainer().get(ownerKey, PersistentDataType.STRING);
if (name != null) return name;
if (meta instanceof SkullMeta skull && skull.getOwningPlayer() != null) {
return skull.getOwningPlayer().getName();
}
return null;
}

/**
* Builds the head for an online player, or returns null (nothing is cached) if the player is
* offline.
*
* <p>A skull whose profile has no skin and no cape makes the server call {@link
* PlayerProfile#update()}, an uncached Mojang profile request that can never succeed for
* non-Mojang UUIDs (such as offline-mode version 3 UUIDs). So a textured player gets their live
* profile, an untextured player with a non-version-4 UUID gets a plain head, and only untextured
* version 4 (Mojang) UUIDs keep the lookup-based {@link SkullMeta#setOwningPlayer} fallback. The
* player name is written to the item's persistent data so plain heads stay selectable.
*/
private CachedHead createHead(Player player, String textures) {
if (!Bukkit.getOnlinePlayers().contains(player)) return null;
var skull = new ItemStack(Material.PLAYER_HEAD);
var meta = (SkullMeta) Bukkit.getItemFactory().getItemMeta(Material.PLAYER_HEAD);
if (meta != null) {
if (textures != null) {
meta.setPlayerProfile(player.getPlayerProfile());
} else if (player.getUniqueId().version() == 4) {
meta.setOwningPlayer(player);
}
meta.getPersistentDataContainer().set(ownerKey, PersistentDataType.STRING, player.getName());
var promptConfig = plugin.getConfigLoader().getPromptConfig();
var format = promptConfig.skullNameFormat();
var cmData = promptConfig.skullCustomModelData();
meta.displayName(ComponentUtil.mini("<!italic>" + format.formatted(player.getName())));
if (cmData != 0) {
applyCustomModelData(meta, cmData);
}
skull.setItemMeta(meta);
}
return new CachedHead(skull, textures);
}

/**
* Returns the profile's skin and cape URLs as one key, or null if it has neither (the same check
* the server makes before looking a skull profile up).
*/
private static String texturesOf(PlayerProfile profile) {
if (profile == null) return null;
var textures = profile.getTextures();
if (textures.isEmpty()) return null;
return textures.getSkin() + "|" + textures.getCape();
}

@SuppressWarnings("deprecation")
Expand All @@ -192,14 +259,17 @@ public void invalidate(Player player) {
* cached.
*/
public List<ItemStack> getHeads() {
return cache.entrySet().stream()
.filter(entry -> entry.getValue().isPresent())
List<Map.Entry<UUID, CachedHead>> entries;
synchronized (cache) {
entries = cache.entrySet().stream().map(e -> Map.entry(e.getKey(), e.getValue())).toList();
}
return entries.stream()
.filter(
entry -> {
var cachedPlayer = Bukkit.getPlayer(entry.getKey());
return cachedPlayer != null && !isVanished(cachedPlayer);
})
.map(entry -> entry.getValue().get())
.map(entry -> entry.getValue().item())
.toList();
}

Expand All @@ -219,8 +289,7 @@ public List<ItemStack> getHeadsSorted() {
}

public int size() {
// Count only populated entries so empty/unloaded heads are detected as stale.
return (int) cache.values().stream().filter(Optional::isPresent).count();
return cache.size();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.SkullMeta;

/**
* Prompt screen that displays online players as clickable heads in a paginated chest GUI, with
Expand Down Expand Up @@ -124,24 +123,7 @@ private void openInternal(long token) {
ItemStack head = currentHeads.get(j).clone();
int x = (j - i) % 9;
int y = (j - i) / 9;
page.addItem(
new GuiItem(
head,
event -> {
var meta = head.getItemMeta();
if (meta instanceof SkullMeta skullMeta
&& skullMeta.getOwningPlayer() != null) {
var name = skullMeta.getOwningPlayer().getName();
if (name != null) {
close();
if (callback != null) {
callback.accept(ScreenResult.answer(name));
}
}
}
}),
x,
y);
page.addItem(new GuiItem(head, event -> selectHead(head)), x, y);
}
headPane.addPane(page);
}
Expand Down Expand Up @@ -242,6 +224,14 @@ private static void sortHeads(List<ItemStack> heads) {
String.CASE_INSENSITIVE_ORDER));
}

/** Answers the prompt with the player the clicked head belongs to, if it belongs to one. */
void selectHead(ItemStack head) {
var name = plugin.getHeadCache().getOwnerName(head);
if (name == null) return;
close();
if (callback != null) callback.accept(ScreenResult.answer(name));
}

/**
* Applies the display format of the first (leftmost) combined filter to the given heads,
* returning cloned items so the shared head cache is never mutated. When the filter has no
Expand All @@ -254,16 +244,15 @@ private List<ItemStack> applyFirstFilterFormat(
List<ItemStack> heads, CacheFilter firstFilter, PromptConfig config) {
var format = firstFilter.getFormat(config);
if (format == null || format.isBlank() || "%s".equals(format)) return heads;
var headCache = plugin.getHeadCache();
var formatted = new ArrayList<ItemStack>(heads.size());
for (ItemStack head : heads) {
var clone = head.clone();
var name = headCache.getOwnerName(clone);
var meta = clone.getItemMeta();
if (meta instanceof SkullMeta skull
&& skull.getOwningPlayer() != null
&& skull.getOwningPlayer().getName() != null) {
skull.displayName(
ComponentUtil.mini("<!italic>" + format.formatted(skull.getOwningPlayer().getName())));
clone.setItemMeta(skull);
if (name != null && meta != null) {
meta.displayName(ComponentUtil.mini("<!italic>" + format.formatted(name)));
clone.setItemMeta(meta);
}
formatted.add(clone);
}
Expand Down
Loading
Loading