From f8fdd55c65547d46eafa52a2d468b87b2d76f683 Mon Sep 17 00:00:00 2001 From: JCThePants Date: Thu, 20 Aug 2015 18:32:33 -0700 Subject: [PATCH 1/2] improve player NPC skins add skin packages with skin classes add profile package with profile fetcher classes update commands update EventListen update NMS fix radius squared fix exception message fix npc sometimes not removed from playerlist fix cannot add npc to playerlist code/comment cleanup and refactoring remove unused skin settings removed NPC_SKIN_RETRY_DELAY, MAX_NPC_SKIN_RETRIES current code uses cached textures if a skin profile request fails. add setting for updating skin added NPC_SKIN_UPDATE: true to always get the latest skin, false to use cached skin if available, default is false. minor code fixes, refactoring, add settings removed assert removed thread checks added setting: NPC_SKIN_VIEW_DISTANCE added setting: NPC_SKIN_UPDATE_DISTANCE added setting: MAX_PACKET_ENTRIES invoke EventListen#SkinUpdateTracker#reset from within #shouldUpdate instead of requiring it to be invoked manually. fix cached locations not used in EventListen#getNearbySkinnableNPCs clamp yaw in EventListen.SkinUpdateTracker use static constants rename EntitySkinnable to SkinnableEntity add SkinnableEntity interface to PlayerNPC (CraftPlayer) remove unused code from PlayerListRemover replace Subscriber with direct notification to entity via method Undo EntityController interface changes moved skin code from HumanController to CitizensNPC fix npcs sometimes do not show ... due to packet tracker not being notified that remove packets have been cancelled fix imports rearranged by incorrect IDE settings --- .../java/net/citizensnpcs/EventListen.java | 249 +++++++++------ src/main/java/net/citizensnpcs/Settings.java | 6 +- .../citizensnpcs/commands/NPCCommands.java | 14 +- .../npc/AbstractEntityController.java | 1 - .../net/citizensnpcs/npc/CitizensNPC.java | 77 +++-- .../npc/entity/EntityHumanNPC.java | 71 ++++- .../npc/entity/HumanController.java | 301 ++---------------- .../npc/profile/ProfileFetchResult.java | 29 ++ .../npc/profile/ProfileFetchSubscriber.java | 14 + .../npc/profile/ProfileFetchThread.java | 99 ++++++ .../npc/profile/ProfileFetcher.java | 128 ++++++++ .../npc/profile/ProfileRequest.java | 110 +++++++ .../npc/skin/PlayerListRemover.java | 156 +++++++++ .../java/net/citizensnpcs/npc/skin/Skin.java | 274 ++++++++++++++++ .../npc/skin/SkinPacketTracker.java | 237 ++++++++++++++ .../npc/skin/SkinnableEntity.java | 40 +++ src/main/java/net/citizensnpcs/util/NMS.java | 144 +++++++-- .../util/nms/PlayerlistTrackerEntry.java | 45 ++- 18 files changed, 1535 insertions(+), 460 deletions(-) create mode 100644 src/main/java/net/citizensnpcs/npc/profile/ProfileFetchResult.java create mode 100644 src/main/java/net/citizensnpcs/npc/profile/ProfileFetchSubscriber.java create mode 100644 src/main/java/net/citizensnpcs/npc/profile/ProfileFetchThread.java create mode 100644 src/main/java/net/citizensnpcs/npc/profile/ProfileFetcher.java create mode 100644 src/main/java/net/citizensnpcs/npc/profile/ProfileRequest.java create mode 100644 src/main/java/net/citizensnpcs/npc/skin/PlayerListRemover.java create mode 100644 src/main/java/net/citizensnpcs/npc/skin/Skin.java create mode 100644 src/main/java/net/citizensnpcs/npc/skin/SkinPacketTracker.java create mode 100644 src/main/java/net/citizensnpcs/npc/skin/SkinnableEntity.java diff --git a/src/main/java/net/citizensnpcs/EventListen.java b/src/main/java/net/citizensnpcs/EventListen.java index a7e76525f..385e428a6 100644 --- a/src/main/java/net/citizensnpcs/EventListen.java +++ b/src/main/java/net/citizensnpcs/EventListen.java @@ -2,46 +2,12 @@ package net.citizensnpcs; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.UUID; -import org.bukkit.Bukkit; -import org.bukkit.Chunk; -import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.craftbukkit.v1_8_R3.entity.CraftPlayer; -import org.bukkit.entity.Entity; -import org.bukkit.entity.EntityType; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.entity.CreatureSpawnEvent; -import org.bukkit.event.entity.EntityCombustByBlockEvent; -import org.bukkit.event.entity.EntityCombustByEntityEvent; -import org.bukkit.event.entity.EntityCombustEvent; -import org.bukkit.event.entity.EntityDamageByBlockEvent; -import org.bukkit.event.entity.EntityDamageByEntityEvent; -import org.bukkit.event.entity.EntityDamageEvent; -import org.bukkit.event.entity.EntityDeathEvent; -import org.bukkit.event.entity.EntityTargetEvent; -import org.bukkit.event.player.PlayerChangedWorldEvent; -import org.bukkit.event.player.PlayerInteractEntityEvent; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.event.player.PlayerQuitEvent; -import org.bukkit.event.player.PlayerRespawnEvent; -import org.bukkit.event.player.PlayerTeleportEvent; -import org.bukkit.event.vehicle.VehicleEnterEvent; -import org.bukkit.event.world.ChunkLoadEvent; -import org.bukkit.event.world.ChunkUnloadEvent; -import org.bukkit.event.world.WorldLoadEvent; -import org.bukkit.event.world.WorldUnloadEvent; -import org.bukkit.inventory.meta.SkullMeta; -import org.bukkit.scheduler.BukkitRunnable; -import org.bukkit.scoreboard.Team; - import com.google.common.base.Predicates; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Iterables; @@ -74,17 +40,53 @@ import net.citizensnpcs.api.trait.trait.Owner; import net.citizensnpcs.api.util.DataKey; import net.citizensnpcs.api.util.Messaging; import net.citizensnpcs.editor.Editor; +import net.citizensnpcs.npc.skin.SkinnableEntity; import net.citizensnpcs.trait.Controllable; import net.citizensnpcs.trait.CurrentLocation; import net.citizensnpcs.util.Messages; import net.citizensnpcs.util.NMS; -import net.minecraft.server.v1_8_R3.EntityPlayer; -import net.minecraft.server.v1_8_R3.PacketPlayOutPlayerInfo; + +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.event.entity.EntityCombustByBlockEvent; +import org.bukkit.event.entity.EntityCombustByEntityEvent; +import org.bukkit.event.entity.EntityCombustEvent; +import org.bukkit.event.entity.EntityDamageByBlockEvent; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.event.entity.EntityTargetEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.vehicle.VehicleEnterEvent; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; +import org.bukkit.event.world.WorldLoadEvent; +import org.bukkit.event.world.WorldUnloadEvent; +import org.bukkit.inventory.meta.SkullMeta; +import org.bukkit.scheduler.BukkitRunnable; +import org.bukkit.scoreboard.Team; public class EventListen implements Listener { private final NPCRegistry npcRegistry = CitizensAPI.getNPCRegistry(); private final Map registries; private final ListMultimap toRespawn = ArrayListMultimap.create(); + private final Map skinUpdateTrackers = + new HashMap(Bukkit.getMaxPlayers() / 2); EventListen(Map registries) { this.registries = registries; @@ -338,7 +340,7 @@ public class EventListen implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onPlayerChangeWorld(PlayerChangedWorldEvent event) { - recalculatePlayer(event.getPlayer()); + recalculatePlayer(event.getPlayer(), 20, true); } @EventHandler(ignoreCancelled = true) @@ -360,7 +362,7 @@ public class EventListen implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onPlayerJoin(PlayerJoinEvent event) { - recalculatePlayer(event.getPlayer()); + recalculatePlayer(event.getPlayer(), 20, true); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @@ -372,16 +374,17 @@ public class EventListen implements Listener { event.getPlayer().leaveVehicle(); } } + skinUpdateTrackers.remove(event.getPlayer().getUniqueId()); } @EventHandler(priority = EventPriority.MONITOR) public void onPlayerRespawn(PlayerRespawnEvent event) { - recalculatePlayer(event.getPlayer()); + recalculatePlayer(event.getPlayer(), 15, true); } @EventHandler(priority = EventPriority.MONITOR) public void onPlayerTeleport(PlayerTeleportEvent event) { - recalculatePlayer(event.getPlayer()); + recalculatePlayer(event.getPlayer(), 15, true); } @EventHandler(ignoreCancelled = true) @@ -423,34 +426,68 @@ public class EventListen implements Listener { } } - public void recalculatePlayer(final Player player) { + // recalculate player NPCs the first time a player moves and every time + // a player moves a certain distance from their last position. + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerMove(final PlayerMoveEvent event) { + + SkinUpdateTracker updateTracker = skinUpdateTrackers.get(event.getPlayer().getUniqueId()); + if (updateTracker == null) + return; + + if (!updateTracker.shouldUpdate(event.getPlayer())) + return; + + recalculatePlayer(event.getPlayer(), 10, false); + } + + public void recalculatePlayer(final Player player, long delay, final boolean isInitial) { + + if (isInitial) { + skinUpdateTrackers.put(player.getUniqueId(), new SkinUpdateTracker(player)); + } + new BukkitRunnable() { + @Override public void run() { - final List nearbyNPCs = new ArrayList(); - for (NPC npc : getAllNPCs()) { - Entity npcEntity = npc.getEntity(); - if (npcEntity instanceof Player && player.canSee((Player) npcEntity) - && player.getWorld().equals(npcEntity.getWorld()) - && player.getLocation().distanceSquared(npcEntity.getLocation()) < 100 * 100) { - nearbyNPCs.add(((CraftPlayer) npcEntity).getHandle()); - } - } - new BukkitRunnable() { - @Override - public void run() { - sendToPlayer(player, nearbyNPCs); - } - }.runTaskLater(CitizensAPI.getPlugin(), 30); - new BukkitRunnable() { - @Override - public void run() { - sendToPlayer(player, nearbyNPCs); - } - }.runTaskLater(CitizensAPI.getPlugin(), 70); - } - }.runTaskLater(CitizensAPI.getPlugin(), 10); + List nearbyNPCs = getNearbySkinnableNPCs(player); + for (SkinnableEntity npc : nearbyNPCs) { + npc.getSkinTracker().updateViewer(player); + } + + if (!nearbyNPCs.isEmpty() && isInitial) { + // one more time to help when resource pack load times + // prevent immediate skin loading + recalculatePlayer(player, 40, false); + } + } + }.runTaskLater(CitizensAPI.getPlugin(), delay); + } + + private List getNearbySkinnableNPCs(Player player) { + + List results = new ArrayList(); + + double viewDistance = Setting.NPC_SKIN_VIEW_DISTANCE.asDouble(); + viewDistance *= viewDistance; + + for (NPC npc : getAllNPCs()) { + + Entity npcEntity = npc.getEntity(); + if (npcEntity instanceof Player + && player.canSee((Player) npcEntity) + && player.getWorld().equals(npcEntity.getWorld()) + && player.getLocation(CACHE_LOCATION) + .distanceSquared(npc.getStoredLocation()) < viewDistance) { + + SkinnableEntity skinnable = NMS.getSkinnableNPC(npcEntity); + + results.add(skinnable); + } + } + return results; } private void respawnAllFromCoord(ChunkCoord coord) { @@ -472,30 +509,6 @@ public class EventListen implements Listener { } } - void sendToPlayer(final Player player, final List nearbyNPCs) { - if (!player.isValid()) - return; - for (EntityPlayer nearbyNPC : nearbyNPCs) { - if (nearbyNPC.isAlive()) - NMS.sendPacket(player, new PacketPlayOutPlayerInfo( - PacketPlayOutPlayerInfo.EnumPlayerInfoAction.ADD_PLAYER, nearbyNPC)); - } - if (Setting.DISABLE_TABLIST.asBoolean()) { - new BukkitRunnable() { - @Override - public void run() { - if (!player.isValid()) - return; - for (EntityPlayer nearbyNPC : nearbyNPCs) { - if (nearbyNPC.isAlive()) - NMS.sendPacket(player, new PacketPlayOutPlayerInfo( - PacketPlayOutPlayerInfo.EnumPlayerInfoAction.REMOVE_PLAYER, nearbyNPC)); - } - } - }.runTaskLater(CitizensAPI.getPlugin(), 2); - } - } - private boolean spawn(NPC npc) { Location spawn = npc.getTrait(CurrentLocation.class).getLocation(); if (spawn == null) { @@ -559,4 +572,62 @@ public class EventListen implements Listener { return prime * (prime * (prime + ((worldName == null) ? 0 : worldName.hashCode())) + x) + z; } } + + private class SkinUpdateTracker { + float initialYaw; + final Location location = new Location(null, 0, 0, 0); + boolean hasMoved; + int rotationCount; + + SkinUpdateTracker(Player player) { + reset(player); + } + + boolean shouldUpdate(Player player) { + + // check if this is the first time the player has moved + if (!hasMoved) { + hasMoved = true; + reset(player); + return true; + } + + Location currentLoc = player.getLocation(YAW_LOCATION); + float currentYaw = currentLoc.getYaw(); + + float rotationDegrees = Setting.NPC_SKIN_ROTATION_UPDATE_DEGREES.asFloat(); + + boolean hasRotated = + Math.abs(NMS.clampYaw(currentYaw - this.initialYaw)) < rotationDegrees; + + // update the first 2 times the player rotates. helps load skins around player + // after the player logs/teleports. + if (hasRotated && rotationCount < 2) { + rotationCount++; + reset(player); + return true; + } + + // update every time a player moves a certain distance + double distance = currentLoc.distanceSquared(this.location); + if (distance > MOVEMENT_SKIN_UPDATE_DISTANCE) { + reset(player); + return true; + } + else { + return false; + } + } + + // resets initial yaw and location to the players + // current location and yaw. + void reset(Player player) { + player.getLocation(location); + this.initialYaw = location.getYaw(); + } + } + + private static final Location YAW_LOCATION = new Location(null, 0, 0, 0); + private static final Location CACHE_LOCATION = new Location(null, 0, 0, 0); + private static final int MOVEMENT_SKIN_UPDATE_DISTANCE = 50 * 50; } diff --git a/src/main/java/net/citizensnpcs/Settings.java b/src/main/java/net/citizensnpcs/Settings.java index 05843b0f4..6b3fc35e9 100644 --- a/src/main/java/net/citizensnpcs/Settings.java +++ b/src/main/java/net/citizensnpcs/Settings.java @@ -88,14 +88,16 @@ public class Settings { KEEP_CHUNKS_LOADED("npc.chunks.always-keep-loaded", false), LOCALE("general.translation.locale", ""), MAX_NPC_LIMIT_CHECKS("npc.limits.max-permission-checks", 100), - MAX_NPC_SKIN_RETRIES("npc.skins.max-retries", -1), + MAX_PACKET_ENTRIES("npc.limits.max-packet-entries", 15), MAX_SPEED("npc.limits.max-speed", 100), MAX_TEXT_RANGE("npc.chat.options.max-text-range", 500), MESSAGE_COLOUR("general.color-scheme.message", ""), NEW_PATHFINDER_OPENS_DOORS("npc.pathfinding.new-finder-open-doors", false), NPC_ATTACK_DISTANCE("npc.pathfinding.attack-range", 1.75 * 1.75), NPC_COST("economy.npc.cost", 100D), - NPC_SKIN_RETRY_DELAY("npc.skins.retry-delay", 120), + NPC_SKIN_UPDATE("npc.skins.update", false), + NPC_SKIN_VIEW_DISTANCE("npc.skins.view-distance", 100D), + NPC_SKIN_ROTATION_UPDATE_DEGREES("npc.skins.rotation-update-degrees", 90f), PACKET_UPDATE_DELAY("npc.packets.update-delay", 30), QUICK_SELECT("npc.selection.quick-select", false), REMOVE_PLAYERS_FROM_PLAYER_LIST("npc.player.remove-from-list", true), diff --git a/src/main/java/net/citizensnpcs/commands/NPCCommands.java b/src/main/java/net/citizensnpcs/commands/NPCCommands.java index cf7548d58..8b5e7095b 100644 --- a/src/main/java/net/citizensnpcs/commands/NPCCommands.java +++ b/src/main/java/net/citizensnpcs/commands/NPCCommands.java @@ -5,6 +5,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; +import net.citizensnpcs.npc.skin.SkinnableEntity; import org.apache.commons.lang3.StringUtils; import org.bukkit.Bukkit; import org.bukkit.DyeColor; @@ -992,11 +993,13 @@ public class NPCCommands { boolean remove = !npc.data().get("removefromplayerlist", Setting.REMOVE_PLAYERS_FROM_PLAYER_LIST.asBoolean()); if (args.hasFlag('a')) { remove = false; - } else if (args.hasFlag('r')) + } else if (args.hasFlag('r')) { remove = true; + } npc.data().setPersistent("removefromplayerlist", remove); if (npc.isSpawned()) { - NMS.addOrRemoveFromPlayerList(npc.getEntity(), remove); + npc.despawn(DespawnReason.PENDING_RESPAWN); + npc.spawn(npc.getTrait(CurrentLocation.class).getLocation()); } Messaging.sendTr(sender, remove ? Messages.REMOVED_FROM_PLAYERLIST : Messages.ADDED_TO_PLAYERLIST, npc.getName()); @@ -1315,8 +1318,11 @@ public class NPCCommands { } Messaging.sendTr(sender, Messages.SKIN_SET, npc.getName(), skinName); if (npc.isSpawned()) { - npc.despawn(DespawnReason.PENDING_RESPAWN); - npc.spawn(npc.getStoredLocation()); + + SkinnableEntity skinnable = NMS.getSkinnableNPC(npc.getEntity()); + if (skinnable != null) { + skinnable.setSkinName(skinName); + } } } diff --git a/src/main/java/net/citizensnpcs/npc/AbstractEntityController.java b/src/main/java/net/citizensnpcs/npc/AbstractEntityController.java index 62113e5aa..70294206a 100644 --- a/src/main/java/net/citizensnpcs/npc/AbstractEntityController.java +++ b/src/main/java/net/citizensnpcs/npc/AbstractEntityController.java @@ -2,7 +2,6 @@ package net.citizensnpcs.npc; import net.citizensnpcs.api.npc.NPC; import net.citizensnpcs.util.NMS; - import org.bukkit.Location; import org.bukkit.entity.Entity; diff --git a/src/main/java/net/citizensnpcs/npc/CitizensNPC.java b/src/main/java/net/citizensnpcs/npc/CitizensNPC.java index 8a71d4424..b59c887c0 100644 --- a/src/main/java/net/citizensnpcs/npc/CitizensNPC.java +++ b/src/main/java/net/citizensnpcs/npc/CitizensNPC.java @@ -1,27 +1,13 @@ package net.citizensnpcs.npc; -import java.util.Arrays; import java.util.Collection; import java.util.UUID; -import org.bukkit.Bukkit; -import org.bukkit.Location; -import org.bukkit.block.Block; -import org.bukkit.craftbukkit.v1_8_R3.entity.CraftEntity; -import org.bukkit.craftbukkit.v1_8_R3.entity.CraftLivingEntity; -import org.bukkit.craftbukkit.v1_8_R3.entity.CraftPlayer; -import org.bukkit.entity.Entity; -import org.bukkit.entity.EntityType; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Player; -import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason; -import org.bukkit.metadata.FixedMetadataValue; -import org.bukkit.scheduler.BukkitRunnable; - import com.google.common.base.Preconditions; import com.google.common.base.Throwables; import net.citizensnpcs.NPCNeedsRespawnEvent; +import net.citizensnpcs.Settings; import net.citizensnpcs.Settings.Setting; import net.citizensnpcs.api.CitizensAPI; import net.citizensnpcs.api.ai.Navigator; @@ -40,13 +26,25 @@ import net.citizensnpcs.api.util.DataKey; import net.citizensnpcs.api.util.Messaging; import net.citizensnpcs.npc.ai.CitizensBlockBreaker; import net.citizensnpcs.npc.ai.CitizensNavigator; +import net.citizensnpcs.npc.skin.SkinnableEntity; import net.citizensnpcs.trait.CurrentLocation; import net.citizensnpcs.util.Messages; import net.citizensnpcs.util.NMS; import net.citizensnpcs.util.Util; -import net.minecraft.server.v1_8_R3.Packet; import net.minecraft.server.v1_8_R3.PacketPlayOutEntityTeleport; -import net.minecraft.server.v1_8_R3.PacketPlayOutPlayerInfo; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.craftbukkit.v1_8_R3.entity.CraftEntity; +import org.bukkit.craftbukkit.v1_8_R3.entity.CraftLivingEntity; +import org.bukkit.craftbukkit.v1_8_R3.entity.CraftPlayer; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.metadata.FixedMetadataValue; public class CitizensNPC extends AbstractNPC { private EntityController entityController; @@ -188,7 +186,28 @@ public class CitizensNPC extends AbstractNPC { entityController.spawn(at, this); net.minecraft.server.v1_8_R3.Entity mcEntity = ((CraftEntity) getEntity()).getHandle(); - boolean couldSpawn = !Util.isLoaded(at) ? false : mcEntity.world.addEntity(mcEntity, SpawnReason.CUSTOM); + boolean couldSpawn = !Util.isLoaded(at) ? false : mcEntity.world.addEntity(mcEntity, CreatureSpawnEvent.SpawnReason.CUSTOM); + + // send skin packets, if applicable, before other NMS packets are sent + SkinnableEntity skinnable = NMS.getSkinnableNPC(getEntity()); + if (skinnable != null) { + final double viewDistance = Settings.Setting.NPC_SKIN_VIEW_DISTANCE.asDouble(); + skinnable.getSkinTracker().updateNearbyViewers(viewDistance); + Bukkit.getScheduler().scheduleSyncDelayedTask(CitizensAPI.getPlugin(), new Runnable() { + @Override + public void run() { + + if (getEntity() == null || !getEntity().isValid()) + return; + + SkinnableEntity npc = NMS.getSkinnableNPC(getEntity()); + if (npc == null) + return; + + npc.getSkinTracker().updateNearbyViewers(viewDistance); + } + }, 20); + } mcEntity.setPositionRotation(at.getX(), at.getY(), at.getZ(), at.getYaw(), at.getPitch()); @@ -245,29 +264,9 @@ public class CitizensNPC extends AbstractNPC { if (getEntity() instanceof Player) { final CraftPlayer player = (CraftPlayer) getEntity(); NMS.replaceTrackerEntry(player); - new BukkitRunnable() { - @Override - public void run() { - NMS.sendPacketsNearby(player, player.getLocation(), - Arrays.asList((Packet) new PacketPlayOutPlayerInfo( - PacketPlayOutPlayerInfo.EnumPlayerInfoAction.ADD_PLAYER, player.getHandle())), - 200.0); - if (Setting.DISABLE_TABLIST.asBoolean()) { - new BukkitRunnable() { - @Override - public void run() { - NMS.sendPacketsNearby(player, player.getLocation(), - Arrays.asList((Packet) new PacketPlayOutPlayerInfo( - PacketPlayOutPlayerInfo.EnumPlayerInfoAction.REMOVE_PLAYER, - player.getHandle())), - 200.0); - } - }.runTaskLater(CitizensAPI.getPlugin(), 2); - } - } - }.runTaskLater(CitizensAPI.getPlugin(), 2); } } + return true; } diff --git a/src/main/java/net/citizensnpcs/npc/entity/EntityHumanNPC.java b/src/main/java/net/citizensnpcs/npc/entity/EntityHumanNPC.java index 501315cbe..120a1d048 100644 --- a/src/main/java/net/citizensnpcs/npc/entity/EntityHumanNPC.java +++ b/src/main/java/net/citizensnpcs/npc/entity/EntityHumanNPC.java @@ -4,9 +4,13 @@ import java.io.IOException; import java.net.Socket; import java.util.List; +import com.google.common.base.Preconditions; +import com.mojang.authlib.GameProfile; + import net.citizensnpcs.Settings.Setting; import net.citizensnpcs.api.CitizensAPI; import net.citizensnpcs.api.event.NPCPushEvent; +import net.citizensnpcs.api.npc.MetadataStore; import net.citizensnpcs.api.npc.NPC; import net.citizensnpcs.api.trait.trait.Inventory; import net.citizensnpcs.npc.CitizensNPC; @@ -14,6 +18,8 @@ import net.citizensnpcs.npc.ai.NPCHolder; import net.citizensnpcs.npc.network.EmptyNetHandler; import net.citizensnpcs.npc.network.EmptyNetworkManager; import net.citizensnpcs.npc.network.EmptySocket; +import net.citizensnpcs.npc.skin.SkinPacketTracker; +import net.citizensnpcs.npc.skin.SkinnableEntity; import net.citizensnpcs.util.NMS; import net.citizensnpcs.util.Util; import net.citizensnpcs.util.nms.PlayerControllerJump; @@ -40,16 +46,16 @@ import net.minecraft.server.v1_8_R3.WorldServer; import net.minecraft.server.v1_8_R3.WorldSettings.EnumGamemode; import org.bukkit.Bukkit; +import org.bukkit.ChatColor; import org.bukkit.Location; import org.bukkit.craftbukkit.v1_8_R3.CraftServer; import org.bukkit.craftbukkit.v1_8_R3.entity.CraftPlayer; +import org.bukkit.entity.Player; import org.bukkit.metadata.MetadataValue; import org.bukkit.plugin.Plugin; import org.bukkit.util.Vector; -import com.mojang.authlib.GameProfile; - -public class EntityHumanNPC extends EntityPlayer implements NPCHolder { +public class EntityHumanNPC extends EntityPlayer implements NPCHolder, SkinnableEntity { private PlayerControllerJump controllerJump; private PlayerControllerLook controllerLook; private PlayerControllerMove controllerMove; @@ -58,16 +64,21 @@ public class EntityHumanNPC extends EntityPlayer implements NPCHolder { private PlayerNavigation navigation; private final CitizensNPC npc; private final Location packetLocationCache = new Location(null, 0, 0, 0); + private final SkinPacketTracker skinTracker; public EntityHumanNPC(MinecraftServer minecraftServer, WorldServer world, GameProfile gameProfile, - PlayerInteractManager playerInteractManager, NPC npc) { + PlayerInteractManager playerInteractManager, NPC npc) { super(minecraftServer, world, gameProfile, playerInteractManager); this.npc = (CitizensNPC) npc; if (npc != null) { + skinTracker = new SkinPacketTracker(this); playerInteractManager.setGameMode(EnumGamemode.SURVIVAL); initialise(minecraftServer); } + else { + skinTracker = null; + } } @Override @@ -185,6 +196,31 @@ public class EntityHumanNPC extends EntityPlayer implements NPCHolder { return npc; } + @Override + public SkinPacketTracker getSkinTracker() { + return skinTracker; + } + + @Override + public String getSkinName() { + + MetadataStore meta = npc.data(); + + String skinName = meta.get(NPC.PLAYER_SKIN_UUID_METADATA); + if (skinName == null) { + skinName = ChatColor.stripColor(getName()); + } + return skinName.toLowerCase(); + } + + @Override + public void setSkinName(String name) { + Preconditions.checkNotNull(name); + + npc.data().setPersistent(NPC.PLAYER_SKIN_UUID_METADATA, name.toLowerCase()); + skinTracker.notifySkinChange(); + } + private void initialise(MinecraftServer minecraftServer) { Socket socket = new EmptySocket(); NetworkManager conn = null; @@ -296,6 +332,7 @@ public class EntityHumanNPC extends EntityPlayer implements NPCHolder { } private void updatePackets(boolean navigating) { + if (world.getWorld().getFullTime() % Setting.PACKET_UPDATE_DELAY.asInt() == 0) { // set skin flag byte to all visible (DataWatcher API is lacking so // catch the NPE as a sign that this is a MC 1.7 server without the @@ -316,10 +353,6 @@ public class EntityHumanNPC extends EntityPlayer implements NPCHolder { packets[i] = new PacketPlayOutEntityEquipment(getId(), i, getEquipment(i)); } - boolean removeFromPlayerList = npc.data().get("removefromplayerlist", - Setting.REMOVE_PLAYERS_FROM_PLAYER_LIST.asBoolean()); - NMS.addOrRemoveFromPlayerList(getBukkitEntity(), removeFromPlayerList); - NMS.sendPlayerlistPacket(false, getBukkitEntity()); NMS.sendPacketsNearby(getBukkitEntity(), current, packets); } } @@ -328,7 +361,7 @@ public class EntityHumanNPC extends EntityPlayer implements NPCHolder { this.navigation.setRange(pathfindingRange); } - public static class PlayerNPC extends CraftPlayer implements NPCHolder { + public static class PlayerNPC extends CraftPlayer implements NPCHolder, SkinnableEntity { private final CraftServer cserver; private final CitizensNPC npc; @@ -372,6 +405,26 @@ public class EntityHumanNPC extends EntityPlayer implements NPCHolder { public void setMetadata(String metadataKey, MetadataValue newMetadataValue) { cserver.getEntityMetadata().setMetadata(this, metadataKey, newMetadataValue); } + + @Override + public SkinPacketTracker getSkinTracker() { + return ((SkinnableEntity)this.entity).getSkinTracker(); + } + + @Override + public Player getBukkitEntity() { + return this; + } + + @Override + public String getSkinName() { + return ((SkinnableEntity)this.entity).getSkinName(); + } + + @Override + public void setSkinName(String name) { + ((SkinnableEntity)this.entity).setSkinName(name); + } } private static final float EPSILON = 0.005F; diff --git a/src/main/java/net/citizensnpcs/npc/entity/HumanController.java b/src/main/java/net/citizensnpcs/npc/entity/HumanController.java index 046fe92bf..6b36e521e 100644 --- a/src/main/java/net/citizensnpcs/npc/entity/HumanController.java +++ b/src/main/java/net/citizensnpcs/npc/entity/HumanController.java @@ -1,57 +1,33 @@ package net.citizensnpcs.npc.entity; -import java.lang.reflect.Method; -import java.net.URL; -import java.util.Iterator; -import java.util.Map; import java.util.UUID; -import java.util.concurrent.BlockingDeque; -import java.util.concurrent.Callable; -import java.util.concurrent.LinkedBlockingDeque; import java.util.regex.Pattern; +import com.mojang.authlib.GameProfile; + +import net.citizensnpcs.api.CitizensAPI; +import net.citizensnpcs.api.npc.NPC; +import net.citizensnpcs.api.util.Colorizer; +import net.citizensnpcs.npc.AbstractEntityController; +import net.citizensnpcs.npc.skin.Skin; +import net.citizensnpcs.npc.skin.SkinnableEntity; +import net.citizensnpcs.util.NMS; +import net.minecraft.server.v1_8_R3.PlayerInteractManager; +import net.minecraft.server.v1_8_R3.WorldServer; + import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.Location; -import org.bukkit.craftbukkit.v1_8_R3.CraftServer; import org.bukkit.craftbukkit.v1_8_R3.CraftWorld; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.scoreboard.Scoreboard; import org.bukkit.scoreboard.Team; -import com.google.common.collect.Iterables; -import com.google.common.collect.Maps; -import com.mojang.authlib.Agent; -import com.mojang.authlib.GameProfile; -import com.mojang.authlib.GameProfileRepository; -import com.mojang.authlib.HttpAuthenticationService; -import com.mojang.authlib.ProfileLookupCallback; -import com.mojang.authlib.minecraft.MinecraftSessionService; -import com.mojang.authlib.properties.Property; -import com.mojang.authlib.yggdrasil.YggdrasilAuthenticationService; -import com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService; -import com.mojang.authlib.yggdrasil.response.MinecraftProfilePropertiesResponse; -import com.mojang.util.UUIDTypeAdapter; - -import net.citizensnpcs.Settings.Setting; -import net.citizensnpcs.api.CitizensAPI; -import net.citizensnpcs.api.event.DespawnReason; -import net.citizensnpcs.api.npc.NPC; -import net.citizensnpcs.api.util.Colorizer; -import net.citizensnpcs.api.util.Messaging; -import net.citizensnpcs.npc.AbstractEntityController; -import net.citizensnpcs.util.NMS; -import net.minecraft.server.v1_8_R3.PlayerInteractManager; -import net.minecraft.server.v1_8_R3.WorldServer; - public class HumanController extends AbstractEntityController { + public HumanController() { super(); - if (SKIN_THREAD == null) { - Bukkit.getScheduler().runTaskTimerAsynchronously(CitizensAPI.getPlugin(), SKIN_THREAD = new SkinThread(), - 10, 10); - } } @Override @@ -101,24 +77,29 @@ public class HumanController extends AbstractEntityController { uuid = new UUID(msb, uuid.getLeastSignificantBits()); } - GameProfile profile = new GameProfile(uuid, coloredName); - updateSkin(npc, nmsWorld, profile); + final GameProfile profile = new GameProfile(uuid, coloredName); final EntityHumanNPC handle = new EntityHumanNPC(nmsWorld.getServer().getServer(), nmsWorld, profile, new PlayerInteractManager(nmsWorld), npc); + + Skin skin = handle.getSkinTracker().getSkin(); + if (skin != null) { + skin.apply(handle); + } + handle.setPositionRotation(at.getX(), at.getY(), at.getZ(), at.getYaw(), at.getPitch()); + Bukkit.getScheduler().scheduleSyncDelayedTask(CitizensAPI.getPlugin(), new Runnable() { + @Override public void run() { + if (getBukkitEntity() == null || !getBukkitEntity().isValid()) return; - boolean removeFromPlayerList = Setting.REMOVE_PLAYERS_FROM_PLAYER_LIST.asBoolean(); - NMS.addOrRemoveFromPlayerList(getBukkitEntity(), - npc.data().get("removefromplayerlist", removeFromPlayerList)); if (prefixCapture != null) { Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard(); - String teamName = UUID.randomUUID().toString().substring(0,16); + String teamName = UUID.randomUUID().toString().substring(0, 16); Team team = scoreboard.getTeam(teamName); if (team == null) { @@ -134,6 +115,7 @@ public class HumanController extends AbstractEntityController { } } }, 1); + handle.getBukkitEntity().setSleepingIgnored(true); return handle.getBukkitEntity(); @@ -146,235 +128,14 @@ public class HumanController extends AbstractEntityController { @Override public void remove() { - NMS.sendPlayerlistPacket(false, getBukkitEntity()); + + NMS.removeFromWorld(getBukkitEntity()); + + SkinnableEntity npc = NMS.getSkinnableNPC(getBukkitEntity()); + npc.getSkinTracker().onRemoveNPC(); + super.remove(); } - private void updateSkin(final NPC npc, final WorldServer nmsWorld, GameProfile profile) { - - String skinUUID = npc.data().get(NPC.PLAYER_SKIN_UUID_METADATA); - if (skinUUID == null) { - skinUUID = npc.getName(); - } - if (npc.data().has(CACHED_SKIN_UUID_METADATA) && npc.data().has(CACHED_SKIN_UUID_NAME_METADATA) - && ChatColor.stripColor(skinUUID).equalsIgnoreCase( - ChatColor.stripColor(npc.data(). get(CACHED_SKIN_UUID_NAME_METADATA)))) { - skinUUID = npc.data().get(CACHED_SKIN_UUID_METADATA); - } - if (npc.data().has(PLAYER_SKIN_TEXTURE_PROPERTIES) - && npc.data(). get(PLAYER_SKIN_TEXTURE_PROPERTIES).equals("cache")) { - SKIN_THREAD.addRunnable( - new SkinFetcher(new UUIDFetcher(skinUUID, npc), nmsWorld.getMinecraftServer().aD(), npc)); - return; - } - Property cached = TEXTURE_CACHE.get(skinUUID); - if (npc.data().has(PLAYER_SKIN_TEXTURE_PROPERTIES) && npc.data().has(PLAYER_SKIN_TEXTURE_PROPERTIES_SIGN)) { - cached = new Property("textures", npc.data(). get(PLAYER_SKIN_TEXTURE_PROPERTIES), - npc.data(). get(PLAYER_SKIN_TEXTURE_PROPERTIES_SIGN)); - } - if (cached != null) { - profile.getProperties().put("textures", cached); - } else { - SKIN_THREAD.addRunnable( - new SkinFetcher(new UUIDFetcher(skinUUID, npc), nmsWorld.getMinecraftServer().aD(), npc)); - } - } - - private static class SkinFetcher implements Runnable { - private final NPC npc; - private final MinecraftSessionService repo; - private final Callable uuid; - - public SkinFetcher(Callable uuid, MinecraftSessionService repo, NPC npc) { - this.uuid = uuid; - this.repo = repo; - this.npc = npc; - } - - /* - * Yggdrasil's default implementation of this method silently fails instead of throwing an Exception like it should. - */ - private GameProfile fillProfileProperties(YggdrasilAuthenticationService auth, GameProfile profile, - boolean requireSecure) throws Exception { - URL url = HttpAuthenticationService.constantURL( - new StringBuilder().append("https://sessionserver.mojang.com/session/minecraft/profile/") - .append(UUIDTypeAdapter.fromUUID(profile.getId())).toString()); - url = HttpAuthenticationService.concatenateURL(url, - new StringBuilder().append("unsigned=").append(!requireSecure).toString()); - MinecraftProfilePropertiesResponse response = (MinecraftProfilePropertiesResponse) MAKE_REQUEST.invoke(auth, - url, null, MinecraftProfilePropertiesResponse.class); - if (response == null) { - return profile; - } - GameProfile result = new GameProfile(response.getId(), response.getName()); - result.getProperties().putAll(response.getProperties()); - profile.getProperties().putAll(response.getProperties()); - return result; - } - - @Override - public void run() { - String realUUID; - try { - realUUID = uuid.call(); - } catch (Exception e) { - return; - } - GameProfile skinProfile = null; - Property cached = TEXTURE_CACHE.get(realUUID); - if (cached != null && !(npc.data().has(PLAYER_SKIN_TEXTURE_PROPERTIES) - && npc.data(). get(PLAYER_SKIN_TEXTURE_PROPERTIES).equals("cache"))) { - if (Messaging.isDebugging()) { - Messaging - .debug("Using cached skin texture for NPC " + npc.getName() + " UUID " + npc.getUniqueId()); - } - skinProfile = new GameProfile(UUID.fromString(realUUID), ""); - skinProfile.getProperties().put("textures", cached); - } else { - try { - skinProfile = fillProfileProperties( - ((YggdrasilMinecraftSessionService) repo).getAuthenticationService(), - new GameProfile(UUID.fromString(realUUID), ""), true); - } catch (Exception e) { - if ((e.getMessage() != null && e.getMessage().contains("too many requests")) - || (e.getCause() != null && e.getCause().getMessage() != null - && e.getCause().getMessage().contains("too many requests"))) { - SKIN_THREAD.delay(); - SKIN_THREAD.addRunnable(this); - } - return; - } - - if (skinProfile == null || !skinProfile.getProperties().containsKey("textures")) - return; - Property textures = Iterables.getFirst(skinProfile.getProperties().get("textures"), null); - if (textures.getValue() == null || textures.getSignature() == null) - return; - if (npc.data().has(PLAYER_SKIN_TEXTURE_PROPERTIES) - && npc.data(). get(PLAYER_SKIN_TEXTURE_PROPERTIES).equals("cache")) { - npc.data().setPersistent(PLAYER_SKIN_TEXTURE_PROPERTIES, textures.getValue()); - npc.data().setPersistent(PLAYER_SKIN_TEXTURE_PROPERTIES_SIGN, textures.getSignature()); - } - if (Messaging.isDebugging()) { - Messaging.debug("Fetched skin texture for UUID " + realUUID + " for NPC " + npc.getName() + " UUID " - + npc.getUniqueId()); - } - TEXTURE_CACHE.put(realUUID, new Property("textures", textures.getValue(), textures.getSignature())); - } - if (CitizensAPI.getPlugin().isEnabled()) { - Bukkit.getScheduler().scheduleSyncDelayedTask(CitizensAPI.getPlugin(), new Runnable() { - @Override - public void run() { - if (npc.isSpawned()) { - npc.despawn(DespawnReason.PENDING_RESPAWN); - npc.spawn(npc.getStoredLocation()); - } - } - }); - } - } - } - - public static class SkinThread implements Runnable { - private volatile int delay = 0; - private volatile int retryTimes = 0; - private final BlockingDeque tasks = new LinkedBlockingDeque(); - - public void addRunnable(Runnable r) { - Iterator itr = tasks.iterator(); - while (itr.hasNext()) { - if (((SkinFetcher) itr.next()).npc.getUniqueId().equals(((SkinFetcher) r).npc.getUniqueId())) { - itr.remove(); - } - } - tasks.offer(r); - } - - public void delay() { - delay = Setting.NPC_SKIN_RETRY_DELAY.asInt(); - // need to wait before Mojang accepts API calls again - retryTimes++; - if (Setting.MAX_NPC_SKIN_RETRIES.asInt() >= 0 && retryTimes > Setting.MAX_NPC_SKIN_RETRIES.asInt()) { - tasks.clear(); - retryTimes = 0; - } - } - - @Override - public void run() { - if (delay > 0) { - delay--; - return; - } - Runnable r = tasks.pollFirst(); - if (r == null) { - return; - } - r.run(); - } - - } - - public static class UUIDFetcher implements Callable { - private final NPC npc; - private String reportedUUID; - - public UUIDFetcher(String reportedUUID, NPC npc) { - this.reportedUUID = reportedUUID; - this.npc = npc; - } - - @Override - public String call() throws Exception { - String skinUUID = UUID_CACHE.get(reportedUUID); - if (skinUUID != null) { - npc.data().setPersistent(CACHED_SKIN_UUID_METADATA, skinUUID); - npc.data().setPersistent(CACHED_SKIN_UUID_NAME_METADATA, reportedUUID); - reportedUUID = skinUUID; - } - if (reportedUUID.contains("-")) { - return reportedUUID; - } - final GameProfileRepository repo = ((CraftServer) Bukkit.getServer()).getServer() - .getGameProfileRepository(); - repo.findProfilesByNames(new String[] { ChatColor.stripColor(reportedUUID) }, Agent.MINECRAFT, - new ProfileLookupCallback() { - @Override - public void onProfileLookupFailed(GameProfile arg0, Exception arg1) { - } - - @Override - public void onProfileLookupSucceeded(final GameProfile profile) { - UUID_CACHE.put(reportedUUID, profile.getId().toString()); - if (Messaging.isDebugging()) { - Messaging.debug("Fetched UUID " + profile.getId() + " for NPC " + npc.getName() - + " UUID " + npc.getUniqueId()); - } - npc.data().setPersistent(CACHED_SKIN_UUID_METADATA, profile.getId().toString()); - npc.data().setPersistent(CACHED_SKIN_UUID_NAME_METADATA, profile.getName()); - } - }); - return npc.data().get(CACHED_SKIN_UUID_METADATA, reportedUUID); - } - } - - private static final String CACHED_SKIN_UUID_METADATA = "cached-skin-uuid"; - private static final String CACHED_SKIN_UUID_NAME_METADATA = "cached-skin-uuid-name"; - private static Method MAKE_REQUEST; private static Pattern NON_ALPHABET_MATCHER = Pattern.compile(".*[^A-Za-z0-9_].*"); - private static final String PLAYER_SKIN_TEXTURE_PROPERTIES = "player-skin-textures"; - private static final String PLAYER_SKIN_TEXTURE_PROPERTIES_SIGN = "player-skin-signature"; - private static SkinThread SKIN_THREAD; - private static final Map TEXTURE_CACHE = Maps.newConcurrentMap(); - private static final Map UUID_CACHE = Maps.newConcurrentMap(); - - static { - try { - MAKE_REQUEST = YggdrasilAuthenticationService.class.getDeclaredMethod("makeRequest", URL.class, - Object.class, Class.class); - MAKE_REQUEST.setAccessible(true); - } catch (Exception ex) { - ex.printStackTrace(); - } - } } diff --git a/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchResult.java b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchResult.java new file mode 100644 index 000000000..73c8188d3 --- /dev/null +++ b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchResult.java @@ -0,0 +1,29 @@ +package net.citizensnpcs.npc.profile; + +/** + * The result status of a profile fetch. + */ +public enum ProfileFetchResult { + /** + * The profile has not been fetched yet. + */ + PENDING, + /** + * The profile was successfully fetched. + */ + SUCCESS, + /** + * The profile request failed for unknown reasons. + */ + FAILED, + /** + * The profile request failed because the profile + * was not found. + */ + NOT_FOUND, + /** + * The profile request failed because too many requests + * were sent. + */ + TOO_MANY_REQUESTS +} diff --git a/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchSubscriber.java b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchSubscriber.java new file mode 100644 index 000000000..9b8a29262 --- /dev/null +++ b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchSubscriber.java @@ -0,0 +1,14 @@ +package net.citizensnpcs.npc.profile; + +/** + * Interface for a subscriber of the results of a profile fetch. + */ +public interface ProfileFetchSubscriber { + + /** + * Invoked when a result for a profile is ready. + * + * @param request The profile request that was handled. + */ + void onResult(ProfileRequest request); +} diff --git a/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchThread.java b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchThread.java new file mode 100644 index 000000000..d3838592e --- /dev/null +++ b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchThread.java @@ -0,0 +1,99 @@ +package net.citizensnpcs.npc.profile; + +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; + +import com.google.common.base.Preconditions; + +import net.citizensnpcs.api.CitizensAPI; + +import org.bukkit.Bukkit; + +/** + * Thread used to fetch profiles from the Mojang servers. + * + *

Maintains a cache of profiles so that no profile is ever requested more than once + * during a single server session.

+ */ +public class ProfileFetchThread implements Runnable { + + private final ProfileFetcher profileFetcher = new ProfileFetcher(); + private final Deque queue = new LinkedList(); + private final Map requested = new HashMap(35); + private final Object sync = new Object(); + + /** + * Get the singleton instance. + */ + public static ProfileFetchThread get() { + if (PROFILE_THREAD == null) { + PROFILE_THREAD = new ProfileFetchThread(); + Bukkit.getScheduler().runTaskTimerAsynchronously(CitizensAPI.getPlugin(), PROFILE_THREAD, + 11, 20); + } + return PROFILE_THREAD; + } + + ProfileFetchThread() {} + + /** + * Fetch a profile. + * + * @param name The name of the player the profile belongs to. + * @param subscriber Optional subscriber to be notified when a result is available. + * Subscriber always invoked from the main thread. + */ + public void fetch(String name, @Nullable ProfileFetchSubscriber subscriber) { + Preconditions.checkNotNull(name); + + ProfileRequest request = requested.get(name); + + if (request != null) { + + if (subscriber != null) { + + if (request.getResult() == ProfileFetchResult.PENDING) { + request.addSubscriber(subscriber); + } + else { + subscriber.onResult(request); + } + } + + return; + } + + request = new ProfileRequest(name, subscriber); + + synchronized (sync) { + queue.add(request); + } + + requested.put(name, request); + + } + + @Override + public void run() { + + List requests; + + synchronized (sync) { + + if (queue.isEmpty()) + return; + + requests = new ArrayList(queue); + queue.clear(); + } + + profileFetcher.fetch(requests); + } + + private static ProfileFetchThread PROFILE_THREAD; +} diff --git a/src/main/java/net/citizensnpcs/npc/profile/ProfileFetcher.java b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetcher.java new file mode 100644 index 000000000..344cf0415 --- /dev/null +++ b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetcher.java @@ -0,0 +1,128 @@ +package net.citizensnpcs.npc.profile; + +import com.google.common.base.Preconditions; +import com.mojang.authlib.Agent; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.GameProfileRepository; +import com.mojang.authlib.ProfileLookupCallback; +import net.citizensnpcs.api.util.Messaging; +import net.citizensnpcs.util.NMS; + +import javax.annotation.Nullable; +import java.util.Collection; + +/** + * Fetches game profiles that include skin data from Mojang servers. + * + * @see ProfileFetchThread + */ +class ProfileFetcher { + + /** + * Fetch one or more profiles. + * + * @param requests The profile requests. + */ + public void fetch(final Collection requests) { + Preconditions.checkNotNull(requests); + + final GameProfileRepository repo = NMS.getGameProfileRepository(); + + String[] playerNames = new String[requests.size()]; + + int i=0; + for (ProfileRequest request : requests) { + playerNames[i] = request.getPlayerName(); + i++; + } + + repo.findProfilesByNames(playerNames, Agent.MINECRAFT, + new ProfileLookupCallback() { + + @Override + public void onProfileLookupFailed(GameProfile profile, Exception e) { + + if (Messaging.isDebugging()) { + Messaging.debug("Profile lookup for skin '" + + profile.getName() + "' failed: " + getExceptionMsg(e)); + } + + ProfileRequest request = findRequest(profile.getName(), requests); + if (request == null) + return; + + if (isProfileNotFound(e)) { + request.setResult(null, ProfileFetchResult.NOT_FOUND); + } else if (isTooManyRequests(e)) { + request.setResult(null, ProfileFetchResult.TOO_MANY_REQUESTS); + } else { + request.setResult(null, ProfileFetchResult.FAILED); + } + } + + @Override + public void onProfileLookupSucceeded(final GameProfile profile) { + + if (Messaging.isDebugging()) { + Messaging.debug("Fetched profile " + profile.getId() + + " for player " + profile.getName()); + } + + ProfileRequest request = findRequest(profile.getName(), requests); + if (request == null) + return; + + try { + request.setResult(NMS.fillProfileProperties(profile, true), ProfileFetchResult.SUCCESS); + } catch (Exception e) { + + if (Messaging.isDebugging()) { + Messaging.debug("Profile lookup for skin '" + + profile.getName() + "' failed: " + getExceptionMsg(e)); + } + + if (isTooManyRequests(e)) { + request.setResult(null, ProfileFetchResult.TOO_MANY_REQUESTS); + } else { + request.setResult(null, ProfileFetchResult.FAILED); + } + } + } + }); + } + + @Nullable + private static ProfileRequest findRequest(String name, Collection requests) { + + name = name.toLowerCase(); + + for (ProfileRequest request : requests) { + if (request.getPlayerName().equals(name)) + return request; + } + return null; + } + + private static boolean isProfileNotFound(Exception e) { + String message = e.getMessage(); + String cause = e.getCause() != null ? e.getCause().getMessage() : null; + + return (message != null && message.contains("did not find")) + || (cause != null && cause.contains("did not find")); + } + + private static String getExceptionMsg(Exception e) { + String message = e.getMessage(); + String cause = e.getCause() != null ? e.getCause().getMessage() : null; + return cause != null ? cause : message; + } + + private static boolean isTooManyRequests(Exception e) { + + String message = e.getMessage(); + String cause = e.getCause() != null ? e.getCause().getMessage() : null; + + return (message != null && message.contains("too many requests")) + || (cause != null && cause.contains("too many requests")); + } +} diff --git a/src/main/java/net/citizensnpcs/npc/profile/ProfileRequest.java b/src/main/java/net/citizensnpcs/npc/profile/ProfileRequest.java new file mode 100644 index 000000000..4a1fcdb11 --- /dev/null +++ b/src/main/java/net/citizensnpcs/npc/profile/ProfileRequest.java @@ -0,0 +1,110 @@ +package net.citizensnpcs.npc.profile; + +import com.google.common.base.Preconditions; +import com.mojang.authlib.GameProfile; +import net.citizensnpcs.api.CitizensAPI; +import org.bukkit.Bukkit; + +import javax.annotation.Nullable; +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * Stores basic information about a single profile used to request + * profiles from the Mojang servers. + * + *

Also stores the result of the request.

+ */ +public class ProfileRequest { + + private final String playerName; + private Deque subscribers; + private GameProfile profile; + private ProfileFetchResult result = ProfileFetchResult.PENDING; + + /** + * Constructor. + * + * @param playerName The name of the player whose profile is being requested. + * @param subscriber Optional subscriber to be notified when a result is available + * for the profile. Subscriber always invoked from the main thread. + */ + ProfileRequest(String playerName, @Nullable ProfileFetchSubscriber subscriber) { + Preconditions.checkNotNull(playerName); + + this.playerName = playerName; + + if (subscriber != null) + addSubscriber(subscriber); + } + + /** + * Get the name of the player the requested profile belongs to. + */ + public String getPlayerName() { + return playerName; + } + + /** + * Get the game profile that was requested. + * + * @return The game profile or null if the profile has not been retrieved + * yet or there was an error while retrieving the profile. + */ + @Nullable + public GameProfile getProfile() { + return profile; + } + + /** + * Get the result of the profile fetch. + */ + public ProfileFetchResult getResult() { + return result; + } + + /** + * Add a result subscriber to be notified when a result is available. + * + *

Subscriber is always invoked from the main thread.

+ * + * @param subscriber The subscriber. + */ + public void addSubscriber(ProfileFetchSubscriber subscriber) { + Preconditions.checkNotNull(subscriber); + + if (subscribers == null) + subscribers = new ArrayDeque(); + + subscribers.addLast(subscriber); + } + + /** + * Invoked to set the profile result. + * + *

Can be invoked from any thread, always executes on the main thread.

+ * + * @param profile The profile. Null if there was an error. + * @param result The result of the request. + */ + void setResult(final @Nullable GameProfile profile, final ProfileFetchResult result) { + + Bukkit.getScheduler().scheduleSyncDelayedTask(CitizensAPI.getPlugin(), new Runnable() { + @Override + public void run() { + + ProfileRequest.this.profile = profile; + ProfileRequest.this.result = result; + + if (subscribers == null) + return; + + while (!subscribers.isEmpty()) { + subscribers.removeFirst().onResult(ProfileRequest.this); + } + + subscribers = null; + } + }); + } +} diff --git a/src/main/java/net/citizensnpcs/npc/skin/PlayerListRemover.java b/src/main/java/net/citizensnpcs/npc/skin/PlayerListRemover.java new file mode 100644 index 000000000..5c1aeda73 --- /dev/null +++ b/src/main/java/net/citizensnpcs/npc/skin/PlayerListRemover.java @@ -0,0 +1,156 @@ +package net.citizensnpcs.npc.skin; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import com.google.common.base.Preconditions; + +import net.citizensnpcs.Settings; +import net.citizensnpcs.api.CitizensAPI; +import net.citizensnpcs.util.NMS; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +/** + * Sends remove packets in batch per player. + * + *

Collects entities to remove and sends them all to the + * player in a single packet.

+ */ +public class PlayerListRemover { + + private final Map pending = + new HashMap(Bukkit.getMaxPlayers() / 2); + + PlayerListRemover() { + Bukkit.getScheduler().runTaskTimer(CitizensAPI.getPlugin(), new Sender(), 2, 2); + } + + /** + * Send a remove packet to the specified player for the specified + * human NPC entity. + * + * @param player The player to send the packet to. + * @param entity The entity to remove. + */ + public void sendPacket(Player player, SkinnableEntity entity) { + Preconditions.checkNotNull(player); + Preconditions.checkNotNull(entity); + + PlayerEntry entry = getEntry(player); + + entry.toRemove.add(entity); + } + + /** + * Cancel packets pending to be sent to the specified player. + * + * @param player The player. + */ + public void cancelPackets(Player player) { + Preconditions.checkNotNull(player); + + PlayerEntry entry = pending.remove(player.getUniqueId()); + + for (SkinnableEntity entity : entry.toRemove) { + entity.getSkinTracker().notifyRemovePacketCancelled(player.getUniqueId()); + } + } + + /** + * Cancel packets pending to be sent to the specified player + * for the specified skinnable NPC. + * + * @param player The player. + * @param skinnable The skinnable NPC. + */ + public void cancelPackets(Player player, SkinnableEntity skinnable) { + Preconditions.checkNotNull(player); + Preconditions.checkNotNull(skinnable); + + PlayerEntry entry = pending.get(player.getUniqueId()); + if (entry == null) + return; + + if (entry.toRemove.remove(skinnable)) { + skinnable.getSkinTracker().notifyRemovePacketCancelled(player.getUniqueId()); + } + + if (entry.toRemove.isEmpty()) + pending.remove(player.getUniqueId()); + } + + private PlayerEntry getEntry(Player player) { + + PlayerEntry entry = pending.get(player.getUniqueId()); + if (entry == null) { + entry = new PlayerEntry(player); + pending.put(player.getUniqueId(), entry); + } + + return entry; + } + + private class PlayerEntry { + Player player; + Set toRemove = new HashSet(25); + + PlayerEntry(Player player) { + this.player = player; + } + } + + private class Sender implements Runnable { + + @Override + public void run() { + + int maxPacketEntries = Settings.Setting.MAX_PACKET_ENTRIES.asInt(); + + Iterator> entryIterator = pending.entrySet().iterator(); + while (entryIterator.hasNext()) { + + Map.Entry mapEntry = entryIterator.next(); + PlayerEntry entry = mapEntry.getValue(); + + int listSize = Math.min(maxPacketEntries, entry.toRemove.size()); + boolean sendAll = listSize == entry.toRemove.size(); + + List skinnableList = new ArrayList(listSize); + + int i =0; + Iterator skinIterator = entry.toRemove.iterator(); + while (skinIterator.hasNext()) { + + SkinnableEntity skinnable = skinIterator.next(); + skinnableList.add(skinnable); + + skinIterator.remove(); + + if (i > maxPacketEntries) + break; + + i++; + } + + if (entry.player.isOnline()) + NMS.sendPlayerListRemove(entry.player, skinnableList); + + // notify skin trackers that a remove packet has been sent to a player + for (SkinnableEntity entity : skinnableList) { + entity.getSkinTracker().notifyRemovePacketSent(entry.player.getUniqueId()); + } + + if (sendAll) + entryIterator.remove(); + } + } + } +} diff --git a/src/main/java/net/citizensnpcs/npc/skin/Skin.java b/src/main/java/net/citizensnpcs/npc/skin/Skin.java new file mode 100644 index 000000000..a06ad7f10 --- /dev/null +++ b/src/main/java/net/citizensnpcs/npc/skin/Skin.java @@ -0,0 +1,274 @@ +package net.citizensnpcs.npc.skin; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; +import javax.annotation.Nullable; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Iterables; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; + +import net.citizensnpcs.Settings; +import net.citizensnpcs.api.event.DespawnReason; +import net.citizensnpcs.api.npc.NPC; +import net.citizensnpcs.npc.profile.ProfileFetchResult; +import net.citizensnpcs.npc.profile.ProfileFetchSubscriber; +import net.citizensnpcs.npc.profile.ProfileFetchThread; +import net.citizensnpcs.npc.profile.ProfileRequest; + +/** + * Stores data for a single skin. + */ +public class Skin { + + private final String skinName; + private volatile Property skinData; + private volatile UUID skinId; + private volatile boolean isValid = true; + private final Map pending = new WeakHashMap(30); + + /** + * Get a skin for a human NPC entity. + * + *

If a Skin instance does not exist, a new one is created and the + * skin data is automatically fetched.

+ * + * @param entity The human NPC entity. + */ + public static Skin get(SkinnableEntity entity) { + Preconditions.checkNotNull(entity); + + String skinName = entity.getSkinName().toLowerCase(); + + Skin skin; + synchronized (CACHE) { + skin = CACHE.get(skinName); + } + + if (skin == null) { + skin = new Skin(skinName); + } + + return skin; + } + + /** + * Constructor. + * + * @param skinName The name of the player the skin belongs to. + */ + Skin(String skinName) { + + this.skinName = skinName.toLowerCase(); + + synchronized (CACHE) { + if (CACHE.containsKey(skinName)) + throw new IllegalArgumentException("There is already a skin named " + skinName); + + CACHE.put(skinName, this); + } + + ProfileFetchThread.get().fetch(skinName, new ProfileFetchSubscriber() { + + @Override + public void onResult(ProfileRequest request) { + + if (request.getResult() == ProfileFetchResult.NOT_FOUND) { + isValid = false; + return; + } + + if (request.getResult() == ProfileFetchResult.SUCCESS) { + + GameProfile profile = request.getProfile(); + + skinId = profile.getId(); + skinData = Iterables.getFirst(profile.getProperties().get("textures"), null); + } + } + }); + } + + /** + * Get the name of the skin. + */ + public String getSkinName() { + return skinName; + } + + /** + * Get the ID of the player the skin belongs to. + * + * @return The skin ID or null if it has not been retrieved yet or + * the skin is invalid. + */ + @Nullable + public UUID getSkinId() { + return skinId; + } + + /** + * Determine if the skin is valid. + */ + public boolean isValid() { + return isValid; + } + + /** + * Determine if the skin data has been retrieved. + */ + public boolean hasSkinData() { + return skinData != null; + } + + /** + * Set skin data. + * + * @param profile The profile that contains the skin data. If set to null, + * it's assumed that the skin is not valid. + * + * @throws IllegalStateException if not invoked from the main thread. + * @throws IllegalArgumentException if the profile name does not match the skin data. + */ + public void setData(@Nullable GameProfile profile) { + + if (profile == null) { + isValid = false; + return; + } + + if (!profile.getName().toLowerCase().equals(skinName)) { + throw new IllegalArgumentException( + "GameProfile name (" + profile.getName() + ") and " + + "skin name (" + skinName + ") do not match."); + } + + skinId = profile.getId(); + skinData = Iterables.getFirst(profile.getProperties().get("textures"), null); + + for (SkinnableEntity entity : pending.keySet()) { + applyAndRespawn(entity); + } + } + + /** + * Apply the skin data to the specified human NPC entity. + * + *

If invoked before the skin data is ready, the skin is retrieved + * and the skin is automatically applied to the entity at a later time.

+ * + * @param entity The human NPC entity. + * + * @return True if the skin data was available and applied, false if + * the data is being retrieved. + * + * @throws IllegalStateException if not invoked from the main thread. + */ + public boolean apply(SkinnableEntity entity) { + Preconditions.checkNotNull(entity); + + NPC npc = entity.getNPC(); + + if (!hasSkinData()) { + pending.put(entity, null); + + // Use npc cached skin if available. + // If npc requires latest skin, cache is used for faster + // availability until the latest skin can be loaded. + String cachedName = npc.data().get(CACHED_SKIN_UUID_NAME_METADATA); + if (this.skinName.equals(cachedName)) { + + skinData = new Property(this.skinName, + npc.data().get(PLAYER_SKIN_TEXTURE_PROPERTIES), + npc.data().get(PLAYER_SKIN_TEXTURE_PROPERTIES_SIGN)); + + skinId = UUID.fromString(npc.data().get(CACHED_SKIN_UUID_METADATA)); + + setNPCSkinData(entity, skinName, skinId, skinData); + + // check if NPC prefers to use cached skin over the latest skin. + if (!entity.getNPC().data().get("update-skin", + Settings.Setting.NPC_SKIN_UPDATE.asBoolean())) { + // cache preferred + return true; + } + + if (!Settings.Setting.NPC_SKIN_UPDATE.asBoolean()) { + // cache preferred + return true; + } + } + + // get latest skin + fetchSkinFor(entity); + + return false; + } + + setNPCSkinData(entity, skinName, skinId, skinData); + + return true; + } + + /** + * Apply the skin data to the specified skinnable entity + * and respawn the NPC. + * + * @param entity The skinnable entity. + */ + public void applyAndRespawn(SkinnableEntity entity) { + Preconditions.checkNotNull(entity); + + if (!apply(entity)) + return; + + NPC npc = entity.getNPC(); + + if (npc.isSpawned()) { + npc.despawn(DespawnReason.PENDING_RESPAWN); + npc.spawn(npc.getStoredLocation()); + } + } + + private void fetchSkinFor(final SkinnableEntity entity) { + + ProfileFetchThread.get().fetch(skinName, new ProfileFetchSubscriber() { + + @Override + public void onResult(ProfileRequest request) { + + if (request.getResult() != ProfileFetchResult.SUCCESS) + return; + + double viewDistance = Settings.Setting.NPC_SKIN_VIEW_DISTANCE.asDouble(); + entity.getSkinTracker().updateNearbyViewers(viewDistance); + } + }); + } + + private static void setNPCSkinData(SkinnableEntity entity, + String skinName, UUID skinId, Property skinProperty) { + + NPC npc = entity.getNPC(); + + // cache skins for faster initial skin availability + npc.data().setPersistent(CACHED_SKIN_UUID_NAME_METADATA, skinName); + npc.data().setPersistent(CACHED_SKIN_UUID_METADATA, skinId.toString()); + npc.data().setPersistent(PLAYER_SKIN_TEXTURE_PROPERTIES, skinProperty.getValue()); + npc.data().setPersistent(PLAYER_SKIN_TEXTURE_PROPERTIES_SIGN, skinProperty.getSignature()); + + GameProfile profile = entity.getProfile(); + profile.getProperties().removeAll("textures"); // ensure client does not crash due to duplicate properties. + profile.getProperties().put("textures", skinProperty); + } + + public static final String PLAYER_SKIN_TEXTURE_PROPERTIES = "player-skin-textures"; + public static final String PLAYER_SKIN_TEXTURE_PROPERTIES_SIGN = "player-skin-signature"; + public static final String CACHED_SKIN_UUID_METADATA = "cached-skin-uuid"; + public static final String CACHED_SKIN_UUID_NAME_METADATA = "cached-skin-uuid-name"; + + private static final Map CACHE = new HashMap(20); +} diff --git a/src/main/java/net/citizensnpcs/npc/skin/SkinPacketTracker.java b/src/main/java/net/citizensnpcs/npc/skin/SkinPacketTracker.java new file mode 100644 index 000000000..4b4e37311 --- /dev/null +++ b/src/main/java/net/citizensnpcs/npc/skin/SkinPacketTracker.java @@ -0,0 +1,237 @@ +package net.citizensnpcs.npc.skin; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import com.google.common.base.Preconditions; + +import net.citizensnpcs.Settings; +import net.citizensnpcs.api.CitizensAPI; +import net.citizensnpcs.util.NMS; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; + +/** + * Handles and synchronizes add and remove packets for Player type NPC's + * in order to properly apply the NPC skin. + * + *

Used as one instance per NPC entity.

+ */ +public class SkinPacketTracker { + + private final SkinnableEntity entity; + private final Map inProgress = + new HashMap(Bukkit.getMaxPlayers() / 2); + + private Skin skin; + private boolean isRemoved; + + /** + * Constructor. + * + * @param entity The human NPC entity the instance belongs to. + */ + public SkinPacketTracker(SkinnableEntity entity) { + Preconditions.checkNotNull(entity); + + this.entity = entity; + this.skin = Skin.get(entity); + + if (LISTENER == null) { + LISTENER = new PlayerListener(); + Bukkit.getPluginManager().registerEvents(LISTENER, CitizensAPI.getPlugin()); + } + } + + /** + * Get the NPC skin. + */ + public Skin getSkin() { + return skin; + } + + /** + * Notify that the NPC skin has been changed. + */ + public void notifySkinChange() { + this.skin = Skin.get(entity); + skin.applyAndRespawn(entity); + } + + /** + * Notify the tracker that a remove packet has been sent to the + * specified player. + * + * @param playerId The ID of the player. + */ + void notifyRemovePacketSent(UUID playerId) { + + PlayerEntry entry = inProgress.get(playerId); + if (entry == null) + return; + + if (entry.removeCount == 0) + return; + + entry.removeCount -= 1; + if (entry.removeCount == 0) { + inProgress.remove(playerId); + } + else { + scheduleRemovePacket(entry); + } + } + + /** + * Notify the tracker that a remove packet has been sent to the + * specified player. + * + * @param playerId The ID of the player. + */ + void notifyRemovePacketCancelled(UUID playerId) { + inProgress.remove(playerId); + } + + /** + * Send skin related packets to a player. + * + * @param player The player. + */ + public void updateViewer(final Player player) { + Preconditions.checkNotNull(player); + + if (player.hasMetadata("NPC")) + return; + + if (isRemoved || inProgress.containsKey(player.getUniqueId())) + return; + + PlayerEntry entry = new PlayerEntry(player); + inProgress.put(player.getUniqueId(), entry); + + PLAYER_LIST_REMOVER.cancelPackets(player, entity); + + skin.apply(entity); + + NMS.sendPlayerListAdd(player, entity.getBukkitEntity()); + + scheduleRemovePacket(entry, 2); + } + + /** + * Send skin related packets to all nearby players within the specified block radius. + * + * @param radius The radius. + */ + public void updateNearbyViewers(double radius) { + + radius *= radius; + + org.bukkit.World world = entity.getBukkitEntity().getWorld(); + Player from = entity.getBukkitEntity(); + Location location = from.getLocation(); + + for (Player player : Bukkit.getServer().getOnlinePlayers()) { + + if (player == null || player.hasMetadata("NPC")) + continue; + + if (world != player.getWorld() || !player.canSee(from)) + continue; + + if (location.distanceSquared(player.getLocation(CACHE_LOCATION)) > radius) + continue; + + updateViewer(player); + } + } + + /** + * Invoke when the NPC entity is removed. + * + *

Sends remove packets to all players.

+ */ + public void onRemoveNPC() { + + isRemoved = true; + + Collection players = Bukkit.getOnlinePlayers(); + + for (Player player : players) { + + if (player.hasMetadata("NPC")) + continue; + + // send packet now and later to ensure removal from player list + NMS.sendPlayerListRemove(player, entity.getBukkitEntity()); + PLAYER_LIST_REMOVER.sendPacket(player, entity); + } + } + + private void scheduleRemovePacket(PlayerEntry entry, int count) { + + if (!shouldRemoveFromPlayerList()) + return; + + entry.removeCount = count; + scheduleRemovePacket(entry); + } + + private void scheduleRemovePacket(final PlayerEntry entry) { + + if (isRemoved) + return; + + Bukkit.getScheduler().runTaskLater(CitizensAPI.getPlugin(), + new Runnable() { + + @Override + public void run() { + + if (shouldRemoveFromPlayerList()) { + PLAYER_LIST_REMOVER.sendPacket(entry.player, entity); + } + } + }, PACKET_DELAY_REMOVE); + } + + private boolean shouldRemoveFromPlayerList() { + + boolean isTablistDisabled = Settings.Setting.DISABLE_TABLIST.asBoolean(); + boolean isNpcRemoved = entity.getNPC().data().get("removefromplayerlist", + Settings.Setting.REMOVE_PLAYERS_FROM_PLAYER_LIST.asBoolean()); + + return isNpcRemoved && isTablistDisabled; + } + + private class PlayerEntry { + Player player; + int removeCount; + PlayerEntry (Player player) { + this.player = player; + } + } + + private static class PlayerListener implements Listener { + + @EventHandler + private void onPlayerQuit(PlayerQuitEvent event) { + + // this also causes any entries in the "inProgress" field to + // be removed. + PLAYER_LIST_REMOVER.cancelPackets(event.getPlayer()); + } + } + + private static final Location CACHE_LOCATION = new Location(null, 0, 0, 0); + private static final int PACKET_DELAY_REMOVE = 1; + private static final PlayerListRemover PLAYER_LIST_REMOVER = new PlayerListRemover(); + private static PlayerListener LISTENER; +} diff --git a/src/main/java/net/citizensnpcs/npc/skin/SkinnableEntity.java b/src/main/java/net/citizensnpcs/npc/skin/SkinnableEntity.java new file mode 100644 index 000000000..e98a54f2e --- /dev/null +++ b/src/main/java/net/citizensnpcs/npc/skin/SkinnableEntity.java @@ -0,0 +1,40 @@ +package net.citizensnpcs.npc.skin; + +import com.mojang.authlib.GameProfile; +import net.citizensnpcs.npc.ai.NPCHolder; +import org.bukkit.entity.Player; + +/** + * Interface for player entities that are skinnable. + */ +public interface SkinnableEntity extends NPCHolder { + + /** + * Get the entities skin packet tracker. + */ + SkinPacketTracker getSkinTracker(); + + /** + * Get the bukkit entity. + */ + Player getBukkitEntity(); + + /** + * Get entity game profile. + */ + GameProfile getProfile(); + + /** + * Get the name of the player whose skin the NPC uses. + */ + String getSkinName(); + + /** + * Set the name of the player whose skin the NPC + * uses. + * + *

Setting the skin name automatically updates and + * respawn the NPC.

+ */ + void setSkinName(String name); +} diff --git a/src/main/java/net/citizensnpcs/util/NMS.java b/src/main/java/net/citizensnpcs/util/NMS.java index 107c84f6f..b9ef23d78 100644 --- a/src/main/java/net/citizensnpcs/util/NMS.java +++ b/src/main/java/net/citizensnpcs/util/NMS.java @@ -2,7 +2,9 @@ package net.citizensnpcs.util; import java.lang.reflect.Constructor; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.net.SocketAddress; +import java.net.URL; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -11,6 +13,15 @@ import java.util.Random; import java.util.Set; import java.util.WeakHashMap; +import com.google.common.base.Preconditions; +import com.mojang.authlib.GameProfileRepository; +import com.mojang.authlib.HttpAuthenticationService; +import com.mojang.authlib.minecraft.MinecraftSessionService; +import com.mojang.authlib.yggdrasil.YggdrasilAuthenticationService; +import com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService; +import com.mojang.authlib.yggdrasil.response.MinecraftProfilePropertiesResponse; +import com.mojang.util.UUIDTypeAdapter; +import net.citizensnpcs.npc.skin.SkinnableEntity; import org.apache.commons.lang.Validate; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -25,12 +36,12 @@ import org.bukkit.entity.EntityType; import org.bukkit.entity.Horse; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; +import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.plugin.PluginLoadOrder; import com.mojang.authlib.GameProfile; -import net.citizensnpcs.Settings.Setting; import net.citizensnpcs.api.command.exception.CommandException; import net.citizensnpcs.api.npc.NPC; import net.citizensnpcs.api.util.Messaging; @@ -60,28 +71,119 @@ import net.minecraft.server.v1_8_R3.NavigationAbstract; import net.minecraft.server.v1_8_R3.NetworkManager; import net.minecraft.server.v1_8_R3.Packet; import net.minecraft.server.v1_8_R3.PacketPlayOutPlayerInfo; -import net.minecraft.server.v1_8_R3.PacketPlayOutPlayerInfo.EnumPlayerInfoAction; import net.minecraft.server.v1_8_R3.PathfinderGoalSelector; import net.minecraft.server.v1_8_R3.World; import net.minecraft.server.v1_8_R3.WorldServer; +import javax.annotation.Nullable; + @SuppressWarnings("unchecked") public class NMS { + private NMS() { // util class } - public static void addOrRemoveFromPlayerList(org.bukkit.entity.Entity entity, boolean remove) { - if (entity == null) - return; - EntityHuman handle = (EntityHuman) getHandle(entity); - if (handle.world == null) - return; - if (remove) { - handle.world.players.remove(handle); - } else if (!handle.world.players.contains(handle)) { - handle.world.players.add(handle); + public static GameProfileRepository getGameProfileRepository() { + return ((CraftServer) Bukkit.getServer()).getServer() + .getGameProfileRepository(); + } + + public static boolean addToWorld(org.bukkit.World world, + org.bukkit.entity.Entity entity, + CreatureSpawnEvent.SpawnReason reason) { + Preconditions.checkNotNull(world); + Preconditions.checkNotNull(entity); + Preconditions.checkNotNull(reason); + + Entity nmsEntity = ((CraftEntity)entity).getHandle(); + return ((CraftWorld)world).getHandle().addEntity(nmsEntity, reason); + } + + public static void removeFromWorld(org.bukkit.entity.Entity entity) { + Preconditions.checkNotNull(entity); + + Entity nmsEntity = ((CraftEntity)entity).getHandle(); + nmsEntity.world.removeEntity(nmsEntity); + } + + @Nullable + public static SkinnableEntity getSkinnableNPC(org.bukkit.entity.Entity entity) { + Preconditions.checkNotNull(entity); + + Entity nmsEntity = ((CraftEntity) entity).getHandle(); + if (nmsEntity instanceof SkinnableEntity) { + return (SkinnableEntity)nmsEntity; } + return null; + } + + public static void sendPlayerListAdd(Player recipient, Player listPlayer) { + Preconditions.checkNotNull(recipient); + Preconditions.checkNotNull(listPlayer); + + EntityPlayer entity = ((CraftPlayer)listPlayer).getHandle(); + + sendPacket(recipient, new PacketPlayOutPlayerInfo( + PacketPlayOutPlayerInfo.EnumPlayerInfoAction.ADD_PLAYER, entity)); + } + + public static void sendPlayerListRemove(Player recipient, Player listPlayer) { + Preconditions.checkNotNull(recipient); + Preconditions.checkNotNull(listPlayer); + + EntityPlayer entity = ((CraftPlayer)listPlayer).getHandle(); + + sendPacket(recipient, new PacketPlayOutPlayerInfo( + PacketPlayOutPlayerInfo.EnumPlayerInfoAction.REMOVE_PLAYER, entity)); + } + + public static void sendPlayerListRemove(Player recipient, Collection skinnableNPCs) { + Preconditions.checkNotNull(recipient); + Preconditions.checkNotNull(skinnableNPCs); + + EntityPlayer[] entities = new EntityPlayer[skinnableNPCs.size()]; + int i=0; + for (SkinnableEntity skinnable : skinnableNPCs) { + entities[i] = (EntityPlayer)skinnable; + i++; + } + + sendPacket(recipient, new PacketPlayOutPlayerInfo( + PacketPlayOutPlayerInfo.EnumPlayerInfoAction.REMOVE_PLAYER, entities)); + } + + /* + * Yggdrasil's default implementation of this method silently fails instead of throwing + * an Exception like it should. + */ + public static GameProfile fillProfileProperties(GameProfile profile, + boolean requireSecure) throws Exception { + + if (Bukkit.isPrimaryThread()) + throw new IllegalStateException("NMS.fillProfileProperties cannot be invoked from the main thread."); + + MinecraftSessionService sessionService = ((CraftServer) Bukkit.getServer()).getServer().aD(); + + YggdrasilAuthenticationService auth = ((YggdrasilMinecraftSessionService) sessionService) + .getAuthenticationService(); + + URL url = HttpAuthenticationService.constantURL( + "https://sessionserver.mojang.com/session/minecraft/profile/" + + UUIDTypeAdapter.fromUUID(profile.getId())); + + url = HttpAuthenticationService.concatenateURL(url, "unsigned=" + !requireSecure); + + MinecraftProfilePropertiesResponse response = (MinecraftProfilePropertiesResponse) + MAKE_REQUEST.invoke(auth, url, null, MinecraftProfilePropertiesResponse.class); + if (response == null) + return profile; + + GameProfile result = new GameProfile(response.getId(), response.getName()); + result.getProperties().putAll(response.getProperties()); + profile.getProperties().putAll(response.getProperties()); + + return result; } public static void attack(EntityLiving handle, Entity target) { @@ -506,15 +608,6 @@ public class NMS { NMS.sendPacketsNearby(from, location, Arrays.asList(packets), 64); } - public static void sendPlayerlistPacket(boolean showInPlayerlist, Player npc) { - if (!showInPlayerlist && !Setting.DISABLE_TABLIST.asBoolean()) - return; - PacketPlayOutPlayerInfo packet = new PacketPlayOutPlayerInfo( - showInPlayerlist ? EnumPlayerInfoAction.ADD_PLAYER : EnumPlayerInfoAction.REMOVE_PLAYER, - ((CraftPlayer) npc).getHandle()); - sendToOnline(packet); - } - public static void sendToOnline(Packet... packets) { Validate.notNull(packets, "packets cannot be null"); for (Player player : Bukkit.getOnlinePlayers()) { @@ -703,6 +796,7 @@ public class NMS { private static Field SKULL_PROFILE_FIELD; private static Field TRACKED_ENTITY_SET = NMS.getField(EntityTracker.class, "c"); + private static Method MAKE_REQUEST; static { try { @@ -713,5 +807,13 @@ public class NMS { } catch (Exception e) { Messaging.logTr(Messages.ERROR_GETTING_ID_MAPPING, e.getMessage()); } + + try { + MAKE_REQUEST = YggdrasilAuthenticationService.class.getDeclaredMethod("makeRequest", URL.class, + Object.class, Class.class); + MAKE_REQUEST.setAccessible(true); + } catch (Exception ex) { + ex.printStackTrace(); + } } } diff --git a/src/main/java/net/citizensnpcs/util/nms/PlayerlistTrackerEntry.java b/src/main/java/net/citizensnpcs/util/nms/PlayerlistTrackerEntry.java index 3e6758b0e..22eb2c9dd 100644 --- a/src/main/java/net/citizensnpcs/util/nms/PlayerlistTrackerEntry.java +++ b/src/main/java/net/citizensnpcs/util/nms/PlayerlistTrackerEntry.java @@ -1,17 +1,14 @@ package net.citizensnpcs.util.nms; -import java.lang.reflect.Field; - -import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitRunnable; - -import net.citizensnpcs.Settings.Setting; -import net.citizensnpcs.api.CitizensAPI; +import net.citizensnpcs.npc.entity.EntityHumanNPC; +import net.citizensnpcs.npc.skin.SkinnableEntity; import net.citizensnpcs.util.NMS; import net.minecraft.server.v1_8_R3.Entity; import net.minecraft.server.v1_8_R3.EntityPlayer; import net.minecraft.server.v1_8_R3.EntityTrackerEntry; -import net.minecraft.server.v1_8_R3.PacketPlayOutPlayerInfo; +import org.bukkit.entity.Player; + +import java.lang.reflect.Field; public class PlayerlistTrackerEntry extends EntityTrackerEntry { public PlayerlistTrackerEntry(Entity entity, int i, int j, boolean flag) { @@ -24,28 +21,26 @@ public class PlayerlistTrackerEntry extends EntityTrackerEntry { @Override public void updatePlayer(final EntityPlayer entityplayer) { + + // prevent updates to NPC "viewers" + if (entityplayer instanceof EntityHumanNPC) + return; + if (entityplayer != this.tracker && c(entityplayer)) { + if (!this.trackedPlayers.contains(entityplayer) && ((entityplayer.u().getPlayerChunkMap().a(entityplayer, this.tracker.ae, this.tracker.ag)) || (this.tracker.attachedToPlayer))) { - if ((this.tracker instanceof EntityPlayer)) { - Player player = ((EntityPlayer) this.tracker).getBukkitEntity(); - if (!entityplayer.getBukkitEntity().canSee(player)) { - return; - } - entityplayer.playerConnection.sendPacket(new PacketPlayOutPlayerInfo( - PacketPlayOutPlayerInfo.EnumPlayerInfoAction.ADD_PLAYER, (EntityPlayer) this.tracker)); - if (Setting.DISABLE_TABLIST.asBoolean()) { - new BukkitRunnable() { - @Override - public void run() { - entityplayer.playerConnection.sendPacket(new PacketPlayOutPlayerInfo( - PacketPlayOutPlayerInfo.EnumPlayerInfoAction.REMOVE_PLAYER, - (EntityPlayer) tracker)); - } - }.runTaskLater(CitizensAPI.getPlugin(), 2); - } + if ((this.tracker instanceof SkinnableEntity)) { + + SkinnableEntity skinnable = (SkinnableEntity)this.tracker; + + Player player = skinnable.getBukkitEntity(); + if (!entityplayer.getBukkitEntity().canSee(player)) + return; + + skinnable.getSkinTracker().updateViewer(entityplayer.getBukkitEntity()); } } } From 3223ba53f62c55c077b9e1b7108a6b9ef1e2792e Mon Sep 17 00:00:00 2001 From: JCThePants Date: Tue, 25 Aug 2015 19:33:09 -0700 Subject: [PATCH 2/2] Fix NPC's are not visible sometimes ...Fixed invisible NPC's by allowing new update tasks to cancel current tasks instead of cancelling the new task in SkinPacketTracker#updateViewer cancel packets in SkinPacketTracker#updateViewer after getting PlayerEntry to ensure current scheduled tasks are cancelled. remove LinkedList, replace with ArrayDeque ensure EventListen#recalculatePlayer does not execute for NPC's do a little less work in EventListen.SkinUpdateTracker#shouldUpdate don't add skinnable entity to pending map if using cache skin - Skin#apply remove redundant fetch per NPC in Skin; Skin can already fetch once for itself fix and improve thread safety for profile fetcher; prevent external access to threads Runnable interface; honor subscriber always invoked from main thread. rename ProfileFetchSubscriber to ProfileFetchHandler since subscriber implies the handler will be used continuously --- .../java/net/citizensnpcs/EventListen.java | 26 ++-- .../citizensnpcs/commands/NPCCommands.java | 2 +- .../net/citizensnpcs/npc/CitizensNPC.java | 4 +- .../npc/entity/HumanController.java | 2 +- ...bscriber.java => ProfileFetchHandler.java} | 2 +- .../npc/profile/ProfileFetchThread.java | 100 ++++++++------- .../npc/profile/ProfileFetcher.java | 38 +++++- .../npc/profile/ProfileRequest.java | 55 +++++---- .../npc/skin/PlayerListRemover.java | 19 +-- .../java/net/citizensnpcs/npc/skin/Skin.java | 87 ++++--------- .../npc/skin/SkinPacketTracker.java | 115 ++++++++++-------- src/main/java/net/citizensnpcs/util/NMS.java | 46 +++---- 12 files changed, 266 insertions(+), 230 deletions(-) rename src/main/java/net/citizensnpcs/npc/profile/{ProfileFetchSubscriber.java => ProfileFetchHandler.java} (87%) diff --git a/src/main/java/net/citizensnpcs/EventListen.java b/src/main/java/net/citizensnpcs/EventListen.java index 4ecd274e5..1eb609b73 100644 --- a/src/main/java/net/citizensnpcs/EventListen.java +++ b/src/main/java/net/citizensnpcs/EventListen.java @@ -453,6 +453,9 @@ public class EventListen implements Listener { public void recalculatePlayer(final Player player, long delay, final boolean isInitial) { + if (player.hasMetadata("NPC")) + return; + if (isInitial) { skinUpdateTrackers.put(player.getUniqueId(), new SkinUpdateTracker(player)); } @@ -492,7 +495,7 @@ public class EventListen implements Listener { && player.getLocation(CACHE_LOCATION) .distanceSquared(npc.getStoredLocation()) < viewDistance) { - SkinnableEntity skinnable = NMS.getSkinnableNPC(npcEntity); + SkinnableEntity skinnable = NMS.getSkinnable(npcEntity); results.add(skinnable); } @@ -605,17 +608,20 @@ public class EventListen implements Listener { Location currentLoc = player.getLocation(YAW_LOCATION); float currentYaw = currentLoc.getYaw(); - float rotationDegrees = Setting.NPC_SKIN_ROTATION_UPDATE_DEGREES.asFloat(); + if (rotationCount < 2) { - boolean hasRotated = - Math.abs(NMS.clampYaw(currentYaw - this.initialYaw)) < rotationDegrees; + float rotationDegrees = Setting.NPC_SKIN_ROTATION_UPDATE_DEGREES.asFloat(); - // update the first 2 times the player rotates. helps load skins around player - // after the player logs/teleports. - if (hasRotated && rotationCount < 2) { - rotationCount++; - reset(player); - return true; + boolean hasRotated = + Math.abs(NMS.clampYaw(currentYaw - this.initialYaw)) < rotationDegrees; + + // update the first 2 times the player rotates. helps load skins around player + // after the player logs/teleports. + if (hasRotated) { + rotationCount++; + reset(player); + return true; + } } // update every time a player moves a certain distance diff --git a/src/main/java/net/citizensnpcs/commands/NPCCommands.java b/src/main/java/net/citizensnpcs/commands/NPCCommands.java index 15c3fb76c..d74e4d4a9 100644 --- a/src/main/java/net/citizensnpcs/commands/NPCCommands.java +++ b/src/main/java/net/citizensnpcs/commands/NPCCommands.java @@ -1314,7 +1314,7 @@ public class NPCCommands { Messaging.sendTr(sender, Messages.SKIN_SET, npc.getName(), skinName); if (npc.isSpawned()) { - SkinnableEntity skinnable = NMS.getSkinnableNPC(npc.getEntity()); + SkinnableEntity skinnable = NMS.getSkinnable(npc.getEntity()); if (skinnable != null) { skinnable.setSkinName(skinName); } diff --git a/src/main/java/net/citizensnpcs/npc/CitizensNPC.java b/src/main/java/net/citizensnpcs/npc/CitizensNPC.java index 7ab1036b7..186ee4afa 100644 --- a/src/main/java/net/citizensnpcs/npc/CitizensNPC.java +++ b/src/main/java/net/citizensnpcs/npc/CitizensNPC.java @@ -189,7 +189,7 @@ public class CitizensNPC extends AbstractNPC { boolean couldSpawn = !Util.isLoaded(at) ? false : mcEntity.world.addEntity(mcEntity, SpawnReason.CUSTOM); // send skin packets, if applicable, before other NMS packets are sent - SkinnableEntity skinnable = NMS.getSkinnableNPC(getEntity()); + SkinnableEntity skinnable = NMS.getSkinnable(getEntity()); if (skinnable != null) { final double viewDistance = Settings.Setting.NPC_SKIN_VIEW_DISTANCE.asDouble(); skinnable.getSkinTracker().updateNearbyViewers(viewDistance); @@ -200,7 +200,7 @@ public class CitizensNPC extends AbstractNPC { if (getEntity() == null || !getEntity().isValid()) return; - SkinnableEntity npc = NMS.getSkinnableNPC(getEntity()); + SkinnableEntity npc = NMS.getSkinnable(getEntity()); if (npc == null) return; diff --git a/src/main/java/net/citizensnpcs/npc/entity/HumanController.java b/src/main/java/net/citizensnpcs/npc/entity/HumanController.java index 41f68ebb8..c0ad8602c 100644 --- a/src/main/java/net/citizensnpcs/npc/entity/HumanController.java +++ b/src/main/java/net/citizensnpcs/npc/entity/HumanController.java @@ -130,7 +130,7 @@ public class HumanController extends AbstractEntityController { NMS.removeFromWorld(getBukkitEntity()); - SkinnableEntity npc = NMS.getSkinnableNPC(getBukkitEntity()); + SkinnableEntity npc = NMS.getSkinnable(getBukkitEntity()); npc.getSkinTracker().onRemoveNPC(); super.remove(); diff --git a/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchSubscriber.java b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchHandler.java similarity index 87% rename from src/main/java/net/citizensnpcs/npc/profile/ProfileFetchSubscriber.java rename to src/main/java/net/citizensnpcs/npc/profile/ProfileFetchHandler.java index 9b8a29262..8f7e1f7e8 100644 --- a/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchSubscriber.java +++ b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchHandler.java @@ -3,7 +3,7 @@ package net.citizensnpcs.npc.profile; /** * Interface for a subscriber of the results of a profile fetch. */ -public interface ProfileFetchSubscriber { +public interface ProfileFetchHandler { /** * Invoked when a result for a profile is ready. diff --git a/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchThread.java b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchThread.java index d3838592e..39e9bb4f0 100644 --- a/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchThread.java +++ b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetchThread.java @@ -1,9 +1,9 @@ package net.citizensnpcs.npc.profile; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; import java.util.HashMap; -import java.util.LinkedList; import java.util.List; import java.util.Map; import javax.annotation.Nullable; @@ -19,63 +19,52 @@ import org.bukkit.Bukkit; * *

Maintains a cache of profiles so that no profile is ever requested more than once * during a single server session.

+ * + * @see ProfileFetcher */ -public class ProfileFetchThread implements Runnable { +class ProfileFetchThread implements Runnable { private final ProfileFetcher profileFetcher = new ProfileFetcher(); - private final Deque queue = new LinkedList(); + private final Deque queue = new ArrayDeque(); private final Map requested = new HashMap(35); - private final Object sync = new Object(); - - /** - * Get the singleton instance. - */ - public static ProfileFetchThread get() { - if (PROFILE_THREAD == null) { - PROFILE_THREAD = new ProfileFetchThread(); - Bukkit.getScheduler().runTaskTimerAsynchronously(CitizensAPI.getPlugin(), PROFILE_THREAD, - 11, 20); - } - return PROFILE_THREAD; - } + private final Object sync = new Object(); // sync for queue & requested fields ProfileFetchThread() {} /** * Fetch a profile. * - * @param name The name of the player the profile belongs to. - * @param subscriber Optional subscriber to be notified when a result is available. - * Subscriber always invoked from the main thread. + * @param name The name of the player the profile belongs to. + * @param handler Optional handler to handle result fetch result. + * Handler always invoked from the main thread. + * + * @see ProfileFetcher#fetch */ - public void fetch(String name, @Nullable ProfileFetchSubscriber subscriber) { + void fetch(String name, @Nullable ProfileFetchHandler handler) { Preconditions.checkNotNull(name); - ProfileRequest request = requested.get(name); - - if (request != null) { - - if (subscriber != null) { - - if (request.getResult() == ProfileFetchResult.PENDING) { - request.addSubscriber(subscriber); - } - else { - subscriber.onResult(request); - } - } - - return; - } - - request = new ProfileRequest(name, subscriber); + name = name.toLowerCase(); + ProfileRequest request; synchronized (sync) { - queue.add(request); + request = requested.get(name); + if (request == null) { + request = new ProfileRequest(name, handler); + queue.add(request); + requested.put(name, request); + return; + } } - requested.put(name, request); + if (handler != null) { + if (request.getResult() == ProfileFetchResult.PENDING) { + addHandler(request, handler); + } + else { + sendResult(handler, request); + } + } } @Override @@ -89,11 +78,38 @@ public class ProfileFetchThread implements Runnable { return; requests = new ArrayList(queue); + queue.clear(); } - profileFetcher.fetch(requests); + profileFetcher.fetchRequests(requests); } - private static ProfileFetchThread PROFILE_THREAD; + private static void sendResult(final ProfileFetchHandler handler, + final ProfileRequest request) { + + Bukkit.getScheduler().scheduleSyncDelayedTask(CitizensAPI.getPlugin(), + new Runnable() { + + @Override + public void run() { + + handler.onResult(request); + } + }, 1); + } + + private static void addHandler(final ProfileRequest request, + final ProfileFetchHandler handler) { + + Bukkit.getScheduler().scheduleSyncDelayedTask(CitizensAPI.getPlugin(), + new Runnable() { + + @Override + public void run() { + + request.addHandler(handler); + } + }, 1); + } } diff --git a/src/main/java/net/citizensnpcs/npc/profile/ProfileFetcher.java b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetcher.java index 344cf0415..af9dcfd7d 100644 --- a/src/main/java/net/citizensnpcs/npc/profile/ProfileFetcher.java +++ b/src/main/java/net/citizensnpcs/npc/profile/ProfileFetcher.java @@ -1,29 +1,53 @@ package net.citizensnpcs.npc.profile; +import java.util.Collection; +import javax.annotation.Nullable; + import com.google.common.base.Preconditions; import com.mojang.authlib.Agent; import com.mojang.authlib.GameProfile; import com.mojang.authlib.GameProfileRepository; import com.mojang.authlib.ProfileLookupCallback; + +import net.citizensnpcs.api.CitizensAPI; import net.citizensnpcs.api.util.Messaging; import net.citizensnpcs.util.NMS; -import javax.annotation.Nullable; -import java.util.Collection; +import org.bukkit.Bukkit; /** * Fetches game profiles that include skin data from Mojang servers. * * @see ProfileFetchThread */ -class ProfileFetcher { +public class ProfileFetcher { + + /** + * Fetch a profile. + * + * @param name The name of the player the profile belongs to. + * @param handler Optional handler to handle the result. + * Handler always invoked from the main thread. + */ + public static void fetch(String name, @Nullable ProfileFetchHandler handler) { + Preconditions.checkNotNull(name); + + if (PROFILE_THREAD == null) { + PROFILE_THREAD = new ProfileFetchThread(); + Bukkit.getScheduler().runTaskTimerAsynchronously(CitizensAPI.getPlugin(), PROFILE_THREAD, + 11, 20); + } + PROFILE_THREAD.fetch(name, handler); + } + + ProfileFetcher() {} /** * Fetch one or more profiles. * * @param requests The profile requests. */ - public void fetch(final Collection requests) { + void fetchRequests(final Collection requests) { Preconditions.checkNotNull(requests); final GameProfileRepository repo = NMS.getGameProfileRepository(); @@ -43,7 +67,7 @@ class ProfileFetcher { public void onProfileLookupFailed(GameProfile profile, Exception e) { if (Messaging.isDebugging()) { - Messaging.debug("Profile lookup for skin '" + + Messaging.debug("Profile lookup for player '" + profile.getName() + "' failed: " + getExceptionMsg(e)); } @@ -77,7 +101,7 @@ class ProfileFetcher { } catch (Exception e) { if (Messaging.isDebugging()) { - Messaging.debug("Profile lookup for skin '" + + Messaging.debug("Profile lookup for player '" + profile.getName() + "' failed: " + getExceptionMsg(e)); } @@ -125,4 +149,6 @@ class ProfileFetcher { return (message != null && message.contains("too many requests")) || (cause != null && cause.contains("too many requests")); } + + private static ProfileFetchThread PROFILE_THREAD; } diff --git a/src/main/java/net/citizensnpcs/npc/profile/ProfileRequest.java b/src/main/java/net/citizensnpcs/npc/profile/ProfileRequest.java index 4a1fcdb11..8688a3ae7 100644 --- a/src/main/java/net/citizensnpcs/npc/profile/ProfileRequest.java +++ b/src/main/java/net/citizensnpcs/npc/profile/ProfileRequest.java @@ -1,13 +1,15 @@ package net.citizensnpcs.npc.profile; +import java.util.ArrayDeque; +import java.util.Deque; +import javax.annotation.Nullable; + import com.google.common.base.Preconditions; import com.mojang.authlib.GameProfile; -import net.citizensnpcs.api.CitizensAPI; -import org.bukkit.Bukkit; -import javax.annotation.Nullable; -import java.util.ArrayDeque; -import java.util.Deque; +import net.citizensnpcs.api.CitizensAPI; + +import org.bukkit.Bukkit; /** * Stores basic information about a single profile used to request @@ -18,24 +20,24 @@ import java.util.Deque; public class ProfileRequest { private final String playerName; - private Deque subscribers; + private Deque handlers; private GameProfile profile; - private ProfileFetchResult result = ProfileFetchResult.PENDING; + private volatile ProfileFetchResult result = ProfileFetchResult.PENDING; /** * Constructor. * * @param playerName The name of the player whose profile is being requested. - * @param subscriber Optional subscriber to be notified when a result is available - * for the profile. Subscriber always invoked from the main thread. + * @param handler Optional handler to handle the result for the profile. + * Handler always invoked from the main thread. */ - ProfileRequest(String playerName, @Nullable ProfileFetchSubscriber subscriber) { + ProfileRequest(String playerName, @Nullable ProfileFetchHandler handler) { Preconditions.checkNotNull(playerName); this.playerName = playerName; - if (subscriber != null) - addSubscriber(subscriber); + if (handler != null) + addHandler(handler); } /** @@ -64,19 +66,24 @@ public class ProfileRequest { } /** - * Add a result subscriber to be notified when a result is available. + * Add one time result handler. * - *

Subscriber is always invoked from the main thread.

+ *

Handler is always invoked from the main thread.

* - * @param subscriber The subscriber. + * @param handler The result handler. */ - public void addSubscriber(ProfileFetchSubscriber subscriber) { - Preconditions.checkNotNull(subscriber); + public void addHandler(ProfileFetchHandler handler) { + Preconditions.checkNotNull(handler); - if (subscribers == null) - subscribers = new ArrayDeque(); + if (result != ProfileFetchResult.PENDING) { + handler.onResult(this); + return; + } - subscribers.addLast(subscriber); + if (handlers == null) + handlers = new ArrayDeque(); + + handlers.addLast(handler); } /** @@ -96,14 +103,14 @@ public class ProfileRequest { ProfileRequest.this.profile = profile; ProfileRequest.this.result = result; - if (subscribers == null) + if (handlers == null) return; - while (!subscribers.isEmpty()) { - subscribers.removeFirst().onResult(ProfileRequest.this); + while (!handlers.isEmpty()) { + handlers.removeFirst().onResult(ProfileRequest.this); } - subscribers = null; + handlers = null; } }); } diff --git a/src/main/java/net/citizensnpcs/npc/skin/PlayerListRemover.java b/src/main/java/net/citizensnpcs/npc/skin/PlayerListRemover.java index 5c1aeda73..d6682550c 100644 --- a/src/main/java/net/citizensnpcs/npc/skin/PlayerListRemover.java +++ b/src/main/java/net/citizensnpcs/npc/skin/PlayerListRemover.java @@ -35,10 +35,10 @@ public class PlayerListRemover { /** * Send a remove packet to the specified player for the specified - * human NPC entity. + * skinnable entity. * - * @param player The player to send the packet to. - * @param entity The entity to remove. + * @param player The player to send the packet to. + * @param entity The entity to remove. */ public void sendPacket(Player player, SkinnableEntity entity) { Preconditions.checkNotNull(player); @@ -58,6 +58,8 @@ public class PlayerListRemover { Preconditions.checkNotNull(player); PlayerEntry entry = pending.remove(player.getUniqueId()); + if (entry == null) + return; for (SkinnableEntity entity : entry.toRemove) { entity.getSkinTracker().notifyRemovePacketCancelled(player.getUniqueId()); @@ -66,10 +68,10 @@ public class PlayerListRemover { /** * Cancel packets pending to be sent to the specified player - * for the specified skinnable NPC. + * for the specified skinnable entity. * * @param player The player. - * @param skinnable The skinnable NPC. + * @param skinnable The skinnable entity. */ public void cancelPackets(Player player, SkinnableEntity skinnable) { Preconditions.checkNotNull(player); @@ -129,14 +131,13 @@ public class PlayerListRemover { Iterator skinIterator = entry.toRemove.iterator(); while (skinIterator.hasNext()) { + if (i >= maxPacketEntries) + break; + SkinnableEntity skinnable = skinIterator.next(); skinnableList.add(skinnable); skinIterator.remove(); - - if (i > maxPacketEntries) - break; - i++; } diff --git a/src/main/java/net/citizensnpcs/npc/skin/Skin.java b/src/main/java/net/citizensnpcs/npc/skin/Skin.java index a06ad7f10..3435ef3eb 100644 --- a/src/main/java/net/citizensnpcs/npc/skin/Skin.java +++ b/src/main/java/net/citizensnpcs/npc/skin/Skin.java @@ -15,8 +15,8 @@ import net.citizensnpcs.Settings; import net.citizensnpcs.api.event.DespawnReason; import net.citizensnpcs.api.npc.NPC; import net.citizensnpcs.npc.profile.ProfileFetchResult; -import net.citizensnpcs.npc.profile.ProfileFetchSubscriber; -import net.citizensnpcs.npc.profile.ProfileFetchThread; +import net.citizensnpcs.npc.profile.ProfileFetchHandler; +import net.citizensnpcs.npc.profile.ProfileFetcher; import net.citizensnpcs.npc.profile.ProfileRequest; /** @@ -28,15 +28,15 @@ public class Skin { private volatile Property skinData; private volatile UUID skinId; private volatile boolean isValid = true; - private final Map pending = new WeakHashMap(30); + private final Map pending = new WeakHashMap(15); /** - * Get a skin for a human NPC entity. + * Get a skin for a skinnable entity. * *

If a Skin instance does not exist, a new one is created and the * skin data is automatically fetched.

* - * @param entity The human NPC entity. + * @param entity The skinnable entity. */ public static Skin get(SkinnableEntity entity) { Preconditions.checkNotNull(entity); @@ -65,13 +65,13 @@ public class Skin { this.skinName = skinName.toLowerCase(); synchronized (CACHE) { - if (CACHE.containsKey(skinName)) + if (CACHE.containsKey(this.skinName)) throw new IllegalArgumentException("There is already a skin named " + skinName); - CACHE.put(skinName, this); + CACHE.put(this.skinName, this); } - ProfileFetchThread.get().fetch(skinName, new ProfileFetchSubscriber() { + ProfileFetcher.fetch(this.skinName, new ProfileFetchHandler() { @Override public void onResult(ProfileRequest request) { @@ -82,11 +82,8 @@ public class Skin { } if (request.getResult() == ProfileFetchResult.SUCCESS) { - GameProfile profile = request.getProfile(); - - skinId = profile.getId(); - skinData = Iterables.getFirst(profile.getProperties().get("textures"), null); + setData(profile); } } }); @@ -125,47 +122,15 @@ public class Skin { } /** - * Set skin data. - * - * @param profile The profile that contains the skin data. If set to null, - * it's assumed that the skin is not valid. - * - * @throws IllegalStateException if not invoked from the main thread. - * @throws IllegalArgumentException if the profile name does not match the skin data. - */ - public void setData(@Nullable GameProfile profile) { - - if (profile == null) { - isValid = false; - return; - } - - if (!profile.getName().toLowerCase().equals(skinName)) { - throw new IllegalArgumentException( - "GameProfile name (" + profile.getName() + ") and " - + "skin name (" + skinName + ") do not match."); - } - - skinId = profile.getId(); - skinData = Iterables.getFirst(profile.getProperties().get("textures"), null); - - for (SkinnableEntity entity : pending.keySet()) { - applyAndRespawn(entity); - } - } - - /** - * Apply the skin data to the specified human NPC entity. + * Apply the skin data to the specified skinnable entity. * *

If invoked before the skin data is ready, the skin is retrieved * and the skin is automatically applied to the entity at a later time.

* - * @param entity The human NPC entity. + * @param entity The skinnable entity. * * @return True if the skin data was available and applied, false if * the data is being retrieved. - * - * @throws IllegalStateException if not invoked from the main thread. */ public boolean apply(SkinnableEntity entity) { Preconditions.checkNotNull(entity); @@ -173,7 +138,6 @@ public class Skin { NPC npc = entity.getNPC(); if (!hasSkinData()) { - pending.put(entity, null); // Use npc cached skin if available. // If npc requires latest skin, cache is used for faster @@ -202,9 +166,7 @@ public class Skin { } } - // get latest skin - fetchSkinFor(entity); - + pending.put(entity, null); return false; } @@ -233,20 +195,25 @@ public class Skin { } } - private void fetchSkinFor(final SkinnableEntity entity) { + private void setData(@Nullable GameProfile profile) { - ProfileFetchThread.get().fetch(skinName, new ProfileFetchSubscriber() { + if (profile == null) { + isValid = false; + return; + } - @Override - public void onResult(ProfileRequest request) { + if (!profile.getName().toLowerCase().equals(skinName)) { + throw new IllegalArgumentException( + "GameProfile name (" + profile.getName() + ") and " + + "skin name (" + skinName + ") do not match."); + } - if (request.getResult() != ProfileFetchResult.SUCCESS) - return; + skinId = profile.getId(); + skinData = Iterables.getFirst(profile.getProperties().get("textures"), null); - double viewDistance = Settings.Setting.NPC_SKIN_VIEW_DISTANCE.asDouble(); - entity.getSkinTracker().updateNearbyViewers(viewDistance); - } - }); + for (SkinnableEntity entity : pending.keySet()) { + applyAndRespawn(entity); + } } private static void setNPCSkinData(SkinnableEntity entity, diff --git a/src/main/java/net/citizensnpcs/npc/skin/SkinPacketTracker.java b/src/main/java/net/citizensnpcs/npc/skin/SkinPacketTracker.java index 4b4e37311..d9fb3a960 100644 --- a/src/main/java/net/citizensnpcs/npc/skin/SkinPacketTracker.java +++ b/src/main/java/net/citizensnpcs/npc/skin/SkinPacketTracker.java @@ -17,6 +17,7 @@ import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.scheduler.BukkitTask; /** * Handles and synchronizes add and remove packets for Player type NPC's @@ -36,7 +37,7 @@ public class SkinPacketTracker { /** * Constructor. * - * @param entity The human NPC entity the instance belongs to. + * @param entity The skinnable entity the instance belongs to. */ public SkinPacketTracker(SkinnableEntity entity) { Preconditions.checkNotNull(entity); @@ -57,48 +58,6 @@ public class SkinPacketTracker { return skin; } - /** - * Notify that the NPC skin has been changed. - */ - public void notifySkinChange() { - this.skin = Skin.get(entity); - skin.applyAndRespawn(entity); - } - - /** - * Notify the tracker that a remove packet has been sent to the - * specified player. - * - * @param playerId The ID of the player. - */ - void notifyRemovePacketSent(UUID playerId) { - - PlayerEntry entry = inProgress.get(playerId); - if (entry == null) - return; - - if (entry.removeCount == 0) - return; - - entry.removeCount -= 1; - if (entry.removeCount == 0) { - inProgress.remove(playerId); - } - else { - scheduleRemovePacket(entry); - } - } - - /** - * Notify the tracker that a remove packet has been sent to the - * specified player. - * - * @param playerId The ID of the player. - */ - void notifyRemovePacketCancelled(UUID playerId) { - inProgress.remove(playerId); - } - /** * Send skin related packets to a player. * @@ -107,19 +66,21 @@ public class SkinPacketTracker { public void updateViewer(final Player player) { Preconditions.checkNotNull(player); - if (player.hasMetadata("NPC")) + if (isRemoved || player.hasMetadata("NPC")) return; - if (isRemoved || inProgress.containsKey(player.getUniqueId())) - return; - - PlayerEntry entry = new PlayerEntry(player); - inProgress.put(player.getUniqueId(), entry); + PlayerEntry entry = inProgress.get(player.getUniqueId()); + if (entry != null) { + entry.cancel(); + } + else { + entry = new PlayerEntry(player); + } PLAYER_LIST_REMOVER.cancelPackets(player, entity); + inProgress.put(player.getUniqueId(), entry); skin.apply(entity); - NMS.sendPlayerListAdd(player, entity.getBukkitEntity()); scheduleRemovePacket(entry, 2); @@ -175,6 +136,48 @@ public class SkinPacketTracker { } } + /** + * Notify that the NPC skin has been changed. + */ + public void notifySkinChange() { + this.skin = Skin.get(entity); + skin.applyAndRespawn(entity); + } + + /** + * Notify the tracker that a remove packet has been sent to the + * specified player. + * + * @param playerId The ID of the player. + */ + void notifyRemovePacketSent(UUID playerId) { + + PlayerEntry entry = inProgress.get(playerId); + if (entry == null) + return; + + if (entry.removeCount == 0) + return; + + entry.removeCount -= 1; + if (entry.removeCount == 0) { + inProgress.remove(playerId); + } + else { + scheduleRemovePacket(entry); + } + } + + /** + * Notify the tracker that a remove packet has been sent to the + * specified player. + * + * @param playerId The ID of the player. + */ + void notifyRemovePacketCancelled(UUID playerId) { + inProgress.remove(playerId); + } + private void scheduleRemovePacket(PlayerEntry entry, int count) { if (!shouldRemoveFromPlayerList()) @@ -189,7 +192,7 @@ public class SkinPacketTracker { if (isRemoved) return; - Bukkit.getScheduler().runTaskLater(CitizensAPI.getPlugin(), + entry.removeTask = Bukkit.getScheduler().runTaskLater(CitizensAPI.getPlugin(), new Runnable() { @Override @@ -214,9 +217,19 @@ public class SkinPacketTracker { private class PlayerEntry { Player player; int removeCount; + BukkitTask removeTask; + PlayerEntry (Player player) { this.player = player; } + + // cancel previous packet tasks so they do not interfere with + // new tasks + void cancel() { + if (removeTask != null) + removeTask.cancel(); + removeCount = 0; + } } private static class PlayerListener implements Listener { diff --git a/src/main/java/net/citizensnpcs/util/NMS.java b/src/main/java/net/citizensnpcs/util/NMS.java index b9ef23d78..90c22aa2b 100644 --- a/src/main/java/net/citizensnpcs/util/NMS.java +++ b/src/main/java/net/citizensnpcs/util/NMS.java @@ -12,8 +12,10 @@ import java.util.Map; import java.util.Random; import java.util.Set; import java.util.WeakHashMap; +import javax.annotation.Nullable; import com.google.common.base.Preconditions; +import com.mojang.authlib.GameProfile; import com.mojang.authlib.GameProfileRepository; import com.mojang.authlib.HttpAuthenticationService; import com.mojang.authlib.minecraft.MinecraftSessionService; @@ -21,26 +23,6 @@ import com.mojang.authlib.yggdrasil.YggdrasilAuthenticationService; import com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService; import com.mojang.authlib.yggdrasil.response.MinecraftProfilePropertiesResponse; import com.mojang.util.UUIDTypeAdapter; -import net.citizensnpcs.npc.skin.SkinnableEntity; -import org.apache.commons.lang.Validate; -import org.bukkit.Bukkit; -import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.Sound; -import org.bukkit.craftbukkit.v1_8_R3.CraftServer; -import org.bukkit.craftbukkit.v1_8_R3.CraftSound; -import org.bukkit.craftbukkit.v1_8_R3.CraftWorld; -import org.bukkit.craftbukkit.v1_8_R3.entity.CraftEntity; -import org.bukkit.craftbukkit.v1_8_R3.entity.CraftPlayer; -import org.bukkit.entity.EntityType; -import org.bukkit.entity.Horse; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Player; -import org.bukkit.event.entity.CreatureSpawnEvent; -import org.bukkit.inventory.meta.SkullMeta; -import org.bukkit.plugin.PluginLoadOrder; - -import com.mojang.authlib.GameProfile; import net.citizensnpcs.api.command.exception.CommandException; import net.citizensnpcs.api.npc.NPC; @@ -48,6 +30,7 @@ import net.citizensnpcs.api.util.Messaging; import net.citizensnpcs.npc.ai.NPCHolder; import net.citizensnpcs.npc.entity.EntityHumanNPC; import net.citizensnpcs.npc.network.EmptyChannel; +import net.citizensnpcs.npc.skin.SkinnableEntity; import net.citizensnpcs.util.nms.PlayerlistTrackerEntry; import net.minecraft.server.v1_8_R3.AttributeInstance; import net.minecraft.server.v1_8_R3.Block; @@ -75,7 +58,23 @@ import net.minecraft.server.v1_8_R3.PathfinderGoalSelector; import net.minecraft.server.v1_8_R3.World; import net.minecraft.server.v1_8_R3.WorldServer; -import javax.annotation.Nullable; +import org.apache.commons.lang.Validate; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.craftbukkit.v1_8_R3.CraftServer; +import org.bukkit.craftbukkit.v1_8_R3.CraftSound; +import org.bukkit.craftbukkit.v1_8_R3.CraftWorld; +import org.bukkit.craftbukkit.v1_8_R3.entity.CraftEntity; +import org.bukkit.craftbukkit.v1_8_R3.entity.CraftPlayer; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Horse; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.inventory.meta.SkullMeta; +import org.bukkit.plugin.PluginLoadOrder; @SuppressWarnings("unchecked") public class NMS { @@ -108,7 +107,7 @@ public class NMS { } @Nullable - public static SkinnableEntity getSkinnableNPC(org.bukkit.entity.Entity entity) { + public static SkinnableEntity getSkinnable(org.bukkit.entity.Entity entity) { Preconditions.checkNotNull(entity); Entity nmsEntity = ((CraftEntity) entity).getHandle(); @@ -138,7 +137,8 @@ public class NMS { PacketPlayOutPlayerInfo.EnumPlayerInfoAction.REMOVE_PLAYER, entity)); } - public static void sendPlayerListRemove(Player recipient, Collection skinnableNPCs) { + public static void sendPlayerListRemove(Player recipient, + Collection skinnableNPCs) { Preconditions.checkNotNull(recipient); Preconditions.checkNotNull(skinnableNPCs);